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

194 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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();