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();
|