finance-tools/scripts/build-patterns-catalog.mjs

298 lines
13 KiB
JavaScript
Raw Permalink Normal View History

2026-06-21 20:28:06 +00:00
// ═══════════════════════════════════════════════════════════
// 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();