// ═══════════════════════════════════════════════════════════
// Emmy 投資台 — 學習教材 / 財報健檢 / 交易復盤
// 本檔在 index.html 的內聯 script 之後載入,可使用其全域函式
// (lineChart、HEX、cssVar…),並負責主視圖切換與三個新分頁。
// ═══════════════════════════════════════════════════════════
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
async function api(path, opts) {
const res = await fetch(path, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw Object.assign(new Error(data.message || res.statusText), { data });
return data;
}
function fmtNum(v, d = 0) {
if (v == null || isNaN(v)) return '—';
return Number(v).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
}
function fmtPct(v, d = 1) { return v == null || isNaN(v) ? '—' : (v >= 0 ? '' : '') + Number(v).toFixed(d) + '%'; }
function fmtMoney(v) {
if (v == null || isNaN(v)) return '—';
const a = Math.abs(v), s = v < 0 ? '-' : '';
if (a >= 1e12) return s + '$' + (a / 1e12).toFixed(2) + 'T';
if (a >= 1e9) return s + '$' + (a / 1e9).toFixed(2) + 'B';
if (a >= 1e6) return s + '$' + (a / 1e6).toFixed(2) + 'M';
if (a >= 1e3) return s + '$' + (a / 1e3).toFixed(1) + 'K';
return s + '$' + a.toFixed(2);
}
// ═══════════════════════════════════════════════════════════
// 輕量 Markdown 渲染(支援標題/清單/表格/引用/粗體/行內碼/[[wikilink]])
// ═══════════════════════════════════════════════════════════
function mdInline(t) {
t = escapeHtml(t);
t = t.replace(/`([^`]+)`/g, (m, c) => '' + c + '');
t = t.replace(/\[\[([^\]]+)\]\]/g, (m, inner) => wlinkHTML(inner));
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ');
t = t.replace(/\*\*([^*]+)\*\*/g, '$1 ');
t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2 ');
return t;
}
function wlinkHTML(inner) {
let [target, display] = inner.split('|');
target = (target || '').trim();
display = (display || '').trim();
if (!display) display = target.includes('#') ? target.split('#').pop() : target.split('/').pop();
return '' + escapeHtml(display) + ' ';
}
function splitRow(line) {
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
}
function renderTable(header, rows) {
let h = '
' + header.map(c => '' + mdInline(c) + ' ').join('') + ' ';
for (const r of rows) h += '' + header.map((_, j) => '' + mdInline(r[j] || '') + ' ').join('') + ' ';
return h + '
';
}
function renderListBlock(lines) {
const root = { children: [] };
const stack = [{ indent: -1, node: root }];
for (const raw of lines) {
const m = raw.match(/^(\s*)([-*]|\d+\.)\s+(.*)$/);
if (!m) continue;
const indent = m[1].replace(/\t/g, ' ').length;
const ordered = /\d/.test(m[2]);
const item = { ordered, html: mdInline(m[3]), children: [] };
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
stack[stack.length - 1].node.children.push(item);
stack.push({ indent, node: item });
}
const emit = (node) => {
if (!node.children.length) return '';
const ordered = node.children[0].ordered;
let h = '<' + (ordered ? 'ol' : 'ul') + '>';
for (const c of node.children) h += '' + c.html + emit(c) + ' ';
return h + '' + (ordered ? 'ol' : 'ul') + '>';
};
return emit(root);
}
function renderMarkdown(md) {
md = String(md || '').replace(/\r\n/g, '\n');
const fences = [];
md = md.replace(/```[\s\S]*?```/g, (m) => { fences.push(m); return '\u0000F' + (fences.length - 1) + '\u0000'; });
const lines = md.split('\n');
const blank = s => !s.trim();
let html = '', i = 0;
while (i < lines.length) {
const line = lines[i];
if (blank(line)) { i++; continue; }
const fm = line.match(/^\u0000F(\d+)\u0000$/);
if (fm) { const code = fences[+fm[1]].replace(/^```[^\n]*\n?/, '').replace(/```\s*$/, ''); html += '' + escapeHtml(code) + ' '; i++; continue; }
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) { const l = h[1].length; html += `${mdInline(h[2])} `; i++; continue; }
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { html += ' '; i++; continue; }
if (line.includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
const header = splitRow(line); i += 2; const rows = [];
while (i < lines.length && lines[i].includes('|') && !blank(lines[i])) { rows.push(splitRow(lines[i])); i++; }
html += renderTable(header, rows); continue;
}
if (/^\s*>/.test(line)) { const buf = []; while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; } html += '' + renderMarkdown(buf.join('\n')) + ' '; continue; }
if (/^\s*([-*]|\d+\.)\s+/.test(line)) { const buf = []; while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i])) { buf.push(lines[i]); i++; } html += renderListBlock(buf); continue; }
const buf = [];
while (i < lines.length && !blank(lines[i]) && !/^(#{1,6})\s/.test(lines[i]) && !/^\s*([-*]|\d+\.)\s+/.test(lines[i]) && !/^\s*>/.test(lines[i]) && !/^\u0000F\d+\u0000$/.test(lines[i]) && !/^(-{3,}|\*{3,}|_{3,})$/.test(lines[i].trim()) && !(lines[i].includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]))) { buf.push(lines[i]); i++; }
if (buf.length) html += '' + mdInline(buf.join(' ')) + '
';
}
return html;
}
// 把容器內所有 [[wikilink]] 綁定成站內跳轉;無法解析的標成 dead
function bindWlinks(container) {
$$('.wlink[data-link]', container).forEach(elx => {
const t = elx.dataset.link;
const hit = (KB.linkMap && (KB.linkMap[t] || KB.linkMap[t.split('#').pop()] || KB.linkMap[t.split('/').pop()])) || null;
if (!hit) { elx.classList.add('dead'); return; }
elx.addEventListener('click', () => openNote(hit.kind, hit.id));
});
}
// ═══════════════════════════════════════════════════════════
// 主視圖路由
// ═══════════════════════════════════════════════════════════
const VIEW_IDS = ['macro', 'learn', 'stock', 'journal'];
const inited = {};
function parseHash() { const m = location.hash.match(/^#\/(\w+)/); const v = m ? m[1] : 'macro'; return VIEW_IDS.includes(v) ? v : 'macro'; }
function setView(view) {
document.body.dataset.view = view;
VIEW_IDS.forEach(v => { const e = $('#view-' + v); if (e) e.hidden = v !== view; });
$$('#viewTabs a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
if (view === 'learn' && !inited.learn) { inited.learn = true; initLearn(); }
if (view === 'stock' && !inited.stock) { inited.stock = true; initStock(); }
if (view === 'journal' && !inited.journal) { inited.journal = true; initJournal(); }
if (view !== 'macro') window.scrollTo({ top: 0 });
}
$$('#viewTabs a').forEach(a => a.addEventListener('click', () => {
location.hash = a.dataset.view === 'macro' ? '#/' : '#/' + a.dataset.view;
}));
window.addEventListener('hashchange', () => setView(parseHash()));
// ═══════════════════════════════════════════════════════════
// 知識庫資料
// ═══════════════════════════════════════════════════════════
let KB = { loaded: false, linkMap: {} };
async function ensureKnowledge() {
if (KB.loaded) return KB;
KB = await api('/api/knowledge');
KB.loaded = true;
KB.linkMap = KB.linkMap || {};
return KB;
}
// 從任何視圖點連結要看的、但 initLearn 尚未建好 DOM 時,先暫存於此,由 initLearn 收尾渲染
let pendingNote = null;
// 把一篇筆記打開在「學習教材」視圖;macro/個股→切到 learn
async function openNote(kind, id) {
await ensureKnowledge();
let note = findLocalNote(kind, id);
if (!note) { try { note = await api(`/api/note/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`); } catch (e) { note = null; } }
const finalNote = note || { body: `# 找不到這篇筆記\n(${kind} / ${id})` };
if (!inited.learn) {
// 學習教材還沒初始化:暫存,切到 learn 後由 initLearn 渲染(避免被課綱總覽蓋掉)
pendingNote = finalNote;
location.hash = '#/learn';
return;
}
if (document.body.dataset.view !== 'learn') location.hash = '#/learn';
renderNote(finalNote);
}
function findLocalNote(kind, id) {
if (kind === 'overview') return KB.overview;
if (kind === 'principleMap') return KB.principleMap;
if (kind === 'quiz') return KB.quiz;
if (kind === 'category') return (KB.categories || []).find(c => c.id === id);
if (kind === 'case') return (KB.cases || []).find(c => c.id === id);
if (kind === 'principle') return (KB.principles || []).find(p => p.id === id);
return null;
}
function renderNote(note) {
const content = $('#learnContent');
const fm = note.frontmatter || {};
let tags = '';
if (fm.ticker) tags += `代號 ${escapeHtml([].concat(fm.ticker).join(' / '))} `;
if (fm.sector) tags += `${escapeHtml(fm.sector)} `;
if (fm.category) tags += `${escapeHtml(fm.category)} `;
if (fm.date) tags += `${escapeHtml(fm.date)} `;
if (Array.isArray(fm.aliases) && fm.aliases.length) tags += `別名 ${escapeHtml(fm.aliases.join(' · '))} `;
content.innerHTML =
`← 返回 ` +
(tags ? `${tags}
` : '') +
`${renderMarkdown(note.body || '')}
`;
bindWlinks(content);
$('#noteBack').addEventListener('click', () => showSection(LEARN.lastSection || 'overview'));
window.scrollTo({ top: 0 });
}
// ═══════════════════════════════════════════════════════════
// 學習教材視圖
// ═══════════════════════════════════════════════════════════
const LEARN = { lastSection: 'overview' };
function setLearnActive(section) {
$$('#learnSide a').forEach(a => a.classList.toggle('active', a.dataset.section === section));
}
async function initLearn() {
const view = $('#view-learn');
view.innerHTML = ``;
try { await ensureKnowledge(); } catch (e) {
view.innerHTML = `知識庫尚未建立。請先在 web/ 目錄執行 npm run build:knowledge 產生 data/knowledge.json,再重新整理。
`;
return;
}
const c = KB.counts || {};
view.innerHTML = `
📚 學習教材
把 Emmy 的知識整理成從零到能跟著判斷的學習路徑:三階段課綱、心法、案例、名詞與公司速查、練習題庫。點任何 紫色連結 都能跳到對應筆記。
`;
$$('#learnSide a').forEach(a => a.addEventListener('click', () => showSection(a.dataset.section)));
if (pendingNote) { const n = pendingNote; pendingNote = null; renderNote(n); }
else showSection('overview');
}
function showSection(section) {
LEARN.lastSection = section;
setLearnActive(section);
const content = $('#learnContent');
if (!content) return;
if (section === 'overview') return renderNote(KB.overview || { body: '# 課綱總覽\n(尚無內容)' });
if (section === 'principleMap') return renderNote(KB.principleMap || { body: '# 心法地圖\n(尚無內容)' });
if (section === 'quiz') return renderQuiz();
if (section === 'categories') return renderCardList('學習分類', KB.categories, 'category');
if (section === 'cases') return renderCardList('案例講解', KB.cases, 'case');
if (section === 'principles') return renderPrincipleList();
if (['terms', 'companies', 'episodes'].includes(section)) return renderGlossary(section);
}
function renderCardList(title, items, kind) {
const content = $('#learnContent');
const cards = (items || []).map(it => `
${escapeHtml(it.title)}
${it.summary ? `
${escapeHtml(it.summary)}
` : ''}
`).join('');
content.innerHTML = `${escapeHtml(title)}
`;
$$('.module-card', content).forEach(el => el.addEventListener('click', () => openNote(kind, el.dataset.id)));
window.scrollTo({ top: 0 });
}
function renderPrincipleList() {
const content = $('#learnContent');
const cards = (KB.principles || []).map(p => `
`).join('');
content.innerHTML = `Emmy 投資心法
共 ${(KB.principles || []).length} 條原則。完整分群與決策流程請看「心法地圖」。
${cards}
`;
$$('.module-card', content).forEach(el => el.addEventListener('click', () => openNote('principle', el.dataset.id)));
window.scrollTo({ top: 0 });
}
function renderGlossary(section) {
const content = $('#learnContent');
const kind = { terms: 'term', companies: 'company', episodes: 'episode' }[section];
const all = (KB.index || []).filter(x => x.kind === kind);
const title = { terms: '名詞速查', companies: '公司速查', episodes: '單集速查' }[section];
content.innerHTML = `
${title}
`;
const grid = $('#glossGrid'), countEl = $('#glossCount');
const draw = (q) => {
q = (q || '').trim().toLowerCase();
const list = !q ? all : all.filter(x =>
x.title.toLowerCase().includes(q) ||
(x.aliases || []).some(a => a.toLowerCase().includes(q)) ||
(x.sub || '').toLowerCase().includes(q));
countEl.textContent = `${list.length} 筆${q ? `(搜尋「${q}」)` : ''}`;
grid.innerHTML = list.slice(0, 400).map(x => `
${escapeHtml(x.title)}
${x.sub ? `
${escapeHtml(x.sub)}
` : ''}
`).join('') || '找不到符合的項目。
';
$$('.gloss-item', grid).forEach(el => el.addEventListener('click', () => openNote(kind, el.dataset.id)));
if (list.length > 400) countEl.textContent += ',只顯示前 400 筆,請用搜尋縮小範圍。';
};
$('#glossSearch').addEventListener('input', e => draw(e.target.value));
draw('');
window.scrollTo({ top: 0 });
}
function renderQuiz() {
renderNote(KB.quiz || { body: '# 練習題庫\n(尚無內容)' });
}
// ═══════════════════════════════════════════════════════════
// 共用 SVG 折線圖(價格走勢 / 回測權益曲線共用,支援多條線 + hover)
// ═══════════════════════════════════════════════════════════
let _chartSeq = 0;
function drawLineChart(el, series, opts = {}) {
series = (series || []).filter(s => s.points && s.points.length >= 2);
if (!series.length) { el.innerHTML = '資料不足,無法繪圖。
'; return; }
const uid = 'c' + (++_chartSeq);
const w = 760, h = opts.height || 300, padL = 60, padR = 14, padT = 16, padB = 28;
const plotW = w - padL - padR, plotH = h - padT - padB;
const n = Math.min(...series.map(s => s.points.length));
const dates = series[0].points.map(p => p.date);
const allVals = []; series.forEach(s => s.points.forEach(p => allVals.push(p.val)));
let yMin = opts.yMin != null ? opts.yMin : Math.min(...allVals);
let yMax = opts.yMax != null ? opts.yMax : Math.max(...allVals);
if (yMin === yMax) { yMin -= 1; yMax += 1; }
if (opts.yMin == null) { const p = (yMax - yMin) * 0.08; yMin -= p; yMax += p; }
const yRange = yMax - yMin || 1;
const fmt = opts.fmt || (v => fmtNum(v, opts.decimals != null ? opts.decimals : 0));
const toX = i => padL + (i / (n - 1)) * plotW;
const toY = v => padT + (1 - (v - yMin) / yRange) * plotH;
let grid = '';
for (let k = 0; k <= 5; k++) { const v = yMin + yRange * k / 5; const y = toY(v); grid += `${fmt(v)} `; }
let xlab = ''; const xt = Math.min(5, n);
for (let k = 0; k < xt; k++) { const idx = Math.round(k * (n - 1) / (xt - 1)); xlab += `${(dates[idx] || '').slice(2, 7).replace('-', '/')} `; }
let paths = '', dots = '';
series.forEach(s => {
const d = s.points.slice(0, n).map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(i).toFixed(1)},${toY(p.val).toFixed(1)}`).join(' ');
paths += ` `;
dots += ` `;
});
const legend = series.length > 1 ? `${series.map(s => ` ${escapeHtml(s.name)} `).join('')}
` : '';
el.innerHTML = `${legend}
${grid}${xlab}${paths}
${dots}
`;
const svg = el.querySelector('#' + uid);
const hg = svg.querySelector('.hg'), hl = svg.querySelector('.hl'), area = svg.querySelector('.ha');
const hds = $$('.hd', svg), info = el.querySelector('#' + uid + 'h');
area.addEventListener('mousemove', evt => {
const r = svg.getBoundingClientRect();
const sx = (evt.clientX - r.left) * (w / r.width);
let i = Math.round(((sx - padL) / plotW) * (n - 1));
i = Math.max(0, Math.min(n - 1, i));
const x = toX(i);
hg.style.display = ''; hl.setAttribute('x1', x); hl.setAttribute('x2', x);
hds.forEach((dot, k) => { const p = series[k].points[i]; if (!p) return; dot.style.display = ''; dot.setAttribute('cx', x); dot.setAttribute('cy', toY(p.val)); });
info.style.display = 'block';
info.innerHTML = `${dates[i]} ` + series.map(s => `${series.length > 1 ? escapeHtml(s.name) + ' ' : ''}${fmt(s.points[i].val)} `).join(' ');
});
area.addEventListener('mouseleave', () => { hg.style.display = 'none'; hds.forEach(d => d.style.display = 'none'); info.style.display = 'none'; });
}
// ═══════════════════════════════════════════════════════════
// 個股工具視圖(共用代號:價格走勢 / 財報健檢 / 投資地圖 / 回測)
// ═══════════════════════════════════════════════════════════
const STOCK = { symbol: '', sub: 'price', priceRange: '1y', rendered: {}, mapAnswers: {}, mapCfg: null };
const SUBS = ['price', 'finbox', 'map', 'backtest'];
function initStock() {
const view = $('#view-stock');
view.innerHTML = `
📈 個股工具
輸入一檔股票代號,所有工具一次到位:價格走勢、財報 健檢、用 Emmy 六層漏斗的投資地圖 判斷該不該進場、以及策略回測 。資料皆會存資料庫快取以節省 API。
查詢
範例:NVDA AMD MSFT AVGO AAPL
`;
ensureKnowledge().then(() => bindWlinks(view)).catch(() => {});
const go = () => setStockSymbol($('#stkSym').value);
$('#stkGo').addEventListener('click', go);
$('#stkSym').addEventListener('keydown', e => { if (e.key === 'Enter') go(); });
$$('.finbox-examples b', view).forEach(b => b.addEventListener('click', () => { $('#stkSym').value = b.dataset.sym; go(); }));
$$('#stkSub a').forEach(a => a.addEventListener('click', () => setSub(a.dataset.sub)));
setSub('map'); // 投資地圖不需代號也能先看判斷流程
}
function setStockSymbol(sym) {
sym = (sym || '').trim().toUpperCase();
if (!sym) return;
STOCK.symbol = sym;
STOCK.rendered = {}; // 換股票 → 各分頁重抓
$('#stkSym').value = sym;
if (STOCK.sub === 'map') setSub('price'); // 輸入代號後預設先看價格
else renderSub(STOCK.sub);
}
function setSub(sub) {
if (!SUBS.includes(sub)) sub = 'price';
STOCK.sub = sub;
$$('#stkSub a').forEach(a => a.classList.toggle('active', a.dataset.sub === sub));
SUBS.forEach(s => { const p = $('#pane-' + s); if (p) p.hidden = s !== sub; });
renderSub(sub);
}
function needSymbol(pane) {
if (STOCK.symbol) return false;
pane.innerHTML = '請先在上方輸入股票代號。
';
return true;
}
function renderSub(sub) {
if (sub === 'price') return renderPrice();
if (sub === 'finbox') return renderFinboxPane();
if (sub === 'map') return renderMap();
if (sub === 'backtest') return renderBacktestPane();
}
// ── 價格走勢 ──
const PRICE_RANGES = [['3mo', '3月'], ['6mo', '6月'], ['1y', '1年'], ['2y', '2年'], ['5y', '5年'], ['max', '全部']];
async function renderPrice(force) {
const pane = $('#pane-price');
if (needSymbol(pane)) return;
if (!force && STOCK.rendered.price === STOCK.symbol + ':' + STOCK.priceRange) return;
pane.innerHTML = `
${PRICE_RANGES.map(r => `${r[1]} `).join('')}
`;
$$('#priceRange button', pane).forEach(b => b.addEventListener('click', () => { STOCK.priceRange = b.dataset.r; renderPrice(true); }));
try {
const d = await api(`/api/price/${encodeURIComponent(STOCK.symbol)}?range=${STOCK.priceRange}&interval=1d`);
STOCK.rendered.price = STOCK.symbol + ':' + STOCK.priceRange;
const pts = d.points.map(p => ({ date: p.date, val: p.close }));
const first = pts[0].val, last = pts[pts.length - 1].val;
const chg = (last / first - 1) * 100;
const chgCls = chg >= 0 ? 'pnl-pos' : 'pnl-neg';
$('#priceHead').innerHTML = `${escapeHtml(d.name || d.symbol)} ${escapeHtml(d.symbol)} · 收盤 ${escapeHtml(d.currency || '')} ${fmtNum(last, 2)} · 此區間 ${chg >= 0 ? '+' : ''}${chg.toFixed(1)}% ${d.cached ? ' · 快取 ' : ''}`;
drawLineChart($('#priceChart'), [{ name: d.symbol, color: HEX.blue, points: pts }], { fmt: v => fmtNum(v, 2) });
} catch (e) {
pane.querySelector('#priceChart').innerHTML = `無法取得 ${escapeHtml(STOCK.symbol)} 的價格:${escapeHtml((e.data && e.data.message) || e.message || '')}
`;
}
}
// ── 財報健檢 ──
function renderFinboxPane() {
const pane = $('#pane-finbox');
if (needSymbol(pane)) return;
if (STOCK.rendered.finbox === STOCK.symbol) return;
pane.innerHTML = '
';
runFincheck(STOCK.symbol);
}
async function runFincheck(sym, fresh) {
sym = (sym || STOCK.symbol || '').trim().toUpperCase();
const out = $('#finResult');
if (!out) return;
if (!sym) { out.innerHTML = '請先輸入股票代號。
'; return; }
out.innerHTML = `
正在${fresh ? '重新抓取' : '查詢'} ${escapeHtml(sym)} 的財報並健檢…
`;
try {
const d = await api('/api/fundamentals/' + encodeURIComponent(sym) + (fresh ? '?fresh=1' : ''));
STOCK.rendered.finbox = sym;
renderFincheck(d);
} catch (e) {
out.innerHTML = `無法取得 ${escapeHtml(sym)} 的財報:${escapeHtml((e.data && e.data.message) || e.message || '未知錯誤')}可試試美股代號(如 NVDA、AMD、MSFT)。
`;
}
}
function renderFincheck(d) {
const out = $('#finResult');
if (!out) return;
const r = d.report || {};
const sum = r.summary || {};
const vColor = { good: 'var(--green)', warn: 'var(--yellow)', bad: 'var(--red)' }[sum.verdictColor] || 'var(--text)';
const steps = (r.steps || []).map(st => `
${st.num}
${escapeHtml(st.title)}
${(st.checks || []).map(ck => checkRowHTML(ck)).join('')}
`).join('');
const caveats = (r.caveats || []).map(c => `${mdLinks(c.text, c.links)}
`).join('');
const fetched = d._fetchedAt ? new Date(d._fetchedAt).toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : null;
const freshNote = d.cached
? `已存資料庫的快取${fetched ? `,抓取於 ${fetched}` : ''}${d._latestForm ? `(依最新申報 ${escapeHtml(d._latestForm)})` : ''}`
: `剛從來源抓取${fetched ? `(${fetched})` : ''}`;
const staleNote = d.stale ? ' · 即時更新失敗,先顯示先前存的資料 ' : '';
out.innerHTML = `
${escapeHtml(d.name || d.symbol)} ${escapeHtml(d.symbol)}${d.price != null ? ` · 股價 $${fmtNum(d.price, 2)}` : ''} · 資料來源 ${escapeHtml(d.source || '—')}${d.asOf ? ` · 最新季別 ${escapeHtml(d.asOf)}` : ''}
${freshNote}${staleNote} ↻ 重新抓取
${escapeHtml(sum.verdict || '—')}
${(sum.good || 0) + (sum.warn || 0) + (sum.bad || 0)} 項檢查
${steps}
${caveats}`;
bindWlinks(out);
const rb = $('#finRefresh');
if (rb) rb.addEventListener('click', () => runFincheck(d.symbol, true));
}
function checkRowHTML(ck) {
const links = (ck.links || []).map(l => `${escapeHtml(l.label)} `).join('');
return `
${escapeHtml(ck.label)}
${ck.note ? `
${escapeHtml(ck.note)}
` : ''}
${links ? `
${links}
` : ''}
${escapeHtml(ck.value != null ? ck.value : '—')}
`;
}
// 把 {text, links:[{target,label}]} 的 [label] 佔位轉成 wlink(links 依序替換)
function mdLinks(text, links) {
let i = 0;
return escapeHtml(text).replace(/\{link\}/g, () => {
const l = (links || [])[i++]; if (!l) return '';
return `${escapeHtml(l.label)} `;
});
}
// ── 投資地圖(互動六層漏斗)──
const ANS = [['yes', '是'], ['unsure', '不確定'], ['no', '否']];
function layerStatus(L) {
const ans = L.questions.map((_, qi) => STOCK.mapAnswers[L.key + ':' + qi]);
const answered = ans.filter(Boolean);
const gateNo = L.questions.some((q, qi) => q.gate && ans[qi] === 'no');
if (gateNo) return 'out';
if (!answered.length) return 'pending';
if (ans.every(a => a === 'yes')) return 'pass';
return 'watch';
}
const ST_META = {
pass: { lab: '通過', cls: 'good' }, watch: { lab: '待確認', cls: 'warn' },
out: { lab: '出局', cls: 'bad' }, pending: { lab: '未評估', cls: 'na' },
};
async function renderMap() {
const pane = $('#pane-map');
pane.innerHTML = '';
if (!STOCK.mapCfg) {
try { STOCK.mapCfg = await api('/api/investmap'); await ensureKnowledge(); }
catch (e) { pane.innerHTML = `載入投資地圖失敗:${escapeHtml(e.message || '')}
`; return; }
}
drawMap();
}
function drawMap() {
const pane = $('#pane-map');
const cfg = STOCK.mapCfg;
const target = STOCK.symbol ? `${escapeHtml(STOCK.symbol)} ` : '這檔標的';
let firstOut = -1;
const layersHTML = cfg.layers.map((L, idx) => {
const st = layerStatus(L);
if (st === 'out' && firstOut < 0) firstOut = idx;
const meta = ST_META[st];
const qs = L.questions.map((q, qi) => {
const cur = STOCK.mapAnswers[L.key + ':' + qi];
const radios = ANS.map(([v, lab]) => ` ${lab} `).join('');
const links = (q.principles || []).map(p => `${escapeHtml(p.title)} `).join('');
return `
${q.gate ? '閘門 ' : ''}${escapeHtml(q.q)}
${radios}
${links ? `
${links}
` : ''}
`;
}).join('');
return `
${idx + 1}
${escapeHtml(L.title)}
${meta.lab}
${escapeHtml(L.ask)}
${escapeHtml(L.pillar)}
${qs}
出局條件:${escapeHtml(L.out)}
`;
}).join('');
// 彙整結論
const statuses = cfg.layers.map(layerStatus);
const anyAnswered = Object.keys(STOCK.mapAnswers).length > 0;
let verdict, vcls;
if (firstOut >= 0) { verdict = `不建議進場:第 ${firstOut + 1} 層「${cfg.layers[firstOut].title}」出局,依漏斗原則應停手。`; vcls = 'bad'; }
else if (statuses.every(s => s === 'pass')) { verdict = '六層皆通過,可進入交易計畫(記得設好減倉/停損規則與底倉)。'; vcls = 'good'; }
else if (anyAnswered) { verdict = '初步可行,但仍有「待確認」項目——把不確定的補齊再決定。'; vcls = 'warn'; }
else { verdict = '逐層回答下面的提問,系統會即時告訴你哪一層卡關。'; vcls = 'na'; }
pane.innerHTML = `
🧭 下單前的核心提問${escapeHtml(cfg.coreQuestion)}
${target} 的判斷
${verdict}
重設 ${STOCK.symbol ? '存成交易紀錄 ' : ''}
${layersHTML}
這是把 Emmy「投資底層邏輯 」六層漏斗變成的自我檢查工具,幫你結構化判斷,不構成投資建議 。任何一層出局就停手,是漏斗的精神。
`;
// 綁定:作答(即時重繪)、原則連結、按鈕
$$('.mq-ans', pane).forEach(box => {
$$('input[type=radio]', box).forEach(r => r.addEventListener('change', () => {
STOCK.mapAnswers[box.dataset.layer + ':' + box.dataset.qi] = r.value;
drawMap();
}));
});
$$('.wlink[data-pid]', pane).forEach(el => el.addEventListener('click', () => openNote('principle', el.dataset.pid)));
bindWlinks(pane);
const rs = $('#mapReset'); if (rs) rs.addEventListener('click', () => { STOCK.mapAnswers = {}; drawMap(); });
const sv = $('#mapSave'); if (sv) sv.addEventListener('click', saveMapToJournal);
}
function saveMapToJournal() {
const cfg = STOCK.mapCfg;
const statuses = cfg.layers.map((L, i) => ({ i, key: L.key, title: L.title, st: layerStatus(L) }));
const firstOut = statuses.find(s => s.st === 'out');
const verdict = firstOut ? `投資地圖:第${firstOut.i + 1}層「${firstOut.title}」出局`
: (statuses.every(s => s.st === 'pass') ? '投資地圖:六層皆通過' : '投資地圖:初步可行、仍有待確認');
const noteLines = statuses.map(s => `${s.i + 1}.${s.title}:${ST_META[s.st].lab}`).join('|');
// 找原則五十四(三面向判斷交易)當預設依據
let principle = '';
for (const L of cfg.layers) for (const q of L.questions) for (const p of (q.principles || [])) if (p.num === 54 && p.id) principle = 'Emmy 投資心法#' + p.id;
openTradeForm({ symbol: STOCK.symbol, entry_reason: verdict, note: '六層漏斗評估:' + noteLines, principle });
}
// ── 回測 ──
const BT_STRATS = {
buyhold: { label: '買進持有(基準)', params: [] },
dca: { label: '定期定額(每月)', params: [{ key: 'monthly', label: '每月投入', def: 1000 }] },
sma: { label: '均線趨勢(短>長在場)', params: [{ key: 'short', label: '短均(日)', def: 50 }, { key: 'long', label: '長均(日)', def: 200 }] },
dip: { label: '逢大跌進場(回落%後買進)', params: [{ key: 'drop', label: '距高點回落%', def: 15 }] },
};
const BT_RANGES = [['1y', '1年'], ['2y', '2年'], ['5y', '5年'], ['10y', '10年'], ['max', '全部']];
function renderBacktestPane() {
const pane = $('#pane-backtest');
if (needSymbol(pane)) return;
if (STOCK.rendered.backtest === STOCK.symbol) return;
STOCK.rendered.backtest = STOCK.symbol;
if (!STOCK.bt) STOCK.bt = { strategy: 'sma', range: '5y', params: {} };
pane.innerHTML = `
策略 ${Object.entries(BT_STRATS).map(([k, v]) => `${v.label} `).join('')}
期間 ${BT_RANGES.map(r => `${r[1]} `).join('')}
跑回測
選好策略與期間,按「跑回測」。以還原股價、初始資金 $10,000 模擬。
`;
const drawParams = () => {
const s = BT_STRATS[$('#btStrat').value];
$('#btParams').innerHTML = s.params.map(p => `${escapeHtml(p.label)}
`).join('');
};
$('#btStrat').addEventListener('change', drawParams);
drawParams();
$('#btRun').addEventListener('click', runBacktestUI);
}
async function runBacktestUI() {
STOCK.bt.strategy = $('#btStrat').value;
STOCK.bt.range = $('#btRange').value;
const params = {}; $$('#btParams input').forEach(i => params[i.dataset.pk] = i.value); STOCK.bt.params = params;
const out = $('#btResult');
out.innerHTML = ``;
const qs = new URLSearchParams({ strategy: STOCK.bt.strategy, range: STOCK.bt.range, ...params });
try {
const d = await api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?${qs}`);
renderBacktest(d);
} catch (e) {
out.innerHTML = `回測失敗:${escapeHtml((e.data && e.data.message) || e.message || '')}
`;
}
}
function renderBacktest(d) {
const out = $('#btResult');
const money = v => '$' + fmtNum(v, 0);
const series = [{ name: d.strategyLabel, color: HEX.blue, points: d.equity }];
if (d.benchmark) series.push({ name: '買進持有', color: HEX.text2, points: d.benchmark });
const statCard = (title, s, color) => s ? `
${escapeHtml(title)}
期末價值 ${money(s.finalValue)}
總報酬 ${s.totalReturn >= 0 ? '+' : ''}${s.totalReturn.toFixed(1)}%
年化(CAGR) ${s.cagr >= 0 ? '+' : ''}${s.cagr.toFixed(1)}%
最大回撤 -${s.maxDrawdown.toFixed(1)}%
在場比例 ${s.exposure.toFixed(0)}%
${s.winRate != null ? '勝率' : '進場次數'} ${s.winRate != null ? s.winRate.toFixed(0) + '%(' + s.trades + '次)' : s.trades + ' 次'}
` : '';
out.innerHTML = `
${escapeHtml(d.name || d.symbol)} ${escapeHtml(d.symbol)} · ${escapeHtml(d.strategyLabel)} · ${escapeHtml(d.from)} ~ ${escapeHtml(d.to)}${d.cached ? ' · 快取 ' : ''}
${statCard(d.strategyLabel, d.stats, HEX.blue)}${statCard('買進持有', d.benchStats, HEX.text2)}
${d.note ? `${escapeHtml(d.note)}
` : ''}
回測以歷史還原股價模擬、未計交易成本與稅,且過去績效不代表未來 。這是用來理解策略行為(如趨勢進出 vs 一直持有)的學習工具,不構成投資建議。對照 長期趨勢跌就買 、觸發式減倉 。
`;
drawLineChart($('#btChart'), series, { fmt: money });
bindWlinks(out);
}
// ═══════════════════════════════════════════════════════════
// 交易復盤視圖
// ═══════════════════════════════════════════════════════════
const JOURNAL = { tab: 'all', trades: [], stats: null };
function initJournal() {
const view = $('#view-journal');
view.innerHTML = `
📓 交易復盤
記錄每一筆進出與理由,自動算盈虧、勝率與賺賠比。重點不是「賺或賠」,而是當初的判斷依據是否成立 ——標記犯錯與依據的心法,定期回頭復盤。對應 交易與資金管理 。
`;
ensureKnowledge().then(() => bindWlinks(view)).catch(() => {});
$$('#journalSeg a').forEach(a => a.addEventListener('click', () => { JOURNAL.tab = a.dataset.tab; $$('#journalSeg a').forEach(x => x.classList.toggle('active', x === a)); renderJournalBody(); }));
$('#addTradeBtn').addEventListener('click', () => openTradeForm());
loadTrades();
}
async function loadTrades() {
try {
const [t, s] = await Promise.all([api('/api/trades'), api('/api/trades/stats')]);
JOURNAL.trades = t.trades || [];
JOURNAL.stats = s;
renderJournalStats();
renderJournalBody();
} catch (e) {
const b = $('#journalBody'); if (b) b.innerHTML = `載入交易紀錄失敗:${escapeHtml(e.message || '')}
`;
}
}
function renderJournalStats() {
const el = $('#journalStats'); if (!el) return;
const s = JOURNAL.stats || {};
const pnlCls = (s.totalPnl || 0) >= 0 ? 'pnl-pos' : 'pnl-neg';
el.innerHTML = `
已實現損益
${s.totalPnl != null ? fmtMoney(s.totalPnl) : '—'}
${s.closed || 0} 筆已平倉 · ${s.open || 0} 筆持倉
勝率
${s.winRate != null ? s.winRate.toFixed(0) + '%' : '—'}
${s.wins || 0} 勝 / ${s.losses || 0} 敗
賺賠比 (Payoff)
${s.payoff != null ? s.payoff.toFixed(2) : '—'}
平均賺 ${s.avgWin != null ? fmtMoney(s.avgWin) : '—'} / 賠 ${s.avgLoss != null ? fmtMoney(Math.abs(s.avgLoss)) : '—'}
`;
}
function renderJournalBody() {
const body = $('#journalBody');
if (!body) return;
if (JOURNAL.tab === 'review') return renderReview();
let list = JOURNAL.trades.slice();
if (JOURNAL.tab === 'open') list = list.filter(t => !t.closed);
if (JOURNAL.tab === 'closed') list = list.filter(t => t.closed);
if (!list.length) { body.innerHTML = `${JOURNAL.trades.length ? '此分類沒有交易。' : '還沒有任何交易紀錄。點右上角「+ 新增交易」開始記錄你的第一筆。'}
`; return; }
const rows = list.map(t => {
const dirPill = `${t.direction === 'short' ? '做空' : '做多'} `;
const kindPill = t.kind ? `${escapeHtml(t.kind)} ` : '';
const statusPill = t.closed ? '' : '持倉 ';
const mistakePill = t.mistake ? '犯錯 ' : '';
const pnl = t.closed ? `${fmtMoney(t.pnl)}${t.pnl_pct >= 0 ? '+' : ''}${t.pnl_pct != null ? t.pnl_pct.toFixed(1) : '—'}% ` : '— ';
return `
${escapeHtml(t.symbol)}${t.name ? `${escapeHtml(t.name)} ` : ''}
${dirPill} ${kindPill} ${statusPill} ${mistakePill}
${escapeHtml(t.entry_date || '—')}$${fmtNum(t.entry_price, 2)} × ${fmtNum(t.shares, 0)}
${t.closed ? escapeHtml(t.exit_date || '—') + `$${fmtNum(t.exit_price, 2)} ` : '— '}
${pnl}
${escapeHtml(t.entry_reason || '')}${t.principle ? `依據:${escapeHtml(t.principle.split('#').pop())} ` : ''}
編輯 刪
`;
}).join('');
body.innerHTML = `
標的 類型 進場 出場 已實現損益 理由 / 依據
${rows}
`;
bindWlinks(body);
$$('[data-edit]', body).forEach(b => b.addEventListener('click', () => openTradeForm(JOURNAL.trades.find(t => t.id == b.dataset.edit))));
$$('[data-del]', body).forEach(b => b.addEventListener('click', () => deleteTrade(b.dataset.del)));
}
function renderReview() {
const s = JOURNAL.stats || {};
const groupHTML = (title, rows, note) => {
if (!rows || !rows.length) return '';
return `${escapeHtml(title)}${note ? ` ${escapeHtml(note)} ` : ''}
筆數 勝率 損益
${rows.map(r => `
${escapeHtml(r.key)} ${r.count} ${r.winRate != null ? r.winRate.toFixed(0) + '%' : '—'} ${fmtMoney(r.pnl)}
`).join('')}
`;
};
const body = $('#journalBody');
if (!body) return;
if (!s.closed) { body.innerHTML = '還沒有已平倉的交易可供復盤。先記錄並平倉幾筆交易,這裡就會出現分析。
'; return; }
body.innerHTML = `
${groupHTML('依「交易 vs 投資」', s.byKind)}
${groupHTML('依「是否犯錯」', s.byMistake, '結果論陷阱:賺錢不代表判斷對,賠錢不代表判斷錯')}
${groupHTML('依「依據的心法」', s.byPrinciple)}
復盤重點:找出「賠錢但判斷正確(可接受)」與「賺錢但其實犯錯(運氣)」的交易。對照 結果論陷阱 、賣弱留強 、觸發式減倉 。
`;
bindWlinks(body);
}
async function deleteTrade(id) {
if (!confirm('確定刪除這筆交易紀錄?')) return;
try { await api('/api/trades/' + id, { method: 'DELETE' }); await loadTrades(); }
catch (e) { alert('刪除失敗:' + e.message); }
}
// ── 交易表單 Modal ──
function ensureTradeModal() {
if ($('#tradeModal')) return;
const div = document.createElement('div');
div.id = 'tradeModal';
div.className = 'view'; // reuse nothing; styled inline below
div.style.cssText = 'position:fixed;inset:0;z-index:600;background:rgba(4,8,14,.72);backdrop-filter:blur(3px);display:none;align-items:center;justify-content:center;padding:20px';
div.innerHTML = ``;
document.body.appendChild(div);
$('#tradeFormClose').addEventListener('click', closeTradeForm);
$('#tradeFormCancel').addEventListener('click', closeTradeForm);
div.addEventListener('click', e => { if (e.target === div) closeTradeForm(); });
$('#tradeForm [name=mistake]').addEventListener('change', e => { $('#mistakeNoteWrap').style.display = e.target.checked ? '' : 'none'; });
$('#tradeForm').addEventListener('submit', submitTradeForm);
}
function principleOptions(selected) {
const ps = (KB.principles || []);
return '(不指定) ' + ps.map(p =>
`${escapeHtml(p.title)} `).join('');
}
async function openTradeForm(trade) {
ensureTradeModal();
await ensureKnowledge();
const f = $('#tradeForm');
f.reset();
const isEdit = !!(trade && trade.id);
$('#tradeFormTitle').textContent = isEdit ? '編輯交易' : '新增交易';
f.dataset.id = isEdit ? trade.id : '';
f.principle.innerHTML = principleOptions(trade ? trade.principle : '');
f.mistake_note.innerHTML = principleOptions(trade ? trade.mistake_note : '');
if (trade) {
['symbol', 'name', 'direction', 'kind', 'entry_date', 'entry_price', 'shares', 'entry_reason', 'exit_date', 'exit_price', 'exit_reason', 'note'].forEach(k => { if (f[k] != null && trade[k] != null) f[k].value = trade[k]; });
f.mistake.checked = !!trade.mistake;
f.principle.value = trade.principle || '';
f.mistake_note.value = trade.mistake_note || '';
$('#mistakeNoteWrap').style.display = trade.mistake ? '' : 'none';
}
$('#tradeModal').style.display = 'flex';
}
function closeTradeForm() { const m = $('#tradeModal'); if (m) m.style.display = 'none'; }
async function submitTradeForm(e) {
e.preventDefault();
const f = e.target;
const body = {
symbol: f.symbol.value.trim().toUpperCase(),
name: f.name.value.trim(),
direction: f.direction.value,
kind: f.kind.value,
entry_date: f.entry_date.value,
entry_price: parseFloat(f.entry_price.value),
shares: parseFloat(f.shares.value),
entry_reason: f.entry_reason.value.trim(),
principle: f.principle.value,
exit_date: f.exit_date.value || null,
exit_price: f.exit_price.value ? parseFloat(f.exit_price.value) : null,
exit_reason: f.exit_reason.value.trim(),
note: f.note.value.trim(),
mistake: f.mistake.checked ? 1 : 0,
mistake_note: f.mistake.checked ? f.mistake_note.value : '',
};
const id = f.dataset.id;
try {
await api('/api/trades' + (id ? '/' + id : ''), { method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
closeTradeForm();
await loadTrades();
} catch (err) { alert('儲存失敗:' + (err.message || '')); }
}
// 啟動:依目前 hash 顯示視圖(macro 由 index.html 內聯負責載入)
setView(parseHash());