298 lines
13 KiB
JavaScript
298 lines
13 KiB
JavaScript
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
// build-patterns-catalog.mjs
|
|||
|
|
// 從《短線交易日線圖大全》目錄 + 72 頁 MD 產生完整 catalog.json
|
|||
|
|
// 用法:cd app && npm run build:patterns
|
|||
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
import fs from 'node:fs';
|
|||
|
|
import path from 'node:path';
|
|||
|
|
import { fileURLToPath } from 'node:url';
|
|||
|
|
import {
|
|||
|
|
findPageForEntry,
|
|||
|
|
loadAllPages,
|
|||
|
|
norm,
|
|||
|
|
resolvePatternContentDir,
|
|||
|
|
} from '../lib/pattern-page-index.js';
|
|||
|
|
import { supplementForCatalogName } from '../lib/pattern-supplements.js';
|
|||
|
|
|
|||
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||
|
|
const BOOK_DIR = resolvePatternContentDir();
|
|||
|
|
const OUT_PATH = path.resolve(__dirname, '..', 'data', 'patterns', 'catalog.json');
|
|||
|
|
const OVERRIDES_PATH = path.resolve(__dirname, '..', 'data', 'patterns', 'automation-overrides.json');
|
|||
|
|
|
|||
|
|
if (!BOOK_DIR) {
|
|||
|
|
console.error('找不到線型教材資料夾(短線交易日線圖大全 或 content/raw/patterns)');
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const overrides = JSON.parse(fs.readFileSync(OVERRIDES_PATH, 'utf8'));
|
|||
|
|
|
|||
|
|
const CHAPTER_NAMES = {
|
|||
|
|
1: '短線交易模式與交易系統',
|
|||
|
|
2: '構成股價走勢圖的要素',
|
|||
|
|
3: '當沖交易圖表型態',
|
|||
|
|
4: '波段交易圖表型態',
|
|||
|
|
5: '超短線交易圖表型態',
|
|||
|
|
6: '大盤觀察與選股',
|
|||
|
|
0: '附錄與心態',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const MODE_SLUG_SUFFIX = { '①': '_1', '②': '_2', '③': '_3', '④': '_4' };
|
|||
|
|
|
|||
|
|
function slugId(name, category, chapter) {
|
|||
|
|
const modeMark = String(name || '').match(/[①②③④]/)?.[0] || '';
|
|||
|
|
const modeSuffix = MODE_SLUG_SUFFIX[modeMark] || '';
|
|||
|
|
const base = norm(name).slice(0, 28) || norm(category).slice(0, 12);
|
|||
|
|
const slug = `p${chapter}_${base}${modeSuffix}`
|
|||
|
|
.replace(/[^\w\u4e00-\u9fff]/g, '_')
|
|||
|
|
.replace(/_+/g, '_')
|
|||
|
|
.replace(/_$/g, '');
|
|||
|
|
return slug.slice(0, 52);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function inferBias(text, name) {
|
|||
|
|
const t = `${text} ${name}`;
|
|||
|
|
if (/賣出|下跌|空方|下殺|偏空|超買|死亡|跌停|黑三兵|空方|減碼|摜破|風險|小心|勿追/.test(t)) return 'bearish';
|
|||
|
|
if (/買進|上漲|多方|起漲|偏多|超賣|黃金|漲停|紅三兵|突破|反彈|續強|買點/.test(t)) return 'bullish';
|
|||
|
|
return 'neutral';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function inferTimeframe(chapter, category) {
|
|||
|
|
if (chapter === 5 || /超短線|跳動點|VWAP|10檔/.test(category)) return ['分', '日'];
|
|||
|
|
if (chapter === 4 || /波段|布林|一目|斐波|酒田/.test(category)) return ['日', '週'];
|
|||
|
|
if (chapter === 3 || /當沖|交易時段|9點/.test(category)) return ['日', '分'];
|
|||
|
|
if (chapter === 1 || chapter === 2) return ['日'];
|
|||
|
|
return ['日'];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseToc() {
|
|||
|
|
const raw = fs.readFileSync(path.join(BOOK_DIR, '003.md'), 'utf8');
|
|||
|
|
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
|||
|
|
const entries = [];
|
|||
|
|
let chapter = 0;
|
|||
|
|
let chapterTitle = '';
|
|||
|
|
let section = 'toc';
|
|||
|
|
|
|||
|
|
const skipExact = new Set([
|
|||
|
|
'交易模式', '時間意識', '未必等於', '10檔報價', '下單方式', '逆限價單', 'OCO單', 'IFD單', 'IFDOCO單',
|
|||
|
|
'構成要素', '基本要素', 'K線', '時間軸', '交易時段', '技術指標', '移動平均線', '葛蘭碧法則',
|
|||
|
|
'複數均線', '隨機指標', '騰落指標', '相對強弱指標', 'MACD', '複數指標', '道氏理論', 'K線組成的型態',
|
|||
|
|
'天花板.地板', '橫盤整理', '三角收斂型態', '旗形型態', '箱型整理', '分價量表', '當日現金交割',
|
|||
|
|
'特定的股價波動', '反彈的時機點', '練習問題①', '練習問題②', '基本操作策略', '避免在連假期間持股',
|
|||
|
|
'布林通道', '趨勢通道', '顧比均線', '一目均衡表', '基本結構', '三役好轉', '延遲線的用法',
|
|||
|
|
'斐波那契回撤', 'SAR拋物線指標', 'ENV包絡線', '不同時間軸的均線', '歷史動率', 'HV歷史波動率',
|
|||
|
|
'動向指標', 'DMI動向指標', 'PSY心理線', 'DMA指標', '酒田五法', '島狀反轉', '菱形頂型態', '杯柄型態',
|
|||
|
|
'海龜交易法', 'K線種類', 'K線型態', 'K線組合', '帶量上漲的大陽線', '上下影線', '跳空缺口', '連續K線',
|
|||
|
|
'壓力線.支撐線', '突破交易', '心理關卡', '摜破大關', '多空趨勢', '哪一種趨勢', 'VWAP', '長期趨勢',
|
|||
|
|
'大盤指數', '美股收盤', '歷史數據', '最近兩週', '新股上市', '變更交易市場', '股票下市', '宣布下市',
|
|||
|
|
'發布財報', '買賣越活躍', '股價漲幅排行', '尋找買賣點', '當沖交易', '波段交易', '超短線交易',
|
|||
|
|
'買進模式①', '買進模式②', '買進模式③', '買進模式④', '賣出模式①', '賣出模式②', '賣出模式③', '賣出模式④',
|
|||
|
|
'道氏理論①', '道氏理論②', '道氏理論③', '9點~11點', '收盤前30分', '長短參數', '移動平均乖離率',
|
|||
|
|
'黃金交叉', '死亡交叉', '多頭排列', '紅三兵', '黑三兵', '下影陽線', '下影陰線', '上影陽線', '上影陰線',
|
|||
|
|
'覆蓋線', '切入線', '穿透線', '環抱線', '孕育線', '大陽線', '大陰線', '同時線', '漲停', '跌停',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
for (const line of lines) {
|
|||
|
|
if (line === '圖表型態一覽表' || line === '本書的頁面構成') continue;
|
|||
|
|
const ch = line.match(/^第([1-6])章 (.+)$/);
|
|||
|
|
if (ch) {
|
|||
|
|
chapter = Number(ch[1]);
|
|||
|
|
chapterTitle = ch[2];
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if (line.startsWith('末章 ')) {
|
|||
|
|
chapter = 0;
|
|||
|
|
chapterTitle = '投資心態';
|
|||
|
|
entries.push({ chapter, chapterTitle, category: '末章', name: line.replace(/^末章 /, ''), fullTitle: line });
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
if (line.startsWith('專欄 ')) {
|
|||
|
|
entries.push({ chapter, chapterTitle, category: '專欄', name: line.replace(/^專欄 /, ''), fullTitle: line });
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
const m = line.match(/^(.+?) (.+)$/);
|
|||
|
|
if (!m) continue;
|
|||
|
|
const category = m[1].trim();
|
|||
|
|
const title = m[2].trim();
|
|||
|
|
if (title.length < 4) continue;
|
|||
|
|
if (skipExact.has(category) && category === title) continue;
|
|||
|
|
if (category === title) continue;
|
|||
|
|
entries.push({ chapter, chapterTitle, category, name: title, fullTitle: `${category} ${title}` });
|
|||
|
|
}
|
|||
|
|
return entries;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const PAGE_OVERRIDES = {
|
|||
|
|
'黃金交叉': 20, '死亡交叉': 20, '多頭排列': 21,
|
|||
|
|
'RSI相對強弱指標': 22, 'MACD指標': 23, 'MACD': 23,
|
|||
|
|
'成交量': 29, '向上跳空缺口': 55, '向下跳空缺口': 55,
|
|||
|
|
'多方吞噬': 54, '空方吞噬': 54, '覆蓋線': 54,
|
|||
|
|
'下影陽線': 53, '下影陰線': 53, '上影陽線': 53, '上影陰線': 53,
|
|||
|
|
'紅三兵': 45, '黑三兵': 45, '漲停': 10, '跌停': 10,
|
|||
|
|
'買進模式①': 15, '買進模式④': 16,
|
|||
|
|
'賣出模式①': 17, '賣出模式②': 18,
|
|||
|
|
'跳動點': 50, 'VWAP': 59,
|
|||
|
|
// 目錄有條目但原始擷取未產生獨立書頁
|
|||
|
|
'10分鐘掌握當沖交易的重點!': false,
|
|||
|
|
'10分鐘掌握波段交易的重點!': false,
|
|||
|
|
|
|||
|
|
'運用葛蘭碧法則的賣出模式③': false,
|
|||
|
|
'運用葛蘭碧法則的賣出模式④': false,
|
|||
|
|
'反轉為上升趨勢的關鍵點位在哪裡?①': false,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function resolveByNameOverride(entry) {
|
|||
|
|
if (overrides.byName?.[entry.name]) return overrides.byName[entry.name];
|
|||
|
|
const keys = Object.keys(overrides.byName || {}).sort((a, b) => b.length - a.length);
|
|||
|
|
for (const key of keys) {
|
|||
|
|
if (entry.name === key) return overrides.byName[key];
|
|||
|
|
}
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyOverride(entry, page) {
|
|||
|
|
const split = overrides.byTitle?.[entry.name]?.split;
|
|||
|
|
if (split?.length) {
|
|||
|
|
return split.map(subName => {
|
|||
|
|
const sub = { ...entry, name: subName };
|
|||
|
|
const o = overrides.byName?.[subName] || {};
|
|||
|
|
return buildPattern(sub, page, o);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return [buildPattern(entry, page, resolveByNameOverride(entry))];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolvePageForEntry(entry, pages) {
|
|||
|
|
return supplementForCatalogName(entry.name) || findPageForEntry(entry, pages, PAGE_OVERRIDES);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildPattern(entry, page, o = {}) {
|
|||
|
|
const summary = page?.summary || entry.name;
|
|||
|
|
const bias = o.bias || inferBias(summary, entry.name);
|
|||
|
|
const chapter = entry.chapter || 0;
|
|||
|
|
const pattern = {
|
|||
|
|
id: o.id || slugId(entry.name, entry.category, chapter),
|
|||
|
|
name: o.name || entry.name,
|
|||
|
|
category: entry.category,
|
|||
|
|
chapter,
|
|||
|
|
chapterTitle: entry.chapterTitle || CHAPTER_NAMES[chapter] || '',
|
|||
|
|
bookPage: page?.pageNum || null,
|
|||
|
|
bias,
|
|||
|
|
timeframe: o.timeframe || inferTimeframe(chapter, entry.category),
|
|||
|
|
automatable: o.automatable === true,
|
|||
|
|
teach: {
|
|||
|
|
summary: summary.slice(0, 280),
|
|||
|
|
entryHint: o.entryHint || (page?.summary2 ? page.summary2.slice(0, 120) : '依書本圖例與當下趨勢判斷進場時機。'),
|
|||
|
|
exitHint: o.exitHint || '趨勢或型態失效時出場;搭配停損/停利紀律。',
|
|||
|
|
caution: page?.isSupplement
|
|||
|
|
? '此節為教材補遺:原始擷取缺頁,內容依書中交叉引用整理,圖例可能為鏡像對照頁。'
|
|||
|
|
: (o.caution || (entry.category === '練習問題' ? '先自行作答,再對照書本解答。' : '訊號需搭配大盤、量能與基本面,避免單一線型決策。')),
|
|||
|
|
bookRef: page?.mdFile || null,
|
|||
|
|
bookHtml: page?.htmlFile || null,
|
|||
|
|
images: page?.imageUrls || [],
|
|||
|
|
fullTitle: entry.fullTitle,
|
|||
|
|
isSupplement: !!page?.isSupplement,
|
|||
|
|
tocHint: page?.tocHint || null,
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
if (o.rule) {
|
|||
|
|
pattern.rule = o.rule;
|
|||
|
|
pattern.params = o.params || {};
|
|||
|
|
}
|
|||
|
|
if (o.markets) pattern.markets = o.markets;
|
|||
|
|
if (o.backtest) pattern.backtest = o.backtest;
|
|||
|
|
|
|||
|
|
const extra = overrides.teachExtras?.[pattern.id];
|
|||
|
|
if (extra) {
|
|||
|
|
const { teach: extraTeach, entryHint, exitHint, caution, ...rest } = extra;
|
|||
|
|
Object.assign(pattern, rest);
|
|||
|
|
if (extraTeach) pattern.teach = { ...pattern.teach, ...extraTeach };
|
|||
|
|
if (entryHint) pattern.teach.entryHint = entryHint;
|
|||
|
|
if (exitHint) pattern.teach.exitHint = exitHint;
|
|||
|
|
if (caution) pattern.teach.caution = caution;
|
|||
|
|
}
|
|||
|
|
return pattern;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function dedupePatterns(list) {
|
|||
|
|
const seen = new Map();
|
|||
|
|
const out = [];
|
|||
|
|
for (const p of list) {
|
|||
|
|
const key = p.id;
|
|||
|
|
if (seen.has(key)) {
|
|||
|
|
const prev = seen.get(key);
|
|||
|
|
if ((p.automatable && !prev.automatable) || (p.bookPage && !prev.bookPage)) {
|
|||
|
|
const idx = out.indexOf(prev);
|
|||
|
|
out[idx] = p;
|
|||
|
|
seen.set(key, p);
|
|||
|
|
}
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
seen.set(key, p);
|
|||
|
|
out.push(p);
|
|||
|
|
}
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function main() {
|
|||
|
|
const toc = parseToc();
|
|||
|
|
const pages = loadAllPages(BOOK_DIR);
|
|||
|
|
const patterns = [];
|
|||
|
|
|
|||
|
|
for (const entry of toc) {
|
|||
|
|
const page = resolvePageForEntry(entry, pages);
|
|||
|
|
// 圖鑑只收錄能回到實際 MD 教材的項目,避免目錄文字變成空卡。
|
|||
|
|
if (!page) continue;
|
|||
|
|
patterns.push(...applyOverride(entry, page));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const extra of overrides.extraPatterns || []) {
|
|||
|
|
const page = pages.find(p => p.pageNum === extra.bookPage);
|
|||
|
|
patterns.push(buildPattern(
|
|||
|
|
{ name: extra.name, category: extra.category, chapter: extra.chapter, chapterTitle: CHAPTER_NAMES[extra.chapter], fullTitle: extra.name },
|
|||
|
|
page,
|
|||
|
|
extra,
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 補充:前言與導讀頁(004 等)若未出現在 TOC
|
|||
|
|
const coveredPages = new Set(patterns.map(p => p.bookPage).filter(Boolean));
|
|||
|
|
for (const page of pages) {
|
|||
|
|
if (coveredPages.has(page.pageNum) || page.pageNum <= 4) continue;
|
|||
|
|
if (page.summary.length < 20) continue;
|
|||
|
|
const entry = {
|
|||
|
|
chapter: page.pageNum <= 13 ? 1 : page.pageNum <= 19 ? 2 : page.pageNum <= 35 ? 3 : page.pageNum <= 49 ? 4 : page.pageNum <= 63 ? 5 : 6,
|
|||
|
|
chapterTitle: '',
|
|||
|
|
category: '書頁',
|
|||
|
|
name: page.headline || page.titleLines[page.titleLines.length - 1] || `第 ${page.pageNum} 頁`,
|
|||
|
|
fullTitle: page.titleBlob.slice(0, 60),
|
|||
|
|
};
|
|||
|
|
entry.chapterTitle = CHAPTER_NAMES[entry.chapter] || '';
|
|||
|
|
patterns.push(buildPattern(entry, page, {}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const final = dedupePatterns(patterns).sort((a, b) => {
|
|||
|
|
if (a.chapter !== b.chapter) return a.chapter - b.chapter;
|
|||
|
|
if ((a.bookPage || 999) !== (b.bookPage || 999)) return (a.bookPage || 999) - (b.bookPage || 999);
|
|||
|
|
return a.name.localeCompare(b.name, 'zh-Hant');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const catalog = {
|
|||
|
|
version: 2,
|
|||
|
|
source: '短線交易日線圖大全',
|
|||
|
|
builtAt: new Date().toISOString(),
|
|||
|
|
stats: {
|
|||
|
|
total: final.length,
|
|||
|
|
automatable: final.filter(p => p.automatable).length,
|
|||
|
|
chapters: Object.keys(CHAPTER_NAMES).map(Number).filter(n => final.some(p => p.chapter === n)),
|
|||
|
|
},
|
|||
|
|
chapterNames: CHAPTER_NAMES,
|
|||
|
|
patterns: final,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
fs.writeFileSync(OUT_PATH, JSON.stringify(catalog, null, 2) + '\n', 'utf8');
|
|||
|
|
console.log(`✓ catalog.json:${final.length} 筆(可掃描 ${catalog.stats.automatable})→ ${OUT_PATH}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
main();
|