// ═══════════════════════════════════════════════════════════ // 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, '&').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); } // ═══════════════════════════════════════════════════════════ // 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 ``; }).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 `
' + c + '');
t = t.replace(/\[\[([^\]]+)\]\]/g, (m, inner) => wlinkHTML(inner));
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
t = t.replace(/\*\*([^*]+)\*\*/g, '$1');
t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2');
return t;
}
function wlinkHTML(inner) {
let [target, display] = inner.split('|');
target = (target || '').trim();
display = (display || '').trim();
if (!display) display = deEmmyText(target.includes('#') ? target.split('#').pop() : target.split('/').pop());
return '' + escapeHtml(display) + '';
}
function splitRow(line) {
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
}
function renderTable(header, rows) {
let h = '| ' + mdInline(c) + ' | ').join('') + '
|---|
| ' + mdInline(r[j] || '') + ' | ').join('') + '
${escapeHtml(code)}' + escapeHtml(code) + '';
i++; continue;
}
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) { const l = h[1].length; html += `' + renderMarkdown(buf.join('\n')) + ''; continue; } if (/^\s*([-*]|\d+\.)\s+/.test(line)) { const buf = []; while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i])) { buf.push(lines[i]); i++; } html += renderListBlock(buf); continue; } const buf = []; while (i < lines.length && !blank(lines[i]) && !/^(#{1,6})\s/.test(lines[i]) && !/^\s*([-*]|\d+\.)\s+/.test(lines[i]) && !/^\s*>/.test(lines[i]) && !/^\u0000F\d+\u0000$/.test(lines[i]) && !/^(-{3,}|\*{3,}|_{3,})$/.test(lines[i].trim()) && !(lines[i].includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]))) { buf.push(lines[i]); i++; } if (buf.length) html += '
' + mdInline(buf.join(' ')) + '
'; } return html; } 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 = '.env(路徑見下方)。金鑰欄位留空代表保留原值;模型與預設 provider 會直接更新。格子裡是當天大事的簡稱;若看到 +3 代表還有更多,點日期可一次看完。每項事件標題旁有 ?,滑鼠移上去有白話解釋(不用懂 ADP、ECB 是什麼也能看)。
npm run build:knowledge 產生 data/knowledge.json,再重新整理。先做配對,再到個股工具套用,最後把自己的判斷寫成筆記。這裡不是要背答案,是訓練你看到資料時知道該問哪一組問題。
${fmtNum(ind.close, 2)} ${escapeHtml(snap?.asOf || '')}
每根 K 棒代表多久(日/周/月),會分開存進資料庫
主圖要顯示多長的走勢;可左右拖曳/捲動看更早的 K
疊在價格上的均線與布林(依 K 根數計算)
券商/TradingView 常一次只看 1~2 個動量副圖;點選開啟,面板右上角可關閉
${escapeHtml(hist.researchNote || '')}${hist.volumeNote ? ` · ${escapeHtml(hist.volumeNote)}` : ''}${hist.firstDate ? ` · ${escapeHtml(hist.firstDate)} → ${escapeHtml(hist.researchThrough || hist.lastDate || '')}` : ''}
圖可左右捲動;下方讀數列固定不動。滑過 K 線可查看該根 OHLC、成交量與指標。
未開啟副圖 — 在上方 ④ 點選指標
附上圖表區間的指標摘要與報酬,白話分析趨勢與風險(非投資建議)。
${escapeHtml(formula)}
${escapeHtml(x)}
`).join('')}${escapeHtml(x)}
`).join('')}${escapeHtml(x)}
`).join('')}earningsTrend(卡片 ? 內有公式與 endpoint);DCF為 MacroScope 本機模型(? 內列出當次 FCF、成長率、折現率)。無資料時顯示「尚無共識/無法估算」,不填假數字。免費報價可能延遲,交易前請對照券商與官方財報。${escapeHtml(desc)}
${escapeHtml(descNote)}
` : '尚無公司簡介,進入本頁會自動抓取。
'} ${notif.length ? `${escapeHtml((e.note || '').slice(0, 200))}
法說逐字稿多由公司 IR 網站發布,免費來源不一定有全文;此處會存財報日、8-K 財報公告與本機副本連結。
${escapeHtml(newsSummaryText(n))}
` : ''} `).join(''); } function newsPanelHtml(list) { return list.length ? `${escapeHtml(profileDesc)}
10-K 摘要 ${escapeHtml(chain.tenKExcerpt)}${chain.tenKExcerpt.length >= 400 ? '…' : ''}
` : ''} ${intelResourceLinksHtml(intel.resources)}${escapeHtml(m.summary || '')}
${m.url ? `原文` : ''}首次進入本頁會從 Yahoo/SEC 10-K 自動取得名單與職稱(中文對照);下次財報公開日前不會重複抓取。
| 標的 | 類型 | 進場 | 出場 | 已實現損益 | 理由 / 依據 |
|---|