finance-dashboard/app.js

4774 lines
244 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', 'watchlist', '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 === 'watchlist' && !inited.watchlist) { inited.watchlist = true; initWatchlist(); }
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 (!$('#aiPanel')?.hidden) refreshAIContextLabel();
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 settings-page">
<div class="page-head">
<div class="page-title">API Key 與 AI Provider 設定</div>
<div class="page-sub">所有金鑰會寫入本機專案的 <code>.env</code>(路徑見下方)。金鑰欄位留空代表保留原值;模型與預設 provider 會直接更新。</div>
<div class="settings-env-path" title="${escapeHtml(envSettings.envPath || '.env')}">${escapeHtml(envSettings.envPath || '.env')}</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 || '');
}
}));
}
let aiWidgetBusy = false;
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 type="button" id="aiClose" aria-label="關閉">×</button></div>
<div class="ai-toolbar">
<label class="ai-field"><span class="ai-field-label">Provider</span><select id="aiProviderSelect"></select></label>
<label class="ai-field"><span class="ai-field-label">模型</span><select id="aiModelSelect"></select></label>
<button type="button" class="btn ghost sm ai-toolbar-settings" id="aiOpenSettings">設定</button>
</div>
<div class="ai-chat" id="aiChatLog">
<div class="ai-msg ai-msg-bot">
<div class="ai-bubble">有頁面資料時會一併附上;沒有資料或你在設定頁時,就是一般聊天。</div>
<div class="ai-msg-meta">MacroScope AI</div>
</div>
</div>
<div class="ai-compose">
<textarea id="aiQuestion" rows="1" placeholder="輸入訊息…Enter 送出、Shift+Enter 換行)"></textarea>
<button type="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]) =>
`<option value="${id}" ${s.active === id ? 'selected' : ''}>${escapeHtml(meta.label)}</option>`,
).join('');
await refreshWidgetModels($('#aiProviderSelect').value);
};
refreshProviders();
$('#aiFab').addEventListener('click', async () => {
await refreshProviders();
const opening = $('#aiPanel').hidden;
$('#aiPanel').hidden = !opening;
if (opening) refreshAIContextLabel();
});
$('#aiClose').addEventListener('click', () => { $('#aiPanel').hidden = true; });
$('#aiProviderSelect').addEventListener('change', async () => {
await refreshWidgetModels($('#aiProviderSelect').value);
refreshAIContextLabel();
});
$('#aiOpenSettings').addEventListener('click', () => { location.hash = '#/settings'; $('#aiPanel').hidden = true; });
$('#aiAskBtn').addEventListener('click', e => { e.preventDefault(); askAIFromWidget(); });
$('#aiQuestion').addEventListener('input', () => autosizeAIInput());
$('#aiQuestion').addEventListener('keydown', e => {
if (e.key !== 'Enter' || e.shiftKey || e.isComposing || e.keyCode === 229) return;
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 || '';
const defaultOpt = (hint) => `<option value="">${escapeHtml(hint)}</option>`;
if (!settings.providers?.[provider]?.hasKey) {
select.innerHTML = defaultOpt('請先在設定填 API key');
return;
}
select.innerHTML = defaultOpt(current ? `預設:${current}` : '使用設定頁的預設模型');
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 = defaultOpt(current ? `預設:${current}` : '選擇模型') + opts;
if (current && [...select.options].some(o => o.value === current)) select.value = current;
} catch (_) {
select.innerHTML = defaultOpt(current ? `預設:${current}` : '無法列出模型,仍用設定值');
if (current) select.innerHTML += `<option value="${escapeHtml(current)}" selected>${escapeHtml(current)}</option>`;
}
}
const AI_VIEW_LABELS = { macro: '總經', calendar: '日曆', learn: '學習', stock: '個股', journal: '復盤', settings: '設定' };
function focusLabelFrom(focus = {}) {
return focus.label || focus.title || focus.symbol || focus.date || focus.key || '';
}
function updateAIContextLabel() {
const el = $('#aiContextLabel');
if (!el) return;
const view = document.body.dataset.view || parseHash();
if (view === 'settings') {
el.textContent = '一般聊天';
return;
}
const focus = focusLabelFrom(window.__AI_FOCUS || {});
el.textContent = focus
? `${AI_VIEW_LABELS[view] || '頁面'} · ${focus}(檢查是否有資料…)`
: `${AI_VIEW_LABELS[view] || '頁面'}(檢查是否有資料…)`;
}
async function refreshAIContextLabel() {
const el = $('#aiContextLabel');
if (!el) return;
const view = document.body.dataset.view || parseHash();
if (view === 'settings') {
el.textContent = '一般聊天';
return;
}
el.textContent = '正在檢查可附帶的頁面資料…';
try {
const ctx = await collectAIContext();
const focus = focusLabelFrom(ctx.focus || window.__AI_FOCUS || {});
if (ctx.hasPageData) {
el.textContent = focus
? `已附上 ${AI_VIEW_LABELS[view] || '頁面'} · ${focus}`
: `已附上 ${AI_VIEW_LABELS[view] || '頁面'} 資料`;
} else if (ctx.contextError) {
el.textContent = `一般聊天(頁面資料未取得:${ctx.contextError}`;
} else {
el.textContent = `一般聊天(${AI_VIEW_LABELS[view] || '此頁'}尚無可用資料)`;
}
} catch (e) {
el.textContent = `一般聊天(${(e.data && e.data.message) || e.message || '無法讀取頁面資料'}`;
}
}
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;
if (STOCK.sub === 'technical' && STOCK.technicalSnapshot) client.technical = STOCK.technicalSnapshot;
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: 'chat',
hasPageData: false,
view,
focus,
client,
contextError: (e.data && e.data.message) || e.message,
}));
}
async function askAIFromWidget() {
if (aiWidgetBusy) return;
const input = $('#aiQuestion');
const send = $('#aiAskBtn');
const question = input?.value.trim();
if (!question) return;
aiWidgetBusy = true;
input.value = '';
autosizeAIInput();
appendAIMessage('user', escapeHtml(question), '你');
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();
refreshAIContextLabel();
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 {
aiWidgetBusy = false;
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));
}
// ═══════════════════════════════════════════════════════════
// 追蹤個股(分群 · 報價快覽 · 一鍵進個股工具)
// ═══════════════════════════════════════════════════════════
const WATCH = { data: null, activeGroupId: null, quotes: {}, saving: false };
const WATCH_SYM_RE = /^[A-Z0-9.\-]{1,12}$/;
function watchActiveGroup() {
const groups = WATCH.data?.groups || [];
return groups.find(g => g.id === WATCH.activeGroupId) || groups[0] || null;
}
async function loadWatchlistFromServer() {
const d = await api('/api/watchlist');
WATCH.data = d;
if (!WATCH.activeGroupId || !d.groups?.some(g => g.id === WATCH.activeGroupId)) {
WATCH.activeGroupId = d.groups?.[0]?.id || 'default';
}
return d;
}
async function saveWatchlistToServer() {
if (!WATCH.data || WATCH.saving) return;
WATCH.saving = true;
const status = $('#watchStatus');
try {
const d = await api('/api/watchlist', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(WATCH.data),
});
WATCH.data = d;
if (status) status.textContent = '已儲存';
} catch (e) {
if (status) status.textContent = (e.data?.message || e.message || '儲存失敗');
} finally {
WATCH.saving = false;
renderWatchlistView();
}
}
function showWatchMsg(text, tone) {
const el = $('#watchMsg');
if (!el) return;
el.textContent = text || '';
el.className = 'watch-msg' + (tone ? ` ${tone}` : '');
el.hidden = !text;
}
async function refreshWatchQuotes(symbols) {
const syms = [...new Set((symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean))];
if (!syms.length) { WATCH.quotes = {}; return; }
try {
const d = await api(`/api/watchlist/quotes?symbols=${encodeURIComponent(syms.join(','))}`);
for (const q of d.quotes || []) {
if (q?.symbol) WATCH.quotes[q.symbol] = q;
}
} catch (_) { /* 報價失敗仍顯示代號 */ }
}
function initWatchlist() {
const view = $('#view-watchlist');
view.innerHTML = `
<div class="page watch-page">
<div class="page-head">
<div class="page-title">⭐ 追蹤個股</div>
<div class="page-sub">依分群管理你關注的標的,快速看報價並一鍵進入個股工具(指標、技術圖、財報、產業鏈)。與「日曆 · 追蹤財報」清單分開儲存。</div>
</div>
<div class="watch-toolbar">
<span class="watch-status" id="watchStatus"></span>
<button type="button" class="btn ghost sm" id="watchRefreshQuotes">更新報價</button>
<button type="button" class="btn ghost sm" id="watchImportCalendar">匯入日曆追蹤</button>
</div>
<p class="watch-msg" id="watchMsg" hidden></p>
<div class="watch-layout">
<aside class="watch-groups" aria-label="分群">
<div class="watch-groups-head">
<b>分群</b>
<button type="button" class="btn sm" id="watchAddGroup"> 新分群</button>
</div>
<ul class="watch-group-list" id="watchGroupList"></ul>
</aside>
<section class="watch-main">
<div class="watch-main-head" id="watchMainHead"></div>
<form class="watch-add-form" id="watchAddForm" autocomplete="off">
<input type="text" id="watchSymInput" placeholder="輸入代號,例如 NVDA" maxlength="12">
<button type="submit" class="btn sm">加入此分群</button>
</form>
<div class="watch-symbol-grid" id="watchSymbolGrid"></div>
</section>
</div>
</div>`;
$('#watchAddGroup').addEventListener('click', () => watchAddGroup());
$('#watchAddForm').addEventListener('submit', e => { e.preventDefault(); watchTryAddSymbol(); });
$('#watchRefreshQuotes').addEventListener('click', () => renderWatchlistView(true));
$('#watchImportCalendar').addEventListener('click', () => watchImportFromCalendar());
$('#watchGroupList').addEventListener('click', e => {
const row = e.target.closest('[data-group-id]');
if (!row) return;
if (e.target.closest('.watch-group-del')) {
watchDeleteGroup(row.dataset.groupId);
return;
}
WATCH.activeGroupId = row.dataset.groupId;
renderWatchlistView();
});
$('#watchSymbolGrid').addEventListener('click', e => {
const open = e.target.closest('[data-open-sym]');
if (open) {
location.hash = '#/stock';
setView('stock');
setStockSymbol(open.dataset.openSym);
return;
}
const rm = e.target.closest('.watch-sym-rm');
if (rm) watchRemoveSymbol(rm.dataset.sym);
});
$('#watchSymbolGrid').addEventListener('change', e => {
const mv = e.target.closest('.watch-sym-move');
if (mv?.value) {
watchMoveSymbol(mv.dataset.sym, mv.value);
mv.value = '';
}
});
loadWatchlistFromServer()
.then(() => renderWatchlistView(true))
.catch(e => {
$('#watchSymbolGrid').innerHTML = `<div class="empty-state">無法載入追蹤清單:${escapeHtml(e.message || '')}</div>`;
});
}
function watchAddGroup() {
const name = window.prompt('新分群名稱', '新分群');
if (name == null) return;
const trimmed = String(name).trim().slice(0, 40);
if (!trimmed) { showWatchMsg('分群名稱不可為空', 'warn'); return; }
const id = `g_${Date.now().toString(36)}`;
WATCH.data.groups.push({ id, name: trimmed, symbols: [], order: WATCH.data.groups.length });
WATCH.activeGroupId = id;
saveWatchlistToServer();
}
function watchDeleteGroup(id) {
if ((WATCH.data?.groups || []).length <= 1) {
showWatchMsg('至少保留一個分群', 'warn');
return;
}
const g = WATCH.data.groups.find(x => x.id === id);
if (!g) return;
if (!window.confirm(`刪除分群「${g.name}」?其中的 ${g.symbols.length} 檔標的會一併移除。`)) return;
WATCH.data.groups = WATCH.data.groups.filter(x => x.id !== id);
if (WATCH.activeGroupId === id) WATCH.activeGroupId = WATCH.data.groups[0]?.id;
saveWatchlistToServer();
}
function watchRenameGroup(id) {
const g = WATCH.data.groups.find(x => x.id === id);
if (!g) return;
const name = window.prompt('分群名稱', g.name);
if (name == null) return;
const trimmed = String(name).trim().slice(0, 40);
if (!trimmed) return;
g.name = trimmed;
saveWatchlistToServer();
}
function watchTryAddSymbol() {
const input = $('#watchSymInput');
const sym = input?.value.trim().toUpperCase();
if (!sym) { showWatchMsg('請輸入股票代號', 'warn'); return; }
if (!WATCH_SYM_RE.test(sym)) {
showWatchMsg('代號格式不正確112 字,英數與 . -', 'bad');
input?.focus();
return;
}
const g = watchActiveGroup();
if (!g) return;
const all = new Set();
for (const grp of WATCH.data.groups) (grp.symbols || []).forEach(s => all.add(s));
if (all.has(sym)) {
showWatchMsg(`${sym} 已在其他分群;同一標的僅能出現一次`, 'warn');
return;
}
if (g.symbols.includes(sym)) {
showWatchMsg(`${sym} 已在此分群`, 'warn');
return;
}
g.symbols.push(sym);
if (input) input.value = '';
showWatchMsg(`${sym} 已加入「${g.name}`, 'good');
saveWatchlistToServer();
}
function watchRemoveSymbol(sym) {
const g = watchActiveGroup();
if (!g) return;
g.symbols = g.symbols.filter(s => s !== sym);
saveWatchlistToServer();
}
function watchMoveSymbol(sym, targetGroupId) {
if (!targetGroupId) return;
const from = watchActiveGroup();
const to = WATCH.data.groups.find(g => g.id === targetGroupId);
if (!from || !to || from.id === to.id) return;
from.symbols = from.symbols.filter(s => s !== sym);
if (!to.symbols.includes(sym)) to.symbols.push(sym);
saveWatchlistToServer();
}
async function watchImportFromCalendar() {
const cal = loadCalendarSymbols();
if (!cal.length) { showWatchMsg('日曆尚無追蹤標的', 'warn'); return; }
const all = new Set();
for (const grp of WATCH.data.groups) (grp.symbols || []).forEach(s => all.add(s));
const g = watchActiveGroup();
let added = 0;
for (const sym of cal) {
if (all.has(sym)) continue;
g.symbols.push(sym);
all.add(sym);
added++;
}
if (!added) { showWatchMsg('日曆中的標的都已在你追蹤清單裡', 'warn'); return; }
showWatchMsg(`已從日曆匯入 ${added} 檔到「${g.name}`, 'good');
await saveWatchlistToServer();
}
function renderWatchGroupList() {
const ul = $('#watchGroupList');
if (!ul || !WATCH.data) return;
const groups = [...(WATCH.data.groups || [])].sort((a, b) => a.order - b.order);
ul.innerHTML = groups.map(g => {
const on = g.id === WATCH.activeGroupId;
const n = (g.symbols || []).length;
return `<li>
<button type="button" class="watch-group-item${on ? ' active' : ''}" data-group-id="${escapeHtml(g.id)}">
<span class="watch-group-name">${escapeHtml(g.name)}</span>
<span class="watch-group-count">${n}</span>
</button>
${groups.length > 1 ? `<button type="button" class="watch-group-del" data-group-id="${escapeHtml(g.id)}" title="刪除分群">×</button>` : ''}
</li>`;
}).join('');
}
function renderWatchMainHead() {
const head = $('#watchMainHead');
const g = watchActiveGroup();
if (!head || !g) return;
const total = (WATCH.data?.groups || []).reduce((n, grp) => n + (grp.symbols?.length || 0), 0);
head.innerHTML = `
<div>
<h2 class="watch-main-title">${escapeHtml(g.name)}</h2>
<span class="watch-main-sub">此群 ${g.symbols.length} 檔 · 全部共 ${total} 檔</span>
</div>
<button type="button" class="btn ghost sm" id="watchRenameGroup">重新命名</button>`;
$('#watchRenameGroup')?.addEventListener('click', () => watchRenameGroup(g.id));
}
function watchSymbolCard(sym, g) {
const q = WATCH.quotes[sym] || {};
const chg = q.changePercent;
const chgCls = chg == null ? '' : (chg >= 0 ? 'pnl-pos' : 'pnl-neg');
const chgTxt = chg == null ? '—' : `${chg >= 0 ? '+' : ''}${Number(chg).toFixed(2)}%`;
const priceTxt = q.price != null ? fmtNum(q.price, 2) : (q.error ? '無報價' : '…');
const otherGroups = (WATCH.data?.groups || []).filter(x => x.id !== g.id);
const moveOpts = otherGroups.length
? `<label class="watch-sym-move-wrap"><span>移至</span><select class="watch-sym-move" data-sym="${escapeHtml(sym)}">
<option value="">—</option>
${otherGroups.map(og => `<option value="${escapeHtml(og.id)}">${escapeHtml(og.name)}</option>`).join('')}
</select></label>`
: '';
return `<article class="watch-sym-card">
<button type="button" class="watch-sym-open" data-open-sym="${escapeHtml(sym)}">
<span class="watch-sym-ticker">${escapeHtml(sym)}</span>
<span class="watch-sym-name">${escapeHtml(q.name && q.name !== sym ? q.name : '')}</span>
<span class="watch-sym-price">${escapeHtml(priceTxt)}</span>
<span class="watch-sym-chg ${chgCls}">${escapeHtml(chgTxt)}</span>
</button>
<div class="watch-sym-actions">
${moveOpts}
<button type="button" class="watch-sym-rm" data-sym="${escapeHtml(sym)}" aria-label="移除 ${escapeHtml(sym)}">移除</button>
</div>
</article>`;
}
async function renderWatchlistView(refreshQuotes = false) {
if (!WATCH.data) return;
renderWatchGroupList();
renderWatchMainHead();
const grid = $('#watchSymbolGrid');
const g = watchActiveGroup();
if (!grid || !g) return;
const symbols = g.symbols || [];
if (refreshQuotes && symbols.length) await refreshWatchQuotes(symbols);
grid.innerHTML = symbols.length
? symbols.map(sym => watchSymbolCard(sym, g)).join('')
: '<div class="watch-empty-panel">此分群尚無標的。在上方輸入代號加入,或從「日曆 · 追蹤財報」匯入。</div>';
const st = $('#watchStatus');
if (st && WATCH.data.updatedAt) {
st.textContent = `${(WATCH.data.groups || []).reduce((n, grp) => n + (grp.symbols?.length || 0), 0)} 檔 · 更新 ${new Date(WATCH.data.updatedAt).toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`;
}
}
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 sticky = !!opts.stickyAxes;
const axisW = sticky ? (opts.axisWidth || TA_AXIS_W) : 0;
const boxW = el?.clientWidth > 0 ? Math.floor(el.clientWidth) : 0;
const totalW = opts.chartWidth || (boxW > 120 ? boxW : 760);
const h = opts.height || 300;
const padL = sticky ? 0 : (opts.padL != null ? opts.padL : 64);
const padR = sticky ? 10 : 18;
const padT = 18;
const padB = sticky ? 8 : 32;
const w = sticky ? totalW - axisW : totalW;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
const dates = opts.dates || series[0].points.map(p => p.date);
const n = dates.length;
if (n < 2) { el.innerHTML = '<div class="chart-empty">資料不足,無法繪圖。</div>'; return; }
const valAt = (pts, i) => (pts[i] && pts[i].val != null && !isNaN(pts[i].val) ? pts[i].val : null);
const allVals = [];
series.forEach(s => s.points.forEach((p, i) => { const v = valAt(s.points, i) ?? p.val; if (v != null && !isNaN(v)) allVals.push(v); }));
if (opts.band) {
for (let i = 0; i < n; i++) {
const u = valAt(opts.band.upper?.points, i);
const l = valAt(opts.band.lower?.points, i);
if (u != null) allVals.push(u);
if (l != null) allVals.push(l);
}
}
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)"/>`;
if (!sticky) grid += `<text x="${padL - 8}" y="${(y + 3.5).toFixed(1)}" fill="#86868b" font-size="11" text-anchor="end">${fmt(v)}</text>`;
}
let xlab = '';
if (!sticky) {
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 - 6}" fill="#86868b" font-size="10" text-anchor="middle">${formatTaDateLabel(dates[idx])}</text>`;
}
}
let paths = '', dots = '';
const flushBand = (idxs) => {
if (idxs.length < 2) return;
let band = `M${toX(idxs[0]).toFixed(1)},${toY(valAt(opts.band.upper.points, idxs[0])).toFixed(1)}`;
for (let k = 1; k < idxs.length; k++) band += ` L${toX(idxs[k]).toFixed(1)},${toY(valAt(opts.band.upper.points, idxs[k])).toFixed(1)}`;
for (let k = idxs.length - 1; k >= 0; k--) band += ` L${toX(idxs[k]).toFixed(1)},${toY(valAt(opts.band.lower.points, idxs[k])).toFixed(1)}`;
paths += `<path d="${band} Z" fill="${opts.band.fill || 'rgba(35,103,199,.07)'}" stroke="none"/>`;
};
if (opts.band?.upper?.points && opts.band?.lower?.points) {
let seg = [];
for (let i = 0; i < n; i++) {
const u = valAt(opts.band.upper.points, i);
const l = valAt(opts.band.lower.points, i);
if (u != null && l != null) seg.push(i);
else { flushBand(seg); seg = []; }
}
flushBand(seg);
}
const linePath = (pts) => {
let d = '';
let move = true;
for (let i = 0; i < n; i++) {
const v = valAt(pts, i);
if (v == null) { move = true; continue; }
d += `${move ? 'M' : 'L'}${toX(i).toFixed(1)},${toY(v).toFixed(1)} `;
move = false;
}
return d.trim();
};
series.forEach(s => {
const d = linePath(s.points);
if (!d) return;
const sw = s.strokeWidth != null ? s.strokeWidth : 2;
const dash = s.dash ? ` stroke-dasharray="${s.dash}"` : '';
paths += `<path d="${d}" fill="none" stroke="${s.color}" stroke-width="${sw}"${dash} 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 && !opts.externalLegend
? `<div class="chart-legend">${series.map(s => `<span><i style="background:${s.color}"></i>${escapeHtml(s.name)}</span>`).join('')}</div>`
: '';
const rootCls = opts.rootClass || '';
const stageCls = opts.stageClass || '';
const aspect = opts.stretch === false ? 'xMidYMid meet' : 'none';
const svgBlock = `<svg id="${uid}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="${aspect}" 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>`;
if (sticky) {
const yEl = opts.yGutterEl;
const plotOnly = yEl ? `
<div class="chart-root chart-root--tv ${rootCls}">
${legend}
<div class="chart-stage ${stageCls}">
<div class="chart-plot-area tv-plot" style="width:${plotW + padR}px;min-width:${plotW + padR}px;height:${h}px">
<div class="chart-wrap">${svgBlock}${opts.hoverEl ? '' : `<div class="chart-hover" id="${uid}h"></div>`}</div>
</div>
</div>
</div>` : `
<div class="chart-root chart-root--sticky ${rootCls}">
${legend}
<div class="chart-stage ${stageCls}">
<div class="chart-row-sticky">
<div class="chart-gutter-y" style="height:${h}px;width:${axisW}px"></div>
<div class="chart-plot-area" style="width:${plotW + padR}px;min-width:${plotW + padR}px">
<div class="chart-wrap">${svgBlock}${opts.hoverEl ? '' : `<div class="chart-hover" id="${uid}h"></div>`}</div>
</div>
</div>
</div>
</div>`;
el.innerHTML = plotOnly;
if (yEl) {
yEl.style.height = `${h}px`;
const sc = { yMin, yMax, plotH, padT, h, fmt };
yEl._tvScale = sc;
renderChartYGutter(yEl, sc);
if (opts.storeMainScale) {
STOCK.taMainScale = { ...sc, n, plotW: plotW + padR };
}
} else {
renderChartYGutter(el.querySelector('.chart-gutter-y'), { yMin, yMax, plotH, padT, h, fmt });
}
} else {
el.innerHTML = `<div class="chart-root ${rootCls}">
${legend}
<div class="chart-stage ${stageCls}">
<div class="chart-wrap">
${svgBlock}
${opts.hoverEl ? '' : `<div class="chart-hover" id="${uid}h"></div>`}
</div>
</div>
</div>`;
}
if (opts.externalLegend && series.length > 1) {
opts.externalLegend.innerHTML = series.map(s =>
`<span class="ta-leg-item"><i style="background:${s.color}"></i>${escapeHtml(s.name)}</span>`,
).join('');
}
const svg = el.querySelector('#' + uid);
const hg = svg.querySelector('.hg'), hl = svg.querySelector('.hl'), area = svg.querySelector('.ha');
const hds = $$('.hd', svg);
const info = opts.hoverEl
? (typeof opts.hoverEl === 'string' ? document.querySelector(opts.hoverEl) : opts.hoverEl)
: 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);
const ohlc = opts.ohlcRows?.[i];
hds.forEach((dot, k) => {
const v = valAt(series[k].points, i);
if (v == null) { dot.style.display = 'none'; return; }
dot.style.display = '';
dot.setAttribute('cx', x);
dot.setAttribute('cy', toY(v));
});
if (info) info.style.display = 'block';
let tip = `<b>${dates[i]}</b>`;
if (ohlc) {
tip += ` <span>O ${fmtNum(ohlc.open, 2)} H ${fmtNum(ohlc.high, 2)} L ${fmtNum(ohlc.low, 2)} C ${fmtNum(ohlc.close, 2)}</span>`;
}
tip += ' ' + series.map(s => {
const v = valAt(s.points, i);
if (v == null) return '';
return `<span style="color:${s.color}">${series.length > 1 ? escapeHtml(s.name) + ' ' : ''}${fmt(v)}</span>`;
}).filter(Boolean).join(' ');
info.innerHTML = tip;
if (info) info.style.display = 'block';
if (opts.onIndex) opts.onIndex(i, dates[i], x);
});
area.addEventListener('mouseleave', () => {
hg.style.display = 'none'; hds.forEach(d => d.style.display = 'none');
if (info) info.style.display = 'none';
if (opts.onIndex) {
const last = (opts.ohlcRows?.length || STOCK.technicalRows?.length || 1) - 1;
opts.onIndex(last >= 0 ? last : -1, null, null);
}
});
return { uid, padL, plotW, n, dates, toX };
}
const TA_BAR_PX = 9;
const TA_AXIS_W = 64;
const TA_AXIS_H = 28;
const TA_Y_SLOT = { vol: 'taYVol', macd: 'taYMacd', rsi: 'taYRsi', kdj: 'taYKdj' };
function taStatLabel(text, tipKey) {
const tip = tipKey && typeof termTipBtn === 'function' ? termTipBtn(tipKey, text) : '';
return `<span class="ta-stat-label">${escapeHtml(text)}${tip}</span>`;
}
function taChartPixelWidth(n) {
return Math.max(720, Math.round(n * TA_BAR_PX + TA_AXIS_W + 24));
}
function formatTaDateLabel(iso) {
if (!iso) return '';
return iso.slice(2, 7).replace('-', '/');
}
function clearAxisTicks(ySlot) {
const ticksEl = ySlot?.querySelector('.chart-gutter-y__ticks');
if (ticksEl) ticksEl.innerHTML = '';
}
function renderChartYGutter(gutterEl, scale) {
if (!gutterEl || !scale) return;
const { yMin, yMax, plotH, padT, h, fmt, refLines } = scale;
const yRange = yMax - yMin || 1;
const toPct = v => ((padT + (1 - (v - yMin) / yRange) * plotH) / h) * 100;
const ticks = new Set();
for (let k = 0; k <= 5; k++) ticks.add(yMin + (yRange * k) / 5);
(refLines || []).forEach(rv => ticks.add(rv));
const labels = [...ticks].sort((a, b) => b - a);
const f = fmt || (v => String(v));
let ticksEl = gutterEl.querySelector('.chart-gutter-y__ticks');
if (!ticksEl) {
ticksEl = document.createElement('div');
ticksEl.className = 'chart-gutter-y__ticks';
gutterEl.insertBefore(ticksEl, gutterEl.firstChild);
}
ticksEl.innerHTML = labels.map(v =>
`<span class="chart-gutter-y__tick" style="top:${toPct(v).toFixed(2)}%">${escapeHtml(f(v))}</span>`,
).join('');
}
function syncTvAxisHeights() {
const sync = (yId, plotSel) => {
const ySlot = document.getElementById(yId);
const plot = document.querySelector(plotSel);
if (!ySlot || !plot) return;
const h = Math.round(plot.offsetHeight);
if (h > 0) ySlot.style.height = `${h}px`;
};
sync('taYMain', '#taChart .tv-plot');
sync('taYVol', '#taVol');
sync('taYMacd', '#taMacd');
sync('taYRsi', '#taRsi');
sync('taYKdj', '#taKdj');
}
function clearTvScaleTags() {
document.querySelectorAll('.tv-scale-tags, .tv-scale-tags--vol-hover').forEach(el => el.remove());
}
function refreshTvScaleTags(barIndex) {
clearTvScaleTags();
const syncGutter = (id) => {
const slot = document.getElementById(id);
if (!slot || slot.hidden || !slot._tvScale) return;
renderChartYGutter(slot, slot._tvScale);
};
syncGutter('taYMain');
syncGutter('taYVol');
syncGutter('taYMacd');
syncGutter('taYRsi');
syncGutter('taYKdj');
}
function updateTaXAxis() {
const box = $('#taXAxis');
const sc = $('#taChartScroll');
const meta = STOCK.taAxisMeta;
if (!box || !sc || !meta?.dates?.length) {
if (box) box.innerHTML = '';
return;
}
const { dates, cw, axisW, n, plotW } = meta;
const plotWidth = plotW || (cw - axisW);
if (n < 2 || plotWidth <= 0) return;
const scrollLeft = Math.max(0, sc.scrollLeft);
const viewPlotW = Math.max(80, sc.clientWidth - axisW);
const iPerPx = (n - 1) / plotWidth;
const i0 = Math.max(0, Math.floor(scrollLeft * iPerPx));
const i1 = Math.min(n - 1, Math.ceil((scrollLeft + viewPlotW) * iPerPx));
const span = Math.max(1, i1 - i0);
const tickN = Math.min(8, Math.max(4, Math.floor(span / 40) + 3));
let html = '';
for (let t = 0; t < tickN; t++) {
const idx = tickN === 1 ? i0 : Math.round(i0 + (t / (tickN - 1)) * span);
const xInView = (idx / (n - 1)) * plotWidth - scrollLeft;
if (xInView < -24 || xInView > viewPlotW + 24) continue;
const pct = (xInView / viewPlotW) * 100;
html += `<span class="ta-x-axis__tick" style="left:${pct.toFixed(2)}%">${escapeHtml(formatTaDateLabel(dates[idx]))}</span>`;
}
if (!html) {
html = `<span class="ta-x-axis__tick" style="left:4%">${escapeHtml(formatTaDateLabel(dates[i0]))}</span>`
+ `<span class="ta-x-axis__tick" style="left:92%">${escapeHtml(formatTaDateLabel(dates[i1]))}</span>`;
}
box.innerHTML = html;
}
function bindTaChartScroll(scrollEl) {
if (!scrollEl || scrollEl._taPanBound) return;
scrollEl._taPanBound = true;
let dragging = false, sx = 0, sl = 0;
scrollEl.addEventListener('mousedown', e => {
if (e.button !== 0) return;
dragging = true;
sx = e.clientX;
sl = scrollEl.scrollLeft;
scrollEl.classList.add('is-dragging');
});
window.addEventListener('mouseup', () => {
dragging = false;
scrollEl.classList.remove('is-dragging');
});
scrollEl.addEventListener('mousemove', e => {
if (!dragging) return;
e.preventDefault();
scrollEl.scrollLeft = sl - (e.clientX - sx);
});
scrollEl.addEventListener('wheel', e => {
if (Math.abs(e.deltaX) < Math.abs(e.deltaY)) {
scrollEl.scrollLeft += e.deltaY;
e.preventDefault();
}
}, { passive: false });
scrollEl.addEventListener('scroll', () => {
updateTaXAxis();
if (_taHoverIndex >= 0 && STOCK.taAxisMeta) {
const { n, plotW } = STOCK.taAxisMeta;
const x = n > 1 ? (_taHoverIndex / (n - 1)) * plotW : 0;
updateTaCrosshairTags(_taHoverIndex, x);
}
const tip = $('#taChartHover');
if (tip?.classList.contains('is-on')) tip.style.transform = 'none';
}, { passive: true });
window.addEventListener('resize', () => {
updateTaXAxis();
if (_taHoverIndex >= 0 && STOCK.taAxisMeta) {
const { n, plotW } = STOCK.taAxisMeta;
updateTaCrosshairTags(_taHoverIndex, n > 1 ? (_taHoverIndex / (n - 1)) * plotW : 0);
}
}, { passive: true });
}
function scrollTaChartEnd() {
const sc = $('#taChartScroll');
if (!sc) return;
requestAnimationFrame(() => {
sc.scrollLeft = sc.scrollWidth - sc.clientWidth;
updateTaXAxis();
});
}
let _taHoverIndex = -1;
function updateTaCrosshairTags(i, plotX) {
const tagX = $('#taTagX');
const tagY = $('#taTagY');
if (tagY) tagY.hidden = true;
if (i < 0 || !STOCK.taMainScale || !tagX) {
if (tagX) tagX.hidden = true;
return;
}
const r = STOCK.technicalRows?.[i];
const sc = $('#taChartScroll');
const meta = STOCK.taAxisMeta;
if (!r || !sc || !meta) return;
const plotW = meta.plotW || (meta.cw - meta.axisW);
const xPos = plotX != null ? plotX : (i / Math.max(1, meta.n - 1)) * plotW;
const xInView = xPos - sc.scrollLeft;
tagX.textContent = meta.dates[i] || formatTaDateLabel(meta.dates[i]);
tagX.style.left = `${TA_AXIS_W + Math.max(0, xInView)}px`;
tagX.hidden = false;
}
const TA_VOL_ELEVATED = 1.5;
const TA_VOL_SPIKE = 2;
function enrichVolumeRows(volRows) {
if (!volRows?.length) return volRows || [];
return volRows.map((row, i) => {
const out = { ...row };
if (row.volume == null || row.volume <= 0) return out;
const hist = [];
for (let j = i - 1; j >= 0 && hist.length < 20; j--) {
const v = volRows[j];
if (v.volume > 0 && !v.partialSession) hist.unshift(v.volume);
}
if (hist.length < 5) return out;
const avg = hist.reduce((a, b) => a + b, 0) / hist.length;
out.volAvg20 = avg;
out.volRatio = row.volume / avg;
if (out.volRatio >= TA_VOL_SPIKE) out.volSignal = 'spike';
else if (out.volRatio >= TA_VOL_ELEVATED) out.volSignal = 'elevated';
return out;
});
}
function buildVolByDate(volRows) {
const m = Object.create(null);
(volRows || []).forEach(r => { if (r?.date) m[r.date] = r; });
return m;
}
function mergeVolumeIntoRows(rows, volByDate) {
if (!rows?.length || !volByDate) return rows;
return rows.map(r => {
const v = volByDate[r.date];
if (!v) return r;
return {
...r,
volume: v.volume,
volAvg20: v.volAvg20,
volRatio: v.volRatio,
volSignal: v.volSignal,
partialSession: v.partialSession,
};
});
}
function getTaVolAtDate(date) {
return (date && STOCK.technicalVolByDate?.[date]) || null;
}
function taReadoutChip(label, value, opts = {}) {
const cls = ['ta-readout-chip'];
if (opts.mod) cls.push(`ta-readout-chip--${opts.mod}`);
const badge = opts.badge
? `<span class="ta-readout-chip__badge${opts.badgeMod ? ` ta-readout-chip__badge--${opts.badgeMod}` : ''}">${escapeHtml(opts.badge)}</span>`
: '';
const sub = opts.sub ? `<small>${escapeHtml(opts.sub)}</small>` : '';
return `<span class="${cls.join(' ')}"><em>${escapeHtml(label)}</em><b>${value}</b>${sub}${badge}</span>`;
}
function buildTaHoverReadout(r, vr) {
const P = STOCK.technicalPanels || {};
const chips = [];
if (r?.date) {
const dateLbl = r.partialSession ? `${r.date}(當日)` : r.date;
chips.push(taReadoutChip('日期', escapeHtml(dateLbl), { mod: 'date' }));
}
if (r?.open != null) chips.push(taReadoutChip('開', fmtNum(r.open, 2)));
if (r?.high != null) chips.push(taReadoutChip('高', fmtNum(r.high, 2)));
if (r?.low != null) chips.push(taReadoutChip('低', fmtNum(r.low, 2)));
if (r?.close != null) chips.push(taReadoutChip('收', fmtNum(r.close, 2), { mod: 'close' }));
if (vr?.volume != null) {
let mod = vr.partialSession ? 'vol-today' : 'vol';
if (vr.volSignal === 'spike') mod = 'vol-spike';
else if (vr.volSignal === 'elevated') mod = 'vol-up';
const badge = vr.volSignal === 'spike' ? '⚠ 放量' : vr.volSignal === 'elevated' ? '量增' : '';
const badgeMod = vr.volSignal === 'spike' ? 'spike' : vr.volSignal === 'elevated' ? 'elevated' : '';
chips.push(taReadoutChip('成交量', fmtMetric(vr.volume, 'compact'), {
mod,
sub: vr.volRatio != null ? `均量 ${fmtRatio(vr.volRatio, 1)}×` : '',
badge,
badgeMod,
}));
}
if (P.macd && r?.macdHist != null) chips.push(taReadoutChip('MACD', fmtNum(r.macdHist, 3), { mod: 'macd' }));
if (P.rsi && r?.rsi14 != null) chips.push(taReadoutChip('RSI', fmtNum(r.rsi14, 1), { mod: 'rsi' }));
if (P.kdj && r?.k != null) {
chips.push(taReadoutChip('KDJ', `${fmtNum(r.k, 1)} / ${fmtNum(r.d, 1)}`, {
mod: 'kdj',
sub: `J ${fmtNum(r.j, 1)}`,
}));
}
return `<div class="ta-readout"><div class="ta-readout__chips">${chips.join('')}</div></div>`;
}
function taVolAlertChipHtml(vr) {
if (!vr?.volSignal) return '';
const label = vr.volSignal === 'spike' ? '放量警示' : '量能偏高';
return `<span class="ta-chip ta-chip--vol ta-chip--vol-${vr.volSignal}" title="相較近 20 根完整 K 均量">${label} ${fmtRatio(vr.volRatio, 1)}×</span>`;
}
function highlightTaVolumeBar(date) {
const svg = $('#taVol')?.querySelector('svg');
if (!svg) return;
$$('rect.vol-bar', svg).forEach(rect => {
const on = !!date && rect.dataset.date === date;
rect.setAttribute('opacity', on ? '1' : '0.48');
rect.classList.toggle('vol-bar--active', on);
});
}
function updateTaVolSignalChip() {
const host = $('#taVolSignal');
if (!host) return;
const volRows = STOCK.technicalVolRows;
const last = volRows?.[volRows.length - 1];
host.innerHTML = last?.volSignal ? taVolAlertChipHtml(last) : '';
}
function refreshTaVolHoverUi(vr) {
highlightTaVolumeBar(vr?.date);
}
function formatTaHoverVolOnly(vr) {
return buildTaHoverReadout({ date: vr.date, partialSession: vr.partialSession }, vr);
}
function taReadoutBarIndex(i) {
const rows = STOCK.technicalRows;
if (!rows?.length) return -1;
if (i >= 0 && i < rows.length) return i;
if (STOCK._taHoverVolRow?.date) {
const vi = rows.findIndex(r => r.date === STOCK._taHoverVolRow.date);
if (vi >= 0) return vi;
}
return rows.length - 1;
}
function updateTaHoverBar(i) {
const tip = $('#taChartHover');
const rows = STOCK.technicalRows;
if (!tip || !rows?.length) return;
if (i < 0 && STOCK._taHoverVolRow) {
tip.innerHTML = formatTaHoverVolOnly(STOCK._taHoverVolRow);
tip.classList.add('is-on');
refreshTaVolHoverUi(STOCK._taHoverVolRow);
return;
}
const idx = taReadoutBarIndex(i);
if (idx < 0) return;
const r = rows[idx];
const vr = getTaVolAtDate(r.date) || r;
tip.innerHTML = buildTaHoverReadout(r, vr);
tip.classList.add('is-on');
tip.style.transform = 'none';
refreshTaVolHoverUi(vr);
}
function pinTaReadoutToLastBar() {
const rows = STOCK.technicalRows;
if (!rows?.length) return;
STOCK._taHoverVolRow = null;
updateTaHoverBar(rows.length - 1);
}
function taMainChartHeight() {
const P = STOCK.technicalPanels || {};
const n = TA_PANEL_DEFS.filter(d => P[d.id]).length;
if (n === 0) return 400;
if (n === 1) return 360;
return 320;
}
function setTaHoverByVolRow(vr, x) {
STOCK._taHoverVolRow = vr || null;
_taHoverIndex = -1;
updateTaHoverBar(-1);
if (vr?.date) {
const meta = STOCK.taAxisMeta;
const tagX = $('#taTagX');
if (tagX) {
tagX.textContent = formatTaDateLabel(vr.date);
if (x != null && meta) {
const xInView = Number(x) - ($('#taChartScroll')?.scrollLeft || 0);
tagX.style.left = `${TA_AXIS_W + Math.max(0, xInView)}px`;
}
tagX.hidden = false;
}
const tagY = $('#taTagY');
if (tagY) tagY.hidden = true;
}
syncTaSubchartCrosshair(x, true);
}
function syncTaSubchartCrosshair(x, show) {
$$('.ta-subchart-body svg, .ta-subchart svg, #taChart svg').forEach(svg => {
const hg = svg.querySelector('.hg');
const hl = svg.querySelector('.hl');
if (!hl) return;
if (!show || x == null) {
if (hg) hg.style.display = 'none';
return;
}
if (hg) hg.style.display = '';
hl.setAttribute('data-x', x);
hl.setAttribute('x1', x);
hl.setAttribute('x2', x);
});
}
function setTaHoverIndex(i, x) {
if (i < 0) {
pinTaReadoutToLastBar();
_taHoverIndex = -1;
updateTaCrosshairTags(-1, null);
refreshTvScaleTags((STOCK.technicalRows?.length || 1) - 1);
syncTaSubchartCrosshair(null, false);
return;
}
STOCK._taHoverVolRow = null;
_taHoverIndex = i;
updateTaHoverBar(i);
updateTaCrosshairTags(i, x);
refreshTvScaleTags(i);
syncTaSubchartCrosshair(x, x != null);
}
function drawTaSubchart(el, rows, spec) {
if (!rows?.length) { el.innerHTML = '<div class="chart-empty">—</div>'; return; }
const uid = 't' + (++_chartSeq);
const n = rows.length;
const sticky = spec.stickyAxes !== false;
const axisW = sticky ? TA_AXIS_W : 0;
const totalW = spec.chartWidth || taChartPixelWidth(n);
const h = spec.height || 128;
const padL = sticky ? 0 : 56;
const padR = sticky ? 10 : 18;
const padT = sticky ? 14 : 22;
const padB = sticky ? 6 : 22;
const w = sticky ? totalW - axisW : totalW;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
const fmt = spec.fmt || (v => (typeof v === 'number' && !Number.isInteger(v) ? fmtNum(v, 1) : String(v)));
const dates = rows.map(r => r.date);
const valAt = key => rows.map(r => ({ date: r.date, val: r[key] }));
let yMin = spec.yMin != null ? spec.yMin : 0;
let yMax = spec.yMax != null ? spec.yMax : 100;
if (spec.autoScale) {
const vals = [];
spec.keys.forEach(k => rows.forEach(r => { if (r[k.key] != null) vals.push(r[k.key]); }));
if (spec.histKey) rows.forEach(r => { if (r[spec.histKey] != null) vals.push(r[spec.histKey]); });
if (vals.length) {
yMin = Math.min(...vals);
yMax = Math.max(...vals);
const pad = (yMax - yMin) * 0.12 || 1;
yMin -= pad;
yMax += pad;
}
}
if (yMin === yMax) { yMin -= 1; yMax += 1; }
const yRange = yMax - yMin || 1;
const toX = i => padL + (i / (n - 1)) * plotW;
const toY = v => padT + (1 - (v - yMin) / yRange) * plotH;
const zeroY = toY(0);
let grid = '';
if (spec.refLines) {
spec.refLines.forEach(rv => {
const y = toY(rv);
grid += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${w - padR}" y2="${y.toFixed(1)}" stroke="rgba(0,0,0,.07)" stroke-dasharray="4,4"/>`;
if (!sticky) grid += `<text x="${padL - 6}" y="${(y + 3).toFixed(1)}" fill="#86868b" font-size="9" text-anchor="end">${rv}</text>`;
});
}
if (sticky && !spec.refLines && spec.autoScale) {
for (let k = 0; k <= 4; k++) {
const v = yMin + yRange * k / 4;
const y = toY(v);
grid += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${w - padR}" y2="${y.toFixed(1)}" stroke="rgba(0,0,0,.05)"/>`;
}
}
let paths = '', bars = '';
if (spec.histKey) {
const bw = Math.max(2, plotW / n * 0.55);
rows.forEach((r, i) => {
const v = r[spec.histKey];
if (v == null) return;
const x = toX(i) - bw / 2;
const y0 = v >= 0 ? zeroY : toY(v);
const y1 = v >= 0 ? toY(v) : zeroY;
const hh = Math.max(1, Math.abs(y1 - y0));
const custom = spec.histColor?.(r);
const col = custom || (v >= 0 ? 'rgba(52,168,83,.55)' : 'rgba(216,79,69,.55)');
const volCls = spec.histKey === 'volume'
? ` class="vol-bar${r.partialSession ? ' vol-bar--today' : ''}${r.volSignal ? ` vol-bar--${r.volSignal}` : ''}" data-date="${escapeHtml(r.date)}"`
: '';
bars += `<rect${volCls} x="${x.toFixed(1)}" y="${Math.min(y0, y1).toFixed(1)}" width="${bw.toFixed(1)}" height="${hh.toFixed(1)}" fill="${col}"/>`;
});
}
const linePath = (pts) => {
let d = '', move = true;
for (let i = 0; i < n; i++) {
const v = pts[i]?.val;
if (v == null || isNaN(v)) { move = true; continue; }
d += `${move ? 'M' : 'L'}${toX(i).toFixed(1)},${toY(v).toFixed(1)} `;
move = false;
}
return d.trim();
};
(spec.keys || []).forEach(k => {
const pts = valAt(k.key);
const d = linePath(pts);
if (!d) return;
const dash = k.dash ? ` stroke-dasharray="${k.dash}"` : '';
paths += `<path d="${d}" fill="none" stroke="${k.color}" stroke-width="${k.width || 1.5}"${dash}/>`;
});
const svgInner = `${grid}${bars}${paths}
<g class="hg" style="display:none"><line class="hl" data-x="0" y1="${padT}" y2="${padT + plotH}" stroke="#86868b" stroke-dasharray="3,3"/></g>
<rect class="ha" x="${padL}" y="${padT}" width="${plotW}" height="${plotH}" fill="transparent"/>`;
if (sticky) {
const yEl = spec.yGutterEl;
if (yEl) {
yEl.style.height = `${h}px`;
el.innerHTML = `<div class="chart-plot-area tv-plot tv-plot--sub" style="width:${plotW + padR}px;min-width:${plotW + padR}px;height:${h}px">
<svg id="${uid}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">${svgInner}</svg>
</div>`;
const sc = { yMin, yMax, plotH, padT, h, fmt, refLines: spec.refLines };
yEl._tvScale = sc;
if (spec.panelId) {
STOCK.taSubScales = STOCK.taSubScales || {};
STOCK.taSubScales[spec.panelId] = sc;
}
renderChartYGutter(yEl, sc);
} else {
el.innerHTML = `<div class="chart-row-sticky chart-row-sticky--sub">
<div class="chart-gutter-y chart-gutter-y--sub" style="height:${h}px;width:${axisW}px"></div>
<div class="chart-plot-area" style="width:${plotW + padR}px;min-width:${plotW + padR}px">
<svg id="${uid}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">${svgInner}</svg>
</div>
</div>`;
renderChartYGutter(el.querySelector('.chart-gutter-y'), {
yMin, yMax, plotH, padT, h, fmt, refLines: spec.refLines,
});
}
} else {
el.style.minWidth = `${w}px`;
el.innerHTML = `<svg id="${uid}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
<text x="${padL}" y="14" fill="#86868b" font-size="10" font-weight="700">${escapeHtml(spec.title)}</text>
${svgInner}</svg>`;
}
const svg = el.querySelector('svg');
const hl = svg.querySelector('.hl');
const area = svg.querySelector('.ha');
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).toFixed(1);
hl.setAttribute('data-x', x);
hl.setAttribute('x1', x);
hl.setAttribute('x2', x);
svg.querySelector('.hg').style.display = '';
const mainRows = STOCK.technicalRows || rows;
const mi = mainRows.findIndex(r => r.date === dates[i]);
if (mi >= 0) setTaHoverIndex(mi, x);
else setTaHoverByVolRow(rows[i], x);
});
area.addEventListener('mouseleave', () => {
STOCK._taHoverVolRow = null;
highlightTaVolumeBar(null);
const hg = svg.querySelector('.hg');
if (hg) hg.style.display = 'none';
pinTaReadoutToLastBar();
});
}
// ═══════════════════════════════════════════════════════════
// 個股工具視圖(共用代號:價格走勢 / 財報健檢 / 投資地圖 / 回測)
// ═══════════════════════════════════════════════════════════
const TA_PANEL_DEFS = [
{ id: 'vol', label: '成交量', defaultOn: true },
{ id: 'macd', label: 'MACD', defaultOn: false },
{ id: 'rsi', label: 'RSI', defaultOn: false },
{ id: 'kdj', label: 'KDJ', defaultOn: false },
];
const TA_PRESETS = {
minimal: { vol: true, macd: false, rsi: false, kdj: false },
momentum: { vol: true, macd: true, rsi: true, kdj: false },
swing: { vol: true, macd: false, rsi: false, kdj: true },
full: { vol: true, macd: true, rsi: true, kdj: true },
};
function defaultTechnicalPanels() {
const o = {};
TA_PANEL_DEFS.forEach(p => { o[p.id] = p.defaultOn; });
return o;
}
function loadTechnicalPanels() {
try {
const raw = localStorage.getItem('macroscope_ta_panels');
if (!raw) return defaultTechnicalPanels();
const s = JSON.parse(raw);
const o = defaultTechnicalPanels();
TA_PANEL_DEFS.forEach(p => { if (typeof s[p.id] === 'boolean') o[p.id] = s[p.id]; });
return o;
} catch (_) {
return defaultTechnicalPanels();
}
}
function saveTechnicalPanels() {
try { localStorage.setItem('macroscope_ta_panels', JSON.stringify(STOCK.technicalPanels)); } catch (_) {}
}
function syncTaPanelDom() {
const P = STOCK.technicalPanels || {};
TA_PANEL_DEFS.forEach(def => {
const wrap = document.getElementById(`taPanelWrap-${def.id}`);
if (wrap) wrap.hidden = !P[def.id];
const ySlot = document.getElementById(TA_Y_SLOT[def.id]);
if (ySlot) ySlot.hidden = !P[def.id];
});
const empty = $('#taPanelsEmpty');
if (empty) empty.hidden = TA_PANEL_DEFS.some(d => P[d.id]);
const plot = $('#taChart');
if (plot) plot.classList.toggle('tv-pane--solo', !TA_PANEL_DEFS.some(d => P[d.id]));
const yMain = $('#taYMain');
if (yMain) yMain.style.height = `${taMainChartHeight()}px`;
}
function refreshTaPanelChips() {
const box = $('#taPanels');
if (!box) return;
const P = STOCK.technicalPanels || {};
$$('button', box).forEach(btn => {
const id = btn.dataset.panel;
if (id) btn.classList.toggle('on', !!P[id]);
});
}
const STOCK = {
symbol: '', sub: 'metrics', priceRange: '1y', technicalRange: '1y', technicalInterval: '1d',
technicalLayers: { close: true, ma20: true, ma50: true, ma100: true, ma200: false, boll: true },
technicalPanels: loadTechnicalPanels(),
rendered: {}, mapAnswers: {}, mapCfg: null, fundamentals: {}, technicalSnapshot: null, technicalHist: null,
technicalRows: null, technicalVolRows: null,
};
const SUBS = ['metrics', 'technical', 'price', 'finbox', 'map', 'backtest'];
const TA_RANGES = [
{ id: '3mo', label: '近 3 月' },
{ id: '6mo', label: '近 6 月' },
{ id: '1y', label: '近 1 年' },
{ id: '2y', label: '近 2 年' },
{ id: '5y', label: '近 5 年' },
];
const TA_INTERVALS = [
{ id: '1d', label: '日線' },
{ id: '1wk', label: '周線' },
{ id: '1mo', label: '月線' },
];
const TA_INTERVAL_LABEL = { '1d': '日線', '1wk': '周線', '1mo': '月線' };
const TA_RANGE_LABEL = { '3mo': '近 3 月', '6mo': '近 6 月', '1y': '近 1 年', '2y': '近 2 年', '5y': '近 5 年' };
const TA_LAYER_DEFS = [
{ key: 'close', label: '收盤', color: '#2367c7', width: 2.5 },
{ key: 'ma20', label: 'MA20', color: '#d4772f', width: 1.5 },
{ key: 'ma50', label: 'MA50', color: '#7b57c9', width: 1.5 },
{ key: 'ma100', label: 'MA100', color: '#0f8f8c', width: 1.5 },
{ key: 'ma200', label: 'MA200', color: '#667064', width: 1.5, dash: '6,4' },
{ key: 'boll', label: '布林', color: '#2367c7', width: 1, dash: '3,3' },
];
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="technical">技術圖表</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-technical" hidden></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-sub-target="technical"><span>3</span><b>技術圖表</b><small>可開關副圖、KDJ、AI</small></button>
<button data-learn-kind="category" data-learn-id="護城河與商業模式"><span>4</span><b>理解生意</b><small>定價權與產業位置</small></button>
<button data-sub-target="map"><span>5</span><b>六層漏斗</b><small>回答能不能進場</small></button>
<button data-sub-target="backtest"><span>6</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 = {};
STOCK.technicalHist = null;
STOCK.technicalSnapshot = null;
$('#stkSym').value = sym;
if (STOCK.sub === 'map') setSub('metrics'); // 輸入代號後預設先看指標面板
else renderSub(STOCK.sub);
}
window.setStockSymbol = setStockSymbol;
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 === 'technical') return renderTechnicalPane();
if (sub === 'price') return renderPrice();
if (sub === 'finbox') return renderFinboxPane();
if (sub === 'map') return renderMap();
if (sub === 'backtest') return renderBacktestPane();
}
function sliceHistoryByRange(points, range) {
points = (points || []).filter(p => p.close != null);
if (!points.length) return [];
const last = points[points.length - 1].date;
const end = new Date(last + 'T12:00:00Z');
const days = { '3mo': 92, '6mo': 183, '1y': 365, '2y': 730, '5y': 1825 }[range] || 365;
const start = new Date(end);
start.setUTCDate(start.getUTCDate() - days);
const iso = start.toISOString().slice(0, 10);
const sliced = points.filter(p => p.date >= iso);
return sliced.length >= 30 ? sliced : points.slice(-Math.min(points.length, 120));
}
function emaSeriesNums(vals, period) {
const k = 2 / (period + 1);
const out = [];
let prev = null;
for (let i = 0; i < vals.length; i++) {
const v = vals[i];
if (v == null || isNaN(v)) { out.push(null); continue; }
prev = prev == null ? v : v * k + prev * (1 - k);
out.push(prev);
}
return out;
}
/** KDJ(9,3,3)RSV → K/D 平滑J = 3K 2D台股券商常用 */
function kdjSeriesNums(rows, n = 9) {
const kOut = [], dOut = [], jOut = [];
let k = 50, d = 50;
for (let i = 0; i < rows.length; i++) {
if (i < n - 1) {
kOut.push(null); dOut.push(null); jOut.push(null);
continue;
}
const slice = rows.slice(i - n + 1, i + 1);
const hn = Math.max(...slice.map(r => r.high));
const ln = Math.min(...slice.map(r => r.low));
const c = rows[i].close;
const rsv = hn === ln ? 50 : ((c - ln) / (hn - ln)) * 100;
k = (2 / 3) * k + (1 / 3) * rsv;
d = (2 / 3) * d + (1 / 3) * k;
const j = 3 * k - 2 * d;
kOut.push(k); dOut.push(d); jOut.push(j);
}
return { k: kOut, d: dOut, j: jOut };
}
function rsiSeriesNums(closes, period = 14) {
const out = new Array(closes.length).fill(null);
if (closes.length <= period) return out;
let ag = 0, al = 0;
for (let i = 1; i <= period; i++) {
const ch = closes[i] - closes[i - 1];
if (ch >= 0) ag += ch; else al -= ch;
}
ag /= period;
al /= period;
out[period] = al === 0 ? 100 : 100 - 100 / (1 + ag / al);
for (let i = period + 1; i < closes.length; i++) {
const ch = closes[i] - closes[i - 1];
const g = ch > 0 ? ch : 0;
const l = ch < 0 ? -ch : 0;
ag = (ag * (period - 1) + g) / period;
al = (al * (period - 1) + l) / period;
out[i] = al === 0 ? 100 : 100 - 100 / (1 + ag / al);
}
return out;
}
function computeIndicatorRows(points) {
const rows = (points || []).map(p => ({
date: p.date,
open: Number(p.open ?? p.close),
high: Number(p.high ?? p.close),
low: Number(p.low ?? p.close),
close: Number(p.close),
volume: p.volume != null ? Number(p.volume) : null,
}));
const closes = rows.map(r => r.close);
const rsi = rsiSeriesNums(closes, 14);
const ema12 = emaSeriesNums(closes, 12);
const ema26 = emaSeriesNums(closes, 26);
const macdLine = ema12.map((v, i) => (v != null && ema26[i] != null ? v - ema26[i] : null));
const macdSig = emaSeriesNums(macdLine.map(v => (v == null ? 0 : v)), 9);
const kdj = kdjSeriesNums(rows, 9);
const sma = (i, period) => {
if (i < period - 1) return null;
let s = 0;
for (let j = i - period + 1; j <= i; j++) s += rows[j].close;
return s / period;
};
return rows.map((row, i) => {
const out = { ...row };
[20, 50, 100, 200].forEach(p => { out[`ma${p}`] = sma(i, p); });
if (out.ma20 != null) {
const win = rows.slice(i - 19, i + 1).map(r => r.close);
const sd = Math.sqrt(win.reduce((a, v) => a + Math.pow(v - out.ma20, 2), 0) / win.length);
out.bollUpper = out.ma20 + 2 * sd;
out.bollLower = out.ma20 - 2 * sd;
out.bollMid = out.ma20;
}
out.rsi14 = rsi[i];
out.macd = macdLine[i];
out.macdSignal = macdSig[i];
out.macdHist = (macdLine[i] != null && macdSig[i] != null) ? macdLine[i] - macdSig[i] : null;
out.k = kdj.k[i];
out.d = kdj.d[i];
out.j = kdj.j[i];
return out;
});
}
function buildTechnicalSnapshot(rows, quote, range, interval = '1d', histMeta = {}) {
const last = rows[rows.length - 1];
if (!last) return null;
const tech = technicalStats(rows.map(r => ({ date: r.date, close: r.close })), quote);
const px = priceStats(rows.map(r => ({ date: r.date, close: r.close })));
const ivLabel = TA_INTERVAL_LABEL[interval] || interval;
return {
symbol: STOCK.symbol,
range,
interval,
intervalLabel: ivLabel,
asOf: last.date,
researchThrough: histMeta.researchThrough || last.date,
researchNote: histMeta.researchNote || '',
close: last.close,
layers: { ...STOCK.technicalLayers },
indicators: {
ma20: last.ma20, ma50: last.ma50, ma100: last.ma100, ma200: last.ma200,
bollUpper: last.bollUpper, bollLower: last.bollLower, bollPos: tech.bollPos,
rsi14: last.rsi14 ?? tech.rsi14, macd: last.macd, macdSignal: last.macdSignal, macdHist: last.macdHist,
k: last.k, d: last.d, j: last.j,
dist50: tech.dist50, dist200: tech.dist200, trendScore: tech.trendScore,
},
returns: { ret1m: px.ret1m, ret3m: px.ret3m, ret6m: px.ret6m, ret1y: px.ret1y, volatility: px.volatility, maxDrawdown: px.maxDrawdown },
formulas: {
ma: 'MA(N) = 最近 N 日收盤價算術平均',
boll: '中軌 = MA20上/下軌 = MA20 ± 2×20日標準差',
rsi: 'RSI(14) 依最近 14 日漲跌幅度計算',
},
source: `${ivLabel} OHLCYahoo Finance周/月僅 Yahoo日線可備援 Nasdaq→ SQLite指標本機計算`,
};
}
function buildVolumeRowsFromHist(hist, quote) {
const base = hist.volumePoints || hist.points || [];
if (hist.interval === '1d' && hist.todayVolume != null) return base;
if (STOCK.technicalInterval !== '1d') return base;
const today = new Date().toISOString().slice(0, 10);
const rows = base.map(p => ({ ...p }));
const vol = hist.todayVolume ?? quote?.volume;
if (vol == null) return rows;
const last = rows[rows.length - 1];
if (last?.date === today) {
rows[rows.length - 1] = { ...last, volume: vol };
return rows;
}
const px = quote?.price ?? last?.close;
if (px == null) return rows;
return [...rows, { date: today, close: px, volume: vol, partialSession: true }];
}
function drawAllTaCharts(rows, volRows) {
if (!rows?.length) return;
clearTvScaleTags();
const vol = volRows || rows;
const P = STOCK.technicalPanels || {};
const cw = taChartPixelWidth(Math.max(rows.length, vol.length));
const dates = rows.map(r => r.date);
const pt = key => rows.map(r => ({ date: r.date, val: r[key] }));
const series = [];
const L = STOCK.technicalLayers;
if (L.close) series.push({ name: '收盤', color: '#2367c7', strokeWidth: 2.5, points: pt('close') });
if (L.ma20) series.push({ name: 'MA20', color: '#d4772f', strokeWidth: 1.5, points: pt('ma20') });
if (L.ma50) series.push({ name: 'MA50', color: '#7b57c9', strokeWidth: 1.5, points: pt('ma50') });
if (L.ma100) series.push({ name: 'MA100', color: '#0f8f8c', strokeWidth: 1.5, points: pt('ma100') });
if (L.ma200) series.push({ name: 'MA200', color: '#667064', strokeWidth: 1.5, dash: '6,4', points: pt('ma200') });
if (L.boll) {
series.push({ name: '布林上軌', color: 'rgba(35,103,199,.55)', strokeWidth: 1, dash: '4,3', points: pt('bollUpper') });
series.push({ name: '布林下軌', color: 'rgba(35,103,199,.55)', strokeWidth: 1, dash: '4,3', points: pt('bollLower') });
}
const band = L.boll ? {
upper: { points: pt('bollUpper') },
lower: { points: pt('bollLower') },
fill: 'rgba(35,103,199,.08)',
} : null;
syncTaPanelDom();
const plot = $('#taChart');
if (plot) {
plot._taLegendEl = $('#taLegend');
drawLineChart(plot, series, {
height: taMainChartHeight(),
chartWidth: cw,
stickyAxes: true,
yGutterEl: $('#taYMain'),
storeMainScale: true,
decimals: 2,
fmt: v => fmtNum(v, 2),
dates,
band,
ohlcRows: rows,
rootClass: 'chart-root--ta',
stageClass: 'chart-stage--ta',
externalLegend: plot._taLegendEl,
hoverEl: null,
onIndex: (i, _date, x) => setTaHoverIndex(i, x),
});
if (_taHoverIndex >= 0 && _taHoverIndex < rows.length) updateTaHoverBar(_taHoverIndex);
else pinTaReadoutToLastBar();
}
if (P.vol) {
const hasVol = vol.some(r => r.volume != null && r.volume > 0);
const volWrap = $('#taVol');
if (volWrap) {
volWrap.innerHTML = hasVol ? '' : '<div class="chart-empty">尚無成交量資料</div>';
if (hasVol) {
const hasToday = vol.some(r => r.partialSession);
drawTaSubchart(volWrap, vol, {
title: hasToday ? '成交量(含當日)' : '成交量',
panelId: 'vol',
histKey: 'volume',
histColor: r => {
if (r.partialSession) return 'rgba(35,103,199,.78)';
if (r.volSignal === 'spike') return 'rgba(216,79,69,.88)';
if (r.volSignal === 'elevated') return 'rgba(200,138,29,.78)';
return null;
},
autoScale: true,
chartWidth: cw,
height: 96,
yGutterEl: $('#taYVol'),
fmt: v => fmtMetric(v, 'compact'),
keys: [],
});
}
}
}
if (P.macd) {
const macdEl = $('#taMacd');
if (macdEl) {
drawTaSubchart(macdEl, rows, {
title: 'MACD (12,26,9)',
panelId: 'macd',
autoScale: true,
chartWidth: cw,
height: 108,
yGutterEl: $('#taYMacd'),
histKey: 'macdHist',
keys: [
{ key: 'macd', color: '#2367c7', name: 'MACD' },
{ key: 'macdSignal', color: '#d4772f', name: 'Signal', dash: '4,3' },
],
});
}
}
if (P.rsi) {
const rsiEl = $('#taRsi');
if (rsiEl) {
drawTaSubchart(rsiEl, rows, {
title: 'RSI (14)',
panelId: 'rsi',
yMin: 0,
yMax: 100,
chartWidth: cw,
height: 96,
yGutterEl: $('#taYRsi'),
refLines: [30, 70],
keys: [{ key: 'rsi14', color: '#7b57c9', name: 'RSI' }],
});
}
}
if (P.kdj) {
const kdjEl = $('#taKdj');
if (kdjEl) {
drawTaSubchart(kdjEl, rows, {
title: 'KDJ (9,3,3)',
panelId: 'kdj',
autoScale: true,
chartWidth: cw,
height: 108,
yGutterEl: $('#taYKdj'),
refLines: [20, 80],
keys: [
{ key: 'k', color: '#2367c7', name: 'K' },
{ key: 'd', color: '#d4772f', name: 'D', dash: '4,3' },
{ key: 'j', color: '#7b57c9', name: 'J', dash: '2,2' },
],
});
}
}
STOCK.taAxisMeta = { dates, cw, axisW: TA_AXIS_W, plotW: cw - TA_AXIS_W, n: rows.length };
const stack = $('#taChartStack');
if (stack) stack.style.minWidth = `${cw}px`;
requestAnimationFrame(() => {
syncTvAxisHeights();
refreshTvScaleTags(_taHoverIndex >= 0 ? _taHoverIndex : rows.length - 1);
updateTaXAxis();
});
scrollTaChartEnd();
updateTaVolSignalChip();
pinTaReadoutToLastBar();
}
function bindTaPanelControls(rows, volRows) {
const applyPanels = () => {
saveTechnicalPanels();
refreshTaPanelChips();
drawAllTaCharts(rows, volRows);
};
const panelBox = $('#taPanels');
if (panelBox) {
panelBox.innerHTML = TA_PANEL_DEFS.map(p =>
`<button type="button" class="chip sm${STOCK.technicalPanels[p.id] ? ' on' : ''}" data-panel="${p.id}">${escapeHtml(p.label)}</button>`,
).join('');
$$('button', panelBox).forEach(btn => btn.addEventListener('click', () => {
const id = btn.dataset.panel;
STOCK.technicalPanels[id] = !STOCK.technicalPanels[id];
applyPanels();
}));
}
$$('[data-preset]', $('#taPresets')).forEach(btn => btn.addEventListener('click', () => {
const preset = TA_PRESETS[btn.dataset.preset];
if (!preset) return;
STOCK.technicalPanels = { ...STOCK.technicalPanels, ...preset };
applyPanels();
}));
$$('.ta-sub-close', $('#pane-technical')).forEach(btn => btn.addEventListener('click', () => {
const id = btn.dataset.panel;
if (id) STOCK.technicalPanels[id] = false;
applyPanels();
}));
}
async function renderTechnicalPane(force) {
const pane = $('#pane-technical');
if (needSymbol(pane)) return;
const iv = STOCK.technicalInterval || '1d';
const cacheKey = `${STOCK.symbol}:${iv}`;
if (!force && STOCK.rendered.technical === cacheKey && STOCK.technicalHist) {
paintTechnicalPane(pane, STOCK.technicalHist);
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)} ${escapeHtml(TA_INTERVAL_LABEL[iv] || '')}…</div>`;
try {
const [hist, quote] = await Promise.all([
api(`/api/price/${encodeURIComponent(STOCK.symbol)}?range=max&interval=${encodeURIComponent(iv)}${force ? '&fresh=1' : ''}`),
api(`/api/quote/${encodeURIComponent(STOCK.symbol)}`).catch(() => ({})),
]);
STOCK.technicalHist = { hist, quote };
STOCK.rendered.technical = cacheKey;
paintTechnicalPane(pane, STOCK.technicalHist);
} catch (e) {
pane.innerHTML = `<div class="empty-state">無法載入:${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
}
}
function paintTechnicalPane(pane, { hist, quote }) {
const sliced = sliceHistoryByRange(hist.points || [], STOCK.technicalRange);
const volSliced = sliceHistoryByRange(hist.volumePoints || hist.points || [], STOCK.technicalRange);
const rowsBase = computeIndicatorRows(sliced);
const volRowsRaw = computeIndicatorRows(buildVolumeRowsFromHist({ ...hist, volumePoints: volSliced }, quote));
const volRows = enrichVolumeRows(volRowsRaw);
STOCK.technicalVolByDate = buildVolByDate(volRows);
const rows = mergeVolumeIntoRows(rowsBase, STOCK.technicalVolByDate);
STOCK.technicalSnapshot = buildTechnicalSnapshot(rows, quote, STOCK.technicalRange, STOCK.technicalInterval, hist);
setAIFocus({ type: 'stock-technical', symbol: STOCK.symbol, subPage: 'technical', label: `${STOCK.symbol} · ${TA_INTERVAL_LABEL[STOCK.technicalInterval] || ''} ${STOCK.technicalRange}` });
const snap = STOCK.technicalSnapshot;
const ind = snap?.indicators || {};
const ret = snap?.returns || {};
const retKey = { '3mo': 'ret3m', '6mo': 'ret6m', '1y': 'ret1y', '2y': 'ret1y', '5y': 'ret1y' }[STOCK.technicalRange] || 'ret1y';
const retVal = ret[retKey] ?? ret.ret1y;
const retCls = (retVal != null && retVal >= 0) ? 'pnl-pos' : 'pnl-neg';
pane.innerHTML = `
<div class="ta-page">
<header class="ta-hero">
<div class="ta-hero-main">
<h2 class="ta-hero-title">${escapeHtml(hist.name || STOCK.symbol)} <span class="ta-hero-sym">${escapeHtml(STOCK.symbol)}</span></h2>
<p class="ta-hero-price">${fmtNum(ind.close, 2)} <small>${escapeHtml(snap?.asOf || '')}</small></p>
</div>
<div class="ta-hero-kpis">
<div><span>區間報酬</span><b class="${retCls}">${fmtPct(retVal, 1)}</b></div>
${(hist.todayVolume != null || quote?.volume != null) ? `<div><span class="ta-stat-label">當日成交量${termTipBtn('volume_ratio', '量')}</span><b>${fmtMetric(hist.todayVolume ?? quote?.volume, 'compact')}</b>${hist.volumeRatio != null ? `<small>均量 ${fmtRatio(hist.volumeRatio, 2)}</small>` : ''}</div>` : ''}
<div><span>RSI(14)</span><b>${fmtNum(ind.rsi14, 1)}</b></div>
<div><span>趨勢分</span><b>${fmtNum(ind.trendScore, 0)}</b></div>
</div>
</header>
<div class="ta-controls">
<div class="ta-control-group">
<span class="ta-label">① K 線週期</span>
<p class="ta-control-hint">每根 K 棒代表多久(日/周/月),會分開存進資料庫</p>
<div class="chip-row" id="taInterval"></div>
</div>
<div class="ta-control-group">
<span class="ta-label">② 圖表區間</span>
<p class="ta-control-hint">主圖要顯示多長的走勢;可左右拖曳/捲動看更早的 K</p>
<div class="chip-row" id="taRange"></div>
</div>
<div class="ta-control-group ta-control-group--layers">
<span class="ta-label">③ 主圖疊加</span>
<p class="ta-control-hint">疊在價格上的均線與布林(依 K 根數計算)</p>
<div class="chip-row" id="taLayers"></div>
</div>
<button type="button" class="btn ghost sm ta-refresh" id="taRefresh" title="只補最新 K 線">↻ 更新</button>
</div>
<div class="ta-controls ta-controls--panels">
<div class="ta-control-group">
<span class="ta-label">④ 副圖指標(可開關)</span>
<p class="ta-control-hint">券商TradingView 常一次只看 12 個動量副圖;點選開啟,面板右上角可關閉</p>
<div class="ta-panels-row">
<div class="chip-row" id="taPanels"></div>
<div class="ta-preset-row" id="taPresets">
<span class="ta-preset-label">快速:</span>
<button type="button" class="chip sm" data-preset="minimal">精簡</button>
<button type="button" class="chip sm" data-preset="momentum">動量</button>
<button type="button" class="chip sm" data-preset="swing">波段 KDJ</button>
<button type="button" class="chip sm" data-preset="full">全部</button>
</div>
</div>
</div>
</div>
<article class="ta-chart-card">
<div class="ta-chart-top">
<div class="ta-meta-chips">
<span class="ta-chip">${escapeHtml(TA_INTERVAL_LABEL[STOCK.technicalInterval] || '日線')}</span>
<span class="ta-chip">${escapeHtml(TA_RANGE_LABEL[STOCK.technicalRange] || STOCK.technicalRange)}</span>
<span class="ta-chip">研究 ${hist.researchBars || sliced.length} 根</span>
<span class="ta-chip">DB ${hist.dbBars || '—'}</span>
${hist.cached ? '<span class="ta-chip ta-chip--ok">本機</span>' : ''}
${hist.fetchMode ? `<span class="ta-chip">補 ${escapeHtml(hist.fetchMode)}</span>` : ''}
<span id="taVolSignal"></span>
</div>
<p class="ta-db-range">${escapeHtml(hist.researchNote || '')}${hist.volumeNote ? ` · ${escapeHtml(hist.volumeNote)}` : ''}${hist.firstDate ? ` · ${escapeHtml(hist.firstDate)}${escapeHtml(hist.researchThrough || hist.lastDate || '')}` : ''}</p>
</div>
<div id="taLegend" class="ta-legend" aria-label="圖例"></div>
<p class="ta-workflow-hint">圖可左右捲動;下方讀數列固定不動。滑過 K 線可查看該根 OHLC、成交量與指標。</p>
<div class="tv-chart">
<div class="tv-chart-view">
<div class="tv-chart-body">
<div class="tv-y-col" id="taYCol">
<div class="tv-y-slot tv-y-slot--main" id="taYMain"></div>
<div class="tv-y-slot tv-y-slot--sub" id="taYVol" hidden><span class="tv-y-slot-label">量</span></div>
<div class="tv-y-slot tv-y-slot--sub" id="taYMacd" hidden><span class="tv-y-slot-label">MACD</span></div>
<div class="tv-y-slot tv-y-slot--sub" id="taYRsi" hidden><span class="tv-y-slot-label">RSI</span></div>
<div class="tv-y-slot tv-y-slot--sub" id="taYKdj" hidden><span class="tv-y-slot-label">KDJ</span></div>
</div>
<div class="tv-scroll ta-chart-scroll" id="taChartScroll">
<div class="tv-stack ta-chart-stack" id="taChartStack">
<div class="tv-pane tv-pane--main" id="taChart"></div>
<p class="ta-panels-empty" id="taPanelsEmpty" hidden>未開啟副圖 — 在上方 ④ 點選指標</p>
<div class="tv-sub-panel ta-sub-panel" id="taPanelWrap-vol" hidden>
<button type="button" class="ta-sub-close ta-sub-close--float" data-panel="vol" title="關閉成交量">×</button>
<div id="taVol" class="tv-sub-plot ta-subchart-body"></div>
</div>
<div class="tv-sub-panel ta-sub-panel" id="taPanelWrap-macd" hidden>
<button type="button" class="ta-sub-close ta-sub-close--float" data-panel="macd" title="關閉 MACD">×</button>
<div id="taMacd" class="tv-sub-plot ta-subchart-body"></div>
</div>
<div class="tv-sub-panel ta-sub-panel" id="taPanelWrap-rsi" hidden>
<button type="button" class="ta-sub-close ta-sub-close--float" data-panel="rsi" title="關閉 RSI">×</button>
<div id="taRsi" class="tv-sub-plot ta-subchart-body"></div>
</div>
<div class="tv-sub-panel ta-sub-panel" id="taPanelWrap-kdj" hidden>
<button type="button" class="ta-sub-close ta-sub-close--float" data-panel="kdj" title="關閉 KDJ">×</button>
<div id="taKdj" class="tv-sub-plot ta-subchart-body"></div>
</div>
</div>
</div>
</div>
<div class="tv-x-wrap">
<div class="tv-x-pad" aria-hidden="true"></div>
<div class="tv-x-track ta-x-axis" id="taXAxis" aria-label="時間軸"></div>
<div class="tv-cursor-x" id="taTagX" hidden></div>
</div>
</div>
</div>
<div class="tv-chart-foot" id="taChartFoot">
<div id="taChartHover" class="ta-readout-wrap ta-readout-wrap--pinned" aria-live="polite"></div>
<div class="ta-glossary-bar" id="taGlossaryBar"></div>
</div>
</article>
<div class="ta-stats-wrap">
<h3 class="ta-section-title">目前指標</h3>
<div class="ta-stat-grid" id="taStats"></div>
</div>
<section class="ta-ai-card">
<h3 class="ta-section-title">AI 技術解讀</h3>
<p class="ta-ai-desc">附上圖表區間的指標摘要與報酬,白話分析趨勢與風險(非投資建議)。</p>
<div class="ta-ai-actions">
<button type="button" class="btn" id="taAiTrend">解讀趨勢與均線</button>
<button type="button" class="btn ghost" id="taAiRisk">解讀波動與位置</button>
</div>
<div id="taAiOut" class="ta-ai-out">按上方按鈕開始分析。</div>
</section>
</div>`;
mountChips($('#taInterval'), TA_INTERVALS, STOCK.technicalInterval, v => {
STOCK.technicalInterval = v;
STOCK.rendered.technical = '';
renderTechnicalPane(true);
});
mountChips($('#taRange'), TA_RANGES, STOCK.technicalRange, v => { STOCK.technicalRange = v; renderTechnicalPane(); });
const layerBox = $('#taLayers');
layerBox.innerHTML = TA_LAYER_DEFS.map(l =>
`<button type="button" class="chip sm${STOCK.technicalLayers[l.key] ? ' on' : ''}" data-layer="${l.key}">${escapeHtml(l.label)}</button>`,
).join('');
$$('button', layerBox).forEach(btn => btn.addEventListener('click', () => {
STOCK.technicalLayers[btn.dataset.layer] = !STOCK.technicalLayers[btn.dataset.layer];
btn.classList.toggle('on', STOCK.technicalLayers[btn.dataset.layer]);
drawAllTaCharts(rows, STOCK.technicalVolRows || volRows);
refreshTvScaleTags(_taHoverIndex);
}));
STOCK.technicalRows = rows;
STOCK.technicalVolRows = volRows;
bindTaChartScroll($('#taChartScroll'));
bindTaPanelControls(rows, volRows);
clearTvScaleTags();
drawAllTaCharts(rows, volRows);
updateTaVolSignalChip();
const s = STOCK.technicalSnapshot?.indicators || {};
$('#taStats').innerHTML = `
<div class="ta-stat">${taStatLabel('收盤')}<b>${fmtNum(s.close, 2)}</b><small>${escapeHtml(STOCK.technicalSnapshot?.asOf || '')}</small></div>
<div class="ta-stat">${taStatLabel('MA20', 'ma')}<b>${fmtNum(s.ma20, 2)}</b></div>
<div class="ta-stat">${taStatLabel('MA50')}<b>${fmtNum(s.ma50, 2)}</b></div>
<div class="ta-stat">${taStatLabel('MA100')}<b>${fmtNum(s.ma100, 2)}</b></div>
<div class="ta-stat">${taStatLabel('MA200')}<b>${fmtNum(s.ma200, 2)}</b></div>
<div class="ta-stat">${taStatLabel('布林', 'boll')}<b>${fmtNum(s.bollUpper, 2)} / ${fmtNum(s.bollLower, 2)}</b><small>位置 ${fmtPct(s.bollPos, 0)}</small></div>
<div class="ta-stat">${taStatLabel('RSI(14)', 'rsi')}<b>${fmtNum(s.rsi14, 1)}</b></div>
<div class="ta-stat">${taStatLabel('MACD 柱', 'macd')}<b>${fmtNum(s.macdHist, 3)}</b></div>
<div class="ta-stat">${taStatLabel('KDJ', 'kdj')}<b>${fmtNum(s.k, 1)} / ${fmtNum(s.d, 1)}</b><small>J ${fmtNum(s.j, 1)}</small></div>
${(hist.todayVolume != null || quote?.volume != null) ? `<div class="ta-stat"><span>當日量</span><b>${fmtMetric(hist.todayVolume ?? quote.volume, 'compact')}</b><small>${hist.volumeRatio != null ? `為均量 ${fmtRatio(hist.volumeRatio, 2)}` : ''}</small></div>` : ''}
<div class="ta-stat"><span>距 MA200</span><b>${fmtPct(s.dist200, 1)}</b></div>`;
$('#taRefresh').addEventListener('click', () => { STOCK.rendered.technical = ''; renderTechnicalPane(true); });
const runTaAi = async (question) => {
const out = $('#taAiOut');
out.innerHTML = '<span class="ai-typing"><i></i><i></i><i></i></span> 分析中…';
try {
const context = await collectAIContext();
const d = await askAI({ provider: $('#aiProviderSelect')?.value, model: $('#aiModelSelect')?.value || '', question, context });
out.innerHTML = `<div class="md ta-ai-md">${renderMarkdown(d?.text || '(無回覆)')}</div>`;
} catch (e) {
out.innerHTML = `<div class="ai-error">${escapeHtml((e.data && e.data.message) || e.message || 'AI 失敗')}</div>`;
}
};
$('#taAiTrend').addEventListener('click', () => runTaAi(
`請用繁體中文解讀 ${STOCK.symbol}${TA_INTERVAL_LABEL[STOCK.technicalInterval] || ''}${STOCK.technicalRange} 區間技術面均線排列、布林、RSI、KDJ、趨勢分數。資料截至 ${STOCK.technicalSnapshot?.researchThrough || ''}。先結論再依據。僅供學習。`,
));
$('#taAiRisk').addEventListener('click', () => runTaAi(
`請用繁體中文分析 ${STOCK.symbol} ${TA_INTERVAL_LABEL[STOCK.technicalInterval] || ''}${STOCK.technicalRange})的風險:波動、回撤、位置。資料截至 ${STOCK.technicalSnapshot?.researchThrough || ''}。先結論再依據。僅供學習。`,
));
const gloss = $('#taGlossaryBar');
if (gloss) {
gloss.innerHTML = `<span class="ta-glossary-title">指標說明</span>
<div class="ta-glossary-chips">
${termTipBtn('section_technical', '技術面')}
${termTipBtn('ma', '均線')}
${termTipBtn('boll', '布林')}
${termTipBtn('rsi', 'RSI')}
${termTipBtn('macd', 'MACD')}
${termTipBtn('kdj', 'KDJ')}
${termTipBtn('volume_ratio', '成交量')}
</div>`;
}
bindTermTips(pane);
}
// ── 指標面板(市場總覽 / 風險 / 回報 / 效率 / 預測 / 穩健度)──
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.tipId ? metricTipBtn(m.tipId, m.label) : (m.tipKey ? termTipBtn(m.tipKey, m.label) : '');
const missingText = m.missingLabel || '尚無可用資料';
return `<div class="metric-card ${cls}">
<div class="metric-name"><span>${escapeHtml(m.label)}</span>${tip}</div>
<div class="metric-value">${m.missing ? escapeHtml(missingText) : escapeHtml(m.value)}</div>
<div class="metric-note">${escapeHtml(m.note || '')}</div>
</div>`;
}
function revenueCagr5y(annual) {
const rows = (annual || []).filter(a => a.revenue > 0).sort((a, b) => String(a.end || '').localeCompare(b.end || ''));
if (rows.length < 2) return null;
const latest = rows[rows.length - 1];
const start = rows[Math.max(0, rows.length - 6)];
if (!start?.revenue || !latest?.revenue || start === latest) return null;
const years = (new Date(latest.end) - new Date(start.end)) / (365.25 * 86400000);
if (years < 2.5) return null;
return { pct: (Math.pow(latest.revenue / start.revenue, 1 / years) - 1) * 100, years, from: start.label || start.end, to: latest.label || latest.end };
}
function buildDcfTips(fair, { fcf, shares, bal, curY, d, revGrowth, netGrowth }) {
if (fair) {
const mosId = registerMetricTip({
label: '安全邊際',
what: `DCF 每股 ${fmtNum(fair.fair, 2)} 相對現價 ${fmtNum(d.price, 2)} 的溢價幅度。`,
formula: `安全邊際(%) = (${fmtNum(fair.fair, 2)} / ${fmtNum(d.price, 2)} - 1) × 100 = ${fmtPct(fair.upside, 1)}`,
source: 'MacroScope 本機 DCF ÷ 即時報價',
});
const assId = registerMetricTip({
label: '估值假設',
what: '這次 DCF 使用的成長、折現與終值假設。',
formula: `5 年 FCF 成長 = ${fair.growth.toFixed(1)}%\n折現率 = ${fair.discount.toFixed(1)}%\n終值成長 = ${fair.terminalGrowth.toFixed(1)}%`,
model: `啟發輸入:營收年增 ${fmtPct(revGrowth, 1)}、淨利年增 ${fmtPct(netGrowth, 1)};波動/槓桿會調整折現率`,
source: `財報 ${d.source}`,
});
const dcfId = registerMetricTip({
label: 'DCF 公允價值',
what: `本次模型估算每股約 ${fmtNum(fair.fair, 2)} 美元(${d.currency || ''})。`,
formula: [
`FCF₀ = OCF + CapEx = ${fmtMetric(curY.ocf, 'compact')} + (${fmtMetric(curY.capex, 'compact')})`,
`現金 ${fmtMetric(bal.cash, 'compact')},總負債 ${fmtMetric(bal.totalDebt, 'compact')},股數 ${fmtMetric(shares, 'shares')}`,
`g=${fair.growth.toFixed(1)}%, r=${fair.discount.toFixed(1)}%, 終值 g=${fair.terminalGrowth.toFixed(1)}%`,
'每股 = (PV FCF + 終值 + 現金 - 負債) / 股數',
].join('\n'),
source: `${d.source} 年度現金流 + 資產負債表 · 現價來自報價 API`,
model: 'MacroScope 教學用簡化 DCF',
caveat: '非分析師共識;假設變動會大幅改變結果。',
});
return { dcfId, mosId, assId };
}
const why = [];
if (!(fcf > 0)) why.push(`FCF₀=${fcf == null ? '缺' : fmtMetric(fcf, 'compact')}(需 > 0`);
if (!(shares > 0)) why.push(`股數=${shares == null ? '缺' : fmtMetric(shares, 'shares')}`);
const missId = registerMetricTip({
label: 'DCF 公允價值',
what: '無法計算 DCF因此不顯示數字。',
formula: '條件:最近年度 FCF₀ > 0 且流通股數 > 0',
source: `${d.source} · 最近年度 ${curY.label || curY.end || '—'}`,
caveat: why.join('') || '資料不足',
});
return { dcfId: missId, mosId: missId, assId: missId };
}
function buildForecastMetrics(d, fair, revGrowth, netGrowth) {
const est = d.estimates;
const ny = est?.nextYear;
const cy = est?.currentYear;
const pick = ny?.revenueAvg != null || ny?.epsAvg != null ? ny : cy;
const histCagr = revenueCagr5y(d.annual);
const dcfTips = buildDcfTips(fair, {
fcf: (d.annual?.[0]?.ocf != null && d.annual?.[0]?.capex != null) ? d.annual[0].ocf + d.annual[0].capex : null,
shares: d.sharesOutstanding ?? ((d.marketCap && d.price) ? d.marketCap / d.price : null),
bal: d.balance || {},
curY: d.annual?.[0] || {},
d, revGrowth, netGrowth,
});
const targetNote = d.targetMeta
? `共識 ${d.targetMeta.analysts != null ? d.targetMeta.analysts + ' 位' : ''}${d.targetMeta.low != null ? ` · 區間 ${fmtNum(d.targetMeta.low, 2)}~${fmtNum(d.targetMeta.high, 2)}` : ''} · ${d.targetMeta.source || ''}`
: (d.targetPrice != null ? 'Nasdaq summary' : '');
const targetTipId = registerMetricTip({
label: '1 年目標價',
what: d.targetPrice != null ? `顯示 ${fmtNum(d.targetPrice, 2)}` : '目前未取得目標價共識。',
formula: 'targetMeanPriceYahoo financialData或 Nasdaq OneYrTarget',
source: d.targetMeta?.endpoint || d.targetMeta?.source || 'Yahoo/Nasdaq 公開 API',
caveat: d.targetPrice == null ? 'Yahoo 未回傳或該股無覆蓋' : '分析師共識,可能延遲',
});
const revVal = pick?.revenueAvg;
const revTipId = registerMetricTip({
label: '預估營收',
what: revVal != null ? `${pick.period || '財年'} 共識營收約 ${fmtMoney(revVal)}` : '未取得營收共識。',
formula: 'revenueEstimate.avg',
source: est ? `${est.source} · ${est.endpoint}` : '—',
caveat: pick ? `期間 ${pick.endDate || '—'} · ${pick.revenueAnalysts ?? '?'} 位分析師` : '需 Yahoo earningsTrend',
});
const epsVal = pick?.epsAvg ?? est?.forwardEps;
const epsTipId = registerMetricTip({
label: '預估 EPS',
what: epsVal != null ? `共識 EPS 約 ${fmtNum(epsVal, 2)}` : '未取得 EPS 共識。',
formula: pick?.epsAvg != null ? 'earningsEstimate.avg' : 'defaultKeyStatistics.forwardEps',
source: est ? `${est.source} · earningsTrend` : '—',
caveat: pick ? `${pick.period} · end ${pick.endDate || '—'} · ${pick.epsAnalysts ?? '?'}` : (est?.forwardEps != null ? 'forwardEps約 NTM' : ''),
});
const ebitdaVal = pick?.ebitdaAvg ?? ny?.ebitdaAvg ?? cy?.ebitdaAvg;
const ebitdaTipId = registerMetricTip({
label: '預估 EBITDA',
what: ebitdaVal != null ? `共識 EBITDA 約 ${fmtMoney(ebitdaVal)}` : 'Yahoo 未提供 ebitdaEstimate。',
formula: 'ebitdaEstimate.avg',
source: est ? `${est.source} · earningsTrend` : '—',
caveat: pick?.ebitdaAnalysts != null ? `${pick.ebitdaAnalysts} 位分析師` : '非所有股票都有',
});
const fwdGrowth = pick?.revenueGrowthPct ?? pick?.epsGrowthPct;
const growthVal = fwdGrowth != null ? fmtPct(fwdGrowth, 1) : (histCagr ? fmtPct(histCagr.pct, 1) : null);
const growthIsHist = fwdGrowth == null && !!histCagr;
const growthTipId = registerMetricTip({
label: '未來 5 年成長',
what: growthIsHist
? `歷史營收 CAGR${histCagr.from}${histCagr.to},約 ${histCagr.years.toFixed(1)} 年)`
: (fwdGrowth != null ? '分析師對所選財年的成長率共識(通常為下一財年)' : '無共識亦無足夠歷史營收'),
formula: growthIsHist
? `CAGR = (Rev_end/Rev_start)^(1/年數)-1\n= (${fmtMetric(d.annual?.[0]?.revenue, 'compact')} 等年度列)`
: 'earnings/revenue Estimate.growthYahoo',
source: growthIsHist ? `歷史營收:${d.source}` : (est?.source || '—'),
caveat: growthIsHist ? '這是過去成長,不是未來 5 年共識' : 'forward 成長通常只涵蓋 1 個財年',
});
return [
d.targetPrice != null
? { label: '1 年目標價', tipId: targetTipId, value: fmtNum(d.targetPrice, 2), status: metricStatus(((d.targetPrice / d.price) - 1) * 100, 15, 0), note: targetNote }
: { label: '1 年目標價', tipKey: 'target_price', tipId: targetTipId, missing: true, missingLabel: '尚無共識', note: '見 ? 說明' },
fair
? { label: 'DCF 公允價值', tipKey: 'dcf', tipId: dcfTips.dcfId, value: fmtNum(fair.fair, 2), status: metricStatus(fair.upside, 20, 0), note: `區間 ${fmtNum(fair.low, 2)} ~ ${fmtNum(fair.high, 2)} · 本機模型` }
: { label: 'DCF 公允價值', tipKey: 'dcf', tipId: dcfTips.dcfId, missing: true, missingLabel: '無法估算', note: '見 ? 原因' },
fair
? { label: '安全邊際', tipKey: 'margin_of_safety', tipId: dcfTips.mosId, value: fmtPct(fair.upside, 1), status: metricStatus(fair.upside, 20, 0), note: 'DCF / 現價 - 1' }
: { label: '安全邊際', tipKey: 'margin_of_safety', tipId: dcfTips.mosId, missing: true, missingLabel: '—', note: '需 DCF' },
fair
? { label: '估值假設', tipKey: 'dcf_assumption', tipId: dcfTips.assId, value: `${fair.growth.toFixed(1)}% / ${fair.discount.toFixed(1)}%`, status: 'na', note: `終值成長 ${fair.terminalGrowth.toFixed(1)}%` }
: { label: '估值假設', tipKey: 'dcf_assumption', tipId: dcfTips.assId, missing: true, missingLabel: '—', note: '需 DCF' },
revVal != null
? { label: '預估營收', tipKey: 'est_revenue', tipId: revTipId, value: fmtMoney(revVal), status: 'na', note: `${pick.period || ''} 共識 · ${pick.endDate || ''}` }
: { label: '預估營收', tipKey: 'est_revenue', tipId: revTipId, missing: true, missingLabel: '尚無共識', note: 'Yahoo earningsTrend' },
epsVal != null
? { label: '預估 EPS', tipKey: 'est_eps', tipId: epsTipId, value: fmtNum(epsVal, 2), status: 'na', note: pick?.epsAvg != null ? 'earningsEstimate.avg' : 'forwardEps' }
: { label: '預估 EPS', tipKey: 'est_eps', tipId: epsTipId, missing: true, missingLabel: '尚無共識', note: '見 ?' },
ebitdaVal != null
? { label: '預估 EBITDA', tipKey: 'est_ebitda', tipId: ebitdaTipId, value: fmtMoney(ebitdaVal), status: 'na', note: 'ebitdaEstimate.avg' }
: { label: '預估 EBITDA', tipKey: 'est_ebitda', tipId: ebitdaTipId, missing: true, missingLabel: '尚無共識', note: '見 ?' },
growthVal != null
? { label: growthIsHist ? '歷史營收 CAGR' : '共識成長率', tipKey: 'growth_5y', tipId: growthTipId, value: growthVal, status: metricStatus(growthIsHist ? histCagr.pct : fwdGrowth, 10, 0), note: growthIsHist ? `${histCagr.years.toFixed(1)} 年 · 非共識` : `${pick?.period || ''} forward` }
: { label: '未來 5 年成長', tipKey: 'growth_5y', tipId: growthTipId, missing: true, missingLabel: '尚無資料', note: '見 ?' },
];
}
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) {
if (typeof resetMetricTips === 'function') resetMetricTips();
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('預測', '共識Yahoo與本機 DCF每格 ? 可看公式與來源', buildForecastMetrics(d, fair, revGrowth, netGrowth), '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 與價格歷史等免費公開來源。預測區:<b>分析師共識</b>來自 Yahoo <code>earningsTrend</code>(卡片 ? 內有公式與 endpoint<b>DCF</b>為 MacroScope 本機模型(? 內列出當次 FCF、成長率、折現率。無資料時顯示「尚無共識無法估算」不填假數字。免費報價可能延遲交易前請對照券商與官方財報。</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>' : ''}`;
const chartEl = $('#priceChart');
const chartW = chartEl ? Math.min(760, Math.max(280, Math.floor(chartEl.clientWidth || 0))) : 760;
drawLineChart(chartEl, [{ name: '收盤價', color: HEX.blue, points: pts }], {
fmt: v => fmtNum(v, 2),
chartWidth: chartW,
stretch: false,
});
const intel = await api(`/api/company-intel/${encodeURIComponent(STOCK.symbol)}${force ? '?fresh=1' : ''}`).catch(() => null);
renderCompanyProfile(profile, d, last, intel);
renderCompanyIntel(STOCK.symbol, profile, force, intel);
} catch (e) {
pane.querySelector('#priceChart').innerHTML = `<div class="empty-state">無法取得 ${escapeHtml(STOCK.symbol)} 的價格:${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
}
}
function marketStatusZh(s) {
const m = { OPEN: '交易中', CLOSED: '已收盤', PRE: '盤前', POST: '盤後', 'PRE-MARKET': '盤前' };
const k = String(s || '').toUpperCase();
return m[k] || s || '—';
}
function sectorIndustryZh(profile, intel) {
const sector = profile?.sector || intel?.management?.sector;
const industry = profile?.industry || intel?.management?.industry;
const sectorMap = { Technology: '科技', 'Financial Services': '金融服務', Healthcare: '醫療保健', Energy: '能源' };
const sectorZh = sectorMap[sector] || sector || '—';
let industryZh = industry || '—';
if (industry && /semiconductor/i.test(industry)) industryZh = `半導體(${industry}`;
else if (industry && /software/i.test(industry)) industryZh = `軟體(${industry}`;
return { sectorZh, industryZh };
}
function renderCompanyProfile(profile, priceData, last, intel) {
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);
const { sectorZh, industryZh } = sectorIndustryZh(profile, intel);
const desc = intel?.profileZh?.description || profile.descriptionZh || profile.description;
const descNote = intel?.profileZh?.description ? '(已整理為中文)' : (profile.description ? '(原文;同步研究資料後會更新)' : '');
box.innerHTML = `
<div class="profile-head">
<div><b>${escapeHtml(profile.name || priceData.name || priceData.symbol)}</b><span>${escapeHtml(profile.exchange || '')} · ${escapeHtml(marketStatusZh(profile.marketStatus))}</span></div>
<div class="profile-price">${fmtNum(q.price ?? last, 2)}</div>
</div>
<div class="profile-stats">
<div><span>買價 / 賣價</span><b>${fmtNum(profile.bidPrice, 2)} / ${fmtNum(profile.askPrice, 2)}</b></div>
<div><span>產業板塊</span><b>${escapeHtml(sectorZh)}</b></div>
<div><span>細產業</span><b>${escapeHtml(industryZh)}</b></div>
<div><span>地區</span><b>${escapeHtml(profile.region || '—')}</b></div>
</div>
${desc ? `<p class="profile-desc">${escapeHtml(desc)}</p><p class="profile-desc-note">${escapeHtml(descNote)}</p>` : '<p class="profile-desc-note">尚無公司簡介,進入本頁會自動抓取。</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>近期事件</b>${notif.map(e => `<span>${escapeHtml(e.message || e.eventName || '')}</span>`).join('')}</div>` : ''}
<div class="metric-source-note">公司資訊來自 Nasdaq報價可能延遲。職稱會自動對照常見中文。</div>
<button class="btn ghost sm" id="intelRefresh" style="width:100%;margin-top:10px">更新研究資訊</button>`;
const rb = $('#intelRefresh');
if (rb) rb.addEventListener('click', () => runIntelSync(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;
}
const INTEL_CUSTOM_SAMPLE = `{
"profileZh": {
"description": "公司簡介(中文,自行整理)"
},
"officers": [
{ "name": "黃仁勳", "title": "President and Chief Executive Officer", "titleZh": "執行長暨總裁" }
],
"news": [
{ "title": "原文標題", "titleZh": "中文標題", "descriptionZh": "中文摘要", "url": "https://..." }
],
"managementNotes": "治理與策略備註(選填)"
}`;
function secArchiveLocalUrl(symbol, accession, localPath) {
if (!localPath) return null;
const file = String(localPath).split('/').pop();
return `/api/sec-archive/${encodeURIComponent(symbol)}/file?accession=${encodeURIComponent(accession)}&file=${encodeURIComponent(file)}`;
}
function renderSecArchiveBody(data) {
const body = $('#secArchiveBody');
const status = $('#secArchiveStatus');
if (!body) return;
const filings = data.filings || [];
const earnings = data.earnings || [];
const meta = data.meta || {};
const synced = meta.lastSyncAt ? new Date(meta.lastSyncAt).toLocaleString('zh-TW') : null;
if (status) {
status.textContent = synced
? `已封存 ${filings.length} 筆申報 · ${earnings.length} 筆財報/法說事件 · 上次同步 ${synced}`
: (filings.length ? `本機已有 ${filings.length}` : '尚未同步,請按「抓取並封存」');
}
const filingRows = filings.length ? filings.map(f => {
const local = f.localPrimary || f.localTxt;
const localUrl = local ? secArchiveLocalUrl(data.symbol, f.accession, local) : null;
const exhibits = (f.earningsExhibits || []).map(ex => {
const u = ex.localPath ? secArchiveLocalUrl(data.symbol, f.accession, ex.localPath) : ex.url;
return u ? `<a href="${escapeHtml(u)}" target="_blank" rel="noreferrer">${escapeHtml(ex.name || '附錄')}</a>` : '';
}).filter(Boolean).join(' ');
return `<div class="sec-filing-row${f.isEarningsRelated ? ' sec-filing-row--earn' : ''}">
<div class="sec-filing-main">
<b>${escapeHtml(f.formZh || f.form || '')}</b>
<span class="sec-filing-form">${escapeHtml(f.form || '')}</span>
<small>${escapeHtml(f.filedDate || '')}${f.reportDate && f.reportDate !== f.filedDate ? ` · 報告日 ${escapeHtml(f.reportDate)}` : ''}</small>
${f.description ? `<p>${escapeHtml(String(f.description).slice(0, 160))}</p>` : ''}
${f.excerpt ? `<p class="sec-filing-excerpt">${escapeHtml(f.excerpt.slice(0, 280))}…</p>` : ''}
</div>
<div class="sec-filing-links">
${localUrl ? `<a class="btn ghost sm" href="${escapeHtml(localUrl)}" target="_blank" rel="noreferrer">本機已存</a>` : '<span class="sec-missing">未下載</span>'}
${f.url ? `<a href="${escapeHtml(f.url)}" target="_blank" rel="noreferrer">SEC 線上</a>` : ''}
${exhibits}
</div>
</div>`;
}).join('') : '<div class="empty-state">尚無封存申報。按「抓取並封存」會從 SEC 下載 10-K、10-Q、8-K、DEF 14A 等重要表格。</div>';
const earnRows = earnings.length ? earnings.map(e => `
<div class="sec-earn-row">
<div><b>${escapeHtml(e.titleZh || e.title || '')}</b><small>${escapeHtml(e.eventDate || '')} ${escapeHtml(e.timeLabel || '')} · ${escapeHtml(e.source || '')}</small></div>
<p>${escapeHtml((e.note || '').slice(0, 200))}</p>
<div class="sec-filing-links">
${e.url ? `<a href="${escapeHtml(e.url)}" target="_blank" rel="noreferrer">連結</a>` : ''}
${e.accession && e.kind === 'sec_8k' ? `<a href="${escapeHtml(secArchiveLocalUrl(data.symbol, e.accession, filings.find(x => x.accession === e.accession)?.localPrimary || '') || '#')}" target="_blank" rel="noreferrer">本機 8-K</a>` : ''}
${e.transcriptSearchUrl ? `<a href="${escapeHtml(e.transcriptSearchUrl)}" target="_blank" rel="noreferrer">投資人關係</a>` : ''}
<a href="https://www.nasdaq.com/market-activity/stocks/${encodeURIComponent(String(data.symbol).toLowerCase())}/earnings" target="_blank" rel="noreferrer">Nasdaq 財報</a>
</div>
</div>`).join('') : '<div class="empty-state">尚無財報日曆或 8-K 財報事件;同步後會一併寫入。</div>';
body.innerHTML = `
<div class="sec-archive-block">
<h4>SEC 重要申報(本機)</h4>
<div class="sec-filing-list">${filingRows}</div>
</div>
<div class="sec-archive-block">
<h4>財報日與法說相關</h4>
<p class="intel-custom-hint">法說逐字稿多由公司 IR 網站發布免費來源不一定有全文此處會存財報日、8-K 財報公告與本機副本連結。</p>
<div class="sec-earn-list">${earnRows}</div>
</div>`;
}
async function loadSecArchive(symbol, sync) {
const body = $('#secArchiveBody');
const status = $('#secArchiveStatus');
if (!body) return;
if (sync) {
body.innerHTML = '<div class="empty-state">正在從 SEC 抓取並寫入本機,可能需要一~兩分鐘…</div>';
if (status) status.textContent = '同步中…';
}
try {
const data = sync
? await api(`/api/sec-archive/${encodeURIComponent(symbol)}/sync?fresh=1`, { method: 'POST' })
: await api(`/api/sec-archive/${encodeURIComponent(symbol)}`);
renderSecArchiveBody({ ...data, symbol });
} catch (e) {
body.innerHTML = `<div class="empty-state">封存資料載入失敗:${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
if (status) status.textContent = '失敗';
}
}
function bindSecArchivePanel(symbol) {
$('#secArchiveSync')?.addEventListener('click', () => loadSecArchive(symbol, true));
$('#secArchiveRefresh')?.addEventListener('click', () => loadSecArchive(symbol, false));
loadSecArchive(symbol, false);
}
function intelResourceLinksHtml(links) {
const list = (links || []).filter(l => l?.url && !/google\.com\/search/i.test(l.url));
if (!list.length) return '';
return `<div class="intel-resource-links">${list.map(l =>
`<a href="${escapeHtml(l.url)}" target="_blank" rel="noreferrer">${escapeHtml(l.labelZh || l.label || '連結')}</a>`,
).join('')}</div>`;
}
function chainEntityChip(entity) {
const item = (entity && typeof entity === 'object')
? entity
: { name: String(entity || ''), symbol: null };
const name = item.name || item.symbol || '—';
const sym = item.symbol || (/^[A-Z0-9.\-]{1,12}$/i.test(name) ? name.toUpperCase() : null);
const title = name.length > 14 ? ` title="${escapeHtml(name)}"` : '';
if (sym && !/^原物料|終端|通路|待查|OEM/i.test(name)) {
return `<button type="button" class="chain-chip-btn" data-peer="${escapeHtml(sym)}"${title}>${escapeHtml(name)}</button>`;
}
return `<span class="chain-chip-static"${title}>${escapeHtml(name)}</span>`;
}
function chainColHtml(items, detail) {
if (detail?.length) {
return detail.map(g => `
<div class="chain-group">
<em>${escapeHtml(g.label || '環節')}</em>
<div class="chain-chips">${(g.entities || []).map(e => chainEntityChip(e)).join('') || '<span class="chain-chip-static">—</span>'}</div>
${g.note ? `<small>${escapeHtml(g.note)}</small>` : ''}
</div>`).join('');
}
return `<div class="chain-chips">${(items || []).length ? items.map(x => chainEntityChip(x)).join('') : '<span class="chain-chip-static">待查證</span>'}</div>`;
}
function bindChainEntityClicks(box) {
$$('.chain-chips [data-peer], .peer-chips [data-peer]', box).forEach(btn => {
btn.addEventListener('click', () => setStockSymbol(btn.dataset.peer));
});
}
function decodeHtmlEntities(s) {
let t = String(s ?? '');
if (!t) return '';
t = t.replace(/&#x([0-9a-f]+);/gi, (_, hex) => {
const cp = parseInt(hex, 16);
return cp > 0 && cp < 0x110000 ? String.fromCodePoint(cp) : '';
});
t = t.replace(/&#(\d+);/g, (_, dec) => {
const cp = Number(dec);
return cp > 0 && cp < 0x110000 ? String.fromCodePoint(cp) : '';
});
for (const [ent, ch] of [['&lt;', '<'], ['&gt;', '>'], ['&amp;', '&'], ['&quot;', '"'], ['&#39;', "'"], ['&nbsp;', ' ']]) {
if (t.includes(ent)) t = t.split(ent).join(ch);
}
return t;
}
function cleanNewsDisplay(s) {
return decodeHtmlEntities(s).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
function newsLooksLikeGarbage(s) {
const t = String(s || '');
return /&lt;|&gt;|href\s*=|news\.google\.com\/rss/i.test(t) || /^https?:\/\//i.test(t);
}
function newsSummaryText(n) {
const raw = cleanNewsDisplay(n.descriptionZh || n.description || '');
if (!raw || newsLooksLikeGarbage(raw) || raw === cleanNewsDisplay(n.titleZh || n.title)) return '';
return raw.slice(0, 220);
}
function newsDisplayTitle(n) {
return cleanNewsDisplay(n.titleZh || n.title || '(無標題)');
}
function newsDisplayPublisher(n) {
const pub = cleanNewsDisplay(n.publisher || '');
if (pub && !newsLooksLikeGarbage(pub)) return pub;
const src = cleanNewsDisplay(n.source || '');
return src && !newsLooksLikeGarbage(src) ? src : '新聞';
}
function newsCardsHtml(list) {
if (!list.length) return '';
return list.map(n => `
<a class="news-card" href="${escapeHtml(n.url || '#')}" target="_blank" rel="noreferrer">
<span class="news-card-title">${escapeHtml(newsDisplayTitle(n))}</span>
${n.title && cleanNewsDisplay(n.title) !== newsDisplayTitle(n) ? `<small class="news-card-en">${escapeHtml(cleanNewsDisplay(n.title))}</small>` : ''}
<span class="news-card-meta">${escapeHtml(newsDisplayPublisher(n))}${n.created ? ` · ${escapeHtml(n.created)}` : ''}</span>
${newsSummaryText(n) ? `<p class="news-card-summary">${escapeHtml(newsSummaryText(n))}</p>` : ''}
</a>`).join('');
}
function newsPanelHtml(list) {
return list.length
? `<div class="news-list">${newsCardsHtml(list)}</div>`
: '<div class="news-empty">此區尚無新聞。</div>';
}
function bindNewsTabs(box) {
const tabs = $$('.news-tab', box);
const panels = $$('.news-panel', box);
tabs.forEach(btn => btn.addEventListener('click', () => {
tabs.forEach(t => {
const on = t === btn;
t.classList.toggle('active', on);
t.setAttribute('aria-selected', on ? 'true' : 'false');
});
panels.forEach(p => p.classList.toggle('hidden', p.dataset.panel !== btn.dataset.tab));
}));
}
function impactCls(impact) {
if (impact === 'positive') return 'good';
if (impact === 'negative') return 'bad';
return 'warn';
}
const _intelSyncInflight = new Set();
function intelSyncStatusText(intel) {
if (intel?.syncSkipReason) return intel.syncSkipReason;
if (intel?.nextRefreshAfter) return `下次更新:${intel.nextRefreshAfter}${intel.nextPublicLabel || '下次財報'}`;
if (intel?.enrichedAt) {
return `已更新 ${new Date(intel.enrichedAt).toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`;
}
return '首次進入將自動抓取';
}
async function runIntelSync(symbol, profile, force) {
const st = $('#intelSyncStatus');
if (_intelSyncInflight.has(symbol)) return;
_intelSyncInflight.add(symbol);
if (st) {
st.textContent = force
? `AI 正在更新 ${symbol} 的供應商與「誰買他們產品」客戶名單…`
: '正在抓取管理層、新聞、產業鏈YahooSECGoogle 新聞)…';
}
try {
const qs = force ? '?fresh=1' : '';
const res = await api(`/api/company-intel/${encodeURIComponent(symbol)}/sync${qs}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: !!force }),
});
if (res.skipped && res.skipReason) {
if (st) st.textContent = res.skipReason;
if (res.intel) await renderCompanyIntel(symbol, profile, false, res.intel);
return;
}
if (st) st.textContent = '同步完成,更新畫面…';
await renderCompanyIntel(symbol, profile, false, res.intel || null);
} catch (e) {
if (st) st.textContent = (e.data && e.data.message) || e.message || '同步失敗';
} finally {
_intelSyncInflight.delete(symbol);
}
}
function chainNeedsEnrich(intel) {
const d = intel?.industryChain || {};
const groups = [...(d.upstreamDetail || []), ...(d.downstreamDetail || [])];
const clickable = groups.flatMap(g => g.entities || []).filter(e => (e?.symbol || null));
const onlyPlaceholder = groups.length > 0 && groups.every(g =>
(g.entities || []).every(e => /待查證|原物料|終端|通路|OEM/i.test(String(e?.name || e))),
);
return clickable.length < 2 && (onlyPlaceholder || !groups.length);
}
function ensureIntelAutoSync(symbol, profile, intel) {
const missingOfficers = !(intel?.management?.officers || []).length;
const missingNews = !(intel?.newsTw || []).length && !(intel?.newsGlobal || []).length;
const weakChain = chainNeedsEnrich(intel);
if (!intel?.needsSync && !missingOfficers && !missingNews && !weakChain) return;
runIntelSync(symbol, profile, missingOfficers || missingNews || weakChain);
}
function bindIntelCustomPanel(symbol) {
const saveBtn = $('#intelCustomSave');
const loadBtn = $('#intelCustomLoad');
const ta = $('#intelCustomJson');
if (!saveBtn || !ta) return;
if (!ta.value.trim()) ta.value = INTEL_CUSTOM_SAMPLE;
loadBtn?.addEventListener('click', async () => {
try {
const d = await api(`/api/company-intel/${encodeURIComponent(symbol)}/custom`);
ta.value = d.data ? JSON.stringify(d.data, null, 2) : INTEL_CUSTOM_SAMPLE;
$('#intelCustomStatus').textContent = d.data ? '已載入本機資料' : '尚無本機資料';
} catch (e) {
$('#intelCustomStatus').textContent = '載入失敗';
}
});
saveBtn.addEventListener('click', async () => {
const status = $('#intelCustomStatus');
try {
const body = JSON.parse(ta.value);
await api(`/api/company-intel/${encodeURIComponent(symbol)}/custom`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
status.textContent = '已存入本機資料庫,正在更新畫面…';
await renderCompanyIntel(symbol, null, true);
} catch (e) {
status.textContent = (e instanceof SyntaxError) ? 'JSON 格式錯誤' : ((e.data && e.data.message) || e.message || '儲存失敗');
}
});
}
async function renderCompanyIntel(symbol, profile, fresh, prefetched) {
const box = renderCompanyIntelSkeleton();
try {
const intel = prefetched && !fresh
? prefetched
: await api(`/api/company-intel/${encodeURIComponent(symbol)}${fresh ? '?fresh=1' : ''}`);
const chain = intel.industryChain || {};
const officers = intel.management?.officers || [];
const insiders = intel.insiders || [];
const newsTw = intel.newsTw || (intel.news || []).filter(n => n.region === 'tw');
const newsGlobal = intel.newsGlobal || (intel.news || []).filter(n => n.region === 'global');
const mgmtBrief = intel.managementBrief || [];
const acquiredCount = insiders.filter(t => t.signal === 'acquire').length;
const disposedCount = insiders.filter(t => t.signal === 'dispose').length;
const mgmtSource = intel.management?.source || '公開資料';
const { sectorZh, industryZh } = sectorIndustryZh(profile, intel);
const profileDesc = intel.profileZh?.description || intel.management?.longBusinessSummary || '';
const enrichNote = intel.aiEnriched ? ' · AI 已整理' : '';
const syncNote = intelSyncStatusText(intel);
const healthNotes = (intel.dataHealth?.notes || []).map(n => `<li>${escapeHtml(n)}</li>`).join('');
box.innerHTML = `
<div class="intel-sync-bar">
<span id="intelSyncStatus">${escapeHtml(syncNote)}${escapeHtml(enrichNote)}</span>
<button type="button" class="btn ghost sm" id="intelSyncBtn">強制更新</button>
</div>
${healthNotes ? `<ul class="intel-health-notes">${healthNotes}</ul>` : ''}
${profileDesc ? `<section class="intel-section intel-section--profile">
<div class="metric-section-head"><h3>公司簡介</h3><span>${escapeHtml(intel.profileZh?.businessModel || industryZh)}</span></div>
<p class="intel-profile-text">${escapeHtml(profileDesc)}</p>
</section>` : ''}
<section class="intel-section intel-section--chain">
<div class="metric-section-head"><h3>產業上下游 · ${escapeHtml(symbol)}</h3><span>${escapeHtml(industryZh)} · 強制更新會請 AI 重查供應商與下游客戶</span></div>
<div class="chain-map chain-map--2">
<div class="chain-col chain-col--up"><b>上游 · 供應商</b>${chainColHtml(chain.upstream, chain.upstreamDetail)}</div>
<div class="chain-col chain-col--down"><b>下游 · 誰買他們的產品</b>${chainColHtml(chain.downstream, chain.downstreamDetail)}</div>
</div>
${chain.tenKExcerpt ? `<p class="chain-excerpt"><b>10-K 摘要</b> ${escapeHtml(chain.tenKExcerpt)}${chain.tenKExcerpt.length >= 400 ? '…' : ''}</p>` : ''}
${intelResourceLinksHtml(intel.resources)}
</section>
<section class="intel-section">
<div class="metric-section-head"><h3>經營層動態</h3><span>人事、指引、併購、治理相關</span></div>
<div class="mgmt-brief-list">${mgmtBrief.length ? mgmtBrief.map(m => `
<div class="mgmt-brief-row ${impactCls(m.impact)}">
<div><b>${escapeHtml(m.headline || '')}</b><small>${escapeHtml(m.date || '')} · ${escapeHtml(m.source || '')}</small></div>
<p>${escapeHtml(m.summary || '')}</p>
${m.url ? `<a href="${escapeHtml(m.url)}" target="_blank" rel="noreferrer">原文</a>` : ''}
</div>`).join('') : '<div class="empty-state">進入本頁會自動從新聞篩選管理層相關消息。</div>'}
</section>
<section class="intel-section">
<div class="metric-section-head"><h3>內部人申報SEC Form 4</h3><span>申報人買賣自家股票A=取得、D=處分</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 || '內部人')}</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>取得 / 處分股數</span></div>
</a>`;
}).join('') : '<div class="empty-state">近期沒有抓到 Form 4 申報。</div>'}</div>
</section>
<section class="intel-section intel-section--news">
<div class="metric-section-head"><h3>相關新聞</h3><span>台灣媒體與國際第一手來源分開顯示</span></div>
<div class="news-tabs" role="tablist">
<button type="button" class="news-tab active" data-tab="tw" role="tab" aria-selected="true">台灣 (${newsTw.length})</button>
<button type="button" class="news-tab" data-tab="global" role="tab" aria-selected="false">國際 (${newsGlobal.length})</button>
</div>
<div class="news-panel" data-panel="tw" role="tabpanel">${newsPanelHtml(newsTw)}</div>
<div class="news-panel hidden" data-panel="global" role="tabpanel">${newsPanelHtml(newsGlobal)}</div>
</section>
<section class="intel-section" id="secArchiveSection">
<div class="metric-section-head"><h3>重要申報與財報/法說</h3><span>10-K、10-Q、8-K、DEF 14A 等會下載到本機,避免日後連結失效</span></div>
<div id="secArchiveBody" class="sec-archive-body"><div class="empty-state">載入封存清單…</div></div>
<div class="sec-archive-actions">
<button type="button" class="btn sm" id="secArchiveSync">抓取並封存到本機</button>
<button type="button" class="btn ghost sm" id="secArchiveRefresh">重新整理清單</button>
<span id="secArchiveStatus" class="intel-custom-status"></span>
</div>
</section>
<section class="intel-section intel-section--mgmt">
<div class="metric-section-head"><h3>經營管理層</h3><span>自動抓取 · 來源:${escapeHtml(mgmtSource)}</span></div>
<p class="intel-custom-hint">首次進入本頁會從 YahooSEC 10-K 自動取得名單與職稱(中文對照);下次財報公開日前不會重複抓取。</p>
<div class="officer-grid">${officers.length ? officers.map(o => `
<div class="officer-card"><b>${escapeHtml(o.name)}</b><span>${escapeHtml(o.titleDisplay || o.titleZh || o.title || '')}</span><small>${o.totalPay != null ? '總薪酬 ' + fmtMoney(o.totalPay) : (o.source ? escapeHtml(o.source) : '')}</small></div>`).join('') : '<div class="empty-state">正在抓取管理層名單…若久未出現請按「強制更新」。</div>'}
${intelResourceLinksHtml(intel.management?.resources || intel.resources)}
</section>`;
bindChainEntityClicks(box);
bindNewsTabs(box);
$('#intelSyncBtn')?.addEventListener('click', () => runIntelSync(symbol, profile, true));
bindSecArchivePanel(symbol);
ensureIntelAutoSync(symbol, profile, intel);
} 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());