2026-06-03 09:21:58 +00:00
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// Emmy 投資台 — 學習教材 / 財報健檢 / 交易復盤
|
|
|
|
|
|
// 本檔在 index.html 的內聯 script 之後載入,可使用其全域函式
|
|
|
|
|
|
// (lineChart、HEX、cssVar…),並負責主視圖切換與三個新分頁。
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
const $ = (s, r = document) => r.querySelector(s);
|
|
|
|
|
|
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
|
|
|
|
|
|
|
|
|
|
|
function escapeHtml(s) {
|
|
|
|
|
|
return String(s == null ? '' : s)
|
|
|
|
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
|
|
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
|
|
|
|
}
|
|
|
|
|
|
async function api(path, opts) {
|
|
|
|
|
|
const res = await fetch(path, opts);
|
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
|
|
|
|
if (!res.ok) throw Object.assign(new Error(data.message || res.statusText), { data });
|
|
|
|
|
|
return data;
|
|
|
|
|
|
}
|
|
|
|
|
|
function fmtNum(v, d = 0) {
|
|
|
|
|
|
if (v == null || isNaN(v)) return '—';
|
|
|
|
|
|
return Number(v).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
|
|
|
|
|
|
}
|
|
|
|
|
|
function fmtPct(v, d = 1) { return v == null || isNaN(v) ? '—' : (v >= 0 ? '' : '') + Number(v).toFixed(d) + '%'; }
|
|
|
|
|
|
function fmtMoney(v) {
|
|
|
|
|
|
if (v == null || isNaN(v)) return '—';
|
|
|
|
|
|
const a = Math.abs(v), s = v < 0 ? '-' : '';
|
|
|
|
|
|
if (a >= 1e12) return s + '$' + (a / 1e12).toFixed(2) + 'T';
|
|
|
|
|
|
if (a >= 1e9) return s + '$' + (a / 1e9).toFixed(2) + 'B';
|
|
|
|
|
|
if (a >= 1e6) return s + '$' + (a / 1e6).toFixed(2) + 'M';
|
|
|
|
|
|
if (a >= 1e3) return s + '$' + (a / 1e3).toFixed(1) + 'K';
|
|
|
|
|
|
return s + '$' + a.toFixed(2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 09:33:23 +00:00
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// 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 (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 09:21:58 +00:00
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// 輕量 Markdown 渲染(支援標題/清單/表格/引用/粗體/行內碼/[[wikilink]])
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
function mdInline(t) {
|
|
|
|
|
|
t = escapeHtml(t);
|
|
|
|
|
|
t = t.replace(/`([^`]+)`/g, (m, c) => '<code>' + c + '</code>');
|
|
|
|
|
|
t = t.replace(/\[\[([^\]]+)\]\]/g, (m, inner) => wlinkHTML(inner));
|
|
|
|
|
|
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
|
|
|
|
t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
|
|
|
|
t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
|
|
|
|
|
return t;
|
|
|
|
|
|
}
|
|
|
|
|
|
function wlinkHTML(inner) {
|
|
|
|
|
|
let [target, display] = inner.split('|');
|
|
|
|
|
|
target = (target || '').trim();
|
|
|
|
|
|
display = (display || '').trim();
|
|
|
|
|
|
if (!display) display = target.includes('#') ? target.split('#').pop() : target.split('/').pop();
|
|
|
|
|
|
return '<span class="wlink" data-link="' + escapeHtml(target) + '">' + escapeHtml(display) + '</span>';
|
|
|
|
|
|
}
|
|
|
|
|
|
function splitRow(line) {
|
|
|
|
|
|
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
|
|
|
|
|
|
}
|
|
|
|
|
|
function renderTable(header, rows) {
|
|
|
|
|
|
let h = '<table><thead><tr>' + header.map(c => '<th>' + mdInline(c) + '</th>').join('') + '</tr></thead><tbody>';
|
|
|
|
|
|
for (const r of rows) h += '<tr>' + header.map((_, j) => '<td>' + mdInline(r[j] || '') + '</td>').join('') + '</tr>';
|
|
|
|
|
|
return h + '</tbody></table>';
|
|
|
|
|
|
}
|
|
|
|
|
|
function renderListBlock(lines) {
|
|
|
|
|
|
const root = { children: [] };
|
|
|
|
|
|
const stack = [{ indent: -1, node: root }];
|
|
|
|
|
|
for (const raw of lines) {
|
|
|
|
|
|
const m = raw.match(/^(\s*)([-*]|\d+\.)\s+(.*)$/);
|
|
|
|
|
|
if (!m) continue;
|
|
|
|
|
|
const indent = m[1].replace(/\t/g, ' ').length;
|
|
|
|
|
|
const ordered = /\d/.test(m[2]);
|
|
|
|
|
|
const item = { ordered, html: mdInline(m[3]), children: [] };
|
|
|
|
|
|
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
|
|
|
|
|
|
stack[stack.length - 1].node.children.push(item);
|
|
|
|
|
|
stack.push({ indent, node: item });
|
|
|
|
|
|
}
|
|
|
|
|
|
const emit = (node) => {
|
|
|
|
|
|
if (!node.children.length) return '';
|
|
|
|
|
|
const ordered = node.children[0].ordered;
|
|
|
|
|
|
let h = '<' + (ordered ? 'ol' : 'ul') + '>';
|
|
|
|
|
|
for (const c of node.children) h += '<li>' + c.html + emit(c) + '</li>';
|
|
|
|
|
|
return h + '</' + (ordered ? 'ol' : 'ul') + '>';
|
|
|
|
|
|
};
|
|
|
|
|
|
return emit(root);
|
|
|
|
|
|
}
|
|
|
|
|
|
function renderMarkdown(md) {
|
|
|
|
|
|
md = String(md || '').replace(/\r\n/g, '\n');
|
|
|
|
|
|
const fences = [];
|
2026-06-03 09:33:23 +00:00
|
|
|
|
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';
|
|
|
|
|
|
});
|
2026-06-03 09:21:58 +00:00
|
|
|
|
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$/);
|
2026-06-03 09:33:23 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-03 09:21:58 +00:00
|
|
|
|
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
|
|
|
|
if (h) { const l = h[1].length; html += `<h${l}>${mdInline(h[2])}</h${l}>`; i++; continue; }
|
|
|
|
|
|
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { html += '<hr>'; i++; continue; }
|
|
|
|
|
|
if (line.includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
|
|
|
|
|
const header = splitRow(line); i += 2; const rows = [];
|
|
|
|
|
|
while (i < lines.length && lines[i].includes('|') && !blank(lines[i])) { rows.push(splitRow(lines[i])); i++; }
|
|
|
|
|
|
html += renderTable(header, rows); continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/^\s*>/.test(line)) { const buf = []; while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; } html += '<blockquote>' + renderMarkdown(buf.join('\n')) + '</blockquote>'; continue; }
|
|
|
|
|
|
if (/^\s*([-*]|\d+\.)\s+/.test(line)) { const buf = []; while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i])) { buf.push(lines[i]); i++; } html += renderListBlock(buf); continue; }
|
|
|
|
|
|
const buf = [];
|
|
|
|
|
|
while (i < lines.length && !blank(lines[i]) && !/^(#{1,6})\s/.test(lines[i]) && !/^\s*([-*]|\d+\.)\s+/.test(lines[i]) && !/^\s*>/.test(lines[i]) && !/^\u0000F\d+\u0000$/.test(lines[i]) && !/^(-{3,}|\*{3,}|_{3,})$/.test(lines[i].trim()) && !(lines[i].includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]))) { buf.push(lines[i]); i++; }
|
|
|
|
|
|
if (buf.length) html += '<p>' + mdInline(buf.join(' ')) + '</p>';
|
|
|
|
|
|
}
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 把容器內所有 [[wikilink]] 綁定成站內跳轉;無法解析的標成 dead
|
|
|
|
|
|
function bindWlinks(container) {
|
|
|
|
|
|
$$('.wlink[data-link]', container).forEach(elx => {
|
|
|
|
|
|
const t = elx.dataset.link;
|
|
|
|
|
|
const hit = (KB.linkMap && (KB.linkMap[t] || KB.linkMap[t.split('#').pop()] || KB.linkMap[t.split('/').pop()])) || null;
|
|
|
|
|
|
if (!hit) { elx.classList.add('dead'); return; }
|
|
|
|
|
|
elx.addEventListener('click', () => openNote(hit.kind, hit.id));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// 主視圖路由
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
const VIEW_IDS = ['macro', 'learn', 'stock', 'journal'];
|
|
|
|
|
|
const inited = {};
|
|
|
|
|
|
function parseHash() { const m = location.hash.match(/^#\/(\w+)/); const v = m ? m[1] : 'macro'; return VIEW_IDS.includes(v) ? v : 'macro'; }
|
|
|
|
|
|
function setView(view) {
|
|
|
|
|
|
document.body.dataset.view = view;
|
|
|
|
|
|
VIEW_IDS.forEach(v => { const e = $('#view-' + v); if (e) e.hidden = v !== view; });
|
|
|
|
|
|
$$('#viewTabs a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
|
|
|
|
|
|
if (view === 'learn' && !inited.learn) { inited.learn = true; initLearn(); }
|
|
|
|
|
|
if (view === 'stock' && !inited.stock) { inited.stock = true; initStock(); }
|
|
|
|
|
|
if (view === 'journal' && !inited.journal) { inited.journal = true; initJournal(); }
|
|
|
|
|
|
if (view !== 'macro') window.scrollTo({ top: 0 });
|
|
|
|
|
|
}
|
|
|
|
|
|
$$('#viewTabs a').forEach(a => a.addEventListener('click', () => {
|
|
|
|
|
|
location.hash = a.dataset.view === 'macro' ? '#/' : '#/' + a.dataset.view;
|
|
|
|
|
|
}));
|
|
|
|
|
|
window.addEventListener('hashchange', () => setView(parseHash()));
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// 知識庫資料
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
let KB = { loaded: false, linkMap: {} };
|
|
|
|
|
|
async function ensureKnowledge() {
|
|
|
|
|
|
if (KB.loaded) return KB;
|
|
|
|
|
|
KB = await api('/api/knowledge');
|
|
|
|
|
|
KB.loaded = true;
|
|
|
|
|
|
KB.linkMap = KB.linkMap || {};
|
|
|
|
|
|
return KB;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 從任何視圖點連結要看的、但 initLearn 尚未建好 DOM 時,先暫存於此,由 initLearn 收尾渲染
|
|
|
|
|
|
let pendingNote = null;
|
|
|
|
|
|
// 把一篇筆記打開在「學習教材」視圖;macro/個股→切到 learn
|
|
|
|
|
|
async function openNote(kind, id) {
|
|
|
|
|
|
await ensureKnowledge();
|
|
|
|
|
|
let note = findLocalNote(kind, id);
|
|
|
|
|
|
if (!note) { try { note = await api(`/api/note/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`); } catch (e) { note = null; } }
|
|
|
|
|
|
const finalNote = note || { body: `# 找不到這篇筆記\n(${kind} / ${id})` };
|
2026-06-03 09:33:23 +00:00
|
|
|
|
finalNote.kind = kind;
|
2026-06-03 09:21:58 +00:00
|
|
|
|
if (!inited.learn) {
|
|
|
|
|
|
// 學習教材還沒初始化:暫存,切到 learn 後由 initLearn 渲染(避免被課綱總覽蓋掉)
|
|
|
|
|
|
pendingNote = finalNote;
|
|
|
|
|
|
location.hash = '#/learn';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (document.body.dataset.view !== 'learn') location.hash = '#/learn';
|
|
|
|
|
|
renderNote(finalNote);
|
|
|
|
|
|
}
|
|
|
|
|
|
function findLocalNote(kind, id) {
|
|
|
|
|
|
if (kind === 'overview') return KB.overview;
|
|
|
|
|
|
if (kind === 'principleMap') return KB.principleMap;
|
|
|
|
|
|
if (kind === 'quiz') return KB.quiz;
|
|
|
|
|
|
if (kind === 'category') return (KB.categories || []).find(c => c.id === id);
|
|
|
|
|
|
if (kind === 'case') return (KB.cases || []).find(c => c.id === id);
|
|
|
|
|
|
if (kind === 'principle') return (KB.principles || []).find(p => p.id === id);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
function renderNote(note) {
|
|
|
|
|
|
const content = $('#learnContent');
|
|
|
|
|
|
const fm = note.frontmatter || {};
|
2026-06-03 09:33:23 +00:00
|
|
|
|
LEARN.currentNote = note;
|
2026-06-03 09:21:58 +00:00
|
|
|
|
let tags = '';
|
|
|
|
|
|
if (fm.ticker) tags += `<span class="fm-tag">代號 ${escapeHtml([].concat(fm.ticker).join(' / '))}</span>`;
|
|
|
|
|
|
if (fm.sector) tags += `<span class="fm-tag">${escapeHtml(fm.sector)}</span>`;
|
|
|
|
|
|
if (fm.category) tags += `<span class="fm-tag">${escapeHtml(fm.category)}</span>`;
|
|
|
|
|
|
if (fm.date) tags += `<span class="fm-tag">${escapeHtml(fm.date)}</span>`;
|
|
|
|
|
|
if (Array.isArray(fm.aliases) && fm.aliases.length) tags += `<span class="fm-tag">別名 ${escapeHtml(fm.aliases.join(' · '))}</span>`;
|
2026-06-03 09:33:23 +00:00
|
|
|
|
const kind = note.kind || LEARN.noteKind;
|
|
|
|
|
|
const center = (kind && note.id) ? `${kind}:${note.id}` : '';
|
2026-06-03 09:21:58 +00:00
|
|
|
|
content.innerHTML =
|
2026-06-03 09:33:23 +00:00
|
|
|
|
`<div class="note-toolbar">
|
|
|
|
|
|
<span class="back-link" id="noteBack">← 返回</span>
|
|
|
|
|
|
${center ? '<button class="btn ghost sm" id="noteGraphBtn">🔗 周邊圖譜</button>' : ''}
|
|
|
|
|
|
</div>` +
|
2026-06-03 09:21:58 +00:00
|
|
|
|
(tags ? `<div class="note-frontmatter">${tags}</div>` : '') +
|
|
|
|
|
|
`<div class="md">${renderMarkdown(note.body || '')}</div>`;
|
|
|
|
|
|
bindWlinks(content);
|
2026-06-03 09:33:23 +00:00
|
|
|
|
renderMermaid(content);
|
2026-06-03 09:21:58 +00:00
|
|
|
|
$('#noteBack').addEventListener('click', () => showSection(LEARN.lastSection || 'overview'));
|
2026-06-03 09:33:23 +00:00
|
|
|
|
const gb = $('#noteGraphBtn');
|
|
|
|
|
|
if (gb) gb.addEventListener('click', () => showGraph({ center, depth: 2 }));
|
2026-06-03 09:21:58 +00:00
|
|
|
|
window.scrollTo({ top: 0 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// 學習教材視圖
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
2026-06-03 09:33:23 +00:00
|
|
|
|
const LEARN = { lastSection: 'overview', graphFilter: 'curriculum', currentNote: null, noteKind: null };
|
|
|
|
|
|
const GRAPH_KINDS = [
|
|
|
|
|
|
{ id: 'curriculum', label: '課程骨架', kinds: 'overview,principleMap,category,case,principle' },
|
|
|
|
|
|
{ id: 'terms', label: '名詞', kinds: 'term', includeIndex: '1' },
|
|
|
|
|
|
{ id: 'companies', label: '公司', kinds: 'company', includeIndex: '1' },
|
|
|
|
|
|
];
|
2026-06-03 09:21:58 +00:00
|
|
|
|
function setLearnActive(section) {
|
|
|
|
|
|
$$('#learnSide a').forEach(a => a.classList.toggle('active', a.dataset.section === section));
|
|
|
|
|
|
}
|
|
|
|
|
|
async function initLearn() {
|
|
|
|
|
|
const view = $('#view-learn');
|
|
|
|
|
|
view.innerHTML = `<div class="page"><div class="empty-state"><div class="spinner" style="width:28px;height:28px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 14px;animation:spin .8s linear infinite"></div>正在載入知識庫…</div></div>`;
|
|
|
|
|
|
try { await ensureKnowledge(); } catch (e) {
|
|
|
|
|
|
view.innerHTML = `<div class="page"><div class="empty-state">知識庫尚未建立。請先在 web/ 目錄執行 <code>npm run build:knowledge</code> 產生 data/knowledge.json,再重新整理。</div></div>`;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const c = KB.counts || {};
|
|
|
|
|
|
view.innerHTML = `
|
|
|
|
|
|
<div class="page">
|
|
|
|
|
|
<div class="page-head">
|
|
|
|
|
|
<div class="page-title">📚 學習教材</div>
|
|
|
|
|
|
<div class="page-sub">把 Emmy 的知識整理成從零到能跟著判斷的學習路徑:三階段課綱、心法、案例、名詞與公司速查、練習題庫。點任何 <span style="color:var(--purple)">紫色連結</span> 都能跳到對應筆記。</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="learn-layout">
|
|
|
|
|
|
<div class="learn-side" id="learnSide">
|
|
|
|
|
|
<div class="side-group">課程</div>
|
|
|
|
|
|
<a data-section="overview">課綱總覽</a>
|
|
|
|
|
|
<a data-section="principleMap">心法地圖</a>
|
|
|
|
|
|
<a data-section="quiz">練習題庫</a>
|
|
|
|
|
|
<div class="side-group">內容</div>
|
|
|
|
|
|
<a data-section="categories">學習分類 <span class="count">${(KB.categories || []).length}</span></a>
|
|
|
|
|
|
<a data-section="cases">案例講解 <span class="count">${(KB.cases || []).length}</span></a>
|
|
|
|
|
|
<a data-section="principles">投資心法 <span class="count">${(KB.principles || []).length}</span></a>
|
2026-06-03 09:33:23 +00:00
|
|
|
|
<div class="side-group">視覺化</div>
|
|
|
|
|
|
<a data-section="graph">🔗 知識圖譜</a>
|
2026-06-03 09:21:58 +00:00
|
|
|
|
<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;
|
2026-06-03 09:33:23 +00:00
|
|
|
|
if (section === 'overview') return renderNote(Object.assign({ kind: 'overview' }, KB.overview || { body: '# 課綱總覽\n(尚無內容)' }));
|
|
|
|
|
|
if (section === 'principleMap') return renderNote(Object.assign({ kind: 'principleMap' }, KB.principleMap || { body: '# 心法地圖\n(尚無內容)' }));
|
2026-06-03 09:21:58 +00:00
|
|
|
|
if (section === 'quiz') return renderQuiz();
|
2026-06-03 09:33:23 +00:00
|
|
|
|
if (section === 'graph') return showGraph();
|
2026-06-03 09:21:58 +00:00
|
|
|
|
if (section === 'categories') return renderCardList('學習分類', KB.categories, 'category');
|
|
|
|
|
|
if (section === 'cases') return renderCardList('案例講解', KB.cases, 'case');
|
|
|
|
|
|
if (section === 'principles') return renderPrincipleList();
|
|
|
|
|
|
if (['terms', 'companies', 'episodes'].includes(section)) return renderGlossary(section);
|
|
|
|
|
|
}
|
|
|
|
|
|
function renderCardList(title, items, kind) {
|
|
|
|
|
|
const content = $('#learnContent');
|
|
|
|
|
|
const cards = (items || []).map(it => `
|
|
|
|
|
|
<div class="module-card" data-id="${escapeHtml(it.id)}">
|
|
|
|
|
|
<div class="mod-name">${escapeHtml(it.title)}</div>
|
|
|
|
|
|
${it.summary ? `<div class="mod-meta">${escapeHtml(it.summary)}</div>` : ''}
|
|
|
|
|
|
</div>`).join('');
|
|
|
|
|
|
content.innerHTML = `<div class="page-title" style="font-size:1.1rem;margin-bottom:14px">${escapeHtml(title)}</div><div class="module-grid">${cards || '<div class="empty-state">尚無內容。</div>'}</div>`;
|
|
|
|
|
|
$$('.module-card', content).forEach(el => el.addEventListener('click', () => openNote(kind, el.dataset.id)));
|
|
|
|
|
|
window.scrollTo({ top: 0 });
|
|
|
|
|
|
}
|
|
|
|
|
|
function renderPrincipleList() {
|
|
|
|
|
|
const content = $('#learnContent');
|
|
|
|
|
|
const cards = (KB.principles || []).map(p => `
|
|
|
|
|
|
<div class="module-card" data-id="${escapeHtml(p.id)}">
|
|
|
|
|
|
<div class="mod-name">${escapeHtml(p.title)}</div>
|
|
|
|
|
|
</div>`).join('');
|
|
|
|
|
|
content.innerHTML = `<div class="page-title" style="font-size:1.1rem;margin-bottom:6px">Emmy 投資心法</div>
|
|
|
|
|
|
<div class="list-meta">共 ${(KB.principles || []).length} 條原則。完整分群與決策流程請看「心法地圖」。</div>
|
|
|
|
|
|
<div class="module-grid">${cards}</div>`;
|
|
|
|
|
|
$$('.module-card', content).forEach(el => el.addEventListener('click', () => openNote('principle', el.dataset.id)));
|
|
|
|
|
|
window.scrollTo({ top: 0 });
|
|
|
|
|
|
}
|
|
|
|
|
|
function renderGlossary(section) {
|
|
|
|
|
|
const content = $('#learnContent');
|
|
|
|
|
|
const kind = { terms: 'term', companies: 'company', episodes: 'episode' }[section];
|
|
|
|
|
|
const all = (KB.index || []).filter(x => x.kind === kind);
|
|
|
|
|
|
const title = { terms: '名詞速查', companies: '公司速查', episodes: '單集速查' }[section];
|
|
|
|
|
|
content.innerHTML = `
|
|
|
|
|
|
<div class="page-title" style="font-size:1.1rem;margin-bottom:10px">${title}</div>
|
|
|
|
|
|
<div class="search-box"><input type="text" id="glossSearch" placeholder="搜尋${title.replace('速查', '')}…(中英別名皆可)"></div>
|
|
|
|
|
|
<div class="list-meta" id="glossCount"></div>
|
|
|
|
|
|
<div class="glossary-grid" id="glossGrid"></div>`;
|
|
|
|
|
|
const grid = $('#glossGrid'), countEl = $('#glossCount');
|
|
|
|
|
|
const draw = (q) => {
|
|
|
|
|
|
q = (q || '').trim().toLowerCase();
|
|
|
|
|
|
const list = !q ? all : all.filter(x =>
|
|
|
|
|
|
x.title.toLowerCase().includes(q) ||
|
|
|
|
|
|
(x.aliases || []).some(a => a.toLowerCase().includes(q)) ||
|
|
|
|
|
|
(x.sub || '').toLowerCase().includes(q));
|
|
|
|
|
|
countEl.textContent = `${list.length} 筆${q ? `(搜尋「${q}」)` : ''}`;
|
|
|
|
|
|
grid.innerHTML = list.slice(0, 400).map(x => `
|
|
|
|
|
|
<div class="gloss-item" data-id="${escapeHtml(x.id)}">
|
|
|
|
|
|
<div class="gi-title">${escapeHtml(x.title)}</div>
|
|
|
|
|
|
${x.sub ? `<div class="gi-sub">${escapeHtml(x.sub)}</div>` : ''}
|
|
|
|
|
|
</div>`).join('') || '<div class="empty-state">找不到符合的項目。</div>';
|
|
|
|
|
|
$$('.gloss-item', grid).forEach(el => el.addEventListener('click', () => openNote(kind, el.dataset.id)));
|
|
|
|
|
|
if (list.length > 400) countEl.textContent += ',只顯示前 400 筆,請用搜尋縮小範圍。';
|
|
|
|
|
|
};
|
|
|
|
|
|
$('#glossSearch').addEventListener('input', e => draw(e.target.value));
|
|
|
|
|
|
draw('');
|
|
|
|
|
|
window.scrollTo({ top: 0 });
|
|
|
|
|
|
}
|
|
|
|
|
|
function renderQuiz() {
|
2026-06-03 09:33:23 +00:00
|
|
|
|
renderNote(Object.assign({ kind: 'quiz' }, KB.quiz || { body: '# 練習題庫\n(尚無內容)' }));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 知識圖譜(vis-network)──
|
|
|
|
|
|
let graphNetwork = null;
|
|
|
|
|
|
const GRAPH_LEGEND = [
|
|
|
|
|
|
['category', '分類', '#0071e3'], ['case', '案例', '#34c759'], ['principle', '心法', '#af52de'],
|
|
|
|
|
|
['term', '名詞', '#ff9500'], ['company', '公司', '#5ac8fa'], ['episode', '單集', '#8e8e93'],
|
|
|
|
|
|
];
|
|
|
|
|
|
async function showGraph(opts = {}) {
|
|
|
|
|
|
LEARN.lastSection = 'graph';
|
|
|
|
|
|
setLearnActive('graph');
|
|
|
|
|
|
const content = $('#learnContent');
|
|
|
|
|
|
const filter = opts.filter || LEARN.graphFilter || 'curriculum';
|
|
|
|
|
|
const center = opts.center || '';
|
|
|
|
|
|
const depth = opts.depth || 2;
|
|
|
|
|
|
LEARN.graphFilter = filter;
|
|
|
|
|
|
content.innerHTML = `
|
|
|
|
|
|
<div class="page-title" style="font-size:1.2rem;margin-bottom:8px">知識圖譜</div>
|
|
|
|
|
|
<div class="page-sub" style="margin-bottom:16px">節點是筆記與概念,連線來自文內 [[連結]]。點一下節點可開啟該篇;拖曳平移、雙指或滾輪縮放。</div>
|
|
|
|
|
|
<div class="graph-panel">
|
|
|
|
|
|
<div class="graph-toolbar"><div id="graphFilterChips" class="chip-row"></div></div>
|
|
|
|
|
|
<div id="graphCanvas" class="graph-canvas"><div class="empty-state">載入圖譜中…</div></div>
|
|
|
|
|
|
<div class="graph-foot"><div class="graph-legend" id="graphLegend"></div><span id="graphStat"></span></div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
mountChips($('#graphFilterChips'), GRAPH_KINDS.map(g => ({ id: g.id, label: g.label })), filter, v => showGraph({ filter: v }));
|
|
|
|
|
|
$('#graphLegend').innerHTML = GRAPH_LEGEND.map(([, lab, col]) =>
|
|
|
|
|
|
`<span><i style="background:${col}"></i>${lab}</span>`).join('');
|
|
|
|
|
|
const cfg = GRAPH_KINDS.find(g => g.id === filter) || GRAPH_KINDS[0];
|
|
|
|
|
|
const qs = new URLSearchParams({ kinds: cfg.kinds, limit: 500 });
|
|
|
|
|
|
if (cfg.includeIndex) qs.set('includeIndex', '1');
|
|
|
|
|
|
if (center) { qs.set('center', center); qs.set('depth', String(depth)); }
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api('/api/graph?' + qs);
|
|
|
|
|
|
const el = $('#graphCanvas');
|
|
|
|
|
|
el.innerHTML = '';
|
|
|
|
|
|
if (!data.nodes || !data.nodes.length) {
|
|
|
|
|
|
el.innerHTML = '<div class="empty-state">此範圍沒有足夠的連結可繪製。</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!window.vis) { el.innerHTML = '<div class="empty-state">圖譜元件載入失敗,請重新整理。</div>'; return; }
|
|
|
|
|
|
const nodes = new vis.DataSet(data.nodes.map(n => ({
|
|
|
|
|
|
id: n.id, label: n.label, title: n.title,
|
|
|
|
|
|
color: { background: n.color, border: n.color, highlight: { background: n.color, border: '#1d1d1f' } },
|
|
|
|
|
|
shape: n.shape === 'box' ? 'box' : 'dot',
|
|
|
|
|
|
font: { face: '-apple-system, BlinkMacSystemFont, sans-serif', size: 13, color: '#1d1d1f' },
|
|
|
|
|
|
margin: 10,
|
|
|
|
|
|
})));
|
|
|
|
|
|
const edges = new vis.DataSet(data.edges.map(e => ({
|
|
|
|
|
|
from: e.from, to: e.to, arrows: { to: { scaleFactor: 0.45 } },
|
|
|
|
|
|
color: { color: 'rgba(0,0,0,.12)', highlight: 'rgba(0,113,227,.45)' },
|
|
|
|
|
|
smooth: { type: 'continuous', roundness: 0.2 },
|
|
|
|
|
|
})));
|
|
|
|
|
|
if (graphNetwork) { graphNetwork.destroy(); graphNetwork = null; }
|
|
|
|
|
|
graphNetwork = new vis.Network(el, { nodes, edges }, {
|
|
|
|
|
|
physics: { stabilization: { iterations: 100 }, barnesHut: { gravitationalConstant: -12000, springLength: 120 } },
|
|
|
|
|
|
interaction: { hover: true, tooltipDelay: 80, navigationButtons: false },
|
|
|
|
|
|
nodes: { borderWidth: 0, shadow: { enabled: true, size: 6, x: 0, y: 2, color: 'rgba(0,0,0,.08)' } },
|
|
|
|
|
|
});
|
|
|
|
|
|
graphNetwork.on('click', p => {
|
|
|
|
|
|
if (!p.nodes.length) return;
|
|
|
|
|
|
const nid = p.nodes[0];
|
|
|
|
|
|
const node = data.nodes.find(n => n.id === nid);
|
|
|
|
|
|
if (!node) return;
|
|
|
|
|
|
const colon = nid.indexOf(':');
|
|
|
|
|
|
if (colon < 0) return;
|
|
|
|
|
|
openNote(nid.slice(0, colon), nid.slice(colon + 1));
|
|
|
|
|
|
});
|
|
|
|
|
|
if (center && data.nodes.some(n => n.id === center)) {
|
|
|
|
|
|
graphNetwork.focus(center, { scale: 1.2, animation: { duration: 500, easingFunction: 'easeInOutQuad' } });
|
|
|
|
|
|
}
|
|
|
|
|
|
$('#graphStat').textContent = `${data.nodes.length} 個節點 · ${data.edges.length} 條連線${center ? '(聚焦模式)' : ''}`;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
$('#graphCanvas').innerHTML = `<div class="empty-state">圖譜載入失敗:${escapeHtml(e.message || '')}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
window.scrollTo({ top: 0 });
|
2026-06-03 09:21:58 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// 共用 SVG 折線圖(價格走勢 / 回測權益曲線共用,支援多條線 + hover)
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
let _chartSeq = 0;
|
|
|
|
|
|
function drawLineChart(el, series, opts = {}) {
|
|
|
|
|
|
series = (series || []).filter(s => s.points && s.points.length >= 2);
|
|
|
|
|
|
if (!series.length) { el.innerHTML = '<div class="chart-empty">資料不足,無法繪圖。</div>'; return; }
|
|
|
|
|
|
const uid = 'c' + (++_chartSeq);
|
|
|
|
|
|
const w = 760, h = opts.height || 300, padL = 60, padR = 14, padT = 16, padB = 28;
|
|
|
|
|
|
const plotW = w - padL - padR, plotH = h - padT - padB;
|
|
|
|
|
|
const n = Math.min(...series.map(s => s.points.length));
|
|
|
|
|
|
const dates = series[0].points.map(p => p.date);
|
|
|
|
|
|
const allVals = []; series.forEach(s => s.points.forEach(p => allVals.push(p.val)));
|
|
|
|
|
|
let yMin = opts.yMin != null ? opts.yMin : Math.min(...allVals);
|
|
|
|
|
|
let yMax = opts.yMax != null ? opts.yMax : Math.max(...allVals);
|
|
|
|
|
|
if (yMin === yMax) { yMin -= 1; yMax += 1; }
|
|
|
|
|
|
if (opts.yMin == null) { const p = (yMax - yMin) * 0.08; yMin -= p; yMax += p; }
|
|
|
|
|
|
const yRange = yMax - yMin || 1;
|
|
|
|
|
|
const fmt = opts.fmt || (v => fmtNum(v, opts.decimals != null ? opts.decimals : 0));
|
|
|
|
|
|
const toX = i => padL + (i / (n - 1)) * plotW;
|
|
|
|
|
|
const toY = v => padT + (1 - (v - yMin) / yRange) * plotH;
|
|
|
|
|
|
let grid = '';
|
2026-06-03 09:33:23 +00:00
|
|
|
|
for (let k = 0; k <= 5; k++) { const v = yMin + yRange * k / 5; const y = toY(v); grid += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${w - padR}" y2="${y.toFixed(1)}" stroke="rgba(0,0,0,.06)"/><text x="${padL - 8}" y="${(y + 3.5).toFixed(1)}" fill="#86868b" font-size="11" text-anchor="end">${fmt(v)}</text>`; }
|
2026-06-03 09:21:58 +00:00
|
|
|
|
let xlab = ''; const xt = Math.min(5, n);
|
2026-06-03 09:33:23 +00:00
|
|
|
|
for (let k = 0; k < xt; k++) { const idx = Math.round(k * (n - 1) / (xt - 1)); xlab += `<text x="${toX(idx).toFixed(1)}" y="${h - 9}" fill="#86868b" font-size="10" text-anchor="middle">${(dates[idx] || '').slice(2, 7).replace('-', '/')}</text>`; }
|
2026-06-03 09:21:58 +00:00
|
|
|
|
let paths = '', dots = '';
|
|
|
|
|
|
series.forEach(s => {
|
|
|
|
|
|
const d = s.points.slice(0, n).map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(i).toFixed(1)},${toY(p.val).toFixed(1)}`).join(' ');
|
|
|
|
|
|
paths += `<path d="${d}" fill="none" stroke="${s.color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>`;
|
|
|
|
|
|
dots += `<circle class="hd" data-c="${s.color}" r="4" fill="${s.color}" stroke="#0a0e17" stroke-width="2" style="display:none"/>`;
|
|
|
|
|
|
});
|
|
|
|
|
|
const legend = series.length > 1 ? `<div class="chart-legend">${series.map(s => `<span><i style="background:${s.color}"></i>${escapeHtml(s.name)}</span>`).join('')}</div>` : '';
|
|
|
|
|
|
el.innerHTML = `${legend}<div class="chart-wrap"><svg id="${uid}" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
|
|
|
|
|
|
${grid}${xlab}${paths}
|
2026-06-03 09:33:23 +00:00
|
|
|
|
<g class="hg" style="display:none"><line class="hl" y1="${padT}" y2="${padT + plotH}" stroke="#86868b" stroke-dasharray="3,3"/></g>
|
2026-06-03 09:21:58 +00:00
|
|
|
|
${dots}
|
|
|
|
|
|
<rect class="ha" x="${padL}" y="${padT}" width="${plotW}" height="${plotH}" fill="transparent" style="cursor:crosshair"/>
|
|
|
|
|
|
</svg><div class="chart-hover" id="${uid}h"></div></div>`;
|
|
|
|
|
|
const svg = el.querySelector('#' + uid);
|
|
|
|
|
|
const hg = svg.querySelector('.hg'), hl = svg.querySelector('.hl'), area = svg.querySelector('.ha');
|
|
|
|
|
|
const hds = $$('.hd', svg), info = el.querySelector('#' + uid + 'h');
|
|
|
|
|
|
area.addEventListener('mousemove', evt => {
|
|
|
|
|
|
const r = svg.getBoundingClientRect();
|
|
|
|
|
|
const sx = (evt.clientX - r.left) * (w / r.width);
|
|
|
|
|
|
let i = Math.round(((sx - padL) / plotW) * (n - 1));
|
|
|
|
|
|
i = Math.max(0, Math.min(n - 1, i));
|
|
|
|
|
|
const x = toX(i);
|
|
|
|
|
|
hg.style.display = ''; hl.setAttribute('x1', x); hl.setAttribute('x2', x);
|
|
|
|
|
|
hds.forEach((dot, k) => { const p = series[k].points[i]; if (!p) return; dot.style.display = ''; dot.setAttribute('cx', x); dot.setAttribute('cy', toY(p.val)); });
|
|
|
|
|
|
info.style.display = 'block';
|
|
|
|
|
|
info.innerHTML = `<b>${dates[i]}</b> ` + series.map(s => `<span style="color:${s.color}">${series.length > 1 ? escapeHtml(s.name) + ' ' : ''}${fmt(s.points[i].val)}</span>`).join(' ');
|
|
|
|
|
|
});
|
|
|
|
|
|
area.addEventListener('mouseleave', () => { hg.style.display = 'none'; hds.forEach(d => d.style.display = 'none'); info.style.display = 'none'; });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// 個股工具視圖(共用代號:價格走勢 / 財報健檢 / 投資地圖 / 回測)
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
const STOCK = { symbol: '', sub: 'price', priceRange: '1y', rendered: {}, mapAnswers: {}, mapCfg: null };
|
|
|
|
|
|
const SUBS = ['price', 'finbox', 'map', 'backtest'];
|
|
|
|
|
|
function initStock() {
|
|
|
|
|
|
const view = $('#view-stock');
|
|
|
|
|
|
view.innerHTML = `
|
|
|
|
|
|
<div class="page">
|
|
|
|
|
|
<div class="page-head">
|
|
|
|
|
|
<div class="page-title">📈 個股工具</div>
|
|
|
|
|
|
<div class="page-sub">輸入一檔股票代號,所有工具一次到位:價格走勢、<span class="wlink" data-link="學習分類/財報基本功">財報</span>健檢、用 Emmy 六層漏斗的<b>投資地圖</b>判斷該不該進場、以及策略<b>回測</b>。資料皆會存資料庫快取以節省 API。</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="finbox-search">
|
|
|
|
|
|
<input type="text" id="stkSym" placeholder="輸入代號,例如 NVDA(美股最完整)" autocomplete="off">
|
|
|
|
|
|
<button id="stkGo">查詢</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="finbox-examples">範例:<b data-sym="NVDA">NVDA</b><b data-sym="AMD">AMD</b><b data-sym="MSFT">MSFT</b><b data-sym="AVGO">AVGO</b><b data-sym="AAPL">AAPL</b></div>
|
|
|
|
|
|
<div class="sub-tabs" id="stkSub">
|
|
|
|
|
|
<a data-sub="price" class="active">價格走勢</a>
|
|
|
|
|
|
<a data-sub="finbox">財報健檢</a>
|
|
|
|
|
|
<a data-sub="map">投資地圖</a>
|
|
|
|
|
|
<a data-sub="backtest">回測</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div id="stkBody">
|
|
|
|
|
|
<div class="stk-pane" id="pane-price"></div>
|
|
|
|
|
|
<div class="stk-pane" id="pane-finbox" hidden></div>
|
|
|
|
|
|
<div class="stk-pane" id="pane-map" hidden></div>
|
|
|
|
|
|
<div class="stk-pane" id="pane-backtest" hidden></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
ensureKnowledge().then(() => bindWlinks(view)).catch(() => {});
|
|
|
|
|
|
const go = () => setStockSymbol($('#stkSym').value);
|
|
|
|
|
|
$('#stkGo').addEventListener('click', go);
|
|
|
|
|
|
$('#stkSym').addEventListener('keydown', e => { if (e.key === 'Enter') go(); });
|
|
|
|
|
|
$$('.finbox-examples b', view).forEach(b => b.addEventListener('click', () => { $('#stkSym').value = b.dataset.sym; go(); }));
|
|
|
|
|
|
$$('#stkSub a').forEach(a => a.addEventListener('click', () => setSub(a.dataset.sub)));
|
|
|
|
|
|
setSub('map'); // 投資地圖不需代號也能先看判斷流程
|
|
|
|
|
|
}
|
|
|
|
|
|
function setStockSymbol(sym) {
|
|
|
|
|
|
sym = (sym || '').trim().toUpperCase();
|
|
|
|
|
|
if (!sym) return;
|
|
|
|
|
|
STOCK.symbol = sym;
|
|
|
|
|
|
STOCK.rendered = {}; // 換股票 → 各分頁重抓
|
|
|
|
|
|
$('#stkSym').value = sym;
|
|
|
|
|
|
if (STOCK.sub === 'map') setSub('price'); // 輸入代號後預設先看價格
|
|
|
|
|
|
else renderSub(STOCK.sub);
|
|
|
|
|
|
}
|
|
|
|
|
|
function setSub(sub) {
|
|
|
|
|
|
if (!SUBS.includes(sub)) sub = 'price';
|
|
|
|
|
|
STOCK.sub = sub;
|
|
|
|
|
|
$$('#stkSub a').forEach(a => a.classList.toggle('active', a.dataset.sub === sub));
|
|
|
|
|
|
SUBS.forEach(s => { const p = $('#pane-' + s); if (p) p.hidden = s !== sub; });
|
|
|
|
|
|
renderSub(sub);
|
|
|
|
|
|
}
|
|
|
|
|
|
function needSymbol(pane) {
|
|
|
|
|
|
if (STOCK.symbol) return false;
|
|
|
|
|
|
pane.innerHTML = '<div class="empty-state">請先在上方輸入股票代號。</div>';
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
function renderSub(sub) {
|
|
|
|
|
|
if (sub === 'price') return renderPrice();
|
|
|
|
|
|
if (sub === 'finbox') return renderFinboxPane();
|
|
|
|
|
|
if (sub === 'map') return renderMap();
|
|
|
|
|
|
if (sub === 'backtest') return renderBacktestPane();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 價格走勢 ──
|
|
|
|
|
|
const PRICE_RANGES = [['3mo', '3月'], ['6mo', '6月'], ['1y', '1年'], ['2y', '2年'], ['5y', '5年'], ['max', '全部']];
|
|
|
|
|
|
async function renderPrice(force) {
|
|
|
|
|
|
const pane = $('#pane-price');
|
|
|
|
|
|
if (needSymbol(pane)) return;
|
|
|
|
|
|
if (!force && STOCK.rendered.price === STOCK.symbol + ':' + STOCK.priceRange) return;
|
|
|
|
|
|
pane.innerHTML = `
|
|
|
|
|
|
<div class="range-btns" id="priceRange">${PRICE_RANGES.map(r => `<button data-r="${r[0]}" class="${r[0] === STOCK.priceRange ? 'active' : ''}">${r[1]}</button>`).join('')}</div>
|
|
|
|
|
|
<div id="priceHead" class="fin-co"></div>
|
|
|
|
|
|
<div id="priceChart"><div class="chart-empty">載入中…</div></div>`;
|
|
|
|
|
|
$$('#priceRange button', pane).forEach(b => b.addEventListener('click', () => { STOCK.priceRange = b.dataset.r; renderPrice(true); }));
|
|
|
|
|
|
try {
|
|
|
|
|
|
const d = await api(`/api/price/${encodeURIComponent(STOCK.symbol)}?range=${STOCK.priceRange}&interval=1d`);
|
|
|
|
|
|
STOCK.rendered.price = STOCK.symbol + ':' + STOCK.priceRange;
|
|
|
|
|
|
const pts = d.points.map(p => ({ date: p.date, val: p.close }));
|
|
|
|
|
|
const first = pts[0].val, last = pts[pts.length - 1].val;
|
|
|
|
|
|
const chg = (last / first - 1) * 100;
|
|
|
|
|
|
const chgCls = chg >= 0 ? 'pnl-pos' : 'pnl-neg';
|
|
|
|
|
|
$('#priceHead').innerHTML = `<b>${escapeHtml(d.name || d.symbol)}</b> ${escapeHtml(d.symbol)} · 收盤 ${escapeHtml(d.currency || '')} ${fmtNum(last, 2)} · 此區間 <span class="${chgCls}">${chg >= 0 ? '+' : ''}${chg.toFixed(1)}%</span>${d.cached ? ' · <span style="color:var(--text2);font-size:.8rem">快取</span>' : ''}`;
|
|
|
|
|
|
drawLineChart($('#priceChart'), [{ name: d.symbol, color: HEX.blue, points: pts }], { fmt: v => fmtNum(v, 2) });
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
pane.querySelector('#priceChart').innerHTML = `<div class="empty-state">無法取得 ${escapeHtml(STOCK.symbol)} 的價格:${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 財報健檢 ──
|
|
|
|
|
|
function renderFinboxPane() {
|
|
|
|
|
|
const pane = $('#pane-finbox');
|
|
|
|
|
|
if (needSymbol(pane)) return;
|
|
|
|
|
|
if (STOCK.rendered.finbox === STOCK.symbol) return;
|
|
|
|
|
|
pane.innerHTML = '<div id="finResult"></div>';
|
|
|
|
|
|
runFincheck(STOCK.symbol);
|
|
|
|
|
|
}
|
|
|
|
|
|
async function runFincheck(sym, fresh) {
|
|
|
|
|
|
sym = (sym || STOCK.symbol || '').trim().toUpperCase();
|
|
|
|
|
|
const out = $('#finResult');
|
|
|
|
|
|
if (!out) return;
|
|
|
|
|
|
if (!sym) { out.innerHTML = '<div class="empty-state">請先輸入股票代號。</div>'; return; }
|
|
|
|
|
|
out.innerHTML = `<div class="empty-state"><div class="spinner" style="width:28px;height:28px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 14px;animation:spin .8s linear infinite"></div>正在${fresh ? '重新抓取' : '查詢'} ${escapeHtml(sym)} 的財報並健檢…</div>`;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const d = await api('/api/fundamentals/' + encodeURIComponent(sym) + (fresh ? '?fresh=1' : ''));
|
|
|
|
|
|
STOCK.rendered.finbox = sym;
|
|
|
|
|
|
renderFincheck(d);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
out.innerHTML = `<div class="empty-state">無法取得 ${escapeHtml(sym)} 的財報:${escapeHtml((e.data && e.data.message) || e.message || '未知錯誤')}<br><span style="font-size:.8rem">可試試美股代號(如 NVDA、AMD、MSFT)。</span></div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function renderFincheck(d) {
|
|
|
|
|
|
const out = $('#finResult');
|
|
|
|
|
|
if (!out) return;
|
|
|
|
|
|
const r = d.report || {};
|
|
|
|
|
|
const sum = r.summary || {};
|
|
|
|
|
|
const vColor = { good: 'var(--green)', warn: 'var(--yellow)', bad: 'var(--red)' }[sum.verdictColor] || 'var(--text)';
|
|
|
|
|
|
const steps = (r.steps || []).map(st => `
|
|
|
|
|
|
<div class="fin-step">
|
|
|
|
|
|
<div class="fin-step-head"><div class="fin-step-num">${st.num}</div><div class="fin-step-title">${escapeHtml(st.title)}</div></div>
|
|
|
|
|
|
${(st.checks || []).map(ck => checkRowHTML(ck)).join('')}
|
|
|
|
|
|
</div>`).join('');
|
|
|
|
|
|
const caveats = (r.caveats || []).map(c => `<div class="disclaimer">${mdLinks(c.text, c.links)}</div>`).join('');
|
|
|
|
|
|
const fetched = d._fetchedAt ? new Date(d._fetchedAt).toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : null;
|
|
|
|
|
|
const freshNote = d.cached
|
|
|
|
|
|
? `已存資料庫的快取${fetched ? `,抓取於 ${fetched}` : ''}${d._latestForm ? `(依最新申報 ${escapeHtml(d._latestForm)})` : ''}`
|
|
|
|
|
|
: `剛從來源抓取${fetched ? `(${fetched})` : ''}`;
|
|
|
|
|
|
const staleNote = d.stale ? '<span style="color:var(--orange)"> · 即時更新失敗,先顯示先前存的資料</span>' : '';
|
|
|
|
|
|
out.innerHTML = `
|
|
|
|
|
|
<div class="fin-co"><b>${escapeHtml(d.name || d.symbol)}</b> ${escapeHtml(d.symbol)}${d.price != null ? ` · 股價 $${fmtNum(d.price, 2)}` : ''} · 資料來源 ${escapeHtml(d.source || '—')}${d.asOf ? ` · 最新季別 ${escapeHtml(d.asOf)}` : ''}</div>
|
|
|
|
|
|
<div class="fin-fresh"><span>${freshNote}${staleNote}</span><button class="btn ghost sm" id="finRefresh">↻ 重新抓取</button></div>
|
|
|
|
|
|
<div class="fin-summary">
|
|
|
|
|
|
<div class="fin-verdict"><div class="v-big" style="color:${vColor}">${escapeHtml(sum.verdict || '—')}</div><div class="v-sub">${(sum.good || 0) + (sum.warn || 0) + (sum.bad || 0)} 項檢查</div></div>
|
|
|
|
|
|
<div class="fin-lights">
|
|
|
|
|
|
<div class="fin-light"><div class="fl-num" style="color:var(--green)">${sum.good || 0}</div><div class="fl-lab">綠燈 通過</div></div>
|
|
|
|
|
|
<div class="fin-light"><div class="fl-num" style="color:var(--yellow)">${sum.warn || 0}</div><div class="fl-lab">黃燈 留意</div></div>
|
|
|
|
|
|
<div class="fin-light"><div class="fl-num" style="color:var(--red)">${sum.bad || 0}</div><div class="fl-lab">紅燈 警訊</div></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${steps}
|
|
|
|
|
|
${caveats}`;
|
|
|
|
|
|
bindWlinks(out);
|
|
|
|
|
|
const rb = $('#finRefresh');
|
|
|
|
|
|
if (rb) rb.addEventListener('click', () => runFincheck(d.symbol, true));
|
|
|
|
|
|
}
|
|
|
|
|
|
function checkRowHTML(ck) {
|
|
|
|
|
|
const links = (ck.links || []).map(l => `<span class="wlink" data-link="${escapeHtml(l.target)}">${escapeHtml(l.label)}</span>`).join('');
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="check-row ${ck.status}">
|
|
|
|
|
|
<span class="check-dot"></span>
|
|
|
|
|
|
<div class="check-main">
|
|
|
|
|
|
<div class="ck-label">${escapeHtml(ck.label)}</div>
|
|
|
|
|
|
${ck.note ? `<div class="ck-note">${escapeHtml(ck.note)}</div>` : ''}
|
|
|
|
|
|
${links ? `<div class="ck-links">${links}</div>` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="check-val ${ck.status}">${escapeHtml(ck.value != null ? ck.value : '—')}</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 把 {text, links:[{target,label}]} 的 [label] 佔位轉成 wlink(links 依序替換)
|
|
|
|
|
|
function mdLinks(text, links) {
|
|
|
|
|
|
let i = 0;
|
|
|
|
|
|
return escapeHtml(text).replace(/\{link\}/g, () => {
|
|
|
|
|
|
const l = (links || [])[i++]; if (!l) return '';
|
|
|
|
|
|
return `<span class="wlink" data-link="${escapeHtml(l.target)}">${escapeHtml(l.label)}</span>`;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 投資地圖(互動六層漏斗)──
|
|
|
|
|
|
const ANS = [['yes', '是'], ['unsure', '不確定'], ['no', '否']];
|
|
|
|
|
|
function layerStatus(L) {
|
|
|
|
|
|
const ans = L.questions.map((_, qi) => STOCK.mapAnswers[L.key + ':' + qi]);
|
|
|
|
|
|
const answered = ans.filter(Boolean);
|
|
|
|
|
|
const gateNo = L.questions.some((q, qi) => q.gate && ans[qi] === 'no');
|
|
|
|
|
|
if (gateNo) return 'out';
|
|
|
|
|
|
if (!answered.length) return 'pending';
|
|
|
|
|
|
if (ans.every(a => a === 'yes')) return 'pass';
|
|
|
|
|
|
return 'watch';
|
|
|
|
|
|
}
|
|
|
|
|
|
const ST_META = {
|
|
|
|
|
|
pass: { lab: '通過', cls: 'good' }, watch: { lab: '待確認', cls: 'warn' },
|
|
|
|
|
|
out: { lab: '出局', cls: 'bad' }, pending: { lab: '未評估', cls: 'na' },
|
|
|
|
|
|
};
|
|
|
|
|
|
async function renderMap() {
|
|
|
|
|
|
const pane = $('#pane-map');
|
|
|
|
|
|
pane.innerHTML = '<div class="empty-state"><div class="spinner" style="width:26px;height:26px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 12px;animation:spin .8s linear infinite"></div>載入投資地圖…</div>';
|
|
|
|
|
|
if (!STOCK.mapCfg) {
|
|
|
|
|
|
try { STOCK.mapCfg = await api('/api/investmap'); await ensureKnowledge(); }
|
|
|
|
|
|
catch (e) { pane.innerHTML = `<div class="empty-state">載入投資地圖失敗:${escapeHtml(e.message || '')}</div>`; return; }
|
|
|
|
|
|
}
|
|
|
|
|
|
drawMap();
|
|
|
|
|
|
}
|
|
|
|
|
|
function drawMap() {
|
|
|
|
|
|
const pane = $('#pane-map');
|
|
|
|
|
|
const cfg = STOCK.mapCfg;
|
|
|
|
|
|
const target = STOCK.symbol ? `<b>${escapeHtml(STOCK.symbol)}</b>` : '這檔標的';
|
|
|
|
|
|
let firstOut = -1;
|
|
|
|
|
|
const layersHTML = cfg.layers.map((L, idx) => {
|
|
|
|
|
|
const st = layerStatus(L);
|
|
|
|
|
|
if (st === 'out' && firstOut < 0) firstOut = idx;
|
|
|
|
|
|
const meta = ST_META[st];
|
|
|
|
|
|
const qs = L.questions.map((q, qi) => {
|
|
|
|
|
|
const cur = STOCK.mapAnswers[L.key + ':' + qi];
|
|
|
|
|
|
const radios = ANS.map(([v, lab]) => `<label class="ans ${v} ${cur === v ? 'on' : ''}"><input type="radio" name="${L.key}_${qi}" value="${v}" ${cur === v ? 'checked' : ''}>${lab}</label>`).join('');
|
|
|
|
|
|
const links = (q.principles || []).map(p => `<span class="wlink ${p.id ? '' : 'dead'}" ${p.id ? `data-pid="${escapeHtml(p.id)}"` : ''}>${escapeHtml(p.title)}</span>`).join('');
|
|
|
|
|
|
return `<div class="map-q">
|
|
|
|
|
|
<div class="mq-text">${q.gate ? '<span class="gate">閘門</span>' : ''}${escapeHtml(q.q)}</div>
|
|
|
|
|
|
<div class="mq-ans" data-layer="${L.key}" data-qi="${qi}">${radios}</div>
|
|
|
|
|
|
${links ? `<div class="ck-links">${links}</div>` : ''}
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
return `<div class="map-layer ${st}">
|
|
|
|
|
|
<div class="ml-head"><div class="ml-num">${idx + 1}</div><div class="ml-title">${escapeHtml(L.title)}</div><span class="ml-badge ${meta.cls}">${meta.lab}</span></div>
|
|
|
|
|
|
<div class="ml-ask">${escapeHtml(L.ask)}</div>
|
|
|
|
|
|
<div class="ml-pillar">${escapeHtml(L.pillar)}</div>
|
|
|
|
|
|
${qs}
|
|
|
|
|
|
<div class="ml-out">出局條件:${escapeHtml(L.out)}</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
// 彙整結論
|
|
|
|
|
|
const statuses = cfg.layers.map(layerStatus);
|
|
|
|
|
|
const anyAnswered = Object.keys(STOCK.mapAnswers).length > 0;
|
|
|
|
|
|
let verdict, vcls;
|
|
|
|
|
|
if (firstOut >= 0) { verdict = `不建議進場:第 ${firstOut + 1} 層「${cfg.layers[firstOut].title}」出局,依漏斗原則應停手。`; vcls = 'bad'; }
|
|
|
|
|
|
else if (statuses.every(s => s === 'pass')) { verdict = '六層皆通過,可進入交易計畫(記得設好減倉/停損規則與底倉)。'; vcls = 'good'; }
|
|
|
|
|
|
else if (anyAnswered) { verdict = '初步可行,但仍有「待確認」項目——把不確定的補齊再決定。'; vcls = 'warn'; }
|
|
|
|
|
|
else { verdict = '逐層回答下面的提問,系統會即時告訴你哪一層卡關。'; vcls = 'na'; }
|
|
|
|
|
|
|
|
|
|
|
|
pane.innerHTML = `
|
|
|
|
|
|
<div class="map-core">🧭 下單前的核心提問<br><span>${escapeHtml(cfg.coreQuestion)}</span></div>
|
|
|
|
|
|
<div class="map-verdict ${vcls}"><div class="mv-lab">${target} 的判斷</div><div class="mv-text">${verdict}</div>
|
|
|
|
|
|
<div class="mv-actions"><button class="btn ghost sm" id="mapReset">重設</button>${STOCK.symbol ? '<button class="btn sm" id="mapSave">存成交易紀錄</button>' : ''}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${layersHTML}
|
|
|
|
|
|
<div class="disclaimer">這是把 Emmy「<span class="wlink" data-link="學習分類/投資底層邏輯">投資底層邏輯</span>」六層漏斗變成的自我檢查工具,幫你結構化判斷,<b>不構成投資建議</b>。任何一層出局就停手,是漏斗的精神。</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
// 綁定:作答(即時重繪)、原則連結、按鈕
|
|
|
|
|
|
$$('.mq-ans', pane).forEach(box => {
|
|
|
|
|
|
$$('input[type=radio]', box).forEach(r => r.addEventListener('change', () => {
|
|
|
|
|
|
STOCK.mapAnswers[box.dataset.layer + ':' + box.dataset.qi] = r.value;
|
|
|
|
|
|
drawMap();
|
|
|
|
|
|
}));
|
|
|
|
|
|
});
|
|
|
|
|
|
$$('.wlink[data-pid]', pane).forEach(el => el.addEventListener('click', () => openNote('principle', el.dataset.pid)));
|
|
|
|
|
|
bindWlinks(pane);
|
|
|
|
|
|
const rs = $('#mapReset'); if (rs) rs.addEventListener('click', () => { STOCK.mapAnswers = {}; drawMap(); });
|
|
|
|
|
|
const sv = $('#mapSave'); if (sv) sv.addEventListener('click', saveMapToJournal);
|
|
|
|
|
|
}
|
|
|
|
|
|
function saveMapToJournal() {
|
|
|
|
|
|
const cfg = STOCK.mapCfg;
|
|
|
|
|
|
const statuses = cfg.layers.map((L, i) => ({ i, key: L.key, title: L.title, st: layerStatus(L) }));
|
|
|
|
|
|
const firstOut = statuses.find(s => s.st === 'out');
|
|
|
|
|
|
const verdict = firstOut ? `投資地圖:第${firstOut.i + 1}層「${firstOut.title}」出局`
|
|
|
|
|
|
: (statuses.every(s => s.st === 'pass') ? '投資地圖:六層皆通過' : '投資地圖:初步可行、仍有待確認');
|
|
|
|
|
|
const noteLines = statuses.map(s => `${s.i + 1}.${s.title}:${ST_META[s.st].lab}`).join('|');
|
|
|
|
|
|
// 找原則五十四(三面向判斷交易)當預設依據
|
|
|
|
|
|
let principle = '';
|
|
|
|
|
|
for (const L of cfg.layers) for (const q of L.questions) for (const p of (q.principles || [])) if (p.num === 54 && p.id) principle = 'Emmy 投資心法#' + p.id;
|
|
|
|
|
|
openTradeForm({ symbol: STOCK.symbol, entry_reason: verdict, note: '六層漏斗評估:' + noteLines, principle });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 回測 ──
|
|
|
|
|
|
const BT_STRATS = {
|
|
|
|
|
|
buyhold: { label: '買進持有(基準)', params: [] },
|
|
|
|
|
|
dca: { label: '定期定額(每月)', params: [{ key: 'monthly', label: '每月投入', def: 1000 }] },
|
|
|
|
|
|
sma: { label: '均線趨勢(短>長在場)', params: [{ key: 'short', label: '短均(日)', def: 50 }, { key: 'long', label: '長均(日)', def: 200 }] },
|
|
|
|
|
|
dip: { label: '逢大跌進場(回落%後買進)', params: [{ key: 'drop', label: '距高點回落%', def: 15 }] },
|
|
|
|
|
|
};
|
|
|
|
|
|
const BT_RANGES = [['1y', '1年'], ['2y', '2年'], ['5y', '5年'], ['10y', '10年'], ['max', '全部']];
|
|
|
|
|
|
function renderBacktestPane() {
|
|
|
|
|
|
const pane = $('#pane-backtest');
|
|
|
|
|
|
if (needSymbol(pane)) return;
|
|
|
|
|
|
if (STOCK.rendered.backtest === STOCK.symbol) return;
|
|
|
|
|
|
STOCK.rendered.backtest = STOCK.symbol;
|
|
|
|
|
|
if (!STOCK.bt) STOCK.bt = { strategy: 'sma', range: '5y', params: {} };
|
|
|
|
|
|
pane.innerHTML = `
|
|
|
|
|
|
<div class="bt-controls">
|
2026-06-03 09:33:23 +00:00
|
|
|
|
<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>
|
2026-06-03 09:21:58 +00:00
|
|
|
|
<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>`;
|
2026-06-03 09:33:23 +00:00
|
|
|
|
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('');
|
2026-06-03 09:21:58 +00:00
|
|
|
|
};
|
2026-06-03 09:33:23 +00:00
|
|
|
|
drawBtParams();
|
2026-06-03 09:21:58 +00:00
|
|
|
|
$('#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
|
2026-06-03 09:33:23 +00:00
|
|
|
|
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';
|
2026-06-03 09:21:58 +00:00
|
|
|
|
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>
|
2026-06-03 09:33:23 +00:00
|
|
|
|
<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>
|
2026-06-03 09:21:58 +00:00
|
|
|
|
<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>
|
2026-06-03 09:33:23 +00:00
|
|
|
|
<div class="field full"><label>依據的心法(點選色塊)</label><div id="principleChips" class="principle-chips"></div><input type="hidden" name="principle" value=""></div>
|
2026-06-03 09:21:58 +00:00
|
|
|
|
<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>
|
2026-06-03 09:33:23 +00:00
|
|
|
|
<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>
|
2026-06-03 09:21:58 +00:00
|
|
|
|
</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);
|
|
|
|
|
|
}
|
2026-06-03 09:33:23 +00:00
|
|
|
|
function mountPrincipleChips(container, hiddenInput, selected) {
|
|
|
|
|
|
const items = [{ id: '', label: '不指定' }].concat((KB.principles || []).map(p => ({
|
|
|
|
|
|
id: 'Emmy 投資心法#' + p.id, label: p.title.replace(/^原則[^:]+:/, '').slice(0, 24),
|
|
|
|
|
|
})));
|
|
|
|
|
|
mountChips(container, items, selected || '', v => { hiddenInput.value = v; }, { sm: true });
|
2026-06-03 09:21:58 +00:00
|
|
|
|
}
|
|
|
|
|
|
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 : '';
|
2026-06-03 09:33:23 +00:00
|
|
|
|
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 : '');
|
2026-06-03 09:21:58 +00:00
|
|
|
|
if (trade) {
|
2026-06-03 09:33:23 +00:00
|
|
|
|
['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 || '投資';
|
2026-06-03 09:21:58 +00:00
|
|
|
|
f.mistake.checked = !!trade.mistake;
|
|
|
|
|
|
f.principle.value = trade.principle || '';
|
|
|
|
|
|
f.mistake_note.value = trade.mistake_note || '';
|
|
|
|
|
|
$('#mistakeNoteWrap').style.display = trade.mistake ? '' : 'none';
|
2026-06-03 09:33:23 +00:00
|
|
|
|
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);
|
2026-06-03 09:21:58 +00:00
|
|
|
|
}
|
|
|
|
|
|
$('#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 內聯負責載入)
|
2026-06-03 09:33:23 +00:00
|
|
|
|
initMermaid();
|
2026-06-03 09:21:58 +00:00
|
|
|
|
setView(parseHash());
|