finance-dashboard/app.js

2555 lines
149 KiB
JavaScript
Raw Permalink 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.

// ═══════════════════════════════════════════════════════════
// MacroScope — 學習教材 / 財報健檢 / 交易復盤
// 本檔在 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 = deEmmyText(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 = deEmmyText(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;
}
function deEmmyText(s) {
return (window.LearnUI && LearnUI.deEmmy) ? LearnUI.deEmmy(s) : String(s || '');
}
// 把容器內所有 [[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', 'calendar', 'learn', 'stock', 'journal', 'settings'];
const inited = {};
function parseHash() { const m = location.hash.match(/^#\/(\w+)/); const v = m ? m[1] : 'macro'; return VIEW_IDS.includes(v) ? v : 'macro'; }
function setAIFocus(focus) {
const view = focus.view || document.body.dataset.view || parseHash();
window.__AI_FOCUS = { ...(window.__AI_FOCUS || {}), ...focus, view, updatedAt: new Date().toISOString() };
updateAIContextLabel();
return window.__AI_FOCUS;
}
window.setAIFocus = setAIFocus;
function setView(view) {
document.body.dataset.view = view;
if ((window.__AI_FOCUS || {}).view !== view) setAIFocus({ view, type: '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 === 'calendar' && !inited.calendar) { inited.calendar = true; initCalendar(); }
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 === 'settings' && !inited.settings) { inited.settings = true; initSettings(); }
updateAIContextLabel();
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()));
// ═══════════════════════════════════════════════════════════
// AI Provider 設定與頁面上下文問答
// ═══════════════════════════════════════════════════════════
const AI_PROVIDER_META = {
'opencode-go': {
label: 'OpenCode Go',
hint: 'OpenAI-compatible chat completions。官方 Go 端點使用 /zen/go/v1/chat/completions。',
},
grok: {
label: 'Grok',
hint: 'xAI/Grok。後端會使用 xAI Responses API且 store=false。',
},
};
function readAISettings() {
const fields = window.__ENV_SETTINGS?.fields || [];
const get = (k) => fields.find(f => f.key === k) || {};
return {
active: get('AI_ACTIVE_PROVIDER').value || 'grok',
providers: {
'opencode-go': {
model: get('OPENCODE_GO_MODEL').value || '',
hasKey: !!get('OPENCODE_GO_API_KEY').hasValue,
},
grok: {
model: get('GROK_MODEL').value || '',
hasKey: !!get('GROK_API_KEY').hasValue,
},
},
};
}
async function loadEnvSettings() {
window.__ENV_SETTINGS = await api('/api/settings/env');
return window.__ENV_SETTINGS;
}
function envField(settings, key) {
return (settings.fields || []).find(f => f.key === key) || { key, value: '', hasValue: false, masked: '' };
}
async function saveEnvSettings(view) {
const values = { AI_ACTIVE_PROVIDER: $('input[name="aiActiveProvider"]:checked')?.value || 'grok' };
$$('[data-env-key]', view).forEach(input => values[input.dataset.envKey] = input.value.trim());
window.__ENV_SETTINGS = await api('/api/settings/env', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values }),
});
updateAIContextLabel();
return window.__ENV_SETTINGS;
}
async function loadProviderModels(provider) {
return api('/api/ai/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
});
}
async function getProviderModels(provider) {
window.__AI_MODEL_CACHE = window.__AI_MODEL_CACHE || {};
if (window.__AI_MODEL_CACHE[provider]) return window.__AI_MODEL_CACHE[provider];
const d = await loadProviderModels(provider);
window.__AI_MODEL_CACHE[provider] = d.models || [];
return window.__AI_MODEL_CACHE[provider];
}
async function initSettings() {
const view = $('#view-settings');
view.innerHTML = '<div class="page"><div class="empty-state">載入設定中…</div></div>';
const envSettings = await loadEnvSettings();
const settings = readAISettings();
view.innerHTML = `
<div class="page">
<div class="page-head">
<div class="page-title">API Key 與 AI Provider 設定</div>
<div class="page-sub">所有金鑰會寫入本機專案的 <code>.env</code>${escapeHtml(envSettings.envPath || '.env')}。金鑰欄位留空代表保留原值;模型與預設 provider 會直接更新。</div>
</div>
<section class="ai-provider-card env-provider-card">
<div class="ai-provider-head">
<div><b>市場資料</b><span>目前總經與日曆使用 FRED API key。儲存後本次伺服器程序會立即使用新值若你改了 PORT 或 TTL 類設定,仍建議重啟。</span></div>
</div>
<div class="form-grid">
<div class="field full"><label>FRED API Key</label><input type="password" data-env-key="FRED_API_KEY" placeholder="${envField(envSettings, 'FRED_API_KEY').hasValue ? '已設定,留空保留原值' : '貼上 FRED API key'}"></div>
</div>
<div class="ai-provider-foot"><span>狀態:${escapeHtml(envField(envSettings, 'FRED_API_KEY').masked || '未設定')}</span></div>
</section>
<div class="ai-settings-grid">
${Object.entries(AI_PROVIDER_META).map(([id, meta]) => {
const p = settings.providers?.[id] || {};
const keyName = id === 'opencode-go' ? 'OPENCODE_GO_API_KEY' : 'GROK_API_KEY';
const modelName = id === 'opencode-go' ? 'OPENCODE_GO_MODEL' : 'GROK_MODEL';
const keyField = envField(envSettings, keyName);
const listId = `models-${id}`;
return `<section class="ai-provider-card" data-provider="${id}">
<div class="ai-provider-head">
<div><b>${escapeHtml(meta.label)}</b><span>${escapeHtml(meta.hint)}</span></div>
<label class="ai-default"><input type="radio" name="aiActiveProvider" value="${id}" ${settings.active === id ? 'checked' : ''}> 預設</label>
</div>
<div class="form-grid">
<div class="field full"><label>API Key</label><input type="password" data-env-key="${keyName}" placeholder="${keyField.hasValue ? '' : ` ${meta.label} API key`}"></div>
<div class="field full"><label>Model</label><div class="ai-model-row"><input type="text" data-env-key="${modelName}" data-ai-model-input="${id}" list="${listId}" value="${escapeHtml(p.model || '')}" placeholder=" provider "><button type="button" class="btn ghost sm" data-ai-models="${id}"></button></div><datalist id="${listId}"></datalist></div>
</div>
<div class="ai-provider-foot"><span>狀態${escapeHtml(keyField.masked || '未設定')}</span><button class="btn ghost sm" data-ai-test="${id}"></button></div>
</section>`;
}).join('')}
</div>
<div class="form-actions"><button class="btn" id="saveAISettings">儲存設定</button></div>
<div id="aiSettingsMsg" class="ai-settings-msg"></div>
</div>`;
$('#saveAISettings').addEventListener('click', async () => {
try {
await saveEnvSettings(view);
$('#aiSettingsMsg').textContent = '已寫入 .env。金鑰留空的欄位已保留原值。';
} catch (e) {
$('#aiSettingsMsg').textContent = '儲存失敗:' + ((e.data && e.data.message) || e.message || '');
}
});
$$('[data-ai-test]').forEach(btn => btn.addEventListener('click', async () => {
try {
$('#aiSettingsMsg').textContent = '先寫入 .env接著測試連線…';
await saveEnvSettings(view);
await askAI({ provider: btn.dataset.aiTest, question: '請用一句話確認你已收到連線測試。', context: { page: 'settings', purpose: 'provider connection test' }, target: '#aiSettingsMsg' });
} catch (e) {
$('#aiSettingsMsg').textContent = '測試失敗:' + ((e.data && e.data.message) || e.message || '');
}
}));
$$('[data-ai-models]').forEach(btn => btn.addEventListener('click', async () => {
const provider = btn.dataset.aiModels;
const card = btn.closest('.ai-provider-card');
const input = card?.querySelector(`[data-ai-model-input="${provider}"]`);
const list = card?.querySelector('datalist');
try {
$('#aiSettingsMsg').textContent = '先寫入 .env接著向 provider 抓取可用模型…';
await saveEnvSettings(view);
const d = await loadProviderModels(provider);
const models = d.models || [];
if (list) list.innerHTML = models.map(m => `<option value="${escapeHtml(m.id)}"></option>`).join('');
if (input && !input.value && models[0]) input.value = models[0].id;
$('#aiSettingsMsg').textContent = models.length ? `已抓到 ${models.length} 個模型,請從 Model 欄位選擇後儲存。` : 'Provider 沒有回傳可用模型。';
} catch (e) {
$('#aiSettingsMsg').textContent = '抓取模型失敗:' + ((e.data && e.data.message) || e.message || '');
}
}));
}
function initAIWidget() {
const dock = $('#aiDock');
if (!dock) return;
dock.innerHTML = `
<button class="ai-fab" id="aiFab">AI</button>
<div class="ai-panel" id="aiPanel" hidden>
<div class="ai-panel-head"><div><b>MacroScope AI</b><span id="aiContextLabel">準備中</span></div><button id="aiClose">×</button></div>
<div class="ai-provider-row">
<select id="aiProviderSelect"></select>
<select id="aiModelSelect"></select>
<button class="btn ghost sm" id="aiOpenSettings">設定</button>
</div>
<div class="ai-chat" id="aiChatLog">
<div class="ai-msg ai-msg-bot">
<div class="ai-bubble">我會看你目前 focus 的頁面資料;沒有資料時也可以直接聊天。</div>
<div class="ai-msg-meta">MacroScope AI</div>
</div>
</div>
<div class="ai-compose">
<textarea id="aiQuestion" rows="1" placeholder="輸入訊息..."></textarea>
<button class="ai-send" id="aiAskBtn" aria-label="送出">↑</button>
</div>
</div>`;
const refreshProviders = async () => {
try { await loadEnvSettings(); } catch (_) {}
const s = readAISettings();
$('#aiProviderSelect').innerHTML = Object.entries(AI_PROVIDER_META).map(([id, meta]) => {
const p = s.providers?.[id] || {};
return `<option value="${id}" ${s.active === id ? 'selected' : ''}>${escapeHtml(meta.label)}${p.model ? ` · ${escapeHtml(p.model)}` : ' · 自動抓取模型'}</option>`;
}).join('');
await refreshWidgetModels($('#aiProviderSelect').value);
};
refreshProviders();
$('#aiFab').addEventListener('click', async () => { await refreshProviders(); $('#aiPanel').hidden = !$('#aiPanel').hidden; updateAIContextLabel(); });
$('#aiClose').addEventListener('click', () => { $('#aiPanel').hidden = true; });
$('#aiProviderSelect').addEventListener('change', async () => {
await refreshWidgetModels($('#aiProviderSelect').value);
updateAIContextLabel();
});
$('#aiOpenSettings').addEventListener('click', () => { location.hash = '#/settings'; $('#aiPanel').hidden = true; });
$('#aiAskBtn').addEventListener('click', () => askAIFromWidget());
$('#aiQuestion').addEventListener('input', () => autosizeAIInput());
$('#aiQuestion').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
askAIFromWidget();
}
});
}
function autosizeAIInput() {
const input = $('#aiQuestion');
if (!input) return;
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 112) + 'px';
}
function appendAIMessage(role, html, meta = '') {
const log = $('#aiChatLog');
if (!log) return null;
const msg = document.createElement('div');
msg.className = `ai-msg ${role === 'user' ? 'ai-msg-user' : 'ai-msg-bot'}`;
msg.innerHTML = `<div class="ai-bubble">${html}</div>${meta ? `<div class="ai-msg-meta">${escapeHtml(meta)}</div>` : ''}`;
log.appendChild(msg);
log.scrollTop = log.scrollHeight;
return msg;
}
async function refreshWidgetModels(provider) {
const select = $('#aiModelSelect');
if (!select) return;
const settings = readAISettings();
const current = settings.providers?.[provider]?.model || '';
select.innerHTML = `<option value="">${current ? `使用設定:${escapeHtml(current)}` : '自動抓取模型'}</option>`;
if (!settings.providers?.[provider]?.hasKey) {
select.innerHTML = '<option value="">先設定 API key</option>';
return;
}
try {
const models = await getProviderModels(provider);
const opts = models.map(m => `<option value="${escapeHtml(m.id)}" ${m.id === current ? 'selected' : ''}>${escapeHtml(m.id)}</option>`).join('');
select.innerHTML = `<option value="">${current ? `使用設定:${escapeHtml(current)}` : '自動抓取模型'}</option>${opts}`;
if (current) select.value = current;
} catch (e) {
select.innerHTML = `<option value="">${current ? `使用設定:${escapeHtml(current)}` : '抓取模型失敗'}</option>`;
}
}
function updateAIContextLabel() {
const el = $('#aiContextLabel');
if (!el) return;
const labels = { macro: '總經', calendar: '日曆', learn: '學習', stock: '個股', journal: '復盤', settings: '設定' };
const view = document.body.dataset.view || parseHash();
const dataViews = new Set(['macro', 'calendar', 'learn', 'stock', 'journal']);
const focus = window.__AI_FOCUS || {};
const focusLabel = focus.label || focus.title || focus.symbol || focus.date || focus.key || '';
el.textContent = dataViews.has(view)
? `會附上「${labels[view] || '頁面'}${focusLabel ? `目前焦點:${focusLabel}` : '目前焦點'}`
: '一般聊天,不附頁面資料';
}
async function collectAIContext() {
const view = document.body.dataset.view || parseHash();
const focus = { ...(window.__AI_FOCUS || {}), view };
const client = { urlHash: location.hash, visibleText: '', currentNote: null, personalNotes: [], symbol: '', subPage: '' };
if (view === 'learn') {
client.currentNote = LEARN.currentNote ? {
kind: LEARN.currentNote.kind, id: LEARN.currentNote.id, title: LEARN.currentNote.title,
summary: LEARN.currentNote.summary,
} : null;
client.visibleText = $('#learnContent')?.innerText?.slice(0, 6000) || '';
client.personalNotes = readLearnNotes().slice(0, 8);
} else if (view === 'stock') {
client.symbol = STOCK.symbol || '';
client.subPage = STOCK.sub;
client.mapAnswers = STOCK.mapAnswers;
if (STOCK.sub === 'map') client.investMap = STOCK.mapCfg;
client.visibleText = $('#view-stock')?.innerText?.slice(0, 5000) || '';
} else if (view === 'journal') {
client.visibleText = $('#view-journal')?.innerText?.slice(0, 5000) || '';
} else if (view === 'calendar') {
client.visibleText = $('#view-calendar')?.innerText?.slice(0, 8000) || '';
} else if (view === 'macro') {
client.visibleText = $('#view-macro')?.innerText?.slice(0, 8000) || '';
}
return api('/api/ai/context', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ view, focus, client, allowFetch: true }),
}).catch(e => ({
mode: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view) ? 'page' : 'chat',
hasPageData: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view),
view,
focus,
client,
contextError: (e.data && e.data.message) || e.message,
}));
}
async function askAIFromWidget() {
const question = $('#aiQuestion').value.trim();
if (!question) return;
const input = $('#aiQuestion');
const send = $('#aiAskBtn');
appendAIMessage('user', escapeHtml(question), '你');
input.value = '';
autosizeAIInput();
if (send) send.disabled = true;
const typing = appendAIMessage('bot', '<span class="ai-typing"><i></i><i></i><i></i></span>', '正在回覆');
try {
const context = await collectAIContext();
const provider = $('#aiProviderSelect').value;
const model = $('#aiModelSelect')?.value || '';
const d = await askAI({ provider, model, question, context });
if (typing) {
typing.querySelector('.ai-bubble').innerHTML = renderMarkdown(d?.text || 'AI 沒有回傳文字)');
const meta = typing.querySelector('.ai-msg-meta');
if (meta) meta.textContent = `${AI_PROVIDER_META[provider]?.label || provider}${d?.model ? ' · ' + d.model : ''}`;
}
} catch (e) {
if (typing) {
typing.querySelector('.ai-bubble').innerHTML = `<div class="ai-error">${escapeHtml((e.data && e.data.message) || e.message || 'AI 呼叫失敗')}</div>`;
const meta = typing.querySelector('.ai-msg-meta');
if (meta) meta.textContent = '傳送失敗';
}
} finally {
if (send) send.disabled = false;
$('#aiChatLog').scrollTop = $('#aiChatLog').scrollHeight;
}
}
async function askAI({ provider, model, question, context, target }) {
const out = target ? $(target) : null;
const settings = readAISettings();
const p = settings.providers?.[provider] || {};
try {
const d = await api('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider, model: model || p.model || '', question, context }),
});
if (out) out.innerHTML = renderMarkdown(d.text || 'AI 沒有回傳文字)');
return d;
} catch (e) {
if (out) out.innerHTML = `<div class="ai-error">${escapeHtml((e.data && e.data.message) || e.message || 'AI 呼叫失敗')}</div>`;
if (!out) throw e;
return null;
}
}
// ═══════════════════════════════════════════════════════════
// 知識庫資料
// ═══════════════════════════════════════════════════════════
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');
LEARN.currentNote = note;
const kind = note.kind || LEARN.noteKind;
setAIFocus({ view: 'learn', type: 'learning-note', kind, id: note.id || '', title: note.title || note.id || '' });
const center = (kind && note.id) ? `${kind}:${note.id}` : '';
content.innerHTML = LearnUI.renderArticle(note, {
escapeHtml,
renderMarkdown,
linkMap: KB.linkMap,
principles: KB.principles,
});
bindWlinks(content);
LearnUI.bindArticle(content, {
onBack: () => showSection(LEARN.lastSection || 'overview'),
onGraph: () => showGraph({ center, depth: 2 }),
openNote,
goView(v) { location.hash = v === 'macro' ? '#/' : '#/' + v; },
});
renderMermaid(content);
bindTermTips(content);
window.scrollTo({ top: 0 });
}
// ═══════════════════════════════════════════════════════════
// 重大事件日曆(網格 · 可增減追蹤 · 今天起兩個月)
// ═══════════════════════════════════════════════════════════
const CAL = { events: [], selectedDate: '' };
const CAL_WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
function loadCalendarSymbols() {
try {
const raw = localStorage.getItem('calendarSymbols');
if (raw) {
const arr = JSON.parse(raw);
if (Array.isArray(arr)) return [...new Set(arr.map(s => String(s).trim().toUpperCase()).filter(Boolean))];
}
} catch (_) {}
const legacy = localStorage.getItem('eventSymbols');
if (legacy && legacy.trim()) {
const arr = legacy.split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
saveCalendarSymbols(arr);
localStorage.removeItem('eventSymbols');
return arr;
}
return [];
}
async function syncCalendarWatchlistFromServer() {
try {
const d = await api('/api/calendar/watchlist');
const remote = (d.symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean);
if (remote.length) {
saveCalendarSymbols(remote);
renderWatchlistChips();
} else {
const local = loadCalendarSymbols();
if (local.length) await pushCalendarWatchlistToServer(local);
}
} catch (_) {}
}
async function pushCalendarWatchlistToServer(symbols) {
try {
const d = await api('/api/calendar/watchlist', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbols: symbols || loadCalendarSymbols() }),
});
if (Array.isArray(d.symbols)) saveCalendarSymbols(d.symbols);
} catch (_) {}
}
function saveCalendarSymbols(symbols) {
const clean = [...new Set((symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean))].slice(0, 30);
localStorage.setItem('calendarSymbols', JSON.stringify(clean));
return clean;
}
function calendarRangeISO() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const end = new Date(today);
end.setMonth(end.getMonth() + 2);
const iso = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return { start: iso(today), end: iso(end), today: iso(today) };
}
function showCalendarMsg(text, tone) {
const el = $('#calendarMsg');
if (!el) return;
el.textContent = text || '';
el.className = 'calendar-msg' + (tone ? ' ' + tone : '');
el.hidden = !text;
}
function renderWatchlistChips() {
const box = $('#calendarWatchlist');
if (!box) return;
const symbols = loadCalendarSymbols();
box.innerHTML = symbols.length
? symbols.map(sym => `<span class="watch-chip" data-sym="${escapeHtml(sym)}"><span class="watch-chip-label">${escapeHtml(sym)}</span><button type="button" class="watch-chip-x" aria-label="移除 ${escapeHtml(sym)}">×</button></span>`).join('')
: '<span class="watch-empty">還沒有追蹤。在上方輸入代號,按 Enter 或「加入」。</span>';
}
function tryAddCalendarSymbol() {
const input = $('#calendarSymAdd');
if (!input) return;
const sym = input.value.trim().toUpperCase();
if (!sym) { showCalendarMsg('請先輸入股票代號', 'warn'); return; }
if (!/^[A-Z0-9.\-]{1,12}$/.test(sym)) {
showCalendarMsg('代號格式不正確112 字,可用英數與 . -', 'bad');
input.focus();
return;
}
const cur = loadCalendarSymbols();
if (cur.includes(sym)) {
showCalendarMsg(`${sym} 已在追蹤清單`, 'warn');
input.select();
return;
}
saveCalendarSymbols([...cur, sym]);
input.value = '';
renderWatchlistChips();
pushCalendarWatchlistToServer();
showCalendarMsg(`已加入 ${sym},正在更新財報日…`, 'good');
refreshCalendarData(true);
}
function removeCalendarSymbol(sym) {
sym = String(sym || '').trim().toUpperCase();
if (!sym) return;
saveCalendarSymbols(loadCalendarSymbols().filter(s => s !== sym));
renderWatchlistChips();
pushCalendarWatchlistToServer();
showCalendarMsg(`已移除 ${sym}`, 'good');
refreshCalendarData(true);
}
function bindCalendarViewEvents(view) {
if (!view || view.dataset.calBound) return;
view.dataset.calBound = '1';
view.addEventListener('click', e => {
const rm = e.target.closest('.watch-chip-x');
if (rm) {
e.preventDefault();
e.stopPropagation();
removeCalendarSymbol(rm.closest('.watch-chip')?.dataset.sym);
return;
}
if (e.target.closest('#calendarSymGo')) {
e.preventDefault();
tryAddCalendarSymbol();
return;
}
if (e.target.closest('#calendarRefresh')) return;
if (e.target.closest('.cal-modal-backdrop') || e.target.closest('.cal-day-close')) {
closeCalendarDay();
return;
}
const cell = e.target.closest('.cal-cell[data-date]');
if (cell) { openCalendarDay(cell.dataset.date); return; }
});
view.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeCalendarDay(); return; }
if (e.target.id === 'calendarSymAdd' && e.key === 'Enter') {
e.preventDefault();
tryAddCalendarSymbol();
return;
}
const cell = e.target.closest('.cal-cell[data-date]');
if (cell && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
openCalendarDay(cell.dataset.date);
}
});
}
function initCalendar() {
const view = $('#view-calendar');
view.innerHTML = `
<div class="page calendar-page">
<div class="page-head calendar-hero">
<div>
<div class="eyebrow">市場日曆</div>
<div class="page-title">市場日曆:兩個月內會動到股市的大事</div>
<div class="page-sub">不只財報——還有美國通膨、就業、Fed 開會、選擇權結算、各國央行、美股休市。點日期看詳情,標題旁的 ? 有白話說明。</div>
</div>
<button type="button" class="btn ghost" id="calendarRefresh">更新日曆</button>
</div>
<section class="calendar-watch-panel" aria-label="追蹤財報">
<div class="calendar-watch-head">
<div><b>追蹤財報</b><span>自行新增或刪除,沒有預設清單</span></div>
<form class="calendar-watch-add" id="calendarWatchForm" autocomplete="off">
<input id="calendarSymAdd" name="symbol" placeholder="輸入代號,例如 NVDA" autocomplete="off" maxlength="12" spellcheck="false">
<button type="submit">加入</button>
</form>
</div>
<div class="watch-chip-row" id="calendarWatchlist"></div>
<div class="calendar-msg" id="calendarMsg" hidden></div>
</section>
<section class="cal-intro" aria-label="怎麼看日曆">
<b>怎麼看?</b>
<p>格子裡是當天大事的<strong>簡稱</strong>;若看到 <span class="cal-more-inline">+3</span> 代表還有更多,<strong>點日期</strong>可一次看完。每項事件標題旁有 <span class="info-btn demo">?</span>,滑鼠移上去有白話解釋(不用懂 ADP、ECB 是什麼也能看)。</p>
<ul>
<li><strong>總經數據</strong>:通膨 CPI、非農就業、零售、房市… 公布時常讓大盤晃一下</li>
<li><strong>聯準會 / 各國央行</strong>:決定利率,影響借錢成本與股價估值</li>
<li><strong>四巫日 / 選擇權到期</strong>:衍生品結算,成交量與波動常變大</li>
<li><strong>財報</strong>:只有你自行加入的股票才會顯示</li>
</ul>
</section>
<div id="calendarBody"></div>
<div id="calendarModal" class="cal-modal" hidden role="dialog" aria-modal="true" aria-labelledby="calendarModalTitle">
<div class="cal-modal-backdrop"></div>
<div class="cal-modal-panel" id="calendarModalPanel"></div>
</div>
</div>`;
$('#calendarWatchForm').addEventListener('submit', e => { e.preventDefault(); tryAddCalendarSymbol(); });
$('#calendarRefresh').addEventListener('click', () => refreshCalendarData(true));
bindCalendarViewEvents(view);
renderWatchlistChips();
bindTermTips(view);
syncCalendarWatchlistFromServer().finally(() => refreshCalendarData(false));
}
function calendarEventLabel(ev) {
if (ev.symbol) return ev.symbol;
const t = ev.title || '';
const rules = [
[/FOMC.*點陣|SEP/i, 'FOMC+點陣'],
[/FOMC|聯準會.*利率/i, 'FOMC決議'],
[/CPI|消費者物價/i, 'CPI通膨'],
[/非農|Employment Situation/i, '非農就業'],
[/PCE|個人收入/i, 'PCE通膨'],
[/GDP|國內生產/i, 'GDP'],
[/PPI|生產者物價/i, 'PPI'],
[/JOLTS|職缺/i, 'JOLTS職缺'],
[/四巫/i, '四巫日'],
[/月選擇權/i, '選擇權到期'],
[/美股休市/i, '美股休市'],
[/歐洲央行|ECB/i, '歐央行'],
[/日本央行/i, '日央行'],
[/英央行|MPC/i, '英央行'],
[/Jackson Hole/i, '央行年會'],
[/ADP/i, 'ADP就業'],
[/初領失業/i, '失業救濟'],
[/密西根/i, '消費信心'],
[/零售銷售/i, '零售銷售'],
[/工業生產/i, '工業生產'],
[/新屋開工|成屋|營建許可/i, '房市數據'],
[/耐久財/i, '耐久財'],
[/消費信貸/i, '消費信貸'],
[/費城 Fed|製造業指數/i, '製造業調查'],
[/非製造業/i, '服務業調查'],
[/就業成本|ECI/i, '就業成本'],
[/生產力/i, '生產力'],
[/進出口物價/i, '進出口價'],
[/實質薪資/i, '實質薪資'],
[/國際貿易/i, '貿易數據'],
[/財報/i, '財報'],
];
for (const [re, label] of rules) if (re.test(t)) return label;
return t.length > 8 ? t.slice(0, 7) + '…' : t;
}
function calendarEventChip(ev) {
const cat = ev.category || 'macro';
const title = `${ev.title || ''}${ev.time ? ' · ' + ev.time : ''}${ev.note ? '\n' + ev.note : ''}`;
return `<button type="button" class="cal-ev ${escapeHtml(ev.impact || 'low')} cat-${escapeHtml(cat)}" title="${escapeHtml(title)}" data-ev-key="${escapeHtml(ev.date + '|' + (ev.symbol || '') + '|' + (ev.title || ''))}">${escapeHtml(calendarEventLabel(ev))}</button>`;
}
function calendarDayDetailHTML(date, events) {
if (!date) return '';
const d = new Date(date + 'T00:00:00');
const label = isNaN(d) ? date : d.toLocaleDateString('zh-TW', { month: 'long', day: 'numeric', weekday: 'long' });
if (!events.length) {
return `<div class="cal-day-detail">
<div class="cal-day-detail-head"><b id="calendarModalTitle">${escapeHtml(label)}</b><button type="button" class="cal-day-close" aria-label="關閉">✕</button></div>
<div class="empty-state" style="padding:18px 0">這天沒有事件。</div>
</div>`;
}
const rows = events.map(ev => {
const tipKey = eventTipKey(ev.title, ev.note);
const tip = tipKey ? termTipBtn(tipKey, ev.title) : '';
const cat = { fed: '聯準會', macro: '總經', earnings: '財報', derivatives: '衍生品', market: '市場', central_bank: '央行' }[ev.category] || '事件';
const impact = { high: '高', medium: '中', low: '低' }[ev.impact] || '低';
return `<div class="cal-detail-row ${escapeHtml(ev.impact || 'low')}">
<div class="cal-detail-main">
<div class="cal-detail-title"><span class="event-impact ${escapeHtml(ev.impact || 'low')}">${impact}</span><b>${escapeHtml(ev.title)}</b>${tip}${ev.symbol ? `<span class="event-symbol">${escapeHtml(ev.symbol)}</span>` : ''}</div>
<div class="cal-detail-note">${escapeHtml(ev.note || '—')}${ev.time ? ' · ' + escapeHtml(ev.time) : ''}</div>
</div>
<div class="cal-detail-meta">${escapeHtml(cat)}<small>${escapeHtml(ev.source || '')}</small></div>
</div>`;
}).join('');
return `<div class="cal-day-detail">
<div class="cal-day-detail-head"><b id="calendarModalTitle">${escapeHtml(label)}</b><span>${events.length} 項事件</span><button type="button" class="cal-day-close" aria-label="關閉">✕</button></div>
<div class="cal-detail-list">${rows}</div>
</div>`;
}
function closeCalendarDay() {
CAL.selectedDate = '';
$$('.cal-cell.selected').forEach(el => el.classList.remove('selected'));
const modal = $('#calendarModal');
if (modal) modal.hidden = true;
document.body.classList.remove('cal-modal-open');
}
function openCalendarDay(date) {
CAL.selectedDate = date || '';
$$('.cal-cell.selected').forEach(el => el.classList.remove('selected'));
const cell = $(`.cal-cell[data-date="${date}"]`);
if (cell) cell.classList.add('selected');
const events = CAL.events.filter(ev => ev.date === date);
setAIFocus({ type: 'calendar-day', date, label: `${date} · ${events.length} 項事件`, eventCount: events.length });
const modal = $('#calendarModal');
const panel = $('#calendarModalPanel');
if (!modal || !panel) return;
panel.innerHTML = calendarDayDetailHTML(date, events);
bindTermTips(panel);
modal.hidden = false;
document.body.classList.add('cal-modal-open');
$('.cal-day-close', panel)?.focus();
}
function buildCalendarGrid(events, range) {
const byDate = new Map();
for (const ev of events) {
if (ev.date < range.start || ev.date > range.end) continue;
if (!byDate.has(ev.date)) byDate.set(ev.date, []);
byDate.get(ev.date).push(ev);
}
for (const [, list] of byDate) {
list.sort((a, b) => {
const rank = { high: 0, medium: 1, low: 2 };
const ra = rank[a.impact] ?? 2, rb = rank[b.impact] ?? 2;
if (ra !== rb) return ra - rb;
return String(a.title).localeCompare(String(b.title));
});
}
const start = new Date(range.start + 'T00:00:00');
const end = new Date(range.end + 'T00:00:00');
const months = [];
let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
while (cursor <= end) {
months.push(new Date(cursor));
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
}
const monthHTML = months.map((m, idx) => {
const y = m.getFullYear(), mo = m.getMonth();
const firstDow = new Date(y, mo, 1).getDay();
const daysInMonth = new Date(y, mo + 1, 0).getDate();
let cells = '';
for (let i = 0; i < firstDow; i++) cells += '<div class="cal-cell pad"></div>';
for (let day = 1; day <= daysInMonth; day++) {
const iso = `${y}-${String(mo + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
if (iso < range.start || iso > range.end) {
cells += `<div class="cal-cell off"><span class="cal-day">${day}</span></div>`;
continue;
}
const dayEvents = byDate.get(iso) || [];
const cls = [
'cal-cell',
iso === range.today ? 'today' : '',
dayEvents.length ? 'has-events' : '',
dayEvents.some(e => e.impact === 'high') ? 'has-hot' : '',
].filter(Boolean).join(' ');
const evHtml = dayEvents.slice(0, 6).map(calendarEventChip).join('')
+ (dayEvents.length > 6 ? `<span class="cal-more" title="點日期看全部">+${dayEvents.length - 6} 更多</span>` : '');
cells += `<div class="${cls}" data-date="${iso}" role="button" tabindex="0" aria-label="${iso}${dayEvents.length} 項事件">
<div class="cal-day-top"><span class="cal-day">${day}</span>${dayEvents.length ? `<span class="cal-count">${dayEvents.length}</span>` : ''}</div>
<div class="cal-events">${evHtml || '<span class="cal-quiet">—</span>'}</div>
</div>`;
}
const title = m.toLocaleDateString('zh-TW', { year: 'numeric', month: 'long' });
return `<section class="cal-month">
<div class="cal-month-head"><h3>${escapeHtml(title)}</h3><span>${idx === 0 ? '從今天起' : ''}</span></div>
<div class="cal-weekdays">${CAL_WEEKDAYS.map(w => `<span>${w}</span>`).join('')}</div>
<div class="cal-grid">${cells}</div>
</section>`;
}).join('');
return `<div class="cal-board">${monthHTML}</div>`;
}
function formatCalendarCachedAt(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
return d.toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
async function refreshCalendarData(force) {
const body = $('#calendarBody');
if (!body) return;
const range = calendarRangeISO();
const symbols = loadCalendarSymbols();
const hadEvents = CAL.events.length > 0;
if (!hadEvents || force) {
body.innerHTML = `
<div class="calendar-summary">
<div class="calendar-stat"><b id="calendarEventCount">—</b><span>區間內事件(自動)</span></div>
<div class="calendar-stat"><b>${escapeHtml(range.start)}</b><span>起算日(今天)</span></div>
<div class="calendar-stat"><b>${escapeHtml(range.end)}</b><span>結束日(約兩個月)</span></div>
<div class="calendar-stat"><b>${symbols.length}</b><span>你追蹤的財報</span></div>
</div>
<div class="cal-legend">
<span><i class="leg high"></i>高衝擊</span>
<span><i class="leg medium"></i>中</span>
<span><i class="leg fed"></i>聯準會</span>
<span><i class="leg deriv"></i>四巫 / 選擇權</span>
<span><i class="leg cb"></i>全球央行</span>
<span><i class="leg earn"></i>財報(自訂)</span>
<span class="cal-legend-note">點日期 → 彈窗看完整列表與 ? 說明</span>
</div>
<div class="cal-loading"><div class="spinner" style="width:24px;height:24px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 10px;animation:spin .8s linear infinite"></div>正在載入日曆…</div>
<div id="calendarGridHost"></div>
<div class="metric-source-note" id="calendarSourceNote"></div>`;
} else {
const note = $('#calendarSourceNote');
if (note) note.textContent = '正在背景更新日曆…';
}
try {
closeCalendarDay();
const qs = new URLSearchParams({ symbols: symbols.join(','), start: range.start, end: range.end });
if (force) qs.set('fresh', '1');
const d = await api('/api/calendar?' + qs.toString());
CAL.events = (d.events || []).filter(ev => ev.date >= range.start && ev.date <= range.end);
const autoCount = CAL.events.filter(ev => ev.category !== 'earnings').length;
const countEl = $('#calendarEventCount');
if (countEl) countEl.textContent = String(autoCount);
const sourceNote = (d.sources || []).map(s => `${s.ok ? '已更新' : '待補'} ${s.name}`).join(' · ');
const loading = $('.cal-loading', body);
if (loading) loading.remove();
$('#calendarGridHost').innerHTML = buildCalendarGrid(CAL.events, range);
const staleHint = d.stale ? '(更新失敗,顯示資料庫舊資料)' : (d.cached ? '(資料庫快取,每日更新)' : '(剛更新)');
const timeHint = formatCalendarCachedAt(d.cachedAt) ? ` · 更新 ${formatCalendarCachedAt(d.cachedAt)}` : '';
$('#calendarSourceNote').textContent = `${CAL.events.length}${staleHint}${timeHint}。來源:${sourceNote}`;
if (CAL.selectedDate) openCalendarDay(CAL.selectedDate);
showCalendarMsg('', '');
} catch (e) {
body.innerHTML = `<div class="empty-state">無法更新日曆:${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
}
}
// ═══════════════════════════════════════════════════════════
// 學習教材視圖
// ═══════════════════════════════════════════════════════════
const LEARN = { lastSection: 'overview', graphFilter: 'curriculum', graphView: 'map', 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 learn-hero">
<div>
<div class="eyebrow">學習路徑</div>
<div class="page-title">照問題學,不要硬背名詞</div>
<div class="page-sub">從「現在大環境如何」「這家公司值不值得研究」「這筆交易哪裡做錯」三條路開始。每步都有連結與可點的工具,案例會標出可重複使用的原則。</div>
</div>
<div class="learn-stats">
<div><b>${(KB.cases || []).length}</b><span>案例講解</span></div>
<div><b>${(KB.principles || []).length}</b><span>投資原則</span></div>
<div><b>${c.terms || 0}</b><span>名詞速查</span></div>
</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="lab">學習實驗室</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 renderLearnHome();
if (section === 'lab') return renderLearnLab();
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 renderLearnHome() {
const content = $('#learnContent');
content.innerHTML = LearnUI.renderHome({ escapeHtml });
LearnUI.bindHome(content, {
openNote,
showSection,
goView(v) { location.hash = v === 'macro' ? '#/' : '#/' + v; },
});
window.scrollTo({ top: 0 });
}
function learnNoteKey(note) {
if (!note || !note.kind || !note.id) return '';
return 'learn_note:' + note.kind + ':' + note.id;
}
function readLearnNotes() {
try {
const out = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key || !key.startsWith('learn_note:')) continue;
const raw = localStorage.getItem(key);
if (!raw) continue;
out.push(JSON.parse(raw));
}
return out.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')));
} catch (_) { return []; }
}
function saveLearnNote(note, text) {
const key = learnNoteKey(note);
if (!key) return;
const payload = {
key, kind: note.kind, id: note.id, title: deEmmyText(note.title || note.id || ''),
text: String(text || '').trim(), updatedAt: new Date().toISOString(),
};
if (!payload.text) localStorage.removeItem(key);
else localStorage.setItem(key, JSON.stringify(payload));
}
function renderLearnLab() {
const content = $('#learnContent');
const pairs = [
['財報基本功', '用營收、毛利率、EPS 判斷公司賺錢品質', 'category', '財報基本功'],
['總經與利率', '用通膨、利率、就業判斷市場順逆風', 'category', '總經與利率'],
['交易與資金管理', '用倉位、停損、復盤控制犯錯成本', 'category', '交易與資金管理'],
['護城河與商業模式', '用定價權、平台、生態位判斷長期競爭力', 'category', '護城河與商業模式'],
];
const notes = readLearnNotes();
content.innerHTML = `
<div class="learn-lab">
<section class="lab-hero">
<div>
<div class="eyebrow">Practice Lab</div>
<h2>把學到的東西連起來</h2>
<p>先做配對,再到個股工具套用,最後把自己的判斷寫成筆記。這裡不是要背答案,是訓練你看到資料時知道該問哪一組問題。</p>
</div>
<div class="lab-score"><b id="labScore">0/${pairs.length}</b><span>已連對</span></div>
</section>
<section class="lab-game">
<div class="metric-section-head"><h3>概念配對</h3><span>左邊選概念,右邊選它真正要解決的判斷問題</span></div>
<div class="match-board">
<div>${pairs.map((p, i) => `<button class="match-card concept" data-pair="${i}" data-kind="${p[2]}" data-id="${escapeHtml(p[3])}">${escapeHtml(p[0])}</button>`).join('')}</div>
<div>${pairs.map((p, i) => `<button class="match-card answer" data-pair="${i}">${escapeHtml(p[1])}</button>`).join('')}</div>
</div>
<div class="lab-actions"><button class="btn ghost sm" id="labReset">重玩</button><button class="btn sm" id="labApply">拿去個股工具用</button></div>
</section>
<section class="lab-notes">
<div class="metric-section-head"><h3>我的學習筆記</h3><span>文章頁的筆記會收在這裡,方便回來複習</span></div>
<div class="note-list">${notes.length ? notes.map(n => `
<button class="note-card" data-kind="${escapeHtml(n.kind)}" data-id="${escapeHtml(n.id)}">
<b>${escapeHtml(n.title)}</b><span>${new Date(n.updatedAt).toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
<p>${escapeHtml(n.text.slice(0, 120))}</p>
</button>`).join('') : '<div class="empty-state">還沒有筆記。打開任一篇學習文章,在「我的筆記」裡寫下你的判斷。</div>'}</div>
</section>
</div>`;
bindLearnLab(content);
window.scrollTo({ top: 0 });
}
function bindLearnLab(content) {
const picked = { concept: null, answer: null };
const matched = new Set();
const total = $$('.match-card.concept', content).length || 1;
const update = () => { const s = $('#labScore'); if (s) s.textContent = `${matched.size}/${total}`; };
const check = () => {
if (!picked.concept || !picked.answer) return;
const ok = picked.concept.dataset.pair === picked.answer.dataset.pair;
if (ok) {
matched.add(picked.concept.dataset.pair);
picked.concept.classList.add('matched');
picked.answer.classList.add('matched');
} else {
picked.concept.classList.add('wrong');
picked.answer.classList.add('wrong');
setTimeout(() => { picked.concept?.classList.remove('wrong'); picked.answer?.classList.remove('wrong'); }, 520);
}
picked.concept?.classList.remove('picked');
picked.answer?.classList.remove('picked');
picked.concept = null; picked.answer = null; update();
};
$$('.match-card.concept', content).forEach(btn => btn.addEventListener('click', () => {
if (btn.classList.contains('matched')) return;
$$('.match-card.concept', content).forEach(x => x.classList.remove('picked'));
picked.concept = btn; btn.classList.add('picked'); check();
}));
$$('.match-card.answer', content).forEach(btn => btn.addEventListener('click', () => {
if (btn.classList.contains('matched')) return;
$$('.match-card.answer', content).forEach(x => x.classList.remove('picked'));
picked.answer = btn; btn.classList.add('picked'); check();
}));
$('#labReset')?.addEventListener('click', renderLearnLab);
$('#labApply')?.addEventListener('click', () => { location.hash = '#/stock'; });
$$('.note-card', content).forEach(btn => btn.addEventListener('click', () => openNote(btn.dataset.kind, btn.dataset.id)));
$$('.match-card.concept', content).forEach(btn => btn.addEventListener('dblclick', () => openNote(btn.dataset.kind, btn.dataset.id)));
}
function renderCardList(title, items, kind) {
const content = $('#learnContent');
let cards;
if (kind === 'case') {
cards = LearnUI.renderCaseCards(items, escapeHtml, { linkMap: KB.linkMap, principles: KB.principles });
} else {
cards = (items || []).map(it => `
<div class="module-card" data-id="${escapeHtml(it.id)}">
<div class="mod-name">${escapeHtml(deEmmyText(it.title))}</div>
${it.summary ? `<div class="mod-meta">${escapeHtml(deEmmyText(it.summary))}</div>` : ''}
</div>`).join('');
}
const hint = kind === 'case'
? '<p class="list-meta">每個案例都會標「可重用原則」——重點不是記住一家公司,而是記住判斷方法。</p>'
: '';
content.innerHTML = `<div class="page-title" style="font-size:1.1rem;margin-bottom:8px">${escapeHtml(title)}</div>${hint}<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');
content.innerHTML = `<div class="page-title" style="font-size:1.1rem;margin-bottom:6px">投資原則庫</div>
<div class="list-meta">共 ${(KB.principles || []).length} 條。已依主題分群;點開可讀白話說明。完整索引見「原則地圖」。</div>
<div class="principle-groups">${LearnUI.renderPrincipleGroups(KB.principles, escapeHtml)}</div>`;
$$('.pg-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;
LEARN.graphView = opts.view || LEARN.graphView || 'map';
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 id="graphViewChips" class="chip-row"></div></div>
<div id="graphCanvas" class="graph-canvas knowledge-map"><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 }));
mountChips($('#graphViewChips'), [
{ id: 'map', label: '分群地圖' },
{ id: 'list', label: '關係清單' },
], LEARN.graphView, v => showGraph({ filter, center, depth, view: v }), { sm: true });
$('#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 (graphNetwork) { graphNetwork.destroy(); graphNetwork = null; }
renderKnowledgeMap(el, data, { center, view: LEARN.graphView });
$('#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 });
}
function graphKind(id) {
return String(id || '').split(':')[0] || 'note';
}
function graphKindLabel(kind) {
const extra = { overview: '課綱', principleMap: '原則地圖', quiz: '練習' };
return extra[kind] || (GRAPH_LEGEND.find(g => g[0] === kind) || [kind, kind])[1];
}
function renderKnowledgeMap(el, data, opts = {}) {
const nodes = data.nodes || [];
const edges = data.edges || [];
const byId = new Map(nodes.map(n => [n.id, n]));
const degree = new Map();
edges.forEach(e => { degree.set(e.from, (degree.get(e.from) || 0) + 1); degree.set(e.to, (degree.get(e.to) || 0) + 1); });
const groups = {};
nodes.forEach(n => {
const k = n.kind || graphKind(n.id);
if (!groups[k]) groups[k] = [];
groups[k].push({ ...n, degree: degree.get(n.id) || 0 });
});
Object.values(groups).forEach(arr => arr.sort((a, b) => b.degree - a.degree || String(a.label).localeCompare(String(b.label))));
if (opts.view === 'list') {
el.innerHTML = `<div class="kg-list">${edges.slice(0, 160).map(e => {
const a = byId.get(e.from), b = byId.get(e.to);
if (!a || !b) return '';
return `<button class="kg-edge" data-id="${escapeHtml(a.id)}">
<span>${escapeHtml(a.label || a.title || a.id)}</span><i></i><span>${escapeHtml(b.label || b.title || b.id)}</span>
</button>`;
}).join('')}</div>`;
} else {
const order = ['overview', 'category', 'case', 'principle', 'term', 'company', 'episode', 'principleMap'];
const keys = Object.keys(groups).sort((a, b) => (order.indexOf(a) < 0 ? 99 : order.indexOf(a)) - (order.indexOf(b) < 0 ? 99 : order.indexOf(b)));
el.innerHTML = `<div class="kg-map">
${keys.map(k => `<section class="kg-column">
<div class="kg-head"><b>${escapeHtml(graphKindLabel(k))}</b><span>${groups[k].length}</span></div>
<div class="kg-nodes">${groups[k].slice(0, 42).map(n => `<button class="kg-node ${opts.center === n.id ? 'active' : ''}" data-id="${escapeHtml(n.id)}">
<span>${escapeHtml(n.label || n.title || n.id)}</span><small>${n.degree} 連結</small>
</button>`).join('')}</div>
</section>`).join('')}
</div><aside class="kg-focus" id="kgFocus"><div class="empty-state">點一個節點,看它的上下游關係。</div></aside>`;
}
const focus = (nid) => {
const n = byId.get(nid);
if (!n) return;
const near = edges.filter(e => e.from === nid || e.to === nid).slice(0, 24).map(e => {
const otherId = e.from === nid ? e.to : e.from;
return byId.get(otherId);
}).filter(Boolean);
const box = $('#kgFocus');
if (box) box.innerHTML = `
<div class="kg-focus-head"><b>${escapeHtml(n.label || n.title || n.id)}</b><span>${escapeHtml(graphKindLabel(n.kind || graphKind(n.id)))}</span></div>
<div class="kg-focus-actions"><button class="btn sm" data-open-node="${escapeHtml(n.id)}">打開筆記</button></div>
<div class="kg-relations">${near.length ? near.map(x => `<button class="kg-relation" data-id="${escapeHtml(x.id)}">
<span>${escapeHtml(graphKindLabel(x.kind || graphKind(x.id)))}</span><b>${escapeHtml(x.label || x.title || x.id)}</b>
</button>`).join('') : '<div class="empty-state">這個節點暫時沒有鄰近連結。</div>'}</div>`;
$$('.kg-node', el).forEach(x => x.classList.toggle('active', x.dataset.id === nid));
};
el.addEventListener('click', e => {
const openBtn = e.target.closest('[data-open-node]');
if (openBtn) {
const colon = openBtn.dataset.openNode.indexOf(':');
if (colon >= 0) openNote(openBtn.dataset.openNode.slice(0, colon), openBtn.dataset.openNode.slice(colon + 1));
return;
}
const nodeBtn = e.target.closest('[data-id]');
if (!nodeBtn) return;
focus(nodeBtn.dataset.id);
});
if (opts.center && byId.has(opts.center)) focus(opts.center);
}
// ═══════════════════════════════════════════════════════════
// 共用 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: 'metrics', priceRange: '1y', rendered: {}, mapAnswers: {}, mapCfg: null, fundamentals: {} };
const SUBS = ['metrics', '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>健檢、用<b>六層漏斗投資地圖</b>判斷該不該進場、以及策略<b>回測</b>。</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 id="stockLearnBridge"></div>
<div class="sub-tabs" id="stkSub">
<a data-sub="metrics" class="active">指標面板</a>
<a data-sub="price">價格走勢</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-metrics"></div>
<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)));
renderStockLearningBridge();
setSub('metrics');
}
function renderStockLearningBridge() {
const box = $('#stockLearnBridge');
if (!box) return;
box.innerHTML = `
<div class="stock-learn-bridge">
<div class="slb-head"><b>個股研究任務</b><span>每看一檔股票,都照這條路把學習連回工具</span></div>
<div class="slb-steps">
<button data-learn-kind="category" data-learn-id="財報基本功"><span>1</span><b>先學財報</b><small>營收、毛利、EPS</small></button>
<button data-sub-target="finbox"><span>2</span><b>套到健檢</b><small>看紅黃綠燈</small></button>
<button data-learn-kind="category" data-learn-id="護城河與商業模式"><span>3</span><b>理解生意</b><small>定價權與產業位置</small></button>
<button data-sub-target="map"><span>4</span><b>六層漏斗</b><small>回答能不能進場</small></button>
<button data-sub-target="backtest"><span>5</span><b>策略驗證</b><small>用歷史測規則</small></button>
</div>
</div>`;
$$('[data-learn-kind]', box).forEach(btn => btn.addEventListener('click', () => openNote(btn.dataset.learnKind, btn.dataset.learnId)));
$$('[data-sub-target]', box).forEach(btn => btn.addEventListener('click', () => setSub(btn.dataset.subTarget)));
}
function setStockSymbol(sym) {
sym = (sym || '').trim().toUpperCase();
if (!sym) return;
STOCK.symbol = sym;
setAIFocus({ type: 'stock', symbol: sym, subPage: STOCK.sub, label: `${sym} · ${STOCK.sub}` });
STOCK.rendered = {}; // 換股票 → 各分頁重抓
STOCK.fundamentals = {};
$('#stkSym').value = sym;
if (STOCK.sub === 'map') setSub('metrics'); // 輸入代號後預設先看指標面板
else renderSub(STOCK.sub);
}
function setSub(sub) {
if (!SUBS.includes(sub)) sub = 'price';
STOCK.sub = sub;
if (STOCK.symbol) setAIFocus({ type: 'stock', symbol: STOCK.symbol, subPage: sub, label: `${STOCK.symbol} · ${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 === 'metrics') return renderMetricsPane();
if (sub === 'price') return renderPrice();
if (sub === 'finbox') return renderFinboxPane();
if (sub === 'map') return renderMap();
if (sub === 'backtest') return renderBacktestPane();
}
// ── 指標面板(市場總覽 / 風險 / 回報 / 效率 / 預測 / 穩健度)──
async function loadFundamentals(sym, fresh) {
sym = (sym || STOCK.symbol || '').trim().toUpperCase();
if (!fresh && STOCK.fundamentals[sym]) return STOCK.fundamentals[sym];
const d = await api('/api/fundamentals/' + encodeURIComponent(sym) + (fresh ? '?fresh=1' : ''));
STOCK.fundamentals[sym] = d;
return d;
}
function calcPct(a, b) { return (a != null && b) ? (a / b) * 100 : null; }
function growth(cur, prev) { return (cur != null && prev) ? ((cur - prev) / Math.abs(prev)) * 100 : null; }
function ratio(a, b) { return (a != null && b) ? a / b : null; }
function fmtRatio(v, d = 1) { return v == null || isNaN(v) ? '—' : Number(v).toFixed(d) + 'x'; }
function fmtMetric(v, kind) {
if (v == null || isNaN(v)) return '—';
if (kind === 'money') return fmtMoney(v);
if (kind === 'pct') return fmtPct(v, 1);
if (kind === 'ratio') return fmtRatio(v, 1);
if (kind === 'num') return fmtNum(v, 2);
if (kind === 'shares') return fmtNum(v / 1e9, 2) + 'B';
if (kind === 'compact') {
const a = Math.abs(v), s = v < 0 ? '-' : '';
if (a >= 1e9) return s + fmtNum(a / 1e9, 2) + 'B';
if (a >= 1e6) return s + fmtNum(a / 1e6, 2) + 'M';
if (a >= 1e3) return s + fmtNum(a / 1e3, 1) + 'K';
return fmtNum(v, 0);
}
return String(v);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function priceStats(points) {
points = (points || []).filter(p => p.close != null);
if (points.length < 2) return {};
const last = points[points.length - 1];
const nearest = (days) => {
const target = new Date(last.date); target.setUTCDate(target.getUTCDate() - days);
let best = points[0], bd = Infinity;
for (const p of points) {
const d = Math.abs(new Date(p.date) - target);
if (d < bd) { bd = d; best = p; }
}
return best;
};
const ret = days => {
const p = nearest(days);
return p && p.close ? (last.close / p.close - 1) * 100 : null;
};
const recent = points.slice(-61);
const daily = [];
for (let i = 1; i < recent.length; i++) if (recent[i - 1].close) daily.push(recent[i].close / recent[i - 1].close - 1);
const mean = daily.reduce((a, b) => a + b, 0) / (daily.length || 1);
const variance = daily.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / Math.max(1, daily.length - 1);
let peak = -Infinity, mdd = 0;
for (const p of points) { peak = Math.max(peak, p.close); if (peak > 0) mdd = Math.min(mdd, (p.close / peak - 1) * 100); }
return { ret1m: ret(30), ret3m: ret(91), ret6m: ret(182), ret1y: ret(365), ret3y: ret(1095), ret5y: ret(1825), volatility: Math.sqrt(variance) * Math.sqrt(252) * 100, maxDrawdown: mdd };
}
function technicalStats(points, quote = {}) {
const clean = (points || []).filter(p => p.close != null).map(p => ({ ...p, close: Number(p.close) }));
if (clean.length < 20) return {};
const closes = clean.map(p => p.close);
const last = quote.price ?? closes[closes.length - 1];
const avg = (arr) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null;
const ma = (n) => closes.length >= n ? avg(closes.slice(-n)) : null;
const dist = (v) => (last != null && v) ? (last / v - 1) * 100 : null;
const emaSeries = (period) => {
const k = 2 / (period + 1);
const out = [];
let prev = null;
for (let i = 0; i < closes.length; i++) {
prev = prev == null ? closes[i] : closes[i] * k + prev * (1 - k);
out.push(prev);
}
return out;
};
let rsi14 = null;
if (closes.length > 14) {
let gains = 0, losses = 0;
for (let i = closes.length - 14; i < closes.length; i++) {
const ch = closes[i] - closes[i - 1];
if (ch >= 0) gains += ch; else losses -= ch;
}
const avgGain = gains / 14, avgLoss = losses / 14;
rsi14 = avgLoss === 0 ? 100 : 100 - (100 / (1 + avgGain / avgLoss));
}
const ema12 = emaSeries(12), ema26 = emaSeries(26);
const macdSeries = ema12.map((v, i) => v - ema26[i]);
const signal = (() => {
const k = 2 / 10;
let prev = null;
for (const v of macdSeries) prev = prev == null ? v : v * k + prev * (1 - k);
return prev;
})();
const macd = macdSeries[macdSeries.length - 1];
const ma20 = ma(20), ma50 = quote.fiftyDayAverage ?? ma(50), ma100 = ma(100), ma200 = quote.twoHundredDayAverage ?? ma(200);
const last20 = closes.slice(-20);
const sd20 = last20.length >= 20 ? Math.sqrt(last20.reduce((a, v) => a + Math.pow(v - ma20, 2), 0) / last20.length) : null;
const bollUpper = ma20 != null && sd20 != null ? ma20 + 2 * sd20 : null;
const bollLower = ma20 != null && sd20 != null ? ma20 - 2 * sd20 : null;
const bollPos = (last != null && bollUpper != null && bollLower != null && bollUpper !== bollLower) ? ((last - bollLower) / (bollUpper - bollLower)) * 100 : null;
const last252 = clean.slice(-252).map(p => p.close);
const high52 = quote.fiftyTwoWeekHigh ?? (last252.length ? Math.max(...last252) : null);
const low52 = quote.fiftyTwoWeekLow ?? (last252.length ? Math.min(...last252) : null);
const pos52 = (last != null && high52 != null && low52 != null && high52 !== low52) ? ((last - low52) / (high52 - low52)) * 100 : null;
const volumeRatio = (quote.volume != null && quote.avgVolume) ? quote.volume / quote.avgVolume : null;
const trendScore =
(last != null && ma20 != null && last > ma20 ? 1 : -1) +
(last != null && ma50 != null && last > ma50 ? 1 : -1) +
(last != null && ma200 != null && last > ma200 ? 1 : -1) +
(ma50 != null && ma200 != null && ma50 > ma200 ? 1 : -1);
return {
last, ma5: ma(5), ma10: ma(10), ma20, ma50, ma100, ma200,
dist20: dist(ma20), dist50: dist(ma50), dist200: dist(ma200),
rsi14, macd, macdSignal: signal, macdHist: macd != null && signal != null ? macd - signal : null,
bollUpper, bollLower, bollPos, high52, low52, pos52, volumeRatio, trendScore,
};
}
function metricStatus(v, good, warn, invert) {
if (v == null || isNaN(v)) return 'na';
if (invert) return v <= good ? 'good' : v <= warn ? 'warn' : 'bad';
return v >= good ? 'good' : v >= warn ? 'warn' : 'bad';
}
function metricCard(m) {
const cls = m.missing ? 'missing' : (m.status || 'na');
const tip = m.tipKey ? termTipBtn(m.tipKey, m.label) : '';
return `<div class="metric-card ${cls}">
<div class="metric-name"><span>${escapeHtml(m.label)}</span>${tip}</div>
<div class="metric-value">${m.missing ? '等待免費來源' : escapeHtml(m.value)}</div>
<div class="metric-note">${escapeHtml(m.note || '')}</div>
</div>`;
}
function metricSection(title, subtitle, items, sectionTipKey) {
const headTip = sectionTipKey ? termTipBtn(sectionTipKey, title) : '';
return `<section class="metric-section">
<div class="metric-section-head"><h3>${escapeHtml(title)}${headTip}</h3><span>${escapeHtml(subtitle)}</span></div>
<div class="metric-grid">${items.map(metricCard).join('')}</div>
</section>`;
}
function formulaBlock(title, formula, note, tipKey) {
const tip = tipKey ? termTipBtn(tipKey, title) : '';
return `<div class="formula-card">
<div class="formula-title">${escapeHtml(title)}${tip}</div>
<pre>${escapeHtml(formula)}</pre>
<div class="formula-note">${escapeHtml(note || '')}</div>
</div>`;
}
function dcfValue({ fcf, cash, debt, shares, price, revGrowth, netGrowth, grossMargin, roe, debtEquity, volatility }) {
if (!(fcf > 0) || !(shares > 0)) return null;
const growthInputs = [revGrowth, netGrowth].filter(v => v != null && isFinite(v));
let growth = growthInputs.length ? growthInputs.reduce((a, b) => a + b, 0) / growthInputs.length : 6;
if (grossMargin >= 55) growth += 2;
if (roe >= 25) growth += 2;
growth = clamp(growth, -5, 25);
const terminalGrowth = 2.5;
let discount = 9;
if (volatility > 35) discount += 1;
if (volatility > 55) discount += 1;
if (debtEquity > 1) discount += 1;
discount = clamp(discount, 8, 13);
const run = (g, dr) => {
const tg = terminalGrowth / 100;
const r = dr / 100;
let pv = 0, cur = fcf;
for (let year = 1; year <= 5; year++) {
const fade = (6 - year) / 5;
const yrGrowth = (tg + ((g / 100) - tg) * fade);
cur *= (1 + yrGrowth);
pv += cur / Math.pow(1 + r, year);
}
const terminal = (cur * (1 + tg)) / Math.max(0.01, r - tg);
const equityValue = pv + terminal + (cash || 0) - (debt || 0);
return equityValue / shares;
};
const fair = run(growth, discount);
const low = run(clamp(growth - 4, -8, 22), discount + 1);
const high = run(clamp(growth + 4, -2, 30), Math.max(7, discount - 1));
return {
fair, low, high, growth, discount, terminalGrowth,
upside: price ? ((fair / price) - 1) * 100 : null,
};
}
function decisionSummary({ d, px, tech, fair, revGrowth, netGrowth, grossMargin, roe, debtEquity, backtests }) {
let score = 0;
const reasons = [];
const cautions = [];
const actions = [];
if (fair?.upside != null) {
if (fair.upside >= 20) { score += 2; reasons.push(`DCF 顯示安全邊際 ${fmtPct(fair.upside, 1)},估值有折價。`); }
else if (fair.upside >= 0) { score += 1; reasons.push(`DCF 公允價值略高於現價,估值不算貴。`); }
else if (fair.upside <= -25) { score -= 2; cautions.push(`DCF 顯示現價高於公允價值 ${fmtPct(Math.abs(fair.upside), 1)},追價風險高。`); }
else { score -= 1; cautions.push(`DCF 略低於現價,估值需要更高成長兌現。`); }
}
if (d.targetPrice && d.price) {
const targetUpside = (d.targetPrice / d.price - 1) * 100;
if (targetUpside >= 15) { score += 1; reasons.push(`公開目標價仍有 ${fmtPct(targetUpside, 1)} 空間。`); }
else if (targetUpside < 0) { score -= 1; cautions.push('公開目標價低於現價,市場共識不支持追高。'); }
}
if ((px.ret6m ?? 0) > 10 && (px.ret1y ?? 0) > 0) { score += 1; reasons.push('中長期價格趨勢仍向上。'); }
if ((px.ret3m ?? 0) < -10 || (px.ret6m ?? 0) < -15) { score -= 1; cautions.push('近期價格動能轉弱,先等趨勢修復。'); }
if (tech?.trendScore >= 3) { score += 1; reasons.push('現價站上主要均線MA50 也高於 MA200技術趨勢偏多。'); }
else if (tech?.dist200 != null && tech.dist200 < -3) { score -= 1; cautions.push(`現價低於 MA200 ${fmtPct(Math.abs(tech.dist200), 1)},長線趨勢仍需修復。`); }
if (tech?.rsi14 != null && tech.rsi14 >= 75) { score -= 1; cautions.push(`RSI(14) 約 ${tech.rsi14.toFixed(0)},短線偏熱,追價要保守。`); }
else if (tech?.rsi14 != null && tech.rsi14 <= 30) { cautions.push(`RSI(14) 約 ${tech.rsi14.toFixed(0)},可能超賣,但需要價格止穩確認。`); }
if ((grossMargin ?? 0) >= 50 && (roe ?? 0) >= 15) { score += 1; reasons.push('毛利率與 ROE 反映品質不錯。'); }
if ((revGrowth ?? 0) >= 15 || (netGrowth ?? 0) >= 10) { score += 1; reasons.push('營收/淨利成長仍有支撐。'); }
if ((px.volatility ?? 0) > 45) { score -= 1; cautions.push('波動偏高,部位要小或分批。'); }
if ((debtEquity ?? 0) > 1.5) { score -= 1; cautions.push('槓桿偏高,利率或景氣逆風時要更保守。'); }
const smaBt = backtests?.find(b => b.strategy === 'sma');
if (smaBt?.stats && smaBt?.benchStats) {
if (smaBt.stats.cagr > 0 && smaBt.stats.maxDrawdown < smaBt.benchStats.maxDrawdown) {
score += 1; reasons.push('均線策略歷史上降低回撤,適合用趨勢訊號控風險。');
} else if (smaBt.stats.cagr < 0) {
score -= 1; cautions.push('均線策略回測不佳,機械追趨勢容易被洗。');
}
}
let label = '觀望', cls = 'na';
if (score >= 4) { label = '偏買 / 可分批'; cls = 'good'; actions.push('可考慮分批建立部位,並用回測策略當進出場規則。'); }
else if (score >= 2) { label = '偏多觀望'; cls = 'good'; actions.push('基本面或趨勢有支撐,但建議等技術訊號或回檔再加。'); }
else if (score >= 0) { label = '觀望 / 等訊號'; cls = 'warn'; actions.push('不急著買,先等估值、趨勢或財報其中一項轉強。'); }
else { label = '減碼 / 避免追高'; cls = 'bad'; actions.push('若已持有,偏向設移動停利或減碼;新倉等更好的價格或訊號。'); }
if (!reasons.length) reasons.push('目前可用資料還不足以形成強烈買進理由。');
if (!cautions.length) cautions.push('仍需留意財報公布、總經環境與單日大幅波動。');
return { label, cls, score, reasons, cautions, actions };
}
function decisionHTML(summary) {
return `<section class="decision-panel ${summary.cls}">
<div class="decision-kicker">行動提醒</div>
<div class="decision-title">${escapeHtml(summary.label)}</div>
<div class="decision-score">綜合分數 ${summary.score >= 0 ? '+' : ''}${summary.score}</div>
<div class="decision-cols">
<div><b>支持理由</b>${summary.reasons.map(x => `<p>${escapeHtml(x)}</p>`).join('')}</div>
<div><b>小提醒</b>${summary.cautions.map(x => `<p>${escapeHtml(x)}</p>`).join('')}</div>
<div><b>操作方式</b>${summary.actions.map(x => `<p>${escapeHtml(x)}</p>`).join('')}</div>
</div>
</section>`;
}
function strategySummaryHTML(backtests) {
if (!Array.isArray(backtests) || !backtests.length) return '';
const rows = backtests.map(b => `
<div class="strategy-row">
<div><b>${escapeHtml(b.strategyLabel)}</b><span>${escapeHtml(b.formula || '')}</span></div>
<div class="${b.stats?.cagr >= 0 ? 'pnl-pos' : 'pnl-neg'}">${b.stats ? (b.stats.cagr >= 0 ? '+' : '') + b.stats.cagr.toFixed(1) + '% CAGR' + termTipBtn('cagr', 'CAGR') : '—'}</div>
<div>${b.stats ? b.stats.maxDrawdown.toFixed(1) + '% 回撤' + termTipBtn('max_drawdown', '最大回撤') : '—'}</div>
</div>`).join('');
return `<section class="strategy-panel">
<div class="metric-section-head"><h3>策略回測摘要</h3><span>用歷史資料輔助判斷,不代表未來</span></div>
<div class="strategy-table">${rows}</div>
<div class="formula-grid">
${formulaBlock('均線趨勢', 'fast = sma(close, 50)\nslow = sma(close, 200)\ninMarket = fast > slow', '短均線在長均線之上才持有,跌破就空手。', 'sma_strategy')}
${formulaBlock('回落買進', 'peak = highest(close)\ndrawdown = close / peak - 1\nbuy = drawdown <= -15%', '等待從高點回落到設定幅度再進場。', 'dip_strategy')}
${formulaBlock('分批投入', 'if monthChanged\n buy(monthlyAmount)', '不猜低點,每月固定投入,適合降低進場時點壓力。', 'dca_strategy')}
</div>
</section>`;
}
function quoteFreshLabel(q) {
if (!q?.marketTime && !q?._fetchedAt) return '';
const t = q.marketTime ? new Date(q.marketTime) : new Date(q._fetchedAt);
if (isNaN(t)) return '';
return t.toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function buildMetrics(d, px, tech, quote, backtests) {
const q = d.quarters || [], a = d.annual || [], bal = d.balance || {};
const curQ = q[0] || {}, prevQ = q[1] || {}, yaQ = q[4] || null;
const curY = a[0] || {}, prevY = a[1] || {};
const equity = bal.totalEquity != null ? bal.totalEquity : ((bal.totalAssets != null && bal.totalLiabilities != null) ? bal.totalAssets - bal.totalLiabilities : null);
const ev = d.marketCap != null ? d.marketCap + (bal.totalDebt || 0) - (bal.cash || 0) : null;
const fcf = curY.ocf != null && curY.capex != null ? curY.ocf + curY.capex : null;
const shares = d.sharesOutstanding ?? ((d.marketCap != null && d.price) ? d.marketCap / d.price : null);
const pe = d.peTrailing ?? ((d.marketCap != null && curY.netIncome > 0) ? d.marketCap / curY.netIncome : null);
const revGrowth = yaQ ? growth(curQ.revenue, yaQ.revenue) : growth(curY.revenue, prevY.revenue);
const netGrowth = yaQ ? growth(curQ.netIncome, yaQ.netIncome) : growth(curY.netIncome, prevY.netIncome);
const opMargin = curQ.operatingMargin ?? null;
const grossMargin = curQ.grossMargin ?? curY.grossMargin ?? null;
const netMargin = curQ.netMargin ?? curY.netMargin ?? null;
const roa = calcPct(curY.netIncome, bal.totalAssets);
const roe = calcPct(curY.netIncome, equity);
const debtEquity = ratio(bal.totalLiabilities, equity);
const currentRatio = ratio(bal.currentAssets, bal.currentLiabilities);
const cashDebt = ratio(bal.cash, bal.totalDebt);
const fair = dcfValue({
fcf, cash: bal.cash, debt: bal.totalDebt, shares, price: d.price,
revGrowth, netGrowth, grossMargin, roe, debtEquity, volatility: px.volatility,
});
const summary = decisionSummary({ d, px, tech, fair, revGrowth, netGrowth, grossMargin, roe, debtEquity, backtests });
const changeText = quote?.changePercent != null
? `${quote.change >= 0 ? '+' : ''}${fmtNum(quote.change, 2)} / ${quote.changePercent >= 0 ? '+' : ''}${fmtPct(quote.changePercent, 2)}`
: '—';
const rsiStatus = tech.rsi14 == null ? 'na' : tech.rsi14 >= 75 ? 'bad' : tech.rsi14 >= 65 ? 'warn' : tech.rsi14 <= 30 ? 'warn' : 'good';
const macdStatus = tech.macdHist == null ? 'na' : tech.macdHist >= 0 ? 'good' : 'bad';
const bollStatus = tech.bollPos == null ? 'na' : tech.bollPos > 95 ? 'bad' : tech.bollPos > 80 ? 'warn' : tech.bollPos < 5 ? 'warn' : 'good';
const pos52Status = tech.pos52 == null ? 'na' : tech.pos52 > 92 ? 'warn' : tech.pos52 < 25 ? 'bad' : 'good';
return [
decisionHTML(summary),
metricSection('市場總覽', '價格、估值與公司規模', [
{ label: '現價', value: fmtNum(d.price, 2), status: quote?.changePercent >= 0 ? 'good' : quote?.changePercent < 0 ? 'bad' : 'na', note: `${d.currency || ''}${quoteFreshLabel(quote) ? ' · ' + quoteFreshLabel(quote) : ''}` },
{ label: '今日漲跌', value: changeText, status: quote?.changePercent >= 0 ? 'good' : quote?.changePercent < 0 ? 'bad' : 'na', note: quote?.source || '免費報價來源' },
{ label: '市值', tipKey: 'market_cap', value: fmtMoney(d.marketCap), status: 'na', note: 'Yahoo/價格資料' },
{ label: '企業價值 EV', tipKey: 'ev', value: fmtMoney(ev), status: 'na', note: '市值 + 債務 - 現金' },
{ label: '市盈率 P/E', tipKey: 'pe', value: fmtRatio(pe, 1), status: metricStatus(pe, 25, 45, true), note: d.peTrailing != null ? 'Yahoo trailing PE' : '市值 / 年度淨利估算' },
{ label: '流通股數', tipKey: 'shares', value: fmtMetric(shares, 'shares'), status: 'na', note: d.sharesOutstanding != null ? '來源揭露' : '市值 / 現價估算' },
{ label: '股息殖利率', tipKey: 'dividend_yield', value: fmtPct(d.dividendYield, 2), status: 'na', note: 'Nasdaq summary' },
{ label: '營收成長', tipKey: 'rev_growth', value: fmtPct(revGrowth, 1), status: metricStatus(revGrowth, 15, 0), note: yaQ ? '最近季年增' : '年度年增' },
], 'section_market'),
metricSection('技術面', 'MA、RSI、MACD、量能與價格位置', [
{ label: 'MA20 / MA50', tipKey: 'ma', value: `${fmtNum(tech.ma20, 2)} / ${fmtNum(tech.ma50, 2)}`, status: metricStatus(tech.dist50, 0, -5), note: `距 MA50 ${fmtPct(tech.dist50, 1)}` },
{ label: 'MA100 / MA200', tipKey: 'ma', value: `${fmtNum(tech.ma100, 2)} / ${fmtNum(tech.ma200, 2)}`, status: metricStatus(tech.dist200, 0, -8), note: `距 MA200 ${fmtPct(tech.dist200, 1)}` },
{ label: 'RSI(14)', tipKey: 'rsi', value: fmtNum(tech.rsi14, 1), status: rsiStatus, note: tech.rsi14 >= 70 ? '偏熱' : tech.rsi14 <= 30 ? '偏弱/超賣' : '中性區間' },
{ label: 'MACD(12,26,9)', tipKey: 'macd', value: fmtNum(tech.macdHist, 2), status: macdStatus, note: tech.macdHist >= 0 ? '柱狀體偏多' : '柱狀體偏空' },
{ label: '布林位置', tipKey: 'boll', value: fmtPct(tech.bollPos, 0), status: bollStatus, note: `下緣 ${fmtNum(tech.bollLower, 2)} / 上緣 ${fmtNum(tech.bollUpper, 2)}` },
{ label: '52週位置', tipKey: 'pos52', value: fmtPct(tech.pos52, 0), status: pos52Status, note: `${fmtNum(tech.low52, 2)} ~ ${fmtNum(tech.high52, 2)}` },
{ label: '成交量 / 均量', tipKey: 'volume_ratio', value: fmtRatio(tech.volumeRatio, 2), status: metricStatus(tech.volumeRatio, 1.2, .7), note: quote?.volume != null ? `今日量 ${fmtMetric(quote.volume, 'compact')}` : '等待成交量' },
{ label: '日內高低', value: `${fmtNum(quote?.dayLow, 2)} ~ ${fmtNum(quote?.dayHigh, 2)}`, status: 'na', note: quote?.previousClose != null ? `昨收 ${fmtNum(quote.previousClose, 2)}` : '' },
], 'section_technical'),
metricSection('風險', '財務壓力與股價波動', [
{ label: '總負債 / 總資產', tipKey: 'debt_assets', value: fmtPct(bal.debtToAssets, 1), status: metricStatus(bal.debtToAssets, 50, 70, true), note: '越低越穩' },
{ label: '負債股本比', tipKey: 'debt_equity', value: fmtRatio(debtEquity, 1), status: metricStatus(debtEquity, 1, 2, true), note: '總負債 / 股東權益' },
{ label: '流動比率', tipKey: 'current_ratio', value: fmtRatio(currentRatio, 1), status: metricStatus(currentRatio, 1.5, 1), note: '流動資產 / 流動負債' },
{ label: '現金 / 債務', tipKey: 'cash_debt', value: fmtRatio(cashDebt, 1), status: metricStatus(cashDebt, 1, .3), note: '償債緩衝' },
{ label: '60日年化波動', tipKey: 'volatility', value: fmtPct(px.volatility, 1), status: metricStatus(px.volatility, 25, 45, true), note: '由日報酬估算' },
{ label: '最大回撤', tipKey: 'max_drawdown', value: fmtPct(px.maxDrawdown, 1), status: metricStatus(Math.abs(px.maxDrawdown), 30, 55, true), note: '目前資料區間內' },
], 'section_risk'),
metricSection('回報', '股價不同期間的報酬', [
{ label: '1個月', value: fmtPct(px.ret1m, 1), status: metricStatus(px.ret1m, 0, -10), note: '價格報酬' },
{ label: '3個月', value: fmtPct(px.ret3m, 1), status: metricStatus(px.ret3m, 0, -15), note: '價格報酬' },
{ label: '6個月', value: fmtPct(px.ret6m, 1), status: metricStatus(px.ret6m, 0, -20), note: '價格報酬' },
{ label: '1年', value: fmtPct(px.ret1y, 1), status: metricStatus(px.ret1y, 0, -25), note: '價格報酬' },
{ label: '3年', value: fmtPct(px.ret3y, 1), status: metricStatus(px.ret3y, 0, -35), note: '價格報酬' },
{ label: '5年', value: fmtPct(px.ret5y, 1), status: metricStatus(px.ret5y, 0, -45), note: '價格報酬' },
], 'section_return'),
metricSection('效率', '獲利品質與資產使用效率', [
{ label: '毛利率', tipKey: 'gross_margin', value: fmtPct(grossMargin, 1), status: metricStatus(grossMargin, 50, 25), note: '定價權與產品組合' },
{ label: '營業利潤率', tipKey: 'op_margin', value: fmtPct(opMargin, 1), status: metricStatus(opMargin, 25, 8), note: '本業獲利效率' },
{ label: '淨利率', tipKey: 'net_margin', value: fmtPct(netMargin, 1), status: metricStatus(netMargin, 15, 0), note: '最後落袋比例' },
{ label: 'ROA', tipKey: 'roa', value: fmtPct(roa, 1), status: metricStatus(roa, 8, 2), note: '淨利 / 資產' },
{ label: 'ROE', tipKey: 'roe', value: fmtPct(roe, 1), status: metricStatus(roe, 15, 5), note: '淨利 / 股東權益' },
{ label: 'FCF Margin', tipKey: 'fcf_margin', value: fmtPct(calcPct(fcf, curY.revenue), 1), status: metricStatus(calcPct(fcf, curY.revenue), 10, 0), note: '自由現金流 / 營收' },
], 'section_efficiency'),
metricSection('預測', '公開摘要與尚待接入的共識資料', [
d.targetPrice != null
? { label: '1年目標價', tipKey: 'target_price', value: fmtNum(d.targetPrice, 2), status: metricStatus(((d.targetPrice / d.price) - 1) * 100, 15, 0), note: 'Nasdaq summary' }
: { label: '1年目標價', tipKey: 'target_price', missing: true, note: '需分析師目標價資料源' },
fair
? { label: 'DCF 公允價值', tipKey: 'dcf', value: fmtNum(fair.fair, 2), status: metricStatus(fair.upside, 20, 0), note: `區間 ${fmtNum(fair.low, 2)} ~ ${fmtNum(fair.high, 2)}` }
: { label: 'DCF 公允價值', tipKey: 'dcf', missing: true, note: '需要正自由現金流與股數' },
fair
? { label: '安全邊際', tipKey: 'margin_of_safety', value: fmtPct(fair.upside, 1), status: metricStatus(fair.upside, 20, 0), note: '公允價值相對現價' }
: { label: '安全邊際', tipKey: 'margin_of_safety', missing: true, note: '需要公允價值與現價' },
fair
? { label: '估值假設', tipKey: 'dcf_assumption', value: `${fair.growth.toFixed(1)}% / ${fair.discount.toFixed(1)}%`, status: 'na', note: `5年FCF成長 / 折現率,終值成長 ${fair.terminalGrowth.toFixed(1)}%` }
: { label: '估值假設', tipKey: 'dcf_assumption', missing: true, note: '資料不足無法估算' },
{ label: '預估營收', missing: true, note: '需分析師共識資料源' },
{ label: '預估 EPS', tipKey: 'eps', missing: true, note: '需分析師共識資料源' },
{ label: '預估 EBITDA', missing: true, note: '需共識預測或付費 API' },
{ label: '未來 5 年成長', missing: true, note: '需分析師長期預測' },
], 'section_forecast'),
strategySummaryHTML(backtests),
metricSection('穩健度', '把目前可得指標整理成可讀結論', [
{ label: '整體評價', value: d.report?.summary?.verdict || '—', status: d.report?.summary?.verdictColor || 'na', note: '來自財報健檢' },
{ label: '綠燈', value: String(d.report?.summary?.good ?? '—'), status: 'good', note: '通過項目' },
{ label: '黃燈', value: String(d.report?.summary?.warn ?? '—'), status: 'warn', note: '留意項目' },
{ label: '紅燈', value: String(d.report?.summary?.bad ?? '—'), status: 'bad', note: '警訊項目' },
{ label: '淨利成長', tipKey: 'rev_growth', value: fmtPct(netGrowth, 1), status: metricStatus(netGrowth, 10, 0), note: yaQ ? '最近季年增' : '年度年增' },
{ label: '資料來源', value: d.source || '—', status: 'na', note: d.asOf ? '最新期別 ' + d.asOf : '' },
], 'section_robust'),
].join('');
}
async function renderMetricsPane(force) {
const pane = $('#pane-metrics');
if (needSymbol(pane)) return;
if (!force && STOCK.rendered.metrics === STOCK.symbol) return;
pane.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>正在整理 ${escapeHtml(STOCK.symbol)} 的指標面板…</div>`;
try {
const [d, h, quote] = await Promise.all([
loadFundamentals(STOCK.symbol),
api(`/api/price/${encodeURIComponent(STOCK.symbol)}?range=max&interval=1d${force ? '&fresh=1' : ''}`),
api(`/api/quote/${encodeURIComponent(STOCK.symbol)}${force ? '?fresh=1' : ''}`).catch(() => ({})),
]);
const backtests = await Promise.all([
api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?strategy=buyhold&range=5y`).then(x => ({ ...x, formula: 'hold = true' })).catch(() => null),
api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?strategy=sma&range=5y&short=50&long=200`).then(x => ({ ...x, formula: 'sma(close,50) > sma(close,200)' })).catch(() => null),
api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?strategy=dip&range=5y&drop=15`).then(x => ({ ...x, formula: 'drawdown <= -15%' })).catch(() => null),
api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?strategy=dca&range=5y&monthly=1000`).then(x => ({ ...x, formula: 'monthly buy' })).catch(() => null),
]).then(xs => xs.filter(Boolean));
STOCK.rendered.metrics = STOCK.symbol;
const pstats = priceStats(h.points || []);
const lastPx = (h.points || []).length ? h.points[h.points.length - 1].close : null;
const enriched = {
...d,
price: quote.price ?? d.price ?? lastPx,
currency: quote.currency || d.currency || h.currency || '',
name: d.name || quote.name || h.name,
marketCap: quote.marketCap ?? d.marketCap,
sharesOutstanding: quote.sharesOutstanding ?? d.sharesOutstanding,
peTrailing: quote.peTrailing ?? d.peTrailing,
targetPrice: quote.targetPrice ?? d.targetPrice,
dividendYield: quote.dividendYield ?? d.dividendYield,
};
if (enriched.marketCap == null && enriched.price != null && enriched.sharesOutstanding != null) enriched.marketCap = enriched.price * enriched.sharesOutstanding;
const tech = technicalStats(h.points || [], quote);
pane.innerHTML = `
<div class="metric-head">
<div><b>${escapeHtml(enriched.name || enriched.symbol)}</b><span>${escapeHtml(enriched.symbol)} · ${escapeHtml(enriched.currency || '')} · ${enriched.asOf ? '最新期別 ' + escapeHtml(enriched.asOf) : ''}</span></div>
<button class="btn ghost sm" id="metricRefresh">更新資料</button>
</div>
<div class="metric-source-note">本面板會優先使用 Yahoo、Nasdaq、SEC 與價格歷史等免費公開來源近即時報價會短暫快取歷史資料會留在本機下次只補新日期。MA、RSI、MACD 與布林通道由本機用日線計算DCF 公允價值以年度自由現金流折現估算。免費報價可能延遲,交易前仍要對照券商報價。</div>
<div class="metric-board">${buildMetrics(enriched, pstats, tech, quote, backtests)}</div>`;
$('#metricRefresh').addEventListener('click', async () => {
STOCK.rendered.metrics = '';
delete STOCK.fundamentals[STOCK.symbol];
renderMetricsPane(true);
});
bindTermTips(pane);
} catch (e) {
pane.innerHTML = `<div class="empty-state">無法整理 ${escapeHtml(STOCK.symbol)} 的指標:${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
}
}
// ── 價格走勢 ──
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 class="stock-detail-layout">
<div id="priceChart"><div class="chart-empty">載入中…</div></div>
<aside id="companyProfile" class="company-profile"><div class="chart-empty">載入公司資訊…</div></aside>
</div>`;
$$('#priceRange button', pane).forEach(b => b.addEventListener('click', () => { STOCK.priceRange = b.dataset.r; renderPrice(true); }));
try {
const [d, profile] = await Promise.all([
api(`/api/price/${encodeURIComponent(STOCK.symbol)}?range=${STOCK.priceRange}&interval=1d`),
api(`/api/profile/${encodeURIComponent(STOCK.symbol)}${force ? '?fresh=1' : ''}`).catch(() => null),
]);
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) });
renderCompanyProfile(profile, d, last);
renderCompanyIntel(STOCK.symbol, profile, force);
} catch (e) {
pane.querySelector('#priceChart').innerHTML = `<div class="empty-state">無法取得 ${escapeHtml(STOCK.symbol)} 的價格:${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
}
}
function renderCompanyProfile(profile, priceData, last) {
const box = $('#companyProfile');
if (!box) return;
if (!profile) {
box.innerHTML = '<div class="empty-state">公司資訊暫時無法取得。</div>';
return;
}
const q = profile.quote || {};
const notif = (profile.notifications || []).flatMap(n => n.eventTypes || []).slice(0, 3);
box.innerHTML = `
<div class="profile-head">
<div><b>${escapeHtml(profile.name || priceData.name || priceData.symbol)}</b><span>${escapeHtml(profile.exchange || '')} · ${escapeHtml(profile.marketStatus || '')}</span></div>
<div class="profile-price">${fmtNum(q.price ?? last, 2)}</div>
</div>
<div class="profile-stats">
<div><span>Bid / Ask</span><b>${fmtNum(profile.bidPrice, 2)} / ${fmtNum(profile.askPrice, 2)}</b></div>
<div><span>Sector</span><b>${escapeHtml(profile.sector || '—')}</b></div>
<div><span>Industry</span><b>${escapeHtml(profile.industry || '—')}</b></div>
<div><span>Region</span><b>${escapeHtml(profile.region || '—')}</b></div>
</div>
${profile.description ? `<p class="profile-desc">${escapeHtml(profile.description)}</p>` : ''}
<div class="profile-meta">
${profile.website ? `<a href="${escapeHtml(profile.website)}" target="_blank" rel="noreferrer">公司網站</a>` : ''}
${profile.address ? `<span>${escapeHtml(profile.address)}</span>` : ''}
</div>
${notif.length ? `<div class="profile-events"><b>Upcoming</b>${notif.map(e => `<span>${escapeHtml(e.message || e.eventName || '')}</span>`).join('')}</div>` : ''}
<div class="metric-source-note">公司資訊來自 Nasdaq profile / quote報價可能延遲。</div>
<button class="btn ghost sm" id="intelRefresh" style="width:100%;margin-top:10px">更新研究資訊</button>`;
const rb = $('#intelRefresh');
if (rb) rb.addEventListener('click', () => renderCompanyIntel(profile.symbol || STOCK.symbol, profile, true));
}
function txSignal(t) {
if (t.signal === 'acquire') return ['取得', 'good'];
if (t.signal === 'dispose') return ['處分', 'bad'];
return ['混合', 'warn'];
}
function renderCompanyIntelSkeleton() {
const pane = $('#pane-price');
let box = $('#companyIntel');
if (!box) {
pane.insertAdjacentHTML('beforeend', '<div id="companyIntel" class="company-intel"></div>');
box = $('#companyIntel');
}
box.innerHTML = '<div class="empty-state">正在整理產業鏈、管理層、內部人交易與新聞…</div>';
return box;
}
async function renderCompanyIntel(symbol, profile, fresh) {
const box = renderCompanyIntelSkeleton();
try {
const intel = await api(`/api/company-intel/${encodeURIComponent(symbol)}${fresh ? '?fresh=1' : ''}`);
const chain = intel.industryChain || {};
const officers = intel.management?.officers || [];
const insiders = intel.insiders || [];
const news = intel.news || [];
const acquiredCount = insiders.filter(t => t.signal === 'acquire').length;
const disposedCount = insiders.filter(t => t.signal === 'dispose').length;
box.innerHTML = `
<section class="intel-section">
<div class="metric-section-head"><h3>產業上下游</h3><span>先建立研究地圖,再點出去查證供應鏈細節</span></div>
<div class="chain-map">
<div><b>上游</b>${(chain.upstream || []).map(x => `<span>${escapeHtml(x)}</span>`).join('')}</div>
<div><b>${escapeHtml(symbol)}</b><span>${escapeHtml(profile?.industry || intel.management?.industry || '公司核心業務')}</span></div>
<div><b>下游</b>${(chain.downstream || []).map(x => `<span>${escapeHtml(x)}</span>`).join('')}</div>
</div>
<div class="chain-links">${(chain.searches || []).map(s => `<a href="${escapeHtml(s.url)}" target="_blank" rel="noreferrer">${escapeHtml(s.label)}</a>`).join('')}</div>
${(chain.peers || []).length ? `<div class="peer-chips">${chain.peers.map(s => `<button data-peer="${escapeHtml(s)}">${escapeHtml(s)}</button>`).join('')}</div>` : ''}
</section>
<section class="intel-section">
<div class="metric-section-head"><h3>經營管理層</h3><span>職位與薪酬來自公開資料,可用來看治理結構</span></div>
<div class="officer-grid">${officers.length ? officers.map(o => `
<div class="officer-card"><b>${escapeHtml(o.name)}</b><span>${escapeHtml(o.title || '')}</span><small>${o.totalPay != null ? 'Total pay ' + fmtMoney(o.totalPay) : ''}</small></div>`).join('') : '<div class="empty-state">暫時沒有抓到管理層資料。</div>'}</div>
<div class="chain-links">${(intel.management?.searches || []).map(s => `<a href="${escapeHtml(s.url)}" target="_blank" rel="noreferrer">${escapeHtml(s.label)}</a>`).join('')}</div>
</section>
<section class="intel-section">
<div class="metric-section-head"><h3>內部人 Form 4</h3><span>A/D 代表 SEC 交易取得/處分代碼,需留意獎酬與選擇權情境</span></div>
<div class="insider-summary">
<div class="${acquiredCount ? 'good' : ''}"><b>${acquiredCount}</b><span>近期偏取得</span></div>
<div class="${disposedCount ? 'bad' : ''}"><b>${disposedCount}</b><span>近期偏處分</span></div>
</div>
<div class="insider-list">${insiders.length ? insiders.map(t => {
const [label, cls] = txSignal(t);
return `<a class="insider-row ${cls}" href="${escapeHtml(t.url)}" target="_blank" rel="noreferrer">
<div><b>${escapeHtml(t.owner || 'Insider')}</b><span>${escapeHtml(t.title || '')}</span></div>
<div><b>${escapeHtml(label)}</b><span>${escapeHtml(t.reportDate || t.filingDate || '')}</span></div>
<div><b>${fmtMetric(t.acquired || 0, 'compact')} / ${fmtMetric(t.disposed || 0, 'compact')}</b><span>A / D shares</span></div>
</a>`;
}).join('') : '<div class="empty-state">近期沒有抓到 Form 4。</div>'}</div>
</section>
<section class="intel-section">
<div class="metric-section-head"><h3>新聞</h3><span>最近與公司或相關代號有關的新聞</span></div>
<div class="news-grid">${news.length ? news.map(n => `
<a class="news-card" href="${escapeHtml(n.url || '#')}" target="_blank" rel="noreferrer">
<b>${escapeHtml(n.title || '')}</b>
<span>${escapeHtml(n.publisher || '')} · ${escapeHtml(n.created || '')}</span>
<p>${escapeHtml(n.description || '')}</p>
</a>`).join('') : '<div class="empty-state">暫時沒有新聞。</div>'}</div>
</section>`;
$$('.peer-chips button', box).forEach(btn => btn.addEventListener('click', () => setStockSymbol(btn.dataset.peer)));
} catch (e) {
box.innerHTML = `<div class="empty-state">無法整理公司研究資訊:${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', () => {
delete STOCK.fundamentals[d.symbol];
runFincheck(d.symbol, false);
});
}
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 funnelHTML = `<div class="map-funnel">${cfg.layers.map((L, i) => {
const st = statuses[i];
return `<button class="funnel-step ${st}" data-jump-layer="${i}">
<span>${i + 1}</span><b>${escapeHtml(L.title)}</b><small>${ST_META[st].lab}</small>
</button>`;
}).join('')}</div>`;
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>
${funnelHTML}
<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">這是把「<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;
setAIFocus({ type: 'stock-map', symbol: STOCK.symbol, subPage: 'map', label: `${STOCK.symbol || '標的'} · 投資地圖`, mapAnswers: STOCK.mapAnswers });
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);
$$('.funnel-step', pane).forEach(btn => btn.addEventListener('click', () => {
const layer = $$('.map-layer', pane)[Number(btn.dataset.jumpLayer)];
layer?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}));
}
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 = 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: p.id, label: (LearnUI.cleanPrincipleTitle ? LearnUI.cleanPrincipleTitle(p.title) : p.title).slice(0, 28),
})));
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();
initAIWidget();
setView(parseHash());