finance-dashboard/app.js

1082 lines
67 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

// ═══════════════════════════════════════════════════════════
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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);
}
// ═══════════════════════════════════════════════════════════
// UI 元件:色塊分段(取代傳統下拉)
// ═══════════════════════════════════════════════════════════
function mountChips(container, items, value, onChange, opts = {}) {
const cls = opts.sm ? 'chip sm' : 'chip';
container.innerHTML = items.map(it => {
const tint = it.tint ? ` tint-${it.tint}` : '';
const on = it.id === value ? ' on' : '';
return `<button type="button" class="${cls}${tint}${on}" data-v="${escapeHtml(it.id)}">${it.icon ? `<span>${it.icon}</span> ` : ''}${escapeHtml(it.label)}</button>`;
}).join('');
$$('button', container).forEach(btn => btn.addEventListener('click', () => {
const v = btn.dataset.v;
onChange(v);
$$('button', container).forEach(b => b.classList.toggle('on', b.dataset.v === v));
}));
}
function mountTiles(container, items, value, onChange) {
container.innerHTML = items.map(it => {
const on = it.id === value ? ' on' : '';
const tint = it.tint ? ` tint-${it.tint}` : '';
return `<div class="tile${tint}${on}" data-v="${escapeHtml(it.id)}" role="button" tabindex="0">
<div class="tile-label">${escapeHtml(it.label)}</div>${it.sub ? `<div class="tile-sub">${escapeHtml(it.sub)}</div>` : ''}</div>`;
}).join('');
$$('.tile', container).forEach(el => {
const pick = () => { onChange(el.dataset.v); $$('.tile', container).forEach(t => t.classList.toggle('on', t.dataset.v === el.dataset.v)); };
el.addEventListener('click', pick);
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
});
}
// Mermaid 初始化Apple 中性淺色主題)
function initMermaid() {
if (!window.mermaid || window._mermaidReady) return;
window._mermaidReady = true;
mermaid.initialize({
startOnLoad: false, theme: 'neutral', securityLevel: 'loose',
fontFamily: '-apple-system, BlinkMacSystemFont, "PingFang TC", sans-serif',
});
}
async function renderMermaid(container) {
initMermaid();
const els = $$('.mermaid', container);
if (!els.length || !window.mermaid) return;
try { await mermaid.run({ nodes: els, suppressErrors: true }); } catch (_) {}
}
// ═══════════════════════════════════════════════════════════
// 輕量 Markdown 渲染(支援標題/清單/表格/引用/粗體/行內碼/[[wikilink]]
// ═══════════════════════════════════════════════════════════
function mdInline(t) {
t = escapeHtml(t);
t = t.replace(/`([^`]+)`/g, (m, c) => '<code>' + c + '</code>');
t = t.replace(/\[\[([^\]]+)\]\]/g, (m, inner) => wlinkHTML(inner));
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
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 '<span class="wlink" data-link="' + escapeHtml(target) + '">' + escapeHtml(display) + '</span>';
}
function splitRow(line) {
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
}
function renderTable(header, rows) {
let h = '<table><thead><tr>' + header.map(c => '<th>' + mdInline(c) + '</th>').join('') + '</tr></thead><tbody>';
for (const r of rows) h += '<tr>' + header.map((_, j) => '<td>' + mdInline(r[j] || '') + '</td>').join('') + '</tr>';
return h + '</tbody></table>';
}
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 += '<li>' + c.html + emit(c) + '</li>';
return h + '</' + (ordered ? 'ol' : 'ul') + '>';
};
return emit(root);
}
function renderMarkdown(md) {
md = String(md || '').replace(/\r\n/g, '\n');
const fences = [];
const fenceLangs = [];
md = md.replace(/```[\s\S]*?```/g, (m) => {
const lang = (m.match(/^```(\w+)/) || [])[1] || '';
fenceLangs.push(lang.toLowerCase());
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 idx = +fm[1];
const raw = fences[idx];
const lang = fenceLangs[idx];
const code = raw.replace(/^```[^\n]*\n?/, '').replace(/```\s*$/, '');
if (lang === 'mermaid') html += `<div class="mermaid-wrap"><pre class="mermaid">${escapeHtml(code)}</pre></div>`;
else html += '<pre><code>' + escapeHtml(code) + '</code></pre>';
i++; continue;
}
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) { const l = h[1].length; html += `<h${l}>${mdInline(h[2])}</h${l}>`; i++; continue; }
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { html += '<hr>'; 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 += '<blockquote>' + renderMarkdown(buf.join('\n')) + '</blockquote>'; 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 += '<p>' + mdInline(buf.join(' ')) + '</p>';
}
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}` };
finalNote.kind = kind;
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 || {};
LEARN.currentNote = note;
let tags = '';
if (fm.ticker) tags += `<span class="fm-tag">代號 ${escapeHtml([].concat(fm.ticker).join(' / '))}</span>`;
if (fm.sector) tags += `<span class="fm-tag">${escapeHtml(fm.sector)}</span>`;
if (fm.category) tags += `<span class="fm-tag">${escapeHtml(fm.category)}</span>`;
if (fm.date) tags += `<span class="fm-tag">${escapeHtml(fm.date)}</span>`;
if (Array.isArray(fm.aliases) && fm.aliases.length) tags += `<span class="fm-tag">別名 ${escapeHtml(fm.aliases.join(' · '))}</span>`;
const kind = note.kind || LEARN.noteKind;
const center = (kind && note.id) ? `${kind}:${note.id}` : '';
content.innerHTML =
`<div class="note-toolbar">
<span class="back-link" id="noteBack">← 返回</span>
${center ? '<button class="btn ghost sm" id="noteGraphBtn">🔗 周邊圖譜</button>' : ''}
</div>` +
(tags ? `<div class="note-frontmatter">${tags}</div>` : '') +
`<div class="md">${renderMarkdown(note.body || '')}</div>`;
bindWlinks(content);
renderMermaid(content);
$('#noteBack').addEventListener('click', () => showSection(LEARN.lastSection || 'overview'));
const gb = $('#noteGraphBtn');
if (gb) gb.addEventListener('click', () => showGraph({ center, depth: 2 }));
window.scrollTo({ top: 0 });
}
// ═══════════════════════════════════════════════════════════
// 學習教材視圖
// ═══════════════════════════════════════════════════════════
const LEARN = { lastSection: 'overview', graphFilter: 'curriculum', currentNote: null, noteKind: null };
const GRAPH_KINDS = [
{ id: 'curriculum', label: '課程骨架', kinds: 'overview,principleMap,category,case,principle' },
{ id: 'terms', label: '名詞', kinds: 'term', includeIndex: '1' },
{ id: 'companies', label: '公司', kinds: 'company', includeIndex: '1' },
];
function setLearnActive(section) {
$$('#learnSide a').forEach(a => a.classList.toggle('active', a.dataset.section === section));
}
async function initLearn() {
const view = $('#view-learn');
view.innerHTML = `<div class="page"><div class="empty-state"><div class="spinner" style="width:28px;height:28px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 14px;animation:spin .8s linear infinite"></div>正在載入知識庫…</div></div>`;
try { await ensureKnowledge(); } catch (e) {
view.innerHTML = `<div class="page"><div class="empty-state">知識庫尚未建立。請先在 web/ 目錄執行 <code>npm run build:knowledge</code> 產生 data/knowledge.json再重新整理。</div></div>`;
return;
}
const c = KB.counts || {};
view.innerHTML = `
<div class="page">
<div class="page-head">
<div class="page-title">📚 學習教材</div>
<div class="page-sub">把 Emmy 的知識整理成從零到能跟著判斷的學習路徑:三階段課綱、心法、案例、名詞與公司速查、練習題庫。點任何 <span style="color:var(--purple)">紫色連結</span> 都能跳到對應筆記。</div>
</div>
<div class="learn-layout">
<div class="learn-side" id="learnSide">
<div class="side-group">課程</div>
<a data-section="overview">課綱總覽</a>
<a data-section="principleMap">心法地圖</a>
<a data-section="quiz">練習題庫</a>
<div class="side-group">內容</div>
<a data-section="categories">學習分類 <span class="count">${(KB.categories || []).length}</span></a>
<a data-section="cases">案例講解 <span class="count">${(KB.cases || []).length}</span></a>
<a data-section="principles">投資心法 <span class="count">${(KB.principles || []).length}</span></a>
<div class="side-group">視覺化</div>
<a data-section="graph">🔗 知識圖譜</a>
<div class="side-group">速查</div>
<a data-section="terms">名詞 <span class="count">${c.terms || 0}</span></a>
<a data-section="companies">公司 <span class="count">${c.companies || 0}</span></a>
<a data-section="episodes">單集 <span class="count">${c.episodes || 0}</span></a>
</div>
<div class="learn-content" id="learnContent"></div>
</div>
</div>`;
$$('#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(Object.assign({ kind: 'overview' }, KB.overview || { body: '# 課綱總覽\n尚無內容' }));
if (section === 'principleMap') return renderNote(Object.assign({ kind: 'principleMap' }, KB.principleMap || { body: '# 心法地圖\n尚無內容' }));
if (section === 'quiz') return renderQuiz();
if (section === 'graph') return showGraph();
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 => `
<div class="module-card" data-id="${escapeHtml(it.id)}">
<div class="mod-name">${escapeHtml(it.title)}</div>
${it.summary ? `<div class="mod-meta">${escapeHtml(it.summary)}</div>` : ''}
</div>`).join('');
content.innerHTML = `<div class="page-title" style="font-size:1.1rem;margin-bottom:14px">${escapeHtml(title)}</div><div class="module-grid">${cards || '<div class="empty-state">尚無內容。</div>'}</div>`;
$$('.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 => `
<div class="module-card" data-id="${escapeHtml(p.id)}">
<div class="mod-name">${escapeHtml(p.title)}</div>
</div>`).join('');
content.innerHTML = `<div class="page-title" style="font-size:1.1rem;margin-bottom:6px">Emmy 投資心法</div>
<div class="list-meta">共 ${(KB.principles || []).length} 條原則。完整分群與決策流程請看「心法地圖」。</div>
<div class="module-grid">${cards}</div>`;
$$('.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 = `
<div class="page-title" style="font-size:1.1rem;margin-bottom:10px">${title}</div>
<div class="search-box"><input type="text" id="glossSearch" placeholder="搜尋${title.replace('速查', '')}…(中英別名皆可)"></div>
<div class="list-meta" id="glossCount"></div>
<div class="glossary-grid" id="glossGrid"></div>`;
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 => `
<div class="gloss-item" data-id="${escapeHtml(x.id)}">
<div class="gi-title">${escapeHtml(x.title)}</div>
${x.sub ? `<div class="gi-sub">${escapeHtml(x.sub)}</div>` : ''}
</div>`).join('') || '<div class="empty-state">找不到符合的項目。</div>';
$$('.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(Object.assign({ kind: 'quiz' }, KB.quiz || { body: '# 練習題庫\n尚無內容' }));
}
// ── 知識圖譜vis-network──
let graphNetwork = null;
const GRAPH_LEGEND = [
['category', '分類', '#0071e3'], ['case', '案例', '#34c759'], ['principle', '心法', '#af52de'],
['term', '名詞', '#ff9500'], ['company', '公司', '#5ac8fa'], ['episode', '單集', '#8e8e93'],
];
async function showGraph(opts = {}) {
LEARN.lastSection = 'graph';
setLearnActive('graph');
const content = $('#learnContent');
const filter = opts.filter || LEARN.graphFilter || 'curriculum';
const center = opts.center || '';
const depth = opts.depth || 2;
LEARN.graphFilter = filter;
content.innerHTML = `
<div class="page-title" style="font-size:1.2rem;margin-bottom:8px">知識圖譜</div>
<div class="page-sub" style="margin-bottom:16px">節點是筆記與概念,連線來自文內 [[連結]]。點一下節點可開啟該篇;拖曳平移、雙指或滾輪縮放。</div>
<div class="graph-panel">
<div class="graph-toolbar"><div id="graphFilterChips" class="chip-row"></div></div>
<div id="graphCanvas" class="graph-canvas"><div class="empty-state">載入圖譜中…</div></div>
<div class="graph-foot"><div class="graph-legend" id="graphLegend"></div><span id="graphStat"></span></div>
</div>`;
mountChips($('#graphFilterChips'), GRAPH_KINDS.map(g => ({ id: g.id, label: g.label })), filter, v => showGraph({ filter: v }));
$('#graphLegend').innerHTML = GRAPH_LEGEND.map(([, lab, col]) =>
`<span><i style="background:${col}"></i>${lab}</span>`).join('');
const cfg = GRAPH_KINDS.find(g => g.id === filter) || GRAPH_KINDS[0];
const qs = new URLSearchParams({ kinds: cfg.kinds, limit: 500 });
if (cfg.includeIndex) qs.set('includeIndex', '1');
if (center) { qs.set('center', center); qs.set('depth', String(depth)); }
try {
const data = await api('/api/graph?' + qs);
const el = $('#graphCanvas');
el.innerHTML = '';
if (!data.nodes || !data.nodes.length) {
el.innerHTML = '<div class="empty-state">此範圍沒有足夠的連結可繪製。</div>';
return;
}
if (!window.vis) { el.innerHTML = '<div class="empty-state">圖譜元件載入失敗,請重新整理。</div>'; return; }
const nodes = new vis.DataSet(data.nodes.map(n => ({
id: n.id, label: n.label, title: n.title,
color: { background: n.color, border: n.color, highlight: { background: n.color, border: '#1d1d1f' } },
shape: n.shape === 'box' ? 'box' : 'dot',
font: { face: '-apple-system, BlinkMacSystemFont, sans-serif', size: 13, color: '#1d1d1f' },
margin: 10,
})));
const edges = new vis.DataSet(data.edges.map(e => ({
from: e.from, to: e.to, arrows: { to: { scaleFactor: 0.45 } },
color: { color: 'rgba(0,0,0,.12)', highlight: 'rgba(0,113,227,.45)' },
smooth: { type: 'continuous', roundness: 0.2 },
})));
if (graphNetwork) { graphNetwork.destroy(); graphNetwork = null; }
graphNetwork = new vis.Network(el, { nodes, edges }, {
physics: { stabilization: { iterations: 100 }, barnesHut: { gravitationalConstant: -12000, springLength: 120 } },
interaction: { hover: true, tooltipDelay: 80, navigationButtons: false },
nodes: { borderWidth: 0, shadow: { enabled: true, size: 6, x: 0, y: 2, color: 'rgba(0,0,0,.08)' } },
});
graphNetwork.on('click', p => {
if (!p.nodes.length) return;
const nid = p.nodes[0];
const node = data.nodes.find(n => n.id === nid);
if (!node) return;
const colon = nid.indexOf(':');
if (colon < 0) return;
openNote(nid.slice(0, colon), nid.slice(colon + 1));
});
if (center && data.nodes.some(n => n.id === center)) {
graphNetwork.focus(center, { scale: 1.2, animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
}
$('#graphStat').textContent = `${data.nodes.length} 個節點 · ${data.edges.length} 條連線${center ? '(聚焦模式)' : ''}`;
} catch (e) {
$('#graphCanvas').innerHTML = `<div class="empty-state">圖譜載入失敗:${escapeHtml(e.message || '')}</div>`;
}
window.scrollTo({ top: 0 });
}
// ═══════════════════════════════════════════════════════════
// 共用 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 = '<div class="chart-empty">資料不足,無法繪圖。</div>'; 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 += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${w - padR}" y2="${y.toFixed(1)}" stroke="rgba(0,0,0,.06)"/><text x="${padL - 8}" y="${(y + 3.5).toFixed(1)}" fill="#86868b" font-size="11" text-anchor="end">${fmt(v)}</text>`; }
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 += `<text x="${toX(idx).toFixed(1)}" y="${h - 9}" fill="#86868b" font-size="10" text-anchor="middle">${(dates[idx] || '').slice(2, 7).replace('-', '/')}</text>`; }
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 += `<path d="${d}" fill="none" stroke="${s.color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>`;
dots += `<circle class="hd" data-c="${s.color}" r="4" fill="${s.color}" stroke="#0a0e17" stroke-width="2" style="display:none"/>`;
});
const legend = series.length > 1 ? `<div class="chart-legend">${series.map(s => `<span><i style="background:${s.color}"></i>${escapeHtml(s.name)}</span>`).join('')}</div>` : '';
el.innerHTML = `${legend}<div class="chart-wrap"><svg id="${uid}" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
${grid}${xlab}${paths}
<g class="hg" style="display:none"><line class="hl" y1="${padT}" y2="${padT + plotH}" stroke="#86868b" stroke-dasharray="3,3"/></g>
${dots}
<rect class="ha" x="${padL}" y="${padT}" width="${plotW}" height="${plotH}" fill="transparent" style="cursor:crosshair"/>
</svg><div class="chart-hover" id="${uid}h"></div></div>`;
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 = `<b>${dates[i]}</b> ` + series.map(s => `<span style="color:${s.color}">${series.length > 1 ? escapeHtml(s.name) + ' ' : ''}${fmt(s.points[i].val)}</span>`).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 = `
<div class="page">
<div class="page-head">
<div class="page-title">📈 個股工具</div>
<div class="page-sub">輸入一檔股票代號,所有工具一次到位:價格走勢、<span class="wlink" data-link="學習分類/財報基本功">財報</span>健檢、用 Emmy 六層漏斗的<b>投資地圖</b>判斷該不該進場、以及策略<b>回測</b>。資料皆會存資料庫快取以節省 API。</div>
</div>
<div class="finbox-search">
<input type="text" id="stkSym" placeholder="輸入代號,例如 NVDA美股最完整" autocomplete="off">
<button id="stkGo">查詢</button>
</div>
<div class="finbox-examples">範例:<b data-sym="NVDA">NVDA</b><b data-sym="AMD">AMD</b><b data-sym="MSFT">MSFT</b><b data-sym="AVGO">AVGO</b><b data-sym="AAPL">AAPL</b></div>
<div class="sub-tabs" id="stkSub">
<a data-sub="price" class="active">價格走勢</a>
<a data-sub="finbox">財報健檢</a>
<a data-sub="map">投資地圖</a>
<a data-sub="backtest">回測</a>
</div>
<div id="stkBody">
<div class="stk-pane" id="pane-price"></div>
<div class="stk-pane" id="pane-finbox" hidden></div>
<div class="stk-pane" id="pane-map" hidden></div>
<div class="stk-pane" id="pane-backtest" hidden></div>
</div>
</div>`;
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 = '<div class="empty-state">請先在上方輸入股票代號。</div>';
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 = `
<div class="range-btns" id="priceRange">${PRICE_RANGES.map(r => `<button data-r="${r[0]}" class="${r[0] === STOCK.priceRange ? 'active' : ''}">${r[1]}</button>`).join('')}</div>
<div id="priceHead" class="fin-co"></div>
<div id="priceChart"><div class="chart-empty">載入中…</div></div>`;
$$('#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 = `<b>${escapeHtml(d.name || d.symbol)}</b> ${escapeHtml(d.symbol)} · 收盤 ${escapeHtml(d.currency || '')} ${fmtNum(last, 2)} · 此區間 <span class="${chgCls}">${chg >= 0 ? '+' : ''}${chg.toFixed(1)}%</span>${d.cached ? ' · <span style="color:var(--text2);font-size:.8rem">快取</span>' : ''}`;
drawLineChart($('#priceChart'), [{ name: d.symbol, color: HEX.blue, points: pts }], { fmt: v => fmtNum(v, 2) });
} catch (e) {
pane.querySelector('#priceChart').innerHTML = `<div class="empty-state">無法取得 ${escapeHtml(STOCK.symbol)} 的價格:${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
}
}
// ── 財報健檢 ──
function renderFinboxPane() {
const pane = $('#pane-finbox');
if (needSymbol(pane)) return;
if (STOCK.rendered.finbox === STOCK.symbol) return;
pane.innerHTML = '<div id="finResult"></div>';
runFincheck(STOCK.symbol);
}
async function runFincheck(sym, fresh) {
sym = (sym || STOCK.symbol || '').trim().toUpperCase();
const out = $('#finResult');
if (!out) return;
if (!sym) { out.innerHTML = '<div class="empty-state">請先輸入股票代號。</div>'; return; }
out.innerHTML = `<div class="empty-state"><div class="spinner" style="width:28px;height:28px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 14px;animation:spin .8s linear infinite"></div>正在${fresh ? '重新抓取' : '查詢'} ${escapeHtml(sym)} 的財報並健檢…</div>`;
try {
const d = await api('/api/fundamentals/' + encodeURIComponent(sym) + (fresh ? '?fresh=1' : ''));
STOCK.rendered.finbox = sym;
renderFincheck(d);
} catch (e) {
out.innerHTML = `<div class="empty-state">無法取得 ${escapeHtml(sym)} 的財報:${escapeHtml((e.data && e.data.message) || e.message || '未知錯誤')}<br><span style="font-size:.8rem">可試試美股代號(如 NVDA、AMD、MSFT。</span></div>`;
}
}
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 => `
<div class="fin-step">
<div class="fin-step-head"><div class="fin-step-num">${st.num}</div><div class="fin-step-title">${escapeHtml(st.title)}</div></div>
${(st.checks || []).map(ck => checkRowHTML(ck)).join('')}
</div>`).join('');
const caveats = (r.caveats || []).map(c => `<div class="disclaimer">${mdLinks(c.text, c.links)}</div>`).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 ? '<span style="color:var(--orange)"> · 即時更新失敗,先顯示先前存的資料</span>' : '';
out.innerHTML = `
<div class="fin-co"><b>${escapeHtml(d.name || d.symbol)}</b> ${escapeHtml(d.symbol)}${d.price != null ? ` · 股價 $${fmtNum(d.price, 2)}` : ''} · 資料來源 ${escapeHtml(d.source || '—')}${d.asOf ? ` · 最新季別 ${escapeHtml(d.asOf)}` : ''}</div>
<div class="fin-fresh"><span>${freshNote}${staleNote}</span><button class="btn ghost sm" id="finRefresh">↻ 重新抓取</button></div>
<div class="fin-summary">
<div class="fin-verdict"><div class="v-big" style="color:${vColor}">${escapeHtml(sum.verdict || '—')}</div><div class="v-sub">${(sum.good || 0) + (sum.warn || 0) + (sum.bad || 0)} 項檢查</div></div>
<div class="fin-lights">
<div class="fin-light"><div class="fl-num" style="color:var(--green)">${sum.good || 0}</div><div class="fl-lab">綠燈 通過</div></div>
<div class="fin-light"><div class="fl-num" style="color:var(--yellow)">${sum.warn || 0}</div><div class="fl-lab">黃燈 留意</div></div>
<div class="fin-light"><div class="fl-num" style="color:var(--red)">${sum.bad || 0}</div><div class="fl-lab">紅燈 警訊</div></div>
</div>
</div>
${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 => `<span class="wlink" data-link="${escapeHtml(l.target)}">${escapeHtml(l.label)}</span>`).join('');
return `
<div class="check-row ${ck.status}">
<span class="check-dot"></span>
<div class="check-main">
<div class="ck-label">${escapeHtml(ck.label)}</div>
${ck.note ? `<div class="ck-note">${escapeHtml(ck.note)}</div>` : ''}
${links ? `<div class="ck-links">${links}</div>` : ''}
</div>
<div class="check-val ${ck.status}">${escapeHtml(ck.value != null ? ck.value : '—')}</div>
</div>`;
}
// 把 {text, links:[{target,label}]} 的 [label] 佔位轉成 wlinklinks 依序替換)
function mdLinks(text, links) {
let i = 0;
return escapeHtml(text).replace(/\{link\}/g, () => {
const l = (links || [])[i++]; if (!l) return '';
return `<span class="wlink" data-link="${escapeHtml(l.target)}">${escapeHtml(l.label)}</span>`;
});
}
// ── 投資地圖(互動六層漏斗)──
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 = '<div class="empty-state"><div class="spinner" style="width:26px;height:26px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 12px;animation:spin .8s linear infinite"></div>載入投資地圖…</div>';
if (!STOCK.mapCfg) {
try { STOCK.mapCfg = await api('/api/investmap'); await ensureKnowledge(); }
catch (e) { pane.innerHTML = `<div class="empty-state">載入投資地圖失敗:${escapeHtml(e.message || '')}</div>`; return; }
}
drawMap();
}
function drawMap() {
const pane = $('#pane-map');
const cfg = STOCK.mapCfg;
const target = STOCK.symbol ? `<b>${escapeHtml(STOCK.symbol)}</b>` : '這檔標的';
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]) => `<label class="ans ${v} ${cur === v ? 'on' : ''}"><input type="radio" name="${L.key}_${qi}" value="${v}" ${cur === v ? 'checked' : ''}>${lab}</label>`).join('');
const links = (q.principles || []).map(p => `<span class="wlink ${p.id ? '' : 'dead'}" ${p.id ? `data-pid="${escapeHtml(p.id)}"` : ''}>${escapeHtml(p.title)}</span>`).join('');
return `<div class="map-q">
<div class="mq-text">${q.gate ? '<span class="gate">閘門</span>' : ''}${escapeHtml(q.q)}</div>
<div class="mq-ans" data-layer="${L.key}" data-qi="${qi}">${radios}</div>
${links ? `<div class="ck-links">${links}</div>` : ''}
</div>`;
}).join('');
return `<div class="map-layer ${st}">
<div class="ml-head"><div class="ml-num">${idx + 1}</div><div class="ml-title">${escapeHtml(L.title)}</div><span class="ml-badge ${meta.cls}">${meta.lab}</span></div>
<div class="ml-ask">${escapeHtml(L.ask)}</div>
<div class="ml-pillar">${escapeHtml(L.pillar)}</div>
${qs}
<div class="ml-out">出局條件:${escapeHtml(L.out)}</div>
</div>`;
}).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 = `
<div class="map-core">🧭 下單前的核心提問<br><span>${escapeHtml(cfg.coreQuestion)}</span></div>
<div class="map-verdict ${vcls}"><div class="mv-lab">${target} 的判斷</div><div class="mv-text">${verdict}</div>
<div class="mv-actions"><button class="btn ghost sm" id="mapReset">重設</button>${STOCK.symbol ? '<button class="btn sm" id="mapSave">存成交易紀錄</button>' : ''}</div>
</div>
${layersHTML}
<div class="disclaimer">這是把 Emmy「<span class="wlink" data-link="學習分類/投資底層邏輯">投資底層邏輯</span>」六層漏斗變成的自我檢查工具,幫你結構化判斷,<b>不構成投資建議</b>。任何一層出局就停手,是漏斗的精神。</div>`;
// 綁定:作答(即時重繪)、原則連結、按鈕
$$('.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 = `
<div class="bt-controls">
<div class="full" style="grid-column:1/-1;width:100%">
<label style="font-size:.72rem;color:var(--text2);font-weight:600;display:block;margin-bottom:8px">策略</label>
<div id="btStratChips" class="chip-row"></div>
</div>
<div class="full" style="grid-column:1/-1;width:100%;margin-top:4px">
<label style="font-size:.72rem;color:var(--text2);font-weight:600;display:block;margin-bottom:8px">期間</label>
<div id="btRangeChips" class="chip-row"></div>
</div>
<div id="btParams" class="bt-params"></div>
<button class="btn" id="btRun">跑回測</button>
</div>
<div id="btResult"><div class="empty-state">選好策略與期間,按「跑回測」。以還原股價、初始資金 $10,000 模擬。</div></div>`;
mountChips($('#btStratChips'), Object.entries(BT_STRATS).map(([k, v]) => ({ id: k, label: v.label })), STOCK.bt.strategy, v => {
STOCK.bt.strategy = v; drawBtParams();
});
mountChips($('#btRangeChips'), BT_RANGES.map(r => ({ id: r[0], label: r[1] })), STOCK.bt.range, v => { STOCK.bt.range = v; });
const drawBtParams = () => {
const s = BT_STRATS[STOCK.bt.strategy];
const box = $('#btParams');
if (!s.params.length) { box.innerHTML = ''; return; }
box.innerHTML = s.params.map(p => `
<div><label style="font-size:.72rem;color:var(--text2);font-weight:600">${escapeHtml(p.label)}</label>
<input type="number" step="any" data-pk="${p.key}" value="${STOCK.bt.params[p.key] != null ? STOCK.bt.params[p.key] : p.def}"
style="width:100px;padding:10px 12px;border-radius:10px;border:1px solid var(--border);margin-top:6px;font-family:inherit"></div>`).join('');
};
drawBtParams();
$('#btRun').addEventListener('click', runBacktestUI);
}
async function runBacktestUI() {
const params = {}; $$('#btParams input').forEach(i => params[i.dataset.pk] = i.value); STOCK.bt.params = params;
const out = $('#btResult');
out.innerHTML = `<div class="empty-state"><div class="spinner" style="width:26px;height:26px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 12px;animation:spin .8s linear infinite"></div>回測中…</div>`;
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 = `<div class="empty-state">回測失敗:${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
}
}
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 ? `
<div class="bt-stat" style="border-top:3px solid ${color}">
<div class="bts-title">${escapeHtml(title)}</div>
<div class="bts-grid">
<div><span>期末價值</span><b>${money(s.finalValue)}</b></div>
<div><span>總報酬</span><b class="${s.totalReturn >= 0 ? 'pnl-pos' : 'pnl-neg'}">${s.totalReturn >= 0 ? '+' : ''}${s.totalReturn.toFixed(1)}%</b></div>
<div><span>年化(CAGR)</span><b class="${s.cagr >= 0 ? 'pnl-pos' : 'pnl-neg'}">${s.cagr >= 0 ? '+' : ''}${s.cagr.toFixed(1)}%</b></div>
<div><span>最大回撤</span><b class="pnl-neg">-${s.maxDrawdown.toFixed(1)}%</b></div>
<div><span>在場比例</span><b>${s.exposure.toFixed(0)}%</b></div>
<div><span>${s.winRate != null ? '勝率' : '進場次數'}</span><b>${s.winRate != null ? s.winRate.toFixed(0) + '%' + s.trades + '次)' : s.trades + ' 次'}</b></div>
</div>
</div>` : '';
out.innerHTML = `
<div class="fin-co"><b>${escapeHtml(d.name || d.symbol)}</b> ${escapeHtml(d.symbol)} · ${escapeHtml(d.strategyLabel)} · ${escapeHtml(d.from)} ~ ${escapeHtml(d.to)}${d.cached ? ' · <span style="color:var(--text2);font-size:.8rem">快取</span>' : ''}</div>
<div id="btChart"></div>
<div class="bt-stats">${statCard(d.strategyLabel, d.stats, HEX.blue)}${statCard('買進持有', d.benchStats, HEX.text2)}</div>
${d.note ? `<div class="bt-note">${escapeHtml(d.note)}</div>` : ''}
<div class="disclaimer">回測以歷史還原股價模擬、未計交易成本與稅,且<b>過去績效不代表未來</b>。這是用來理解策略行為(如趨勢進出 vs 一直持有)的學習工具,不構成投資建議。對照 <span class="wlink" data-link="Emmy 投資心法#原則十三:長期趨勢跌就買">長期趨勢跌就買</span>、<span class="wlink" data-link="Emmy 投資心法#原則五十九:觸發式減倉">觸發式減倉</span>。</div>`;
drawLineChart($('#btChart'), series, { fmt: money });
bindWlinks(out);
}
// ═══════════════════════════════════════════════════════════
// 交易復盤視圖
// ═══════════════════════════════════════════════════════════
const JOURNAL = { tab: 'all', trades: [], stats: null };
function initJournal() {
const view = $('#view-journal');
view.innerHTML = `
<div class="page">
<div class="page-head">
<div class="page-title">📓 交易復盤</div>
<div class="page-sub">記錄每一筆進出與理由,自動算盈虧、勝率與賺賠比。重點不是「賺或賠」,而是<b>當初的判斷依據是否成立</b>——標記犯錯與依據的心法,定期回頭復盤。對應 <span class="wlink" data-link="學習分類/交易與資金管理">交易與資金管理</span>。</div>
</div>
<div id="journalStats"></div>
<div class="journal-bar">
<div class="seg" id="journalSeg">
<a data-tab="all" class="active">全部</a>
<a data-tab="open">持倉中</a>
<a data-tab="closed">已平倉</a>
<a data-tab="review">復盤分析</a>
</div>
<button class="btn" id="addTradeBtn"> 新增交易</button>
</div>
<div id="journalBody"></div>
</div>`;
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 = `<div class="empty-state">載入交易紀錄失敗:${escapeHtml(e.message || '')}</div>`;
}
}
function renderJournalStats() {
const el = $('#journalStats'); if (!el) return;
const s = JOURNAL.stats || {};
const pnlCls = (s.totalPnl || 0) >= 0 ? 'pnl-pos' : 'pnl-neg';
el.innerHTML = `
<div class="stat-grid">
<div class="stat-card"><div class="st-lab">已實現損益</div><div class="st-val ${pnlCls}">${s.totalPnl != null ? fmtMoney(s.totalPnl) : '—'}</div><div class="st-sub">${s.closed || 0} 筆已平倉 · ${s.open || 0} 筆持倉</div></div>
<div class="stat-card"><div class="st-lab">勝率</div><div class="st-val">${s.winRate != null ? s.winRate.toFixed(0) + '%' : '—'}</div><div class="st-sub">${s.wins || 0} 勝 / ${s.losses || 0} 敗</div></div>
<div class="stat-card"><div class="st-lab">賺賠比 (Payoff)</div><div class="st-val">${s.payoff != null ? s.payoff.toFixed(2) : '—'}</div><div class="st-sub">平均賺 ${s.avgWin != null ? fmtMoney(s.avgWin) : '—'} / 賠 ${s.avgLoss != null ? fmtMoney(Math.abs(s.avgLoss)) : '—'}</div></div>
<div class="stat-card"><div class="st-lab">紀律提醒</div><div class="st-val" style="font-size:1rem;font-weight:600;line-height:1.4">六成看對<br>就夠賺錢</div><div class="st-sub">勝率不必高,賺賠比是關鍵</div></div>
</div>`;
}
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 = `<div class="empty-state">${JOURNAL.trades.length ? '此分類沒有交易。' : '還沒有任何交易紀錄。點右上角「+ 新增交易」開始記錄你的第一筆。'}</div>`; return; }
const rows = list.map(t => {
const dirPill = `<span class="pill ${t.direction === 'short' ? 'short' : 'long'}">${t.direction === 'short' ? '做空' : '做多'}</span>`;
const kindPill = t.kind ? `<span class="pill ${t.kind === '投資' ? 'invest' : 'trade'}">${escapeHtml(t.kind)}</span>` : '';
const statusPill = t.closed ? '' : '<span class="pill open">持倉</span>';
const mistakePill = t.mistake ? '<span class="pill mistake">犯錯</span>' : '';
const pnl = t.closed ? `<span class="${t.pnl >= 0 ? 'pnl-pos' : 'pnl-neg'}">${fmtMoney(t.pnl)}<br><span style="font-size:.74rem;font-weight:400">${t.pnl_pct >= 0 ? '+' : ''}${t.pnl_pct != null ? t.pnl_pct.toFixed(1) : '—'}%</span></span>` : '<span style="color:var(--text2)">—</span>';
return `<tr>
<td><span class="t-sym">${escapeHtml(t.symbol)}${t.name ? `<span class="t-name">${escapeHtml(t.name)}</span>` : ''}</span></td>
<td>${dirPill} ${kindPill} ${statusPill} ${mistakePill}</td>
<td>${escapeHtml(t.entry_date || '—')}<br><span style="color:var(--text2);font-size:.76rem">$${fmtNum(t.entry_price, 2)} × ${fmtNum(t.shares, 0)}</span></td>
<td>${t.closed ? escapeHtml(t.exit_date || '—') + `<br><span style="color:var(--text2);font-size:.76rem">$${fmtNum(t.exit_price, 2)}</span>` : '<span style="color:var(--text2)">—</span>'}</td>
<td>${pnl}</td>
<td style="max-width:200px;color:var(--text2);font-size:.78rem">${escapeHtml(t.entry_reason || '')}${t.principle ? `<br><span class="wlink" data-link="${escapeHtml(t.principle)}" style="font-size:.74rem">依據:${escapeHtml(t.principle.split('#').pop())}</span>` : ''}</td>
<td class="t-actions"><button class="btn ghost sm" data-edit="${t.id}">編輯</button><button class="btn danger sm" data-del="${t.id}">刪</button></td>
</tr>`;
}).join('');
body.innerHTML = `<table class="trade-table">
<thead><tr><th>標的</th><th>類型</th><th>進場</th><th>出場</th><th>已實現損益</th><th>理由 / 依據</th><th></th></tr></thead>
<tbody>${rows}</tbody></table>`;
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 `<div class="group-stat"><h4>${escapeHtml(title)}${note ? ` <span style="color:var(--text2);font-weight:400;font-size:.76rem">${escapeHtml(note)}</span>` : ''}</h4>
<div class="gs-row" style="color:var(--text2);font-size:.72rem"><span class="gs-name"></span><span class="gs-cell">筆數</span><span class="gs-cell">勝率</span><span class="gs-cell">損益</span></div>
${rows.map(r => `<div class="gs-row"><span class="gs-name">${escapeHtml(r.key)}</span><span class="gs-cell">${r.count}</span><span class="gs-cell">${r.winRate != null ? r.winRate.toFixed(0) + '%' : '—'}</span><span class="gs-cell ${r.pnl >= 0 ? 'pnl-pos' : 'pnl-neg'}">${fmtMoney(r.pnl)}</span></div>`).join('')}
</div>`;
};
const body = $('#journalBody');
if (!body) return;
if (!s.closed) { body.innerHTML = '<div class="empty-state">還沒有已平倉的交易可供復盤。先記錄並平倉幾筆交易,這裡就會出現分析。</div>'; return; }
body.innerHTML = `
${groupHTML('依「交易 vs 投資」', s.byKind)}
${groupHTML('依「是否犯錯」', s.byMistake, '結果論陷阱:賺錢不代表判斷對,賠錢不代表判斷錯')}
${groupHTML('依「依據的心法」', s.byPrinciple)}
<div class="disclaimer">復盤重點:找出「賠錢但判斷正確(可接受)」與「賺錢但其實犯錯(運氣)」的交易。對照 <span class="wlink" data-link="Emmy 投資心法#原則九十六結果論陷阱Outcome Bias">結果論陷阱</span>、<span class="wlink" data-link="Emmy 投資心法#原則六十二:賣弱留強">賣弱留強</span>、<span class="wlink" data-link="Emmy 投資心法#原則五十九:觸發式減倉">觸發式減倉</span>。</div>`;
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(0,0,0,.35);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center;padding:20px';
div.innerHTML = `<div class="modal-panel" style="width:min(640px,100%)">
<div class="modal-head"><div class="modal-title" id="tradeFormTitle">新增交易</div><button class="modal-close" id="tradeFormClose">✕</button></div>
<form id="tradeForm"><div class="form-grid">
<div class="field"><label>股票代號 *</label><input name="symbol" required placeholder="NVDA"></div>
<div class="field"><label>名稱</label><input name="name" placeholder="輝達"></div>
<div class="field full"><label>方向</label><div id="dirTiles" class="tile-row"></div><input type="hidden" name="direction" value="long"></div>
<div class="field full"><label>交易 / 投資</label><div id="kindTiles" class="tile-row"></div><input type="hidden" name="kind" value="投資"></div>
<div class="field"><label>進場日期 *</label><input name="entry_date" type="date" required></div>
<div class="field"><label>進場價 *</label><input name="entry_price" type="number" step="any" required placeholder="120.5"></div>
<div class="field"><label>股數 *</label><input name="shares" type="number" step="any" required placeholder="100"></div>
<div class="field"><label>進場理由</label><input name="entry_reason" placeholder="資料中心營收續強,趨勢回測支撐"></div>
<div class="field full"><label>依據的心法(點選色塊)</label><div id="principleChips" class="principle-chips"></div><input type="hidden" name="principle" value=""></div>
<div class="field"><label>出場日期(留空=持倉中)</label><input name="exit_date" type="date"></div>
<div class="field"><label>出場價</label><input name="exit_price" type="number" step="any"></div>
<div class="field full"><label>出場理由</label><input name="exit_reason" placeholder="觸發減倉條件 / 停損 / 換倉"></div>
<div class="field full"><label>心得 / 復盤筆記</label><textarea name="note" placeholder="當初判斷是否成立?事後看哪裡對、哪裡錯?"></textarea></div>
<div class="field full"><label class="check-inline"><input type="checkbox" name="mistake"> 這筆交易我判斷犯了錯(與結果無關)</label></div>
<div class="field full" id="mistakeNoteWrap" style="display:none"><label>違反 / 該注意的心法</label><div id="mistakeChips" class="principle-chips"></div><input type="hidden" name="mistake_note" value=""></div>
</div>
<div class="form-actions"><button type="button" class="btn ghost" id="tradeFormCancel">取消</button><button type="submit" class="btn">儲存</button></div>
</form></div>`;
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 mountPrincipleChips(container, hiddenInput, selected) {
const items = [{ id: '', label: '不指定' }].concat((KB.principles || []).map(p => ({
id: 'Emmy 投資心法#' + p.id, label: p.title.replace(/^原則[^]+/, '').slice(0, 24),
})));
mountChips(container, items, selected || '', v => { hiddenInput.value = v; }, { sm: true });
}
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 : '';
const dir = trade ? trade.direction : 'long';
const kind = trade ? trade.kind : '投資';
mountTiles($('#dirTiles'), [
{ id: 'long', label: '做多', sub: 'Long', tint: 'green' },
{ id: 'short', label: '做空', sub: 'Short', tint: 'red' },
], dir, v => { f.direction.value = v; });
mountTiles($('#kindTiles'), [
{ id: '投資', label: '投資', sub: '基本面 · 趨勢' },
{ id: '交易', label: '交易', sub: '情緒 · 資金' },
], kind, v => { f.kind.value = v; });
mountPrincipleChips($('#principleChips'), f.principle, trade ? trade.principle : '');
mountPrincipleChips($('#mistakeChips'), f.mistake_note, trade ? trade.mistake_note : '');
if (trade) {
['symbol', 'name', '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.direction.value = trade.direction || 'long';
f.kind.value = trade.kind || '投資';
f.mistake.checked = !!trade.mistake;
f.principle.value = trade.principle || '';
f.mistake_note.value = trade.mistake_note || '';
$('#mistakeNoteWrap').style.display = trade.mistake ? '' : 'none';
mountTiles($('#dirTiles'), [{ id: 'long', label: '做多', sub: 'Long', tint: 'green' }, { id: 'short', label: '做空', sub: 'Short', tint: 'red' }], f.direction.value, v => { f.direction.value = v; });
mountTiles($('#kindTiles'), [{ id: '投資', label: '投資', sub: '基本面 · 趨勢' }, { id: '交易', label: '交易', sub: '情緒 · 資金' }], f.kind.value, v => { f.kind.value = v; });
mountPrincipleChips($('#principleChips'), f.principle, f.principle.value);
mountPrincipleChips($('#mistakeChips'), f.mistake_note, f.mistake_note.value);
}
$('#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 內聯負責載入)
initMermaid();
setView(parseHash());