finance-tools/scripts/build-skill-drills.mjs

194 lines
6.2 KiB
JavaScript
Raw Permalink Normal View History

2026-06-21 20:28:06 +00:00
#!/usr/bin/env node
/**
* knowledge.json 產生 skill-drills.json 題庫覆寫
* - 解析 EP 實例的標的日期視窗
* - 同群心法作為第 1 關干擾項
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, "..", "data");
const KNOWLEDGE_PATH = path.join(DATA_DIR, "knowledge.json");
const OUT_PATH = path.join(DATA_DIR, "skill-drills.json");
const TICKER_IN_TEXT =
/\b(NVDA|AMD|AAPL|MSFT|GOOGL|GOOGL|META|AMZN|TSLA|TSM|AVGO|NOC|BA|LMT|RTX|MU|SMCI|ORCL|CRM|COST|WMT|QQQ|SPY|SMH|IWM|LYV|DKNG|2330\.TW)\b/gi;
const NAME_TO_SYMBOL = [
[/輝達|NVIDIA/i, "NVDA"],
[/台積電|TSMC/i, "TSM"],
[/蘋果|Apple/i, "AAPL"],
[/微軟|Microsoft/i, "MSFT"],
[/谷歌|Google|Alphabet/i, "GOOGL"],
[/亞馬遜|Amazon/i, "AMZN"],
[/特斯拉|Tesla/i, "TSLA"],
[/博通|Broadcom/i, "AVGO"],
[/洛克希德|Raytheon/i, "LMT"],
[/萊茵金屬/i, "RTX"],
[/台股|加權|大盤/i, "SPY"],
[/標普|S&P|美股大盤/i, "SPY"],
[/那斯達|Nasdaq|科技板塊/i, "QQQ"],
];
const GROUP_DEFAULT_SYMBOL = {
總經與市場水位: "SPY",
進攻與擴張時機: "QQQ",
護城河與商業模式: "NVDA",
財報估值與管理層: "AAPL",
交易紀律與資金管理: "SPY",
地緣政治與政策博弈: "LMT",
生活觀察與消費信號: "COST",
市場心理與反指標: "QQQ",
};
function hashSeed(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
function pick(arr, seed, n) {
const copy = [...arr];
const out = [];
let s = hashSeed(seed);
while (copy.length && out.length < n) {
s = (s * 1103515245 + 12345) | 0;
const idx = Math.abs(s) % copy.length;
out.push(copy.splice(idx, 1)[0]);
}
return out;
}
function parseInstances(body) {
const instances = [];
for (const raw of String(body || "").split("\n")) {
const line = raw.trim();
if (!line.startsWith("- ") && !line.startsWith("* ")) continue;
const content = line.slice(2).trim();
if (/^(EP\d|EP\.|會員影片|member_)/i.test(content) || /^EP/.test(content.split(/[:]/)[0] || "")) {
const parts = content.split(/[:]/);
instances.push({
ep: parts[0]?.trim() || "",
text: parts.slice(1).join("").trim() || content,
});
}
}
return instances;
}
function resolveSymbol(text, groupName) {
const tickers = [...text.matchAll(TICKER_IN_TEXT)].map((m) => m[1].toUpperCase());
if (tickers[0]) return tickers[0];
for (const [re, sym] of NAME_TO_SYMBOL) {
if (re.test(text)) return sym;
}
return GROUP_DEFAULT_SYMBOL[groupName] || "SPY";
}
function parseScenarioWindow(text, ep) {
const zh = text.match(/(20\d{2})\s*年\s*(\d{1,2})\s*月/);
if (zh) return `${zh[1]}-${String(zh[2]).padStart(2, "0")}`;
const slash = text.match(/(20\d{2})\s*[\/年]\s*(\d{1,2})\s*[\/月]\s*(\d{1,2})/);
if (slash) return `${slash[1]}-${String(slash[2]).padStart(2, "0")}`;
const en = text.match(/(20\d{2})-(\d{2})(?:-(\d{2}))?/);
if (en) return `${en[1]}-${en[2]}`;
const epNum = ep?.match(/EP\.?\s*(\d+)/i);
if (epNum) return `EP${epNum[1]}`;
return null;
}
function chartRangeForWindow(window) {
if (!window || window.startsWith("EP")) return "1y";
const y = Number(window.slice(0, 4));
const now = new Date().getFullYear();
if (y >= now - 1) return "1y";
if (y >= now - 3) return "3y";
return "5y";
}
function buildGroupMap(principleMapBody, principles) {
const byId = new Map(principles.map((p) => [p.id, p]));
const groupOf = new Map();
const groups = new Map();
if (!principleMapBody) return { groupOf, groups };
let curName = "全部心法";
for (const line of principleMapBody.split("\n")) {
const hm = line.match(/^## \d+\.\s*(.+?)/);
if (hm) {
curName = hm[1].trim();
if (!groups.has(curName)) groups.set(curName, []);
continue;
}
const links = [...line.matchAll(/\[\[Emmy 投資心法#([^\]]+)\]\]/g)];
for (const m of links) {
const p = byId.get(m[1]);
if (!p) continue;
groupOf.set(p.id, curName);
groups.get(curName)?.push(p.id);
}
}
return { groupOf, groups };
}
function cleanTitle(title) {
return String(title || "")
.replace(/^原則[^:]+[:]\s*/, "")
.trim();
}
function main() {
if (!fs.existsSync(KNOWLEDGE_PATH)) {
console.error("找不到 knowledge.json請先執行 npm run build:knowledge");
process.exit(1);
}
const knowledge = JSON.parse(fs.readFileSync(KNOWLEDGE_PATH, "utf8"));
const principles = knowledge.principles || [];
const { groupOf, groups } = buildGroupMap(knowledge.principleMap?.body, principles);
const entries = {};
for (const p of principles) {
const instances = parseInstances(p.body);
const groupName = groupOf.get(p.id) || "全部心法";
const idx = instances.length ? hashSeed(p.id) % instances.length : 0;
const inst = instances[idx] || instances[0];
const blob = inst ? `${inst.ep} ${inst.text} ${p.title}` : p.title;
const scenarioSymbol = resolveSymbol(blob, groupName);
const scenarioWindow = inst ? parseScenarioWindow(`${inst.text} ${inst.ep}`, inst.ep) : null;
const peers = (groups.get(groupName) || []).filter((id) => id !== p.id);
const distractorPrinciples = pick(peers, `${p.id}:dist`, Math.min(3, peers.length));
const entry = {
groupName,
instanceIndex: idx,
scenarioSymbol,
chartRange: chartRangeForWindow(scenarioWindow),
};
if (scenarioWindow) entry.scenarioWindow = scenarioWindow;
if (inst?.ep) entry.epLabel = inst.ep;
if (distractorPrinciples.length) entry.distractorPrinciples = distractorPrinciples;
// 傳說/史詩卡加強提示
if (p.num != null && p.num <= 10) {
entry.chartHint = "十大基石心法:結合總經水位與長期趨勢判斷";
}
entries[p.id] = entry;
}
const payload = {
version: 1,
generatedAt: new Date().toISOString(),
principleCount: principles.length,
entries,
};
fs.writeFileSync(OUT_PATH, JSON.stringify(payload, null, 2), "utf8");
console.log(`Wrote ${OUT_PATH} (${principles.length} entries)`);
}
main();