2026-06-03 09:21:58 +00:00
// ═══════════════════════════════════════════════════════════
2026-06-03 16:42:07 +00:00
// MacroScope — 學習教材 / 財報健檢 / 交易復盤
2026-06-03 09:21:58 +00:00
// 本檔在 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 ) } < / d i v > $ { i t . s u b ? ` < d i v c l a s s = " t i l e - s u b " > $ { e s c a p e H t m l ( i t . s u b ) } < / d i v > ` : ' ' } < / d i v > ` ;
} ) . 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 ( ) ;
2026-06-03 16:42:07 +00:00
if ( ! display ) display = deEmmyText ( target . includes ( '#' ) ? target . split ( '#' ) . pop ( ) : target . split ( '/' ) . pop ( ) ) ;
2026-06-03 09:21:58 +00:00
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 ) {
2026-06-03 16:42:07 +00:00
md = deEmmyText ( String ( md || '' ) . replace ( /\r\n/g , '\n' ) ) ;
2026-06-03 09:21:58 +00:00
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 ;
}
2026-06-03 16:42:07 +00:00
function deEmmyText ( s ) {
return ( window . LearnUI && LearnUI . deEmmy ) ? LearnUI . deEmmy ( s ) : String ( s || '' ) ;
}
2026-06-03 09:21:58 +00:00
// 把容器內所有 [[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 ) ) ;
} ) ;
}
// ═══════════════════════════════════════════════════════════
// 主視圖路由
// ═══════════════════════════════════════════════════════════
2026-06-04 01:35:37 +00:00
const VIEW _IDS = [ 'macro' , 'calendar' , 'learn' , 'stock' , 'journal' , 'settings' ] ;
2026-06-03 09:21:58 +00:00
const inited = { } ;
function parseHash ( ) { const m = location . hash . match ( /^#\/(\w+)/ ) ; const v = m ? m [ 1 ] : 'macro' ; return VIEW _IDS . includes ( v ) ? v : 'macro' ; }
2026-06-04 01:35:37 +00:00
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 ;
2026-06-03 09:21:58 +00:00
function setView ( view ) {
document . body . dataset . view = view ;
2026-06-04 01:35:37 +00:00
if ( ( window . _ _AI _FOCUS || { } ) . view !== view ) setAIFocus ( { view , type : 'view' } ) ;
2026-06-03 09:21:58 +00:00
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 ) ) ;
2026-06-03 16:42:07 +00:00
if ( view === 'calendar' && ! inited . calendar ) { inited . calendar = true ; initCalendar ( ) ; }
2026-06-03 09:21:58 +00:00
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 ( ) ; }
2026-06-04 01:35:37 +00:00
if ( view === 'settings' && ! inited . settings ) { inited . settings = true ; initSettings ( ) ; }
updateAIContextLabel ( ) ;
2026-06-03 09:21:58 +00:00
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 ( ) ) ) ;
2026-06-04 01:35:37 +00:00
// ═══════════════════════════════════════════════════════════
// AI Provider 設定與頁面上下文問答
// ═══════════════════════════════════════════════════════════
const AI _PROVIDER _META = {
'opencode-go' : {
label : 'OpenCode Go' ,
hint : 'OpenAI-compatible chat completions。官方 Go 端點使用 /zen/go/v1/chat/completions。' ,
} ,
grok : {
label : 'Grok' ,
hint : 'xAI/Grok。後端會使用 xAI Responses API, 且 store=false。' ,
} ,
} ;
function readAISettings ( ) {
const fields = window . _ _ENV _SETTINGS ? . fields || [ ] ;
const get = ( k ) => fields . find ( f => f . key === k ) || { } ;
return {
active : get ( 'AI_ACTIVE_PROVIDER' ) . value || 'grok' ,
providers : {
'opencode-go' : {
model : get ( 'OPENCODE_GO_MODEL' ) . value || '' ,
hasKey : ! ! get ( 'OPENCODE_GO_API_KEY' ) . hasValue ,
} ,
grok : {
model : get ( 'GROK_MODEL' ) . value || '' ,
hasKey : ! ! get ( 'GROK_API_KEY' ) . hasValue ,
} ,
} ,
} ;
}
async function loadEnvSettings ( ) {
window . _ _ENV _SETTINGS = await api ( '/api/settings/env' ) ;
return window . _ _ENV _SETTINGS ;
}
function envField ( settings , key ) {
return ( settings . fields || [ ] ) . find ( f => f . key === key ) || { key , value : '' , hasValue : false , masked : '' } ;
}
async function saveEnvSettings ( view ) {
const values = { AI _ACTIVE _PROVIDER : $ ( 'input[name="aiActiveProvider"]:checked' ) ? . value || 'grok' } ;
$$ ( '[data-env-key]' , view ) . forEach ( input => values [ input . dataset . envKey ] = input . value . trim ( ) ) ;
window . _ _ENV _SETTINGS = await api ( '/api/settings/env' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { values } ) ,
} ) ;
updateAIContextLabel ( ) ;
return window . _ _ENV _SETTINGS ;
}
async function loadProviderModels ( provider ) {
return api ( '/api/ai/models' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { provider } ) ,
} ) ;
}
async function getProviderModels ( provider ) {
window . _ _AI _MODEL _CACHE = window . _ _AI _MODEL _CACHE || { } ;
if ( window . _ _AI _MODEL _CACHE [ provider ] ) return window . _ _AI _MODEL _CACHE [ provider ] ;
const d = await loadProviderModels ( provider ) ;
window . _ _AI _MODEL _CACHE [ provider ] = d . models || [ ] ;
return window . _ _AI _MODEL _CACHE [ provider ] ;
}
async function initSettings ( ) {
const view = $ ( '#view-settings' ) ;
view . innerHTML = '<div class="page"><div class="empty-state">載入設定中…</div></div>' ;
const envSettings = await loadEnvSettings ( ) ;
const settings = readAISettings ( ) ;
view . innerHTML = `
< div class = "page" >
< div class = "page-head" >
< div class = "page-title" > API Key 與 AI Provider 設定 < / d i v >
< div class = "page-sub" > 所有金鑰會寫入本機專案的 < code > . env < / c o d e > : $ { e s c a p e H t m l ( e n v S e t t i n g s . e n v P a t h | | ' . e n v ' ) } 。 金 鑰 欄 位 留 空 代 表 保 留 原 值 ; 模 型 與 預 設 p r o v i d e r 會 直 接 更 新 。 < / d i v >
< / d i v >
< section class = "ai-provider-card env-provider-card" >
< div class = "ai-provider-head" >
< div > < b > 市場資料 < / b > < s p a n > 目 前 總 經 與 日 曆 使 用 F R E D A P I k e y 。 儲 存 後 本 次 伺 服 器 程 序 會 立 即 使 用 新 值 ; 若 你 改 了 P O R T 或 T T L 類 設 定 , 仍 建 議 重 啟 。 < / s p a n > < / d i v >
< / d i v >
< div class = "form-grid" >
< div class = "field full" > < label > FRED API Key < / l a b e l > < i n p u t t y p e = " p a s s w o r d " d a t a - e n v - k e y = " F R E D _ A P I _ K E Y " p l a c e h o l d e r = " $ { e n v F i e l d ( e n v S e t t i n g s , ' F R E D _ A P I _ K E Y ' ) . h a s V a l u e ? ' 已 設 定 , 留 空 保 留 原 值 ' : ' 貼 上 F R E D A P I k e y ' } " > < / d i v >
< / d i v >
< div class = "ai-provider-foot" > < span > 狀態 : $ { escapeHtml ( envField ( envSettings , 'FRED_API_KEY' ) . masked || '未設定' ) } < / s p a n > < / d i v >
< / s e c t i o n >
< 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 > < s p a n > $ { e s c a p e H t m l ( m e t a . h i n t ) } < / s p a n > < / d i v >
< label class = "ai-default" > < input type = "radio" name = "aiActiveProvider" value = "${id}" $ { settings . active === id ? 'checked' : '' } > 預設 < / l a b e l >
< / d i v >
< div class = "form-grid" >
< div class = "field full" > < label > API Key < / l a b e l > < i n p u t t y p e = " p a s s w o r d " d a t a - e n v - k e y = " $ { k e y N a m e } " p l a c e h o l d e r = " $ { k e y F i e l d . h a s V a l u e ? ' 已 設 定 , 留 空 保 留 原 值 ' : ` 貼 上 $ { m e t a . l a b e l } A P I k e y ` } " > < / d i v >
< div class = "field full" > < label > Model < / l a b e l > < d i v c l a s s = " a i - m o d e l - r o w " > < i n p u t t y p e = " t e x t " d a t a - e n v - k e y = " $ { m o d e l N a m e } " d a t a - a i - m o d e l - i n p u t = " $ { i d } " l i s t = " $ { l i s t I d } " v a l u e = " $ { e s c a p e H t m l ( p . m o d e l | | ' ' ) } " p l a c e h o l d e r = " 先 抓 取 此 p r o v i d e r 可 用 模 型 " > < b u t t o n t y p e = " b u t t o n " c l a s s = " b t n g h o s t s m " d a t a - a i - m o d e l s = " $ { i d } " > 抓 取 模 型 < / b u t t o n > < / d i v > < d a t a l i s t i d = " $ { l i s t I d } " > < / d a t a l i s t > < / d i v >
< / d i v >
< div class = "ai-provider-foot" > < span > 狀態 : $ { escapeHtml ( keyField . masked || '未設定' ) } < / s p a n > < b u t t o n c l a s s = " b t n g h o s t s m " d a t a - a i - t e s t = " $ { i d } " > 測 試 連 線 < / b u t t o n > < / d i v >
< / s e c t i o n > ` ;
} ) . join ( '' ) }
< / d i v >
< div class = "form-actions" > < button class = "btn" id = "saveAISettings" > 儲存設定 < / b u t t o n > < / d i v >
< div id = "aiSettingsMsg" class = "ai-settings-msg" > < / d i v >
< / d i v > ` ;
$ ( '#saveAISettings' ) . addEventListener ( 'click' , async ( ) => {
try {
await saveEnvSettings ( view ) ;
$ ( '#aiSettingsMsg' ) . textContent = '已寫入 .env。金鑰留空的欄位已保留原值。' ;
} catch ( e ) {
$ ( '#aiSettingsMsg' ) . textContent = '儲存失敗:' + ( ( e . data && e . data . message ) || e . message || '' ) ;
}
} ) ;
$$ ( '[data-ai-test]' ) . forEach ( btn => btn . addEventListener ( 'click' , async ( ) => {
try {
$ ( '#aiSettingsMsg' ) . textContent = '先寫入 .env, 接著測試連線…' ;
await saveEnvSettings ( view ) ;
await askAI ( { provider : btn . dataset . aiTest , question : '請用一句話確認你已收到連線測試。' , context : { page : 'settings' , purpose : 'provider connection test' } , target : '#aiSettingsMsg' } ) ;
} catch ( e ) {
$ ( '#aiSettingsMsg' ) . textContent = '測試失敗:' + ( ( e . data && e . data . message ) || e . message || '' ) ;
}
} ) ) ;
$$ ( '[data-ai-models]' ) . forEach ( btn => btn . addEventListener ( 'click' , async ( ) => {
const provider = btn . dataset . aiModels ;
const card = btn . closest ( '.ai-provider-card' ) ;
const input = card ? . querySelector ( ` [data-ai-model-input=" ${ provider } "] ` ) ;
const list = card ? . querySelector ( 'datalist' ) ;
try {
$ ( '#aiSettingsMsg' ) . textContent = '先寫入 .env, 接著向 provider 抓取可用模型…' ;
await saveEnvSettings ( view ) ;
const d = await loadProviderModels ( provider ) ;
const models = d . models || [ ] ;
if ( list ) list . innerHTML = models . map ( m => ` <option value=" ${ escapeHtml ( m . id ) } "></option> ` ) . join ( '' ) ;
if ( input && ! input . value && models [ 0 ] ) input . value = models [ 0 ] . id ;
$ ( '#aiSettingsMsg' ) . textContent = models . length ? ` 已抓到 ${ models . length } 個模型,請從 Model 欄位選擇後儲存。 ` : 'Provider 沒有回傳可用模型。' ;
} catch ( e ) {
$ ( '#aiSettingsMsg' ) . textContent = '抓取模型失敗:' + ( ( e . data && e . data . message ) || e . message || '' ) ;
}
} ) ) ;
}
function initAIWidget ( ) {
const dock = $ ( '#aiDock' ) ;
if ( ! dock ) return ;
dock . innerHTML = `
< button class = "ai-fab" id = "aiFab" > AI < / b u t t o n >
< div class = "ai-panel" id = "aiPanel" hidden >
< div class = "ai-panel-head" > < div > < b > MacroScope AI < / b > < s p a n i d = " a i C o n t e x t L a b e l " > 準 備 中 < / s p a n > < / d i v > < b u t t o n i d = " a i C l o s e " > × < / b u t t o n > < / d i v >
< div class = "ai-provider-row" >
< select id = "aiProviderSelect" > < / s e l e c t >
< select id = "aiModelSelect" > < / s e l e c t >
< button class = "btn ghost sm" id = "aiOpenSettings" > 設定 < / b u t t o n >
< / d i v >
< div class = "ai-chat" id = "aiChatLog" >
< div class = "ai-msg ai-msg-bot" >
< div class = "ai-bubble" > 我會看你目前 focus 的頁面資料 ; 沒有資料時也可以直接聊天 。 < / d i v >
< div class = "ai-msg-meta" > MacroScope AI < / d i v >
< / d i v >
< / d i v >
< div class = "ai-compose" >
< textarea id = "aiQuestion" rows = "1" placeholder = "輸入訊息..." > < / t e x t a r e a >
< button class = "ai-send" id = "aiAskBtn" aria - label = "送出" > ↑ < / b u t t o n >
< / d i v >
< / d i v > ` ;
const refreshProviders = async ( ) => {
try { await loadEnvSettings ( ) ; } catch ( _ ) { }
const s = readAISettings ( ) ;
$ ( '#aiProviderSelect' ) . innerHTML = Object . entries ( AI _PROVIDER _META ) . map ( ( [ id , meta ] ) => {
const p = s . providers ? . [ id ] || { } ;
return ` <option value=" ${ id } " ${ s . active === id ? 'selected' : '' } > ${ escapeHtml ( meta . label ) } ${ p . model ? ` · ${ escapeHtml ( p . model ) } ` : ' · 自動抓取模型' } </option> ` ;
} ) . join ( '' ) ;
await refreshWidgetModels ( $ ( '#aiProviderSelect' ) . value ) ;
} ;
refreshProviders ( ) ;
$ ( '#aiFab' ) . addEventListener ( 'click' , async ( ) => { await refreshProviders ( ) ; $ ( '#aiPanel' ) . hidden = ! $ ( '#aiPanel' ) . hidden ; updateAIContextLabel ( ) ; } ) ;
$ ( '#aiClose' ) . addEventListener ( 'click' , ( ) => { $ ( '#aiPanel' ) . hidden = true ; } ) ;
$ ( '#aiProviderSelect' ) . addEventListener ( 'change' , async ( ) => {
await refreshWidgetModels ( $ ( '#aiProviderSelect' ) . value ) ;
updateAIContextLabel ( ) ;
} ) ;
$ ( '#aiOpenSettings' ) . addEventListener ( 'click' , ( ) => { location . hash = '#/settings' ; $ ( '#aiPanel' ) . hidden = true ; } ) ;
$ ( '#aiAskBtn' ) . addEventListener ( 'click' , ( ) => askAIFromWidget ( ) ) ;
$ ( '#aiQuestion' ) . addEventListener ( 'input' , ( ) => autosizeAIInput ( ) ) ;
$ ( '#aiQuestion' ) . addEventListener ( 'keydown' , e => {
if ( e . key === 'Enter' && ! e . shiftKey ) {
e . preventDefault ( ) ;
askAIFromWidget ( ) ;
}
} ) ;
}
function autosizeAIInput ( ) {
const input = $ ( '#aiQuestion' ) ;
if ( ! input ) return ;
input . style . height = 'auto' ;
input . style . height = Math . min ( input . scrollHeight , 112 ) + 'px' ;
}
function appendAIMessage ( role , html , meta = '' ) {
const log = $ ( '#aiChatLog' ) ;
if ( ! log ) return null ;
const msg = document . createElement ( 'div' ) ;
msg . className = ` ai-msg ${ role === 'user' ? 'ai-msg-user' : 'ai-msg-bot' } ` ;
msg . innerHTML = ` <div class="ai-bubble"> ${ html } </div> ${ meta ? ` <div class="ai-msg-meta"> ${ escapeHtml ( meta ) } </div> ` : '' } ` ;
log . appendChild ( msg ) ;
log . scrollTop = log . scrollHeight ;
return msg ;
}
async function refreshWidgetModels ( provider ) {
const select = $ ( '#aiModelSelect' ) ;
if ( ! select ) return ;
const settings = readAISettings ( ) ;
const current = settings . providers ? . [ provider ] ? . model || '' ;
select . innerHTML = ` <option value=""> ${ current ? ` 使用設定: ${ escapeHtml ( current ) } ` : '自動抓取模型' } </option> ` ;
if ( ! settings . providers ? . [ provider ] ? . hasKey ) {
select . innerHTML = '<option value="">先設定 API key</option>' ;
return ;
}
try {
const models = await getProviderModels ( provider ) ;
const opts = models . map ( m => ` <option value=" ${ escapeHtml ( m . id ) } " ${ m . id === current ? 'selected' : '' } > ${ escapeHtml ( m . id ) } </option> ` ) . join ( '' ) ;
select . innerHTML = ` <option value=""> ${ current ? ` 使用設定: ${ escapeHtml ( current ) } ` : '自動抓取模型' } </option> ${ opts } ` ;
if ( current ) select . value = current ;
} catch ( e ) {
select . innerHTML = ` <option value=""> ${ current ? ` 使用設定: ${ escapeHtml ( current ) } ` : '抓取模型失敗' } </option> ` ;
}
}
function updateAIContextLabel ( ) {
const el = $ ( '#aiContextLabel' ) ;
if ( ! el ) return ;
const labels = { macro : '總經' , calendar : '日曆' , learn : '學習' , stock : '個股' , journal : '復盤' , settings : '設定' } ;
const view = document . body . dataset . view || parseHash ( ) ;
const dataViews = new Set ( [ 'macro' , 'calendar' , 'learn' , 'stock' , 'journal' ] ) ;
const focus = window . _ _AI _FOCUS || { } ;
const focusLabel = focus . label || focus . title || focus . symbol || focus . date || focus . key || '' ;
el . textContent = dataViews . has ( view )
? ` 會附上「 ${ labels [ view ] || '頁面' } 」 ${ focusLabel ? ` 目前焦點: ${ focusLabel } ` : '目前焦點' } `
: '一般聊天,不附頁面資料' ;
}
async function collectAIContext ( ) {
const view = document . body . dataset . view || parseHash ( ) ;
const focus = { ... ( window . _ _AI _FOCUS || { } ) , view } ;
const client = { urlHash : location . hash , visibleText : '' , currentNote : null , personalNotes : [ ] , symbol : '' , subPage : '' } ;
if ( view === 'learn' ) {
client . currentNote = LEARN . currentNote ? {
kind : LEARN . currentNote . kind , id : LEARN . currentNote . id , title : LEARN . currentNote . title ,
summary : LEARN . currentNote . summary ,
} : null ;
client . visibleText = $ ( '#learnContent' ) ? . innerText ? . slice ( 0 , 6000 ) || '' ;
client . personalNotes = readLearnNotes ( ) . slice ( 0 , 8 ) ;
} else if ( view === 'stock' ) {
client . symbol = STOCK . symbol || '' ;
client . subPage = STOCK . sub ;
client . mapAnswers = STOCK . mapAnswers ;
if ( STOCK . sub === 'map' ) client . investMap = STOCK . mapCfg ;
client . visibleText = $ ( '#view-stock' ) ? . innerText ? . slice ( 0 , 5000 ) || '' ;
} else if ( view === 'journal' ) {
client . visibleText = $ ( '#view-journal' ) ? . innerText ? . slice ( 0 , 5000 ) || '' ;
} else if ( view === 'calendar' ) {
client . visibleText = $ ( '#view-calendar' ) ? . innerText ? . slice ( 0 , 8000 ) || '' ;
} else if ( view === 'macro' ) {
client . visibleText = $ ( '#view-macro' ) ? . innerText ? . slice ( 0 , 8000 ) || '' ;
}
return api ( '/api/ai/context' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { view , focus , client , allowFetch : true } ) ,
} ) . catch ( e => ( {
mode : [ 'macro' , 'calendar' , 'learn' , 'stock' , 'journal' ] . includes ( view ) ? 'page' : 'chat' ,
hasPageData : [ 'macro' , 'calendar' , 'learn' , 'stock' , 'journal' ] . includes ( view ) ,
view ,
focus ,
client ,
contextError : ( e . data && e . data . message ) || e . message ,
} ) ) ;
}
async function askAIFromWidget ( ) {
const question = $ ( '#aiQuestion' ) . value . trim ( ) ;
if ( ! question ) return ;
const input = $ ( '#aiQuestion' ) ;
const send = $ ( '#aiAskBtn' ) ;
appendAIMessage ( 'user' , escapeHtml ( question ) , '你' ) ;
input . value = '' ;
autosizeAIInput ( ) ;
if ( send ) send . disabled = true ;
const typing = appendAIMessage ( 'bot' , '<span class="ai-typing"><i></i><i></i><i></i></span>' , '正在回覆' ) ;
try {
const context = await collectAIContext ( ) ;
const provider = $ ( '#aiProviderSelect' ) . value ;
const model = $ ( '#aiModelSelect' ) ? . value || '' ;
const d = await askAI ( { provider , model , question , context } ) ;
if ( typing ) {
typing . querySelector ( '.ai-bubble' ) . innerHTML = renderMarkdown ( d ? . text || '( AI 沒有回傳文字)' ) ;
const meta = typing . querySelector ( '.ai-msg-meta' ) ;
if ( meta ) meta . textContent = ` ${ AI _PROVIDER _META [ provider ] ? . label || provider } ${ d ? . model ? ' · ' + d . model : '' } ` ;
}
} catch ( e ) {
if ( typing ) {
typing . querySelector ( '.ai-bubble' ) . innerHTML = ` <div class="ai-error"> ${ escapeHtml ( ( e . data && e . data . message ) || e . message || 'AI 呼叫失敗' ) } </div> ` ;
const meta = typing . querySelector ( '.ai-msg-meta' ) ;
if ( meta ) meta . textContent = '傳送失敗' ;
}
} finally {
if ( send ) send . disabled = false ;
$ ( '#aiChatLog' ) . scrollTop = $ ( '#aiChatLog' ) . scrollHeight ;
}
}
async function askAI ( { provider , model , question , context , target } ) {
const out = target ? $ ( target ) : null ;
const settings = readAISettings ( ) ;
const p = settings . providers ? . [ provider ] || { } ;
try {
const d = await api ( '/api/ai/chat' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { provider , model : model || p . model || '' , question , context } ) ,
} ) ;
if ( out ) out . innerHTML = renderMarkdown ( d . text || '( AI 沒有回傳文字)' ) ;
return d ;
} catch ( e ) {
if ( out ) out . innerHTML = ` <div class="ai-error"> ${ escapeHtml ( ( e . data && e . data . message ) || e . message || 'AI 呼叫失敗' ) } </div> ` ;
if ( ! out ) throw e ;
return null ;
}
}
2026-06-03 09:21:58 +00:00
// ═══════════════════════════════════════════════════════════
// 知識庫資料
// ═══════════════════════════════════════════════════════════
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' ) ;
2026-06-03 09:33:23 +00:00
LEARN . currentNote = note ;
const kind = note . kind || LEARN . noteKind ;
2026-06-04 01:35:37 +00:00
setAIFocus ( { view : 'learn' , type : 'learning-note' , kind , id : note . id || '' , title : note . title || note . id || '' } ) ;
2026-06-03 09:33:23 +00:00
const center = ( kind && note . id ) ? ` ${ kind } : ${ note . id } ` : '' ;
2026-06-03 16:42:07 +00:00
content . innerHTML = LearnUI . renderArticle ( note , {
escapeHtml ,
renderMarkdown ,
linkMap : KB . linkMap ,
principles : KB . principles ,
} ) ;
2026-06-03 09:21:58 +00:00
bindWlinks ( content ) ;
2026-06-03 16:42:07 +00:00
LearnUI . bindArticle ( content , {
onBack : ( ) => showSection ( LEARN . lastSection || 'overview' ) ,
onGraph : ( ) => showGraph ( { center , depth : 2 } ) ,
openNote ,
goView ( v ) { location . hash = v === 'macro' ? '#/' : '#/' + v ; } ,
} ) ;
2026-06-03 09:33:23 +00:00
renderMermaid ( content ) ;
2026-06-03 16:42:07 +00:00
bindTermTips ( content ) ;
2026-06-03 09:21:58 +00:00
window . scrollTo ( { top : 0 } ) ;
}
2026-06-03 16:42:07 +00:00
// ═══════════════════════════════════════════════════════════
// 重大事件日曆(網格 · 可增減追蹤 · 今天起兩個月)
// ═══════════════════════════════════════════════════════════
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 ( '代號格式不正確( 1– 12 字,可用英數與 . -) ' , '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" > 市場日曆 < / d i v >
< div class = "page-title" > 市場日曆 : 兩個月內會動到股市的大事 < / d i v >
< div class = "page-sub" > 不只財報 — — 還有美國通膨 、 就業 、 Fed 開會 、 選擇權結算 、 各國央行 、 美股休市 。 點日期看詳情 , 標題旁的 ? 有白話說明 。 < / d i v >
< / d i v >
< button type = "button" class = "btn ghost" id = "calendarRefresh" > 更新日曆 < / b u t t o n >
< / d i v >
< section class = "calendar-watch-panel" aria - label = "追蹤財報" >
< div class = "calendar-watch-head" >
< div > < b > 追蹤財報 < / b > < s p a n > 自 行 新 增 或 刪 除 , 沒 有 預 設 清 單 < / s p a n > < / d i v >
< 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" > 加入 < / b u t t o n >
< / f o r m >
< / d i v >
< div class = "watch-chip-row" id = "calendarWatchlist" > < / d i v >
< div class = "calendar-msg" id = "calendarMsg" hidden > < / d i v >
< / s e c t i o n >
< section class = "cal-intro" aria - label = "怎麼看日曆" >
< b > 怎麼看 ? < / b >
< p > 格子裡是當天大事的 < strong > 簡稱 < / s t r o n g > ; 若 看 到 < s p a n c l a s s = " c a l - m o r e - i n l i n e " > + 3 < / s p a n > 代 表 還 有 更 多 , < s t r o n g > 點 日 期 < / s t r o n g > 可 一 次 看 完 。 每 項 事 件 標 題 旁 有 < s p a n c l a s s = " i n f o - b t n d e m o " > ? < / s p a n > , 滑 鼠 移 上 去 有 白 話 解 釋 ( 不 用 懂 A D P 、 E C B 是 什 麼 也 能 看 ) 。 < / p >
< ul >
< li > < strong > 總經數據 < / s t r o n g > : 通 膨 C P I 、 非 農 就 業 、 零 售 、 房 市 … 公 布 時 常 讓 大 盤 晃 一 下 < / l i >
< li > < strong > 聯準會 / 各國央行 < / s t r o n g > : 決 定 利 率 , 影 響 借 錢 成 本 與 股 價 估 值 < / l i >
< li > < strong > 四巫日 / 選擇權到期 < / s t r o n g > : 衍 生 品 結 算 , 成 交 量 與 波 動 常 變 大 < / l i >
< li > < strong > 財報 < / s t r o n g > : 只 有 你 自 行 加 入 的 股 票 才 會 顯 示 < / l i >
< / u l >
< / s e c t i o n >
< div id = "calendarBody" > < / d i v >
< div id = "calendarModal" class = "cal-modal" hidden role = "dialog" aria - modal = "true" aria - labelledby = "calendarModalTitle" >
< div class = "cal-modal-backdrop" > < / d i v >
< div class = "cal-modal-panel" id = "calendarModalPanel" > < / d i v >
< / d i v >
< / d i v > ` ;
$ ( '#calendarWatchForm' ) . addEventListener ( 'submit' , e => { e . preventDefault ( ) ; tryAddCalendarSymbol ( ) ; } ) ;
$ ( '#calendarRefresh' ) . addEventListener ( 'click' , ( ) => refreshCalendarData ( true ) ) ;
bindCalendarViewEvents ( view ) ;
renderWatchlistChips ( ) ;
bindTermTips ( view ) ;
syncCalendarWatchlistFromServer ( ) . finally ( ( ) => refreshCalendarData ( false ) ) ;
}
function calendarEventLabel ( ev ) {
if ( ev . symbol ) return ev . symbol ;
const t = ev . title || '' ;
const rules = [
[ /FOMC.*點陣|SEP/i , 'FOMC+點陣' ] ,
[ /FOMC|聯準會.*利率/i , 'FOMC決議' ] ,
[ /CPI|消費者物價/i , 'CPI通膨' ] ,
[ /非農|Employment Situation/i , '非農就業' ] ,
[ /PCE|個人收入/i , 'PCE通膨' ] ,
[ /GDP|國內生產/i , 'GDP' ] ,
[ /PPI|生產者物價/i , 'PPI' ] ,
[ /JOLTS|職缺/i , 'JOLTS職缺' ] ,
[ /四巫/i , '四巫日' ] ,
[ /月選擇權/i , '選擇權到期' ] ,
[ /美股休市/i , '美股休市' ] ,
[ /歐洲央行|ECB/i , '歐央行' ] ,
[ /日本央行/i , '日央行' ] ,
[ /英央行|MPC/i , '英央行' ] ,
[ /Jackson Hole/i , '央行年會' ] ,
[ /ADP/i , 'ADP就業' ] ,
[ /初領失業/i , '失業救濟' ] ,
[ /密西根/i , '消費信心' ] ,
[ /零售銷售/i , '零售銷售' ] ,
[ /工業生產/i , '工業生產' ] ,
[ /新屋開工|成屋|營建許可/i , '房市數據' ] ,
[ /耐久財/i , '耐久財' ] ,
[ /消費信貸/i , '消費信貸' ] ,
[ /費城 Fed|製造業指數/i , '製造業調查' ] ,
[ /非製造業/i , '服務業調查' ] ,
[ /就業成本|ECI/i , '就業成本' ] ,
[ /生產力/i , '生產力' ] ,
[ /進出口物價/i , '進出口價' ] ,
[ /實質薪資/i , '實質薪資' ] ,
[ /國際貿易/i , '貿易數據' ] ,
[ /財報/i , '財報' ] ,
] ;
for ( const [ re , label ] of rules ) if ( re . test ( t ) ) return label ;
return t . length > 8 ? t . slice ( 0 , 7 ) + '…' : t ;
}
function calendarEventChip ( ev ) {
const cat = ev . category || 'macro' ;
const title = ` ${ ev . title || '' } ${ ev . time ? ' · ' + ev . time : '' } ${ ev . note ? '\n' + ev . note : '' } ` ;
return ` <button type="button" class="cal-ev ${ escapeHtml ( ev . impact || 'low' ) } cat- ${ escapeHtml ( cat ) } " title=" ${ escapeHtml ( title ) } " data-ev-key=" ${ escapeHtml ( ev . date + '|' + ( ev . symbol || '' ) + '|' + ( ev . title || '' ) ) } "> ${ escapeHtml ( calendarEventLabel ( ev ) ) } </button> ` ;
}
function calendarDayDetailHTML ( date , events ) {
if ( ! date ) return '' ;
const d = new Date ( date + 'T00:00:00' ) ;
const label = isNaN ( d ) ? date : d . toLocaleDateString ( 'zh-TW' , { month : 'long' , day : 'numeric' , weekday : 'long' } ) ;
if ( ! events . length ) {
return ` <div class="cal-day-detail">
< div class = "cal-day-detail-head" > < b id = "calendarModalTitle" > $ { escapeHtml ( label ) } < / b > < b u t t o n t y p e = " b u t t o n " c l a s s = " c a l - d a y - c l o s e " a r i a - l a b e l = " 關 閉 " > ✕ < / b u t t o n > < / d i v >
< div class = "empty-state" style = "padding:18px 0" > 這天沒有事件 。 < / d i v >
< / d i v > ` ;
}
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 } < / s p a n > < b > $ { e s c a p e H t m l ( e v . t i t l e ) } < / b > $ { t i p } $ { e v . s y m b o l ? ` < s p a n c l a s s = " e v e n t - s y m b o l " > $ { e s c a p e H t m l ( e v . s y m b o l ) } < / s p a n > ` : ' ' } < / d i v >
< div class = "cal-detail-note" > $ { escapeHtml ( ev . note || '—' ) } $ { ev . time ? ' · ' + escapeHtml ( ev . time ) : '' } < / d i v >
< / d i v >
< div class = "cal-detail-meta" > $ { escapeHtml ( cat ) } < small > $ { escapeHtml ( ev . source || '' ) } < / s m a l l > < / d i v >
< / d i v > ` ;
} ) . join ( '' ) ;
return ` <div class="cal-day-detail">
< div class = "cal-day-detail-head" > < b id = "calendarModalTitle" > $ { escapeHtml ( label ) } < / b > < s p a n > $ { e v e n t s . l e n g t h } 項 事 件 < / s p a n > < b u t t o n t y p e = " b u t t o n " c l a s s = " c a l - d a y - c l o s e " a r i a - l a b e l = " 關 閉 " > ✕ < / b u t t o n > < / d i v >
< div class = "cal-detail-list" > $ { rows } < / d i v >
< / d i v > ` ;
}
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 ) ;
2026-06-04 01:35:37 +00:00
setAIFocus ( { type : 'calendar-day' , date , label : ` ${ date } · ${ events . length } 項事件 ` , eventCount : events . length } ) ;
2026-06-03 16:42:07 +00:00
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 } < / s p a n > $ { d a y E v e n t s . l e n g t h ? ` < s p a n c l a s s = " c a l - c o u n t " > $ { d a y E v e n t s . l e n g t h } < / s p a n > ` : ' ' } < / d i v >
< div class = "cal-events" > $ { evHtml || '<span class="cal-quiet">—</span>' } < / d i v >
< / d i v > ` ;
}
const title = m . toLocaleDateString ( 'zh-TW' , { year : 'numeric' , month : 'long' } ) ;
return ` <section class="cal-month">
< div class = "cal-month-head" > < h3 > $ { escapeHtml ( title ) } < / h 3 > < s p a n > $ { i d x = = = 0 ? ' 從 今 天 起 ' : ' ' } < / s p a n > < / d i v >
< div class = "cal-weekdays" > $ { CAL _WEEKDAYS . map ( w => ` <span> ${ w } </span> ` ) . join ( '' ) } < / d i v >
< div class = "cal-grid" > $ { cells } < / d i v >
< / s e c t i o n > ` ;
} ) . 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 > < s p a n > 區 間 內 事 件 ( 自 動 ) < / s p a n > < / d i v >
< div class = "calendar-stat" > < b > $ { escapeHtml ( range . start ) } < / b > < s p a n > 起 算 日 ( 今 天 ) < / s p a n > < / d i v >
< div class = "calendar-stat" > < b > $ { escapeHtml ( range . end ) } < / b > < s p a n > 結 束 日 ( 約 兩 個 月 ) < / s p a n > < / d i v >
< div class = "calendar-stat" > < b > $ { symbols . length } < / b > < s p a n > 你 追 蹤 的 財 報 < / s p a n > < / d i v >
< / d i v >
< div class = "cal-legend" >
< span > < i class = "leg high" > < / i > 高 衝 擊 < / s p a n >
< span > < i class = "leg medium" > < / i > 中 < / s p a n >
< span > < i class = "leg fed" > < / i > 聯 準 會 < / s p a n >
< span > < i class = "leg deriv" > < /i>四巫 / 選擇權 < / s p a n >
< span > < i class = "leg cb" > < / i > 全 球 央 行 < / s p a n >
< span > < i class = "leg earn" > < / i > 財 報 ( 自 訂 ) < / s p a n >
< span class = "cal-legend-note" > 點日期 → 彈窗看完整列表與 ? 說明 < / s p a n >
< / d i v >
< 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" > < / d i v > 正 在 載 入 日 曆 … < / d i v >
< div id = "calendarGridHost" > < / d i v >
< div class = "metric-source-note" id = "calendarSourceNote" > < / d i v > ` ;
} 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> ` ;
}
}
2026-06-03 09:21:58 +00:00
// ═══════════════════════════════════════════════════════════
// 學習教材視圖
// ═══════════════════════════════════════════════════════════
2026-06-04 01:35:37 +00:00
const LEARN = { lastSection : 'overview' , graphFilter : 'curriculum' , graphView : 'map' , currentNote : null , noteKind : null } ;
2026-06-03 09:33:23 +00:00
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" >
2026-06-03 16:42:07 +00:00
< div class = "page-head learn-hero" >
< div >
< div class = "eyebrow" > 學習路徑 < / d i v >
< div class = "page-title" > 照問題學 , 不要硬背名詞 < / d i v >
< div class = "page-sub" > 從 「 現在大環境如何 」 「 這家公司值不值得研究 」 「 這筆交易哪裡做錯 」 三條路開始 。 每步都有連結與可點的工具 , 案例會標出可重複使用的原則 。 < / d i v >
< / d i v >
< div class = "learn-stats" >
< div > < b > $ { ( KB . cases || [ ] ) . length } < / b > < s p a n > 案 例 講 解 < / s p a n > < / d i v >
< div > < b > $ { ( KB . principles || [ ] ) . length } < / b > < s p a n > 投 資 原 則 < / s p a n > < / d i v >
< div > < b > $ { c . terms || 0 } < / b > < s p a n > 名 詞 速 查 < / s p a n > < / d i v >
< / d i v >
2026-06-03 09:21:58 +00:00
< / d i v >
< div class = "learn-layout" >
< div class = "learn-side" id = "learnSide" >
< div class = "side-group" > 課程 < / d i v >
2026-06-03 16:42:07 +00:00
< a data - section = "overview" > 今日入口 < / a >
2026-06-04 01:35:37 +00:00
< a data - section = "lab" > 學習實驗室 < / a >
2026-06-03 16:42:07 +00:00
< a data - section = "principleMap" > 原則地圖 < / a >
2026-06-03 09:21:58 +00:00
< a data - section = "quiz" > 練習題庫 < / a >
< div class = "side-group" > 內容 < / d i v >
< a data - section = "categories" > 學習分類 < span class = "count" > $ { ( KB . categories || [ ] ) . length } < / s p a n > < / a >
< a data - section = "cases" > 案例講解 < span class = "count" > $ { ( KB . cases || [ ] ) . length } < / s p a n > < / a >
2026-06-03 16:42:07 +00:00
< a data - section = "principles" > 投資原則 < span class = "count" > $ { ( KB . principles || [ ] ) . length } < / s p a n > < / a >
2026-06-03 09:33:23 +00:00
< div class = "side-group" > 視覺化 < / d i v >
< a data - section = "graph" > 🔗 知識圖譜 < / a >
2026-06-03 09:21:58 +00:00
< div class = "side-group" > 速查 < / d i v >
< a data - section = "terms" > 名詞 < span class = "count" > $ { c . terms || 0 } < / s p a n > < / a >
< a data - section = "companies" > 公司 < span class = "count" > $ { c . companies || 0 } < / s p a n > < / a >
< a data - section = "episodes" > 單集 < span class = "count" > $ { c . episodes || 0 } < / s p a n > < / a >
< / d i v >
< div class = "learn-content" id = "learnContent" > < / d i v >
< / d i v >
< / d i v > ` ;
$$ ( '#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 16:42:07 +00:00
if ( section === 'overview' ) return renderLearnHome ( ) ;
2026-06-04 01:35:37 +00:00
if ( section === 'lab' ) return renderLearnLab ( ) ;
2026-06-03 09:33:23 +00:00
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 ) ;
}
2026-06-03 16:42:07 +00:00
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 } ) ;
}
2026-06-04 01:35:37 +00:00
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 < / d i v >
< h2 > 把學到的東西連起來 < / h 2 >
< p > 先做配對 , 再到個股工具套用 , 最後把自己的判斷寫成筆記 。 這裡不是要背答案 , 是訓練你看到資料時知道該問哪一組問題 。 < / p >
< / d i v >
< div class = "lab-score" > < b id = "labScore" > 0 / $ { pairs . length } < / b > < s p a n > 已 連 對 < / s p a n > < / d i v >
< / s e c t i o n >
< section class = "lab-game" >
< div class = "metric-section-head" > < h3 > 概念配對 < / h 3 > < s p a n > 左 邊 選 概 念 , 右 邊 選 它 真 正 要 解 決 的 判 斷 問 題 < / s p a n > < / d i v >
< 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 ( '' ) } < / d i v >
< div > $ { pairs . map ( ( p , i ) => ` <button class="match-card answer" data-pair=" ${ i } "> ${ escapeHtml ( p [ 1 ] ) } </button> ` ) . join ( '' ) } < / d i v >
< / d i v >
< div class = "lab-actions" > < button class = "btn ghost sm" id = "labReset" > 重玩 < / b u t t o n > < b u t t o n c l a s s = " b t n s m " i d = " l a b A p p l y " > 拿 去 個 股 工 具 用 < / b u t t o n > < / d i v >
< / s e c t i o n >
< section class = "lab-notes" >
< div class = "metric-section-head" > < h3 > 我的學習筆記 < / h 3 > < s p a n > 文 章 頁 的 筆 記 會 收 在 這 裡 , 方 便 回 來 複 習 < / s p a n > < / d i v >
< 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 > < s p a n > $ { n e w D a t e ( n . u p d a t e d A t ) . t o L o c a l e S t r i n g ( ' z h - T W ' , { m o n t h : ' n u m e r i c ' , d a y : ' n u m e r i c ' , h o u r : ' 2 - d i g i t ' , m i n u t e : ' 2 - d i g i t ' } ) } < / s p a n >
< p > $ { escapeHtml ( n . text . slice ( 0 , 120 ) ) } < / p >
< / b u t t o n > ` ) . j o i n ( ' ' ) : ' < d i v c l a s s = " e m p t y - s t a t e " > 還 沒 有 筆 記 。 打 開 任 一 篇 學 習 文 章 , 在 「 我 的 筆 記 」 裡 寫 下 你 的 判 斷 。 < / d i v > ' } < / d i v >
< / s e c t i o n >
< / d i v > ` ;
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 ) ) ) ;
}
2026-06-03 09:21:58 +00:00
function renderCardList ( title , items , kind ) {
const content = $ ( '#learnContent' ) ;
2026-06-03 16:42:07 +00:00
let cards ;
if ( kind === 'case' ) {
cards = LearnUI . renderCaseCards ( items , escapeHtml , { linkMap : KB . linkMap , principles : KB . principles } ) ;
} else {
cards = ( items || [ ] ) . map ( it => `
2026-06-03 09:21:58 +00:00
< div class = "module-card" data - id = "${escapeHtml(it.id)}" >
2026-06-03 16:42:07 +00:00
< div class = "mod-name" > $ { escapeHtml ( deEmmyText ( it . title ) ) } < / d i v >
$ { it . summary ? ` <div class="mod-meta"> ${ escapeHtml ( deEmmyText ( it . summary ) ) } </div> ` : '' }
2026-06-03 09:21:58 +00:00
< / d i v > ` ) . j o i n ( ' ' ) ;
2026-06-03 16:42:07 +00:00
}
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> ` ;
2026-06-03 09:21:58 +00:00
$$ ( '.module-card' , content ) . forEach ( el => el . addEventListener ( 'click' , ( ) => openNote ( kind , el . dataset . id ) ) ) ;
window . scrollTo ( { top : 0 } ) ;
}
function renderPrincipleList ( ) {
const content = $ ( '#learnContent' ) ;
2026-06-03 16:42:07 +00:00
content . innerHTML = ` <div class="page-title" style="font-size:1.1rem;margin-bottom:6px">投資原則庫</div>
< div class = "list-meta" > 共 $ { ( KB . principles || [ ] ) . length } 條 。 已依主題分群 ; 點開可讀白話說明 。 完整索引見 「 原則地圖 」 。 < / d i v >
< div class = "principle-groups" > $ { LearnUI . renderPrincipleGroups ( KB . principles , escapeHtml ) } < / d i v > ` ;
$$ ( '.pg-card' , content ) . forEach ( el => el . addEventListener ( 'click' , ( ) => openNote ( 'principle' , el . dataset . id ) ) ) ;
2026-06-03 09:21:58 +00:00
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 } < / d i v >
< div class = "search-box" > < input type = "text" id = "glossSearch" placeholder = "搜尋${title.replace('速查', '')}…(中英別名皆可)" > < / d i v >
< div class = "list-meta" id = "glossCount" > < / d i v >
< div class = "glossary-grid" id = "glossGrid" > < / d i v > ` ;
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 ) } < / d i v >
$ { x . sub ? ` <div class="gi-sub"> ${ escapeHtml ( x . sub ) } </div> ` : '' }
< / d i v > ` ) . j o i n ( ' ' ) | | ' < d i v c l a s s = " e m p t y - s t a t e " > 找 不 到 符 合 的 項 目 。 < / d i v > ' ;
$$ ( '.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 = [
2026-06-03 16:42:07 +00:00
[ 'category' , '分類' , '#0071e3' ] , [ 'case' , '案例' , '#34c759' ] , [ 'principle' , '原則' , '#af52de' ] ,
2026-06-03 09:33:23 +00:00
[ '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 ;
2026-06-04 01:35:37 +00:00
LEARN . graphView = opts . view || LEARN . graphView || 'map' ;
2026-06-03 09:33:23 +00:00
content . innerHTML = `
2026-06-04 01:35:37 +00:00
< div class = "page-title" style = "font-size:1.2rem;margin-bottom:8px" > 知識地圖 < / d i v >
< div class = "page-sub" style = "margin-bottom:16px" > 不再把所有連線擠成一團 。 先按類型分群 , 再點節點看它真正連到哪些案例 、 原則 、 名詞與公司 。 < / d i v >
2026-06-03 09:33:23 +00:00
< div class = "graph-panel" >
2026-06-04 01:35:37 +00:00
< div class = "graph-toolbar" > < div id = "graphFilterChips" class = "chip-row" > < / d i v > < d i v i d = " g r a p h V i e w C h i p s " c l a s s = " c h i p - r o w " > < / d i v > < / d i v >
< div id = "graphCanvas" class = "graph-canvas knowledge-map" > < div class = "empty-state" > 載入知識地圖中 … < / d i v > < / d i v >
2026-06-03 09:33:23 +00:00
< div class = "graph-foot" > < div class = "graph-legend" id = "graphLegend" > < / d i v > < s p a n i d = " g r a p h S t a t " > < / s p a n > < / d i v >
< / d i v > ` ;
mountChips ( $ ( '#graphFilterChips' ) , GRAPH _KINDS . map ( g => ( { id : g . id , label : g . label } ) ) , filter , v => showGraph ( { filter : v } ) ) ;
2026-06-04 01:35:37 +00:00
mountChips ( $ ( '#graphViewChips' ) , [
{ id : 'map' , label : '分群地圖' } ,
{ id : 'list' , label : '關係清單' } ,
] , LEARN . graphView , v => showGraph ( { filter , center , depth , view : v } ) , { sm : true } ) ;
2026-06-03 09:33:23 +00:00
$ ( '#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 ; }
2026-06-04 01:35:37 +00:00
renderKnowledgeMap ( el , data , { center , view : LEARN . graphView } ) ;
2026-06-03 09:33:23 +00:00
$ ( '#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
}
2026-06-04 01:35:37 +00:00
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 ) } < / s p a n >
< / b u t t o n > ` ;
} ) . join ( '' ) } < / d i v > ` ;
} 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 > < s p a n > $ { g r o u p s [ k ] . l e n g t h } < / s p a n > < / d i v >
< 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 ) } < / s p a n > < s m a l l > $ { n . d e g r e e } 連 結 < / s m a l l >
< / b u t t o n > ` ) . j o i n ( ' ' ) } < / d i v >
< / s e c t i o n > ` ) . j o i n ( ' ' ) }
< / d i v > < a s i d e c l a s s = " k g - f o c u s " i d = " k g F o c u s " > < d i v c l a s s = " e m p t y - s t a t e " > 點 一 個 節 點 , 看 它 的 上 下 游 關 係 。 < / d i v > < / a s i d e > ` ;
}
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 > < s p a n > $ { e s c a p e H t m l ( g r a p h K i n d L a b e l ( n . k i n d | | g r a p h K i n d ( n . i d ) ) ) } < / s p a n > < / d i v >
< div class = "kg-focus-actions" > < button class = "btn sm" data - open - node = "${escapeHtml(n.id)}" > 打開筆記 < / b u t t o n > < / d i v >
< 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 ) ) ) } < / s p a n > < b > $ { e s c a p e H t m l ( x . l a b e l | | x . t i t l e | | x . i d ) } < / b >
< / b u t t o n > ` ) . j o i n ( ' ' ) : ' < d i v c l a s s = " e m p t y - s t a t e " > 這 個 節 點 暫 時 沒 有 鄰 近 連 結 。 < / d i v > ' } < / d i v > ` ;
$$ ( '.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 ) ;
}
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" / >
< / s v g > < d i v c l a s s = " c h a r t - h o v e r " i d = " $ { u i d } h " > < / d i v > < / d i v > ` ;
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' ; } ) ;
}
// ═══════════════════════════════════════════════════════════
// 個股工具視圖(共用代號:價格走勢 / 財報健檢 / 投資地圖 / 回測)
// ═══════════════════════════════════════════════════════════
2026-06-03 16:42:07 +00:00
const STOCK = { symbol : '' , sub : 'metrics' , priceRange : '1y' , rendered : { } , mapAnswers : { } , mapCfg : null , fundamentals : { } } ;
const SUBS = [ 'metrics' , 'price' , 'finbox' , 'map' , 'backtest' ] ;
2026-06-03 09:21:58 +00:00
function initStock ( ) {
const view = $ ( '#view-stock' ) ;
view . innerHTML = `
< div class = "page" >
< div class = "page-head" >
< div class = "page-title" > 📈 個股工具 < / d i v >
2026-06-03 16:42:07 +00:00
< div class = "page-sub" > 輸入一檔股票代號 , 所有工具一次到位 : 價格走勢 、 < span class = "wlink" data - link = "學習分類/財報基本功" > 財報 < / s p a n > 健 檢 、 用 < b > 六 層 漏 斗 投 資 地 圖 < / b > 判 斷 該 不 該 進 場 、 以 及 策 略 < b > 回 測 < / b > 。 < / d i v >
2026-06-03 09:21:58 +00:00
< / d i v >
< div class = "finbox-search" >
< input type = "text" id = "stkSym" placeholder = "輸入代號,例如 NVDA( 美股最完整) " autocomplete = "off" >
< button id = "stkGo" > 查詢 < / b u t t o n >
< / d i v >
< div class = "finbox-examples" > 範例 : < b data - sym = "NVDA" > NVDA < / b > < b d a t a - s y m = " A M D " > A M D < / b > < b d a t a - s y m = " M S F T " > M S F T < / b > < b d a t a - s y m = " A V G O " > A V G O < / b > < b d a t a - s y m = " A A P L " > A A P L < / b > < / d i v >
2026-06-04 01:35:37 +00:00
< div id = "stockLearnBridge" > < / d i v >
2026-06-03 09:21:58 +00:00
< div class = "sub-tabs" id = "stkSub" >
2026-06-03 16:42:07 +00:00
< a data - sub = "metrics" class = "active" > 指標面板 < / a >
< a data - sub = "price" > 價格走勢 < / a >
2026-06-03 09:21:58 +00:00
< a data - sub = "finbox" > 財報健檢 < / a >
< a data - sub = "map" > 投資地圖 < / a >
< a data - sub = "backtest" > 回測 < / a >
< / d i v >
< div id = "stkBody" >
2026-06-03 16:42:07 +00:00
< div class = "stk-pane" id = "pane-metrics" > < / d i v >
2026-06-03 09:21:58 +00:00
< div class = "stk-pane" id = "pane-price" > < / d i v >
< div class = "stk-pane" id = "pane-finbox" hidden > < / d i v >
< div class = "stk-pane" id = "pane-map" hidden > < / d i v >
< div class = "stk-pane" id = "pane-backtest" hidden > < / d i v >
< / d i v >
< / d i v > ` ;
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 ) ) ) ;
2026-06-04 01:35:37 +00:00
renderStockLearningBridge ( ) ;
2026-06-03 16:42:07 +00:00
setSub ( 'metrics' ) ;
2026-06-03 09:21:58 +00:00
}
2026-06-04 01:35:37 +00:00
function renderStockLearningBridge ( ) {
const box = $ ( '#stockLearnBridge' ) ;
if ( ! box ) return ;
box . innerHTML = `
< div class = "stock-learn-bridge" >
< div class = "slb-head" > < b > 個股研究任務 < / b > < s p a n > 每 看 一 檔 股 票 , 都 照 這 條 路 把 學 習 連 回 工 具 < / s p a n > < / d i v >
< div class = "slb-steps" >
< button data - learn - kind = "category" data - learn - id = "財報基本功" > < span > 1 < / s p a n > < b > 先 學 財 報 < / b > < s m a l l > 營 收 、 毛 利 、 E P S < / s m a l l > < / b u t t o n >
< button data - sub - target = "finbox" > < span > 2 < / s p a n > < b > 套 到 健 檢 < / b > < s m a l l > 看 紅 黃 綠 燈 < / s m a l l > < / b u t t o n >
< button data - learn - kind = "category" data - learn - id = "護城河與商業模式" > < span > 3 < / s p a n > < b > 理 解 生 意 < / b > < s m a l l > 定 價 權 與 產 業 位 置 < / s m a l l > < / b u t t o n >
< button data - sub - target = "map" > < span > 4 < / s p a n > < b > 六 層 漏 斗 < / b > < s m a l l > 回 答 能 不 能 進 場 < / s m a l l > < / b u t t o n >
< button data - sub - target = "backtest" > < span > 5 < / s p a n > < b > 策 略 驗 證 < / b > < s m a l l > 用 歷 史 測 規 則 < / s m a l l > < / b u t t o n >
< / d i v >
< / d i v > ` ;
$$ ( '[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 ) ) ) ;
}
2026-06-03 09:21:58 +00:00
function setStockSymbol ( sym ) {
sym = ( sym || '' ) . trim ( ) . toUpperCase ( ) ;
if ( ! sym ) return ;
STOCK . symbol = sym ;
2026-06-04 01:35:37 +00:00
setAIFocus ( { type : 'stock' , symbol : sym , subPage : STOCK . sub , label : ` ${ sym } · ${ STOCK . sub } ` } ) ;
2026-06-03 09:21:58 +00:00
STOCK . rendered = { } ; // 換股票 → 各分頁重抓
2026-06-03 16:42:07 +00:00
STOCK . fundamentals = { } ;
2026-06-03 09:21:58 +00:00
$ ( '#stkSym' ) . value = sym ;
2026-06-03 16:42:07 +00:00
if ( STOCK . sub === 'map' ) setSub ( 'metrics' ) ; // 輸入代號後預設先看指標面板
2026-06-03 09:21:58 +00:00
else renderSub ( STOCK . sub ) ;
}
function setSub ( sub ) {
if ( ! SUBS . includes ( sub ) ) sub = 'price' ;
STOCK . sub = sub ;
2026-06-04 01:35:37 +00:00
if ( STOCK . symbol ) setAIFocus ( { type : 'stock' , symbol : STOCK . symbol , subPage : sub , label : ` ${ STOCK . symbol } · ${ sub } ` } ) ;
2026-06-03 09:21:58 +00:00
$$ ( '#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 ) {
2026-06-03 16:42:07 +00:00
if ( sub === 'metrics' ) return renderMetricsPane ( ) ;
2026-06-03 09:21:58 +00:00
if ( sub === 'price' ) return renderPrice ( ) ;
if ( sub === 'finbox' ) return renderFinboxPane ( ) ;
if ( sub === 'map' ) return renderMap ( ) ;
if ( sub === 'backtest' ) return renderBacktestPane ( ) ;
}
2026-06-03 16:42:07 +00:00
// ── 指標面板(市場總覽 / 風險 / 回報 / 效率 / 預測 / 穩健度)──
async function loadFundamentals ( sym , fresh ) {
sym = ( sym || STOCK . symbol || '' ) . trim ( ) . toUpperCase ( ) ;
if ( ! fresh && STOCK . fundamentals [ sym ] ) return STOCK . fundamentals [ sym ] ;
const d = await api ( '/api/fundamentals/' + encodeURIComponent ( sym ) + ( fresh ? '?fresh=1' : '' ) ) ;
STOCK . fundamentals [ sym ] = d ;
return d ;
}
function calcPct ( a , b ) { return ( a != null && b ) ? ( a / b ) * 100 : null ; }
function growth ( cur , prev ) { return ( cur != null && prev ) ? ( ( cur - prev ) / Math . abs ( prev ) ) * 100 : null ; }
function ratio ( a , b ) { return ( a != null && b ) ? a / b : null ; }
function fmtRatio ( v , d = 1 ) { return v == null || isNaN ( v ) ? '—' : Number ( v ) . toFixed ( d ) + 'x' ; }
function fmtMetric ( v , kind ) {
if ( v == null || isNaN ( v ) ) return '—' ;
if ( kind === 'money' ) return fmtMoney ( v ) ;
if ( kind === 'pct' ) return fmtPct ( v , 1 ) ;
if ( kind === 'ratio' ) return fmtRatio ( v , 1 ) ;
if ( kind === 'num' ) return fmtNum ( v , 2 ) ;
if ( kind === 'shares' ) return fmtNum ( v / 1e9 , 2 ) + 'B' ;
if ( kind === 'compact' ) {
const a = Math . abs ( v ) , s = v < 0 ? '-' : '' ;
if ( a >= 1e9 ) return s + fmtNum ( a / 1e9 , 2 ) + 'B' ;
if ( a >= 1e6 ) return s + fmtNum ( a / 1e6 , 2 ) + 'M' ;
if ( a >= 1e3 ) return s + fmtNum ( a / 1e3 , 1 ) + 'K' ;
return fmtNum ( v , 0 ) ;
}
return String ( v ) ;
}
function clamp ( v , lo , hi ) { return Math . max ( lo , Math . min ( hi , v ) ) ; }
function priceStats ( points ) {
points = ( points || [ ] ) . filter ( p => p . close != null ) ;
if ( points . length < 2 ) return { } ;
const last = points [ points . length - 1 ] ;
const nearest = ( days ) => {
const target = new Date ( last . date ) ; target . setUTCDate ( target . getUTCDate ( ) - days ) ;
let best = points [ 0 ] , bd = Infinity ;
for ( const p of points ) {
const d = Math . abs ( new Date ( p . date ) - target ) ;
if ( d < bd ) { bd = d ; best = p ; }
}
return best ;
} ;
const ret = days => {
const p = nearest ( days ) ;
return p && p . close ? ( last . close / p . close - 1 ) * 100 : null ;
} ;
const recent = points . slice ( - 61 ) ;
const daily = [ ] ;
for ( let i = 1 ; i < recent . length ; i ++ ) if ( recent [ i - 1 ] . close ) daily . push ( recent [ i ] . close / recent [ i - 1 ] . close - 1 ) ;
const mean = daily . reduce ( ( a , b ) => a + b , 0 ) / ( daily . length || 1 ) ;
const variance = daily . reduce ( ( a , b ) => a + Math . pow ( b - mean , 2 ) , 0 ) / Math . max ( 1 , daily . length - 1 ) ;
let peak = - Infinity , mdd = 0 ;
for ( const p of points ) { peak = Math . max ( peak , p . close ) ; if ( peak > 0 ) mdd = Math . min ( mdd , ( p . close / peak - 1 ) * 100 ) ; }
return { ret1m : ret ( 30 ) , ret3m : ret ( 91 ) , ret6m : ret ( 182 ) , ret1y : ret ( 365 ) , ret3y : ret ( 1095 ) , ret5y : ret ( 1825 ) , volatility : Math . sqrt ( variance ) * Math . sqrt ( 252 ) * 100 , maxDrawdown : mdd } ;
}
function technicalStats ( points , quote = { } ) {
const clean = ( points || [ ] ) . filter ( p => p . close != null ) . map ( p => ( { ... p , close : Number ( p . close ) } ) ) ;
if ( clean . length < 20 ) return { } ;
const closes = clean . map ( p => p . close ) ;
const last = quote . price ? ? closes [ closes . length - 1 ] ;
const avg = ( arr ) => arr . length ? arr . reduce ( ( a , b ) => a + b , 0 ) / arr . length : null ;
const ma = ( n ) => closes . length >= n ? avg ( closes . slice ( - n ) ) : null ;
const dist = ( v ) => ( last != null && v ) ? ( last / v - 1 ) * 100 : null ;
const emaSeries = ( period ) => {
const k = 2 / ( period + 1 ) ;
const out = [ ] ;
let prev = null ;
for ( let i = 0 ; i < closes . length ; i ++ ) {
prev = prev == null ? closes [ i ] : closes [ i ] * k + prev * ( 1 - k ) ;
out . push ( prev ) ;
}
return out ;
} ;
let rsi14 = null ;
if ( closes . length > 14 ) {
let gains = 0 , losses = 0 ;
for ( let i = closes . length - 14 ; i < closes . length ; i ++ ) {
const ch = closes [ i ] - closes [ i - 1 ] ;
if ( ch >= 0 ) gains += ch ; else losses -= ch ;
}
const avgGain = gains / 14 , avgLoss = losses / 14 ;
rsi14 = avgLoss === 0 ? 100 : 100 - ( 100 / ( 1 + avgGain / avgLoss ) ) ;
}
const ema12 = emaSeries ( 12 ) , ema26 = emaSeries ( 26 ) ;
const macdSeries = ema12 . map ( ( v , i ) => v - ema26 [ i ] ) ;
const signal = ( ( ) => {
const k = 2 / 10 ;
let prev = null ;
for ( const v of macdSeries ) prev = prev == null ? v : v * k + prev * ( 1 - k ) ;
return prev ;
} ) ( ) ;
const macd = macdSeries [ macdSeries . length - 1 ] ;
const ma20 = ma ( 20 ) , ma50 = quote . fiftyDayAverage ? ? ma ( 50 ) , ma100 = ma ( 100 ) , ma200 = quote . twoHundredDayAverage ? ? ma ( 200 ) ;
const last20 = closes . slice ( - 20 ) ;
const sd20 = last20 . length >= 20 ? Math . sqrt ( last20 . reduce ( ( a , v ) => a + Math . pow ( v - ma20 , 2 ) , 0 ) / last20 . length ) : null ;
const bollUpper = ma20 != null && sd20 != null ? ma20 + 2 * sd20 : null ;
const bollLower = ma20 != null && sd20 != null ? ma20 - 2 * sd20 : null ;
const bollPos = ( last != null && bollUpper != null && bollLower != null && bollUpper !== bollLower ) ? ( ( last - bollLower ) / ( bollUpper - bollLower ) ) * 100 : null ;
const last252 = clean . slice ( - 252 ) . map ( p => p . close ) ;
const high52 = quote . fiftyTwoWeekHigh ? ? ( last252 . length ? Math . max ( ... last252 ) : null ) ;
const low52 = quote . fiftyTwoWeekLow ? ? ( last252 . length ? Math . min ( ... last252 ) : null ) ;
const pos52 = ( last != null && high52 != null && low52 != null && high52 !== low52 ) ? ( ( last - low52 ) / ( high52 - low52 ) ) * 100 : null ;
const volumeRatio = ( quote . volume != null && quote . avgVolume ) ? quote . volume / quote . avgVolume : null ;
const trendScore =
( last != null && ma20 != null && last > ma20 ? 1 : - 1 ) +
( last != null && ma50 != null && last > ma50 ? 1 : - 1 ) +
( last != null && ma200 != null && last > ma200 ? 1 : - 1 ) +
( ma50 != null && ma200 != null && ma50 > ma200 ? 1 : - 1 ) ;
return {
last , ma5 : ma ( 5 ) , ma10 : ma ( 10 ) , ma20 , ma50 , ma100 , ma200 ,
dist20 : dist ( ma20 ) , dist50 : dist ( ma50 ) , dist200 : dist ( ma200 ) ,
rsi14 , macd , macdSignal : signal , macdHist : macd != null && signal != null ? macd - signal : null ,
bollUpper , bollLower , bollPos , high52 , low52 , pos52 , volumeRatio , trendScore ,
} ;
}
function metricStatus ( v , good , warn , invert ) {
if ( v == null || isNaN ( v ) ) return 'na' ;
if ( invert ) return v <= good ? 'good' : v <= warn ? 'warn' : 'bad' ;
return v >= good ? 'good' : v >= warn ? 'warn' : 'bad' ;
}
function metricCard ( m ) {
const cls = m . missing ? 'missing' : ( m . status || 'na' ) ;
const tip = m . tipKey ? termTipBtn ( m . tipKey , m . label ) : '' ;
return ` <div class="metric-card ${ cls } ">
< div class = "metric-name" > < span > $ { escapeHtml ( m . label ) } < / s p a n > $ { t i p } < / d i v >
< div class = "metric-value" > $ { m . missing ? '等待免費來源' : escapeHtml ( m . value ) } < / d i v >
< div class = "metric-note" > $ { escapeHtml ( m . note || '' ) } < / d i v >
< / d i v > ` ;
}
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 } < / h 3 > < s p a n > $ { e s c a p e H t m l ( s u b t i t l e ) } < / s p a n > < / d i v >
< div class = "metric-grid" > $ { items . map ( metricCard ) . join ( '' ) } < / d i v >
< / s e c t i o n > ` ;
}
function formulaBlock ( title , formula , note , tipKey ) {
const tip = tipKey ? termTipBtn ( tipKey , title ) : '' ;
return ` <div class="formula-card">
< div class = "formula-title" > $ { escapeHtml ( title ) } $ { tip } < / d i v >
< pre > $ { escapeHtml ( formula ) } < / p r e >
< div class = "formula-note" > $ { escapeHtml ( note || '' ) } < / d i v >
< / d i v > ` ;
}
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" > 行動提醒 < / d i v >
< div class = "decision-title" > $ { escapeHtml ( summary . label ) } < / d i v >
< div class = "decision-score" > 綜合分數 $ { summary . score >= 0 ? '+' : '' } $ { summary . score } < / d i v >
< div class = "decision-cols" >
< div > < b > 支持理由 < / b > $ { s u m m a r y . r e a s o n s . m a p ( x = > ` < p > $ { e s c a p e H t m l ( x ) } < / p > ` ) . j o i n ( ' ' ) } < / d i v >
< div > < b > 小提醒 < / b > $ { s u m m a r y . c a u t i o n s . m a p ( x = > ` < p > $ { e s c a p e H t m l ( x ) } < / p > ` ) . j o i n ( ' ' ) } < / d i v >
< div > < b > 操作方式 < / b > $ { s u m m a r y . a c t i o n s . m a p ( x = > ` < p > $ { e s c a p e H t m l ( x ) } < / p > ` ) . j o i n ( ' ' ) } < / d i v >
< / d i v >
< / s e c t i o n > ` ;
}
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 > < s p a n > $ { e s c a p e H t m l ( b . f o r m u l a | | ' ' ) } < / s p a n > < / d i v >
< 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' ) : '—' } < / d i v >
< div > $ { b . stats ? b . stats . maxDrawdown . toFixed ( 1 ) + '% 回撤' + termTipBtn ( 'max_drawdown' , '最大回撤' ) : '—' } < / d i v >
< / d i v > ` ) . j o i n ( ' ' ) ;
return ` <section class="strategy-panel">
< div class = "metric-section-head" > < h3 > 策略回測摘要 < / h 3 > < s p a n > 用 歷 史 資 料 輔 助 判 斷 , 不 代 表 未 來 < / s p a n > < / d i v >
< div class = "strategy-table" > $ { rows } < / d i v >
< 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' ) }
< / d i v >
< / s e c t i o n > ` ;
}
function quoteFreshLabel ( q ) {
if ( ! q ? . marketTime && ! q ? . _fetchedAt ) return '' ;
const t = q . marketTime ? new Date ( q . marketTime ) : new Date ( q . _fetchedAt ) ;
if ( isNaN ( t ) ) return '' ;
return t . toLocaleString ( 'zh-TW' , { month : 'numeric' , day : 'numeric' , hour : '2-digit' , minute : '2-digit' } ) ;
}
function buildMetrics ( d , px , tech , quote , backtests ) {
const q = d . quarters || [ ] , a = d . annual || [ ] , bal = d . balance || { } ;
const curQ = q [ 0 ] || { } , prevQ = q [ 1 ] || { } , yaQ = q [ 4 ] || null ;
const curY = a [ 0 ] || { } , prevY = a [ 1 ] || { } ;
const equity = bal . totalEquity != null ? bal . totalEquity : ( ( bal . totalAssets != null && bal . totalLiabilities != null ) ? bal . totalAssets - bal . totalLiabilities : null ) ;
const ev = d . marketCap != null ? d . marketCap + ( bal . totalDebt || 0 ) - ( bal . cash || 0 ) : null ;
const fcf = curY . ocf != null && curY . capex != null ? curY . ocf + curY . capex : null ;
const shares = d . sharesOutstanding ? ? ( ( d . marketCap != null && d . price ) ? d . marketCap / d . price : null ) ;
const pe = d . peTrailing ? ? ( ( d . marketCap != null && curY . netIncome > 0 ) ? d . marketCap / curY . netIncome : null ) ;
const revGrowth = yaQ ? growth ( curQ . revenue , yaQ . revenue ) : growth ( curY . revenue , prevY . revenue ) ;
const netGrowth = yaQ ? growth ( curQ . netIncome , yaQ . netIncome ) : growth ( curY . netIncome , prevY . netIncome ) ;
const opMargin = curQ . operatingMargin ? ? null ;
const grossMargin = curQ . grossMargin ? ? curY . grossMargin ? ? null ;
const netMargin = curQ . netMargin ? ? curY . netMargin ? ? null ;
const roa = calcPct ( curY . netIncome , bal . totalAssets ) ;
const roe = calcPct ( curY . netIncome , equity ) ;
const debtEquity = ratio ( bal . totalLiabilities , equity ) ;
const currentRatio = ratio ( bal . currentAssets , bal . currentLiabilities ) ;
const cashDebt = ratio ( bal . cash , bal . totalDebt ) ;
const fair = dcfValue ( {
fcf , cash : bal . cash , debt : bal . totalDebt , shares , price : d . price ,
revGrowth , netGrowth , grossMargin , roe , debtEquity , volatility : px . volatility ,
} ) ;
const summary = decisionSummary ( { d , px , tech , fair , revGrowth , netGrowth , grossMargin , roe , debtEquity , backtests } ) ;
const changeText = quote ? . changePercent != null
? ` ${ quote . change >= 0 ? '+' : '' } ${ fmtNum ( quote . change , 2 ) } / ${ quote . changePercent >= 0 ? '+' : '' } ${ fmtPct ( quote . changePercent , 2 ) } `
: '—' ;
const rsiStatus = tech . rsi14 == null ? 'na' : tech . rsi14 >= 75 ? 'bad' : tech . rsi14 >= 65 ? 'warn' : tech . rsi14 <= 30 ? 'warn' : 'good' ;
const macdStatus = tech . macdHist == null ? 'na' : tech . macdHist >= 0 ? 'good' : 'bad' ;
const bollStatus = tech . bollPos == null ? 'na' : tech . bollPos > 95 ? 'bad' : tech . bollPos > 80 ? 'warn' : tech . bollPos < 5 ? 'warn' : 'good' ;
const pos52Status = tech . pos52 == null ? 'na' : tech . pos52 > 92 ? 'warn' : tech . pos52 < 25 ? 'bad' : 'good' ;
return [
decisionHTML ( summary ) ,
metricSection ( '市場總覽' , '價格、估值與公司規模' , [
{ label : '現價' , value : fmtNum ( d . price , 2 ) , status : quote ? . changePercent >= 0 ? 'good' : quote ? . changePercent < 0 ? 'bad' : 'na' , note : ` ${ d . currency || '' } ${ quoteFreshLabel ( quote ) ? ' · ' + quoteFreshLabel ( quote ) : '' } ` } ,
{ label : '今日漲跌' , value : changeText , status : quote ? . changePercent >= 0 ? 'good' : quote ? . changePercent < 0 ? 'bad' : 'na' , note : quote ? . source || '免費報價來源' } ,
{ label : '市值' , tipKey : 'market_cap' , value : fmtMoney ( d . marketCap ) , status : 'na' , note : 'Yahoo/價格資料' } ,
{ label : '企業價值 EV' , tipKey : 'ev' , value : fmtMoney ( ev ) , status : 'na' , note : '市值 + 債務 - 現金' } ,
{ label : '市盈率 P/E' , tipKey : 'pe' , value : fmtRatio ( pe , 1 ) , status : metricStatus ( pe , 25 , 45 , true ) , note : d . peTrailing != null ? 'Yahoo trailing PE' : '市值 / 年度淨利估算' } ,
{ label : '流通股數' , tipKey : 'shares' , value : fmtMetric ( shares , 'shares' ) , status : 'na' , note : d . sharesOutstanding != null ? '來源揭露' : '市值 / 現價估算' } ,
{ label : '股息殖利率' , tipKey : 'dividend_yield' , value : fmtPct ( d . dividendYield , 2 ) , status : 'na' , note : 'Nasdaq summary' } ,
{ label : '營收成長' , tipKey : 'rev_growth' , value : fmtPct ( revGrowth , 1 ) , status : metricStatus ( revGrowth , 15 , 0 ) , note : yaQ ? '最近季年增' : '年度年增' } ,
] , 'section_market' ) ,
metricSection ( '技術面' , 'MA、RSI、MACD、量能與價格位置' , [
{ label : 'MA20 / MA50' , tipKey : 'ma' , value : ` ${ fmtNum ( tech . ma20 , 2 ) } / ${ fmtNum ( tech . ma50 , 2 ) } ` , status : metricStatus ( tech . dist50 , 0 , - 5 ) , note : ` 距 MA50 ${ fmtPct ( tech . dist50 , 1 ) } ` } ,
{ label : 'MA100 / MA200' , tipKey : 'ma' , value : ` ${ fmtNum ( tech . ma100 , 2 ) } / ${ fmtNum ( tech . ma200 , 2 ) } ` , status : metricStatus ( tech . dist200 , 0 , - 8 ) , note : ` 距 MA200 ${ fmtPct ( tech . dist200 , 1 ) } ` } ,
{ label : 'RSI(14)' , tipKey : 'rsi' , value : fmtNum ( tech . rsi14 , 1 ) , status : rsiStatus , note : tech . rsi14 >= 70 ? '偏熱' : tech . rsi14 <= 30 ? '偏弱/超賣' : '中性區間' } ,
{ label : 'MACD(12,26,9)' , tipKey : 'macd' , value : fmtNum ( tech . macdHist , 2 ) , status : macdStatus , note : tech . macdHist >= 0 ? '柱狀體偏多' : '柱狀體偏空' } ,
{ label : '布林位置' , tipKey : 'boll' , value : fmtPct ( tech . bollPos , 0 ) , status : bollStatus , note : ` 下緣 ${ fmtNum ( tech . bollLower , 2 ) } / 上緣 ${ fmtNum ( tech . bollUpper , 2 ) } ` } ,
{ label : '52週位置' , tipKey : 'pos52' , value : fmtPct ( tech . pos52 , 0 ) , status : pos52Status , note : ` ${ fmtNum ( tech . low52 , 2 ) } ~ ${ fmtNum ( tech . high52 , 2 ) } ` } ,
{ label : '成交量 / 均量' , tipKey : 'volume_ratio' , value : fmtRatio ( tech . volumeRatio , 2 ) , status : metricStatus ( tech . volumeRatio , 1.2 , . 7 ) , note : quote ? . volume != null ? ` 今日量 ${ fmtMetric ( quote . volume , 'compact' ) } ` : '等待成交量' } ,
{ label : '日內高低' , value : ` ${ fmtNum ( quote ? . dayLow , 2 ) } ~ ${ fmtNum ( quote ? . dayHigh , 2 ) } ` , status : 'na' , note : quote ? . previousClose != null ? ` 昨收 ${ fmtNum ( quote . previousClose , 2 ) } ` : '' } ,
] , 'section_technical' ) ,
metricSection ( '風險' , '財務壓力與股價波動' , [
{ label : '總負債 / 總資產' , tipKey : 'debt_assets' , value : fmtPct ( bal . debtToAssets , 1 ) , status : metricStatus ( bal . debtToAssets , 50 , 70 , true ) , note : '越低越穩' } ,
{ label : '負債股本比' , tipKey : 'debt_equity' , value : fmtRatio ( debtEquity , 1 ) , status : metricStatus ( debtEquity , 1 , 2 , true ) , note : '總負債 / 股東權益' } ,
{ label : '流動比率' , tipKey : 'current_ratio' , value : fmtRatio ( currentRatio , 1 ) , status : metricStatus ( currentRatio , 1.5 , 1 ) , note : '流動資產 / 流動負債' } ,
{ label : '現金 / 債務' , tipKey : 'cash_debt' , value : fmtRatio ( cashDebt , 1 ) , status : metricStatus ( cashDebt , 1 , . 3 ) , note : '償債緩衝' } ,
{ label : '60日年化波動' , tipKey : 'volatility' , value : fmtPct ( px . volatility , 1 ) , status : metricStatus ( px . volatility , 25 , 45 , true ) , note : '由日報酬估算' } ,
{ label : '最大回撤' , tipKey : 'max_drawdown' , value : fmtPct ( px . maxDrawdown , 1 ) , status : metricStatus ( Math . abs ( px . maxDrawdown ) , 30 , 55 , true ) , note : '目前資料區間內' } ,
] , 'section_risk' ) ,
metricSection ( '回報' , '股價不同期間的報酬' , [
{ label : '1個月' , value : fmtPct ( px . ret1m , 1 ) , status : metricStatus ( px . ret1m , 0 , - 10 ) , note : '價格報酬' } ,
{ label : '3個月' , value : fmtPct ( px . ret3m , 1 ) , status : metricStatus ( px . ret3m , 0 , - 15 ) , note : '價格報酬' } ,
{ label : '6個月' , value : fmtPct ( px . ret6m , 1 ) , status : metricStatus ( px . ret6m , 0 , - 20 ) , note : '價格報酬' } ,
{ label : '1年' , value : fmtPct ( px . ret1y , 1 ) , status : metricStatus ( px . ret1y , 0 , - 25 ) , note : '價格報酬' } ,
{ label : '3年' , value : fmtPct ( px . ret3y , 1 ) , status : metricStatus ( px . ret3y , 0 , - 35 ) , note : '價格報酬' } ,
{ label : '5年' , value : fmtPct ( px . ret5y , 1 ) , status : metricStatus ( px . ret5y , 0 , - 45 ) , note : '價格報酬' } ,
] , 'section_return' ) ,
metricSection ( '效率' , '獲利品質與資產使用效率' , [
{ label : '毛利率' , tipKey : 'gross_margin' , value : fmtPct ( grossMargin , 1 ) , status : metricStatus ( grossMargin , 50 , 25 ) , note : '定價權與產品組合' } ,
{ label : '營業利潤率' , tipKey : 'op_margin' , value : fmtPct ( opMargin , 1 ) , status : metricStatus ( opMargin , 25 , 8 ) , note : '本業獲利效率' } ,
{ label : '淨利率' , tipKey : 'net_margin' , value : fmtPct ( netMargin , 1 ) , status : metricStatus ( netMargin , 15 , 0 ) , note : '最後落袋比例' } ,
{ label : 'ROA' , tipKey : 'roa' , value : fmtPct ( roa , 1 ) , status : metricStatus ( roa , 8 , 2 ) , note : '淨利 / 資產' } ,
{ label : 'ROE' , tipKey : 'roe' , value : fmtPct ( roe , 1 ) , status : metricStatus ( roe , 15 , 5 ) , note : '淨利 / 股東權益' } ,
{ label : 'FCF Margin' , tipKey : 'fcf_margin' , value : fmtPct ( calcPct ( fcf , curY . revenue ) , 1 ) , status : metricStatus ( calcPct ( fcf , curY . revenue ) , 10 , 0 ) , note : '自由現金流 / 營收' } ,
] , 'section_efficiency' ) ,
metricSection ( '預測' , '公開摘要與尚待接入的共識資料' , [
d . targetPrice != null
? { label : '1年目標價' , tipKey : 'target_price' , value : fmtNum ( d . targetPrice , 2 ) , status : metricStatus ( ( ( d . targetPrice / d . price ) - 1 ) * 100 , 15 , 0 ) , note : 'Nasdaq summary' }
: { label : '1年目標價' , tipKey : 'target_price' , missing : true , note : '需分析師目標價資料源' } ,
fair
? { label : 'DCF 公允價值' , tipKey : 'dcf' , value : fmtNum ( fair . fair , 2 ) , status : metricStatus ( fair . upside , 20 , 0 ) , note : ` 區間 ${ fmtNum ( fair . low , 2 ) } ~ ${ fmtNum ( fair . high , 2 ) } ` }
: { label : 'DCF 公允價值' , tipKey : 'dcf' , missing : true , note : '需要正自由現金流與股數' } ,
fair
? { label : '安全邊際' , tipKey : 'margin_of_safety' , value : fmtPct ( fair . upside , 1 ) , status : metricStatus ( fair . upside , 20 , 0 ) , note : '公允價值相對現價' }
: { label : '安全邊際' , tipKey : 'margin_of_safety' , missing : true , note : '需要公允價值與現價' } ,
fair
? { label : '估值假設' , tipKey : 'dcf_assumption' , value : ` ${ fair . growth . toFixed ( 1 ) } % / ${ fair . discount . toFixed ( 1 ) } % ` , status : 'na' , note : ` 5年FCF成長 / 折現率,終值成長 ${ fair . terminalGrowth . toFixed ( 1 ) } % ` }
: { label : '估值假設' , tipKey : 'dcf_assumption' , missing : true , note : '資料不足無法估算' } ,
{ label : '預估營收' , missing : true , note : '需分析師共識資料源' } ,
{ label : '預估 EPS' , tipKey : 'eps' , missing : true , note : '需分析師共識資料源' } ,
{ label : '預估 EBITDA' , missing : true , note : '需共識預測或付費 API' } ,
{ label : '未來 5 年成長' , missing : true , note : '需分析師長期預測' } ,
] , 'section_forecast' ) ,
strategySummaryHTML ( backtests ) ,
metricSection ( '穩健度' , '把目前可得指標整理成可讀結論' , [
{ label : '整體評價' , value : d . report ? . summary ? . verdict || '—' , status : d . report ? . summary ? . verdictColor || 'na' , note : '來自財報健檢' } ,
{ label : '綠燈' , value : String ( d . report ? . summary ? . good ? ? '—' ) , status : 'good' , note : '通過項目' } ,
{ label : '黃燈' , value : String ( d . report ? . summary ? . warn ? ? '—' ) , status : 'warn' , note : '留意項目' } ,
{ label : '紅燈' , value : String ( d . report ? . summary ? . bad ? ? '—' ) , status : 'bad' , note : '警訊項目' } ,
{ label : '淨利成長' , tipKey : 'rev_growth' , value : fmtPct ( netGrowth , 1 ) , status : metricStatus ( netGrowth , 10 , 0 ) , note : yaQ ? '最近季年增' : '年度年增' } ,
{ label : '資料來源' , value : d . source || '—' , status : 'na' , note : d . asOf ? '最新期別 ' + d . asOf : '' } ,
] , 'section_robust' ) ,
] . join ( '' ) ;
}
async function renderMetricsPane ( force ) {
const pane = $ ( '#pane-metrics' ) ;
if ( needSymbol ( pane ) ) return ;
if ( ! force && STOCK . rendered . metrics === STOCK . symbol ) return ;
pane . innerHTML = ` <div class="empty-state"><div class="spinner" style="width:28px;height:28px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 14px;animation:spin .8s linear infinite"></div>正在整理 ${ escapeHtml ( STOCK . symbol ) } 的指標面板…</div> ` ;
try {
const [ d , h , quote ] = await Promise . all ( [
loadFundamentals ( STOCK . symbol ) ,
api ( ` /api/price/ ${ encodeURIComponent ( STOCK . symbol ) } ?range=max&interval=1d ${ force ? '&fresh=1' : '' } ` ) ,
api ( ` /api/quote/ ${ encodeURIComponent ( STOCK . symbol ) } ${ force ? '?fresh=1' : '' } ` ) . catch ( ( ) => ( { } ) ) ,
] ) ;
const backtests = await Promise . all ( [
api ( ` /api/backtest/ ${ encodeURIComponent ( STOCK . symbol ) } ?strategy=buyhold&range=5y ` ) . then ( x => ( { ... x , formula : 'hold = true' } ) ) . catch ( ( ) => null ) ,
api ( ` /api/backtest/ ${ encodeURIComponent ( STOCK . symbol ) } ?strategy=sma&range=5y&short=50&long=200 ` ) . then ( x => ( { ... x , formula : 'sma(close,50) > sma(close,200)' } ) ) . catch ( ( ) => null ) ,
api ( ` /api/backtest/ ${ encodeURIComponent ( STOCK . symbol ) } ?strategy=dip&range=5y&drop=15 ` ) . then ( x => ( { ... x , formula : 'drawdown <= -15%' } ) ) . catch ( ( ) => null ) ,
api ( ` /api/backtest/ ${ encodeURIComponent ( STOCK . symbol ) } ?strategy=dca&range=5y&monthly=1000 ` ) . then ( x => ( { ... x , formula : 'monthly buy' } ) ) . catch ( ( ) => null ) ,
] ) . then ( xs => xs . filter ( Boolean ) ) ;
STOCK . rendered . metrics = STOCK . symbol ;
const pstats = priceStats ( h . points || [ ] ) ;
const lastPx = ( h . points || [ ] ) . length ? h . points [ h . points . length - 1 ] . close : null ;
const enriched = {
... d ,
price : quote . price ? ? d . price ? ? lastPx ,
currency : quote . currency || d . currency || h . currency || '' ,
name : d . name || quote . name || h . name ,
marketCap : quote . marketCap ? ? d . marketCap ,
sharesOutstanding : quote . sharesOutstanding ? ? d . sharesOutstanding ,
peTrailing : quote . peTrailing ? ? d . peTrailing ,
targetPrice : quote . targetPrice ? ? d . targetPrice ,
dividendYield : quote . dividendYield ? ? d . dividendYield ,
} ;
if ( enriched . marketCap == null && enriched . price != null && enriched . sharesOutstanding != null ) enriched . marketCap = enriched . price * enriched . sharesOutstanding ;
const tech = technicalStats ( h . points || [ ] , quote ) ;
pane . innerHTML = `
< div class = "metric-head" >
< div > < b > $ { escapeHtml ( enriched . name || enriched . symbol ) } < / b > < s p a n > $ { e s c a p e H t m l ( e n r i c h e d . s y m b o l ) } · $ { e s c a p e H t m l ( e n r i c h e d . c u r r e n c y | | ' ' ) } · $ { e n r i c h e d . a s O f ? ' 最 新 期 別 ' + e s c a p e H t m l ( e n r i c h e d . a s O f ) : ' ' } < / s p a n > < / d i v >
< button class = "btn ghost sm" id = "metricRefresh" > 更新資料 < / b u t t o n >
< / d i v >
< div class = "metric-source-note" > 本面板會優先使用 Yahoo 、 Nasdaq 、 SEC 與價格歷史等免費公開來源 ; 近即時報價會短暫快取 , 歷史資料會留在本機 , 下次只補新日期 。 MA 、 RSI 、 MACD 與布林通道由本機用日線計算 ; DCF 公允價值以年度自由現金流折現估算 。 免費報價可能延遲 , 交易前仍要對照券商報價 。 < / d i v >
< div class = "metric-board" > $ { buildMetrics ( enriched , pstats , tech , quote , backtests ) } < / d i v > ` ;
$ ( '#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> ` ;
}
}
2026-06-03 09:21:58 +00:00
// ── 價格走勢 ──
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 ( '' ) } < / d i v >
< div id = "priceHead" class = "fin-co" > < / d i v >
2026-06-03 16:42:07 +00:00
< div class = "stock-detail-layout" >
< div id = "priceChart" > < div class = "chart-empty" > 載入中 … < / d i v > < / d i v >
< aside id = "companyProfile" class = "company-profile" > < div class = "chart-empty" > 載入公司資訊 … < / d i v > < / a s i d e >
< / d i v > ` ;
2026-06-03 09:21:58 +00:00
$$ ( '#priceRange button' , pane ) . forEach ( b => b . addEventListener ( 'click' , ( ) => { STOCK . priceRange = b . dataset . r ; renderPrice ( true ) ; } ) ) ;
try {
2026-06-03 16:42:07 +00:00
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 ) ,
] ) ;
2026-06-03 09:21:58 +00:00
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 ) } ) ;
2026-06-03 16:42:07 +00:00
renderCompanyProfile ( profile , d , last ) ;
renderCompanyIntel ( STOCK . symbol , profile , force ) ;
2026-06-03 09:21:58 +00:00
} catch ( e ) {
pane . querySelector ( '#priceChart' ) . innerHTML = ` <div class="empty-state">無法取得 ${ escapeHtml ( STOCK . symbol ) } 的價格: ${ escapeHtml ( ( e . data && e . data . message ) || e . message || '' ) } </div> ` ;
}
}
2026-06-03 16:42:07 +00:00
function renderCompanyProfile ( profile , priceData , last ) {
const box = $ ( '#companyProfile' ) ;
if ( ! box ) return ;
if ( ! profile ) {
box . innerHTML = '<div class="empty-state">公司資訊暫時無法取得。</div>' ;
return ;
}
const q = profile . quote || { } ;
const notif = ( profile . notifications || [ ] ) . flatMap ( n => n . eventTypes || [ ] ) . slice ( 0 , 3 ) ;
box . innerHTML = `
< div class = "profile-head" >
< div > < b > $ { escapeHtml ( profile . name || priceData . name || priceData . symbol ) } < / b > < s p a n > $ { e s c a p e H t m l ( p r o f i l e . e x c h a n g e | | ' ' ) } · $ { e s c a p e H t m l ( p r o f i l e . m a r k e t S t a t u s | | ' ' ) } < / s p a n > < / d i v >
< div class = "profile-price" > $ { fmtNum ( q . price ? ? last , 2 ) } < / d i v >
< / d i v >
< div class = "profile-stats" >
< div > < span > Bid / Ask < /span><b>${fmtNum(profile.bidPrice, 2)} / $ { fmtNum ( profile . askPrice , 2 ) } < / b > < / d i v >
< div > < span > Sector < / s p a n > < b > $ { e s c a p e H t m l ( p r o f i l e . s e c t o r | | ' — ' ) } < / b > < / d i v >
< div > < span > Industry < / s p a n > < b > $ { e s c a p e H t m l ( p r o f i l e . i n d u s t r y | | ' — ' ) } < / b > < / d i v >
< div > < span > Region < / s p a n > < b > $ { e s c a p e H t m l ( p r o f i l e . r e g i o n | | ' — ' ) } < / b > < / d i v >
< / d i v >
$ { profile . description ? ` <p class="profile-desc"> ${ escapeHtml ( profile . description ) } </p> ` : '' }
< div class = "profile-meta" >
$ { profile . website ? ` <a href=" ${ escapeHtml ( profile . website ) } " target="_blank" rel="noreferrer">公司網站</a> ` : '' }
$ { profile . address ? ` <span> ${ escapeHtml ( profile . address ) } </span> ` : '' }
< / d i v >
$ { notif . length ? ` <div class="profile-events"><b>Upcoming</b> ${ notif . map ( e => ` <span> ${ escapeHtml ( e . message || e . eventName || '' ) } </span> ` ) . join ( '' ) } </div> ` : '' }
< div class = "metric-source-note" > 公司資訊來自 Nasdaq profile / quote , 報價可能延遲 。 < / d i v >
< button class = "btn ghost sm" id = "intelRefresh" style = "width:100%;margin-top:10px" > 更新研究資訊 < / b u t t o n > ` ;
const rb = $ ( '#intelRefresh' ) ;
if ( rb ) rb . addEventListener ( 'click' , ( ) => renderCompanyIntel ( profile . symbol || STOCK . symbol , profile , true ) ) ;
}
function txSignal ( t ) {
if ( t . signal === 'acquire' ) return [ '取得' , 'good' ] ;
if ( t . signal === 'dispose' ) return [ '處分' , 'bad' ] ;
return [ '混合' , 'warn' ] ;
}
function renderCompanyIntelSkeleton ( ) {
const pane = $ ( '#pane-price' ) ;
let box = $ ( '#companyIntel' ) ;
if ( ! box ) {
pane . insertAdjacentHTML ( 'beforeend' , '<div id="companyIntel" class="company-intel"></div>' ) ;
box = $ ( '#companyIntel' ) ;
}
box . innerHTML = '<div class="empty-state">正在整理產業鏈、管理層、內部人交易與新聞…</div>' ;
return box ;
}
async function renderCompanyIntel ( symbol , profile , fresh ) {
const box = renderCompanyIntelSkeleton ( ) ;
try {
const intel = await api ( ` /api/company-intel/ ${ encodeURIComponent ( symbol ) } ${ fresh ? '?fresh=1' : '' } ` ) ;
const chain = intel . industryChain || { } ;
const officers = intel . management ? . officers || [ ] ;
const insiders = intel . insiders || [ ] ;
const news = intel . news || [ ] ;
const acquiredCount = insiders . filter ( t => t . signal === 'acquire' ) . length ;
const disposedCount = insiders . filter ( t => t . signal === 'dispose' ) . length ;
box . innerHTML = `
< section class = "intel-section" >
< div class = "metric-section-head" > < h3 > 產業上下游 < / h 3 > < s p a n > 先 建 立 研 究 地 圖 , 再 點 出 去 查 證 供 應 鏈 細 節 < / s p a n > < / d i v >
< div class = "chain-map" >
< div > < b > 上游 < / b > $ { ( c h a i n . u p s t r e a m | | [ ] ) . m a p ( x = > ` < s p a n > $ { e s c a p e H t m l ( x ) } < / s p a n > ` ) . j o i n ( ' ' ) } < / d i v >
< div > < b > $ { escapeHtml ( symbol ) } < / b > < s p a n > $ { e s c a p e H t m l ( p r o f i l e ? . i n d u s t r y | | i n t e l . m a n a g e m e n t ? . i n d u s t r y | | ' 公 司 核 心 業 務 ' ) } < / s p a n > < / d i v >
< div > < b > 下游 < / b > $ { ( c h a i n . d o w n s t r e a m | | [ ] ) . m a p ( x = > ` < s p a n > $ { e s c a p e H t m l ( x ) } < / s p a n > ` ) . j o i n ( ' ' ) } < / d i v >
< / d i v >
< div class = "chain-links" > $ { ( chain . searches || [ ] ) . map ( s => ` <a href=" ${ escapeHtml ( s . url ) } " target="_blank" rel="noreferrer"> ${ escapeHtml ( s . label ) } </a> ` ) . join ( '' ) } < / d i v >
$ { ( chain . peers || [ ] ) . length ? ` <div class="peer-chips"> ${ chain . peers . map ( s => ` <button data-peer=" ${ escapeHtml ( s ) } "> ${ escapeHtml ( s ) } </button> ` ) . join ( '' ) } </div> ` : '' }
< / s e c t i o n >
< section class = "intel-section" >
< div class = "metric-section-head" > < h3 > 經營管理層 < / h 3 > < s p a n > 職 位 與 薪 酬 來 自 公 開 資 料 , 可 用 來 看 治 理 結 構 < / s p a n > < / d i v >
< div class = "officer-grid" > $ { officers . length ? officers . map ( o => `
< div class = "officer-card" > < b > $ { escapeHtml ( o . name ) } < / b > < s p a n > $ { e s c a p e H t m l ( o . t i t l e | | ' ' ) } < / s p a n > < s m a l l > $ { o . t o t a l P a y ! = n u l l ? ' T o t a l p a y ' + f m t M o n e y ( o . t o t a l P a y ) : ' ' } < / s m a l l > < / d i v > ` ) . j o i n ( ' ' ) : ' < d i v c l a s s = " e m p t y - s t a t e " > 暫 時 沒 有 抓 到 管 理 層 資 料 。 < / d i v > ' } < / d i v >
< div class = "chain-links" > $ { ( intel . management ? . searches || [ ] ) . map ( s => ` <a href=" ${ escapeHtml ( s . url ) } " target="_blank" rel="noreferrer"> ${ escapeHtml ( s . label ) } </a> ` ) . join ( '' ) } < / d i v >
< / s e c t i o n >
< section class = "intel-section" >
< div class = "metric-section-head" > < h3 > 內部人 Form 4 < / h 3 > < s p a n > A / D 代 表 S E C 交 易 取 得 / 處 分 代 碼 , 需 留 意 獎 酬 與 選 擇 權 情 境 < / s p a n > < / d i v >
< div class = "insider-summary" >
< div class = "${acquiredCount ? 'good' : ''}" > < b > $ { acquiredCount } < / b > < s p a n > 近 期 偏 取 得 < / s p a n > < / d i v >
< div class = "${disposedCount ? 'bad' : ''}" > < b > $ { disposedCount } < / b > < s p a n > 近 期 偏 處 分 < / s p a n > < / d i v >
< / d i v >
< div class = "insider-list" > $ { insiders . length ? insiders . map ( t => {
const [ label , cls ] = txSignal ( t ) ;
return ` <a class="insider-row ${ cls } " href=" ${ escapeHtml ( t . url ) } " target="_blank" rel="noreferrer">
< div > < b > $ { escapeHtml ( t . owner || 'Insider' ) } < / b > < s p a n > $ { e s c a p e H t m l ( t . t i t l e | | ' ' ) } < / s p a n > < / d i v >
< div > < b > $ { escapeHtml ( label ) } < / b > < s p a n > $ { e s c a p e H t m l ( t . r e p o r t D a t e | | t . f i l i n g D a t e | | ' ' ) } < / s p a n > < / d i v >
< div > < b > $ { fmtMetric ( t . acquired || 0 , 'compact' ) } / $ { fmtMetric ( t . disposed || 0 , 'compact' ) } < /b><span>A / D shares < / s p a n > < / d i v >
< / a > ` ;
} ) . join ( '' ) : '<div class="empty-state">近期沒有抓到 Form 4。</div>' } < / d i v >
< / s e c t i o n >
< section class = "intel-section" >
< div class = "metric-section-head" > < h3 > 新聞 < / h 3 > < s p a n > 最 近 與 公 司 或 相 關 代 號 有 關 的 新 聞 < / s p a n > < / d i v >
< div class = "news-grid" > $ { news . length ? news . map ( n => `
< a class = "news-card" href = "${escapeHtml(n.url || '#')}" target = "_blank" rel = "noreferrer" >
< b > $ { escapeHtml ( n . title || '' ) } < / b >
< span > $ { escapeHtml ( n . publisher || '' ) } · $ { escapeHtml ( n . created || '' ) } < / s p a n >
< p > $ { escapeHtml ( n . description || '' ) } < / p >
< / a > ` ) . j o i n ( ' ' ) : ' < d i v c l a s s = " e m p t y - s t a t e " > 暫 時 沒 有 新 聞 。 < / d i v > ' } < / d i v >
< / s e c t i o n > ` ;
$$ ( '.peer-chips button' , box ) . forEach ( btn => btn . addEventListener ( 'click' , ( ) => setStockSymbol ( btn . dataset . peer ) ) ) ;
} catch ( e ) {
box . innerHTML = ` <div class="empty-state">無法整理公司研究資訊: ${ escapeHtml ( ( e . data && e . data . message ) || e . message || '' ) } </div> ` ;
}
}
2026-06-03 09:21:58 +00:00
// ── 財報健檢 ──
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 ; }
2026-06-03 16:42:07 +00:00
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> ` ;
2026-06-03 09:21:58 +00:00
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 } < / d i v > < d i v c l a s s = " f i n - s t e p - t i t l e " > $ { e s c a p e H t m l ( s t . t i t l e ) } < / d i v > < / d i v >
$ { ( st . checks || [ ] ) . map ( ck => checkRowHTML ( ck ) ) . join ( '' ) }
< / d i v > ` ) . j o i n ( ' ' ) ;
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 > $ { e s c a p e H t m l ( d . s y m b o l ) } $ { d . p r i c e ! = n u l l ? ` · 股 價 $ $ { f m t N u m ( d . p r i c e , 2 ) } ` : ' ' } · 資 料 來 源 $ { e s c a p e H t m l ( d . s o u r c e | | ' — ' ) } $ { d . a s O f ? ` · 最 新 季 別 $ { e s c a p e H t m l ( d . a s O f ) } ` : ' ' } < / d i v >
2026-06-03 16:42:07 +00:00
< div class = "fin-fresh" > < span > $ { freshNote } $ { staleNote } < / s p a n > < b u t t o n c l a s s = " b t n g h o s t s m " i d = " f i n R e f r e s h " > ↻ 更 新 資 料 < / b u t t o n > < / d i v >
2026-06-03 09:21:58 +00:00
< div class = "fin-summary" >
< div class = "fin-verdict" > < div class = "v-big" style = "color:${vColor}" > $ { escapeHtml ( sum . verdict || '—' ) } < / d i v > < d i v c l a s s = " v - s u b " > $ { ( s u m . g o o d | | 0 ) + ( s u m . w a r n | | 0 ) + ( s u m . b a d | | 0 ) } 項 檢 查 < / d i v > < / d i v >
< div class = "fin-lights" >
< div class = "fin-light" > < div class = "fl-num" style = "color:var(--green)" > $ { sum . good || 0 } < / d i v > < d i v c l a s s = " f l - l a b " > 綠 燈 通 過 < / d i v > < / d i v >
< div class = "fin-light" > < div class = "fl-num" style = "color:var(--yellow)" > $ { sum . warn || 0 } < / d i v > < d i v c l a s s = " f l - l a b " > 黃 燈 留 意 < / d i v > < / d i v >
< div class = "fin-light" > < div class = "fl-num" style = "color:var(--red)" > $ { sum . bad || 0 } < / d i v > < d i v c l a s s = " f l - l a b " > 紅 燈 警 訊 < / d i v > < / d i v >
< / d i v >
< / d i v >
$ { steps }
$ { caveats } ` ;
bindWlinks ( out ) ;
const rb = $ ( '#finRefresh' ) ;
2026-06-03 16:42:07 +00:00
if ( rb ) rb . addEventListener ( 'click' , ( ) => {
delete STOCK . fundamentals [ d . symbol ] ;
runFincheck ( d . symbol , false ) ;
} ) ;
2026-06-03 09:21:58 +00:00
}
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" > < / s p a n >
< div class = "check-main" >
< div class = "ck-label" > $ { escapeHtml ( ck . label ) } < / d i v >
$ { ck . note ? ` <div class="ck-note"> ${ escapeHtml ( ck . note ) } </div> ` : '' }
$ { links ? ` <div class="ck-links"> ${ links } </div> ` : '' }
< / d i v >
< div class = "check-val ${ck.status}" > $ { escapeHtml ( ck . value != null ? ck . value : '—' ) } < / d i v >
< / d i v > ` ;
}
// 把 {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 ) } < / d i v >
< div class = "mq-ans" data - layer = "${L.key}" data - qi = "${qi}" > $ { radios } < / d i v >
$ { links ? ` <div class="ck-links"> ${ links } </div> ` : '' }
< / d i v > ` ;
} ) . join ( '' ) ;
return ` <div class="map-layer ${ st } ">
< div class = "ml-head" > < div class = "ml-num" > $ { idx + 1 } < / d i v > < d i v c l a s s = " m l - t i t l e " > $ { e s c a p e H t m l ( L . t i t l e ) } < / d i v > < s p a n c l a s s = " m l - b a d g e $ { m e t a . c l s } " > $ { m e t a . l a b } < / s p a n > < / d i v >
< div class = "ml-ask" > $ { escapeHtml ( L . ask ) } < / d i v >
< div class = "ml-pillar" > $ { escapeHtml ( L . pillar ) } < / d i v >
$ { qs }
< div class = "ml-out" > 出局條件 : $ { escapeHtml ( L . out ) } < / d i v >
< / d i v > ` ;
} ) . join ( '' ) ;
// 彙整結論
const statuses = cfg . layers . map ( layerStatus ) ;
2026-06-04 01:35:37 +00:00
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 } < / s p a n > < b > $ { e s c a p e H t m l ( L . t i t l e ) } < / b > < s m a l l > $ { S T _ M E T A [ s t ] . l a b } < / s m a l l >
< / b u t t o n > ` ;
} ) . join ( '' ) } < / d i v > ` ;
2026-06-03 09:21:58 +00:00
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 ) } < / s p a n > < / d i v >
2026-06-04 01:35:37 +00:00
$ { funnelHTML }
2026-06-03 09:21:58 +00:00
< div class = "map-verdict ${vcls}" > < div class = "mv-lab" > $ { target } 的判斷 < / d i v > < d i v c l a s s = " m v - t e x t " > $ { v e r d i c t } < / d i v >
< div class = "mv-actions" > < button class = "btn ghost sm" id = "mapReset" > 重設 < / b u t t o n > $ { S T O C K . s y m b o l ? ' < b u t t o n c l a s s = " b t n s m " i d = " m a p S a v e " > 存 成 交 易 紀 錄 < / b u t t o n > ' : ' ' } < / d i v >
< / d i v >
$ { layersHTML }
2026-06-03 16:42:07 +00:00
< div class = "disclaimer" > 這是把 「 < span class = "wlink" data - link = "學習分類/投資底層邏輯" > 投資底層邏輯 < / s p a n > 」 六 層 漏 斗 變 成 的 自 我 檢 查 工 具 , 幫 你 結 構 化 判 斷 , < b > 不 構 成 投 資 建 議 < / b > 。 任 何 一 層 出 局 就 停 手 , 是 漏 斗 的 精 神 。 < / d i v > ` ;
2026-06-03 09:21:58 +00:00
// 綁定:作答(即時重繪)、原則連結、按鈕
$$ ( '.mq-ans' , pane ) . forEach ( box => {
$$ ( 'input[type=radio]' , box ) . forEach ( r => r . addEventListener ( 'change' , ( ) => {
STOCK . mapAnswers [ box . dataset . layer + ':' + box . dataset . qi ] = r . value ;
2026-06-04 01:35:37 +00:00
setAIFocus ( { type : 'stock-map' , symbol : STOCK . symbol , subPage : 'map' , label : ` ${ STOCK . symbol || '標的' } · 投資地圖 ` , mapAnswers : STOCK . mapAnswers } ) ;
2026-06-03 09:21:58 +00:00
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 ) ;
2026-06-04 01:35:37 +00:00
$$ ( '.funnel-step' , pane ) . forEach ( btn => btn . addEventListener ( 'click' , ( ) => {
const layer = $$ ( '.map-layer' , pane ) [ Number ( btn . dataset . jumpLayer ) ] ;
layer ? . scrollIntoView ( { behavior : 'smooth' , block : 'start' } ) ;
} ) ) ;
2026-06-03 09:21:58 +00:00
}
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 = '' ;
2026-06-03 16:42:07 +00:00
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 ;
2026-06-03 09:21:58 +00:00
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" > 策略 < / l a b e l >
< div id = "btStratChips" class = "chip-row" > < / d i v >
< / d i v >
< 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" > 期間 < / l a b e l >
< div id = "btRangeChips" class = "chip-row" > < / d i v >
< / d i v >
2026-06-03 09:21:58 +00:00
< div id = "btParams" class = "bt-params" > < / d i v >
< button class = "btn" id = "btRun" > 跑回測 < / b u t t o n >
< / d i v >
< div id = "btResult" > < div class = "empty-state" > 選好策略與期間 , 按 「 跑回測 」 。 以還原股價 、 初始資金 $10 , 000 模擬 。 < / d i v > < / d i v > ` ;
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 ) } < / l a b e l >
< 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" > < / d i v > ` ) . j o i n ( ' ' ) ;
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 ) } < / d i v >
< div class = "bts-grid" >
< div > < span > 期末價值 < / s p a n > < b > $ { m o n e y ( s . f i n a l V a l u e ) } < / b > < / d i v >
< div > < span > 總報酬 < / s p a n > < b c l a s s = " $ { s . t o t a l R e t u r n > = 0 ? ' p n l - p o s ' : ' p n l - n e g ' } " > $ { s . t o t a l R e t u r n > = 0 ? ' + ' : ' ' } $ { s . t o t a l R e t u r n . t o F i x e d ( 1 ) } % < / b > < / d i v >
< div > < span > 年化 ( CAGR ) < / s p a n > < b c l a s s = " $ { s . c a g r > = 0 ? ' p n l - p o s ' : ' p n l - n e g ' } " > $ { s . c a g r > = 0 ? ' + ' : ' ' } $ { s . c a g r . t o F i x e d ( 1 ) } % < / b > < / d i v >
< div > < span > 最大回撤 < / s p a n > < b c l a s s = " p n l - n e g " > - $ { s . m a x D r a w d o w n . t o F i x e d ( 1 ) } % < / b > < / d i v >
< div > < span > 在場比例 < / s p a n > < b > $ { s . e x p o s u r e . t o F i x e d ( 0 ) } % < / b > < / d i v >
< div > < span > $ { s . winRate != null ? '勝率' : '進場次數' } < / s p a n > < b > $ { s . w i n R a t e ! = n u l l ? s . w i n R a t e . t o F i x e d ( 0 ) + ' % ( ' + s . t r a d e s + ' 次 ) ' : s . t r a d e s + ' 次 ' } < / b > < / d i v >
< / d i v >
< / d i v > ` : ' ' ;
out . innerHTML = `
< div class = "fin-co" > < b > $ { escapeHtml ( d . name || d . symbol ) } < / b > $ { e s c a p e H t m l ( d . s y m b o l ) } · $ { e s c a p e H t m l ( d . s t r a t e g y L a b e l ) } · $ { e s c a p e H t m l ( d . f r o m ) } ~ $ { e s c a p e H t m l ( d . t o ) } $ { d . c a c h e d ? ' · < s p a n s t y l e = " c o l o r : v a r ( - - t e x t 2 ) ; f o n t - s i z e : . 8 r e m " > 快 取 < / s p a n > ' : ' ' } < / d i v >
< div id = "btChart" > < / d i v >
< div class = "bt-stats" > $ { statCard ( d . strategyLabel , d . stats , HEX . blue ) } $ { statCard ( '買進持有' , d . benchStats , HEX . text2 ) } < / d i v >
$ { d . note ? ` <div class="bt-note"> ${ escapeHtml ( d . note ) } </div> ` : '' }
< div class = "disclaimer" > 回測以歷史還原股價模擬 、 未計交易成本與稅 , 且 < b > 過去績效不代表未來 < / b > 。 這 是 用 來 理 解 策 略 行 為 ( 如 趨 勢 進 出 v s 一 直 持 有 ) 的 學 習 工 具 , 不 構 成 投 資 建 議 。 對 照 < s p a n c l a s s = " w l i n k " d a t a - l i n k = " E m m y 投 資 心 法 # 原 則 十 三 : 長 期 趨 勢 跌 就 買 " > 長 期 趨 勢 跌 就 買 < / s p a n > 、 < s p a n c l a s s = " w l i n k " d a t a - l i n k = " E m m y 投 資 心 法 # 原 則 五 十 九 : 觸 發 式 減 倉 " > 觸 發 式 減 倉 < / s p a n > 。 < / d i v > ` ;
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" > 📓 交易復盤 < / d i v >
< div class = "page-sub" > 記錄每一筆進出與理由 , 自動算盈虧 、 勝率與賺賠比 。 重點不是 「 賺或賠 」 , 而是 < b > 當初的判斷依據是否成立 < / b > — — 標 記 犯 錯 與 依 據 的 心 法 , 定 期 回 頭 復 盤 。 對 應 < s p a n c l a s s = " w l i n k " d a t a - l i n k = " 學 習 分 類 / 交 易 與 資 金 管 理 " > 交 易 與 資 金 管 理 < / s p a n > 。 < / d i v >
< / d i v >
< div id = "journalStats" > < / d i v >
< 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 >
< / d i v >
< button class = "btn" id = "addTradeBtn" > + 新增交易 < / b u t t o n >
< / d i v >
< div id = "journalBody" > < / d i v >
< / d i v > ` ;
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" > 已實現損益 < / d i v > < d i v c l a s s = " s t - v a l $ { p n l C l s } " > $ { s . t o t a l P n l ! = n u l l ? f m t M o n e y ( s . t o t a l P n l ) : ' — ' } < / d i v > < d i v c l a s s = " s t - s u b " > $ { s . c l o s e d | | 0 } 筆 已 平 倉 · $ { s . o p e n | | 0 } 筆 持 倉 < / d i v > < / d i v >
< div class = "stat-card" > < div class = "st-lab" > 勝率 < / d i v > < d i v c l a s s = " s t - v a l " > $ { s . w i n R a t e ! = n u l l ? s . w i n R a t e . t o F i x e d ( 0 ) + ' % ' : ' — ' } < / d i v > < d i v c l a s s = " s t - s u b " > $ { s . w i n s | | 0 } 勝 / $ { s . l o s s e s | | 0 } 敗 < / d i v > < / d i v >
< div class = "stat-card" > < div class = "st-lab" > 賺賠比 ( Payoff ) < / d i v > < d i v c l a s s = " s t - v a l " > $ { s . p a y o f f ! = n u l l ? s . p a y o f f . t o F i x e d ( 2 ) : ' — ' } < / d i v > < d i v c l a s s = " s t - s u b " > 平 均 賺 $ { s . a v g W i n ! = n u l l ? f m t M o n e y ( s . a v g W i n ) : ' — ' } / 賠 $ { s . a v g L o s s ! = n u l l ? f m t M o n e y ( M a t h . a b s ( s . a v g L o s s ) ) : ' — ' } < / d i v > < / d i v >
< div class = "stat-card" > < div class = "st-lab" > 紀律提醒 < / d i v > < d i v c l a s s = " s t - v a l " s t y l e = " f o n t - s i z e : 1 r e m ; f o n t - w e i g h t : 6 0 0 ; l i n e - h e i g h t : 1 . 4 " > 六 成 看 對 < b r > 就 夠 賺 錢 < / d i v > < d i v c l a s s = " s t - s u b " > 勝 率 不 必 高 , 賺 賠 比 是 關 鍵 < / d i v > < / d i v >
< / d i v > ` ;
}
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> ` : '' } < / s p a n > < / t d >
< td > $ { dirPill } $ { kindPill } $ { statusPill } $ { mistakePill } < / t d >
< td > $ { escapeHtml ( t . entry _date || '—' ) } < br > < span style = "color:var(--text2);font-size:.76rem" > $$ { fmtNum ( t . entry _price , 2 ) } × $ { fmtNum ( t . shares , 0 ) } < / s p a n > < / t d >
< 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>' } < / t d >
< td > $ { pnl } < / t d >
< 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> ` : '' } < / t d >
< td class = "t-actions" > < button class = "btn ghost sm" data - edit = "${t.id}" > 編輯 < / b u t t o n > < b u t t o n c l a s s = " b t n d a n g e r s m " d a t a - d e l = " $ { t . i d } " > 刪 < / b u t t o n > < / t d >
< / t r > ` ;
} ) . join ( '' ) ;
body . innerHTML = ` <table class="trade-table">
< thead > < tr > < th > 標的 < / t h > < t h > 類 型 < / t h > < t h > 進 場 < / t h > < t h > 出 場 < / t h > < t h > 已 實 現 損 益 < / t h > < t h > 理 由 / 依 據 < / t h > < t h > < / t h > < / t r > < / t h e a d >
< tbody > $ { rows } < / t b o d y > < / t a b l e > ` ;
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" > < / s p a n > < s p a n c l a s s = " g s - c e l l " > 筆 數 < / s p a n > < s p a n c l a s s = " g s - c e l l " > 勝 率 < / s p a n > < s p a n c l a s s = " g s - c e l l " > 損 益 < / s p a n > < / d i v >
$ { 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 ( '' ) }
< / d i v > ` ;
} ;
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) " > 結果論陷阱 < / s p a n > 、 < s p a n c l a s s = " w l i n k " d a t a - l i n k = " E m m y 投 資 心 法 # 原 則 六 十 二 : 賣 弱 留 強 " > 賣 弱 留 強 < / s p a n > 、 < s p a n c l a s s = " w l i n k " d a t a - l i n k = " E m m y 投 資 心 法 # 原 則 五 十 九 : 觸 發 式 減 倉 " > 觸 發 式 減 倉 < / s p a n > 。 < / d i v > ` ;
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" > 新增交易 < / d i v > < b u t t o n c l a s s = " m o d a l - c l o s e " i d = " t r a d e F o r m C l o s e " > ✕ < / b u t t o n > < / d i v >
< form id = "tradeForm" > < div class = "form-grid" >
< div class = "field" > < label > 股票代號 * < / l a b e l > < i n p u t n a m e = " s y m b o l " r e q u i r e d p l a c e h o l d e r = " N V D A " > < / d i v >
< div class = "field" > < label > 名稱 < / l a b e l > < i n p u t n a m e = " n a m e " p l a c e h o l d e r = " 輝 達 " > < / d i v >
2026-06-03 09:33:23 +00:00
< div class = "field full" > < label > 方向 < / l a b e l > < d i v i d = " d i r T i l e s " c l a s s = " t i l e - r o w " > < / d i v > < i n p u t t y p e = " h i d d e n " n a m e = " d i r e c t i o n " v a l u e = " l o n g " > < / d i v >
< div class = "field full" > < label > 交易 / 投資 < / l a b e l > < d i v i d = " k i n d T i l e s " c l a s s = " t i l e - r o w " > < / d i v > < i n p u t t y p e = " h i d d e n " n a m e = " k i n d " v a l u e = " 投 資 " > < / d i v >
2026-06-03 09:21:58 +00:00
< div class = "field" > < label > 進場日期 * < / l a b e l > < i n p u t n a m e = " e n t r y _ d a t e " t y p e = " d a t e " r e q u i r e d > < / d i v >
< div class = "field" > < label > 進場價 * < / l a b e l > < i n p u t n a m e = " e n t r y _ p r i c e " t y p e = " n u m b e r " s t e p = " a n y " r e q u i r e d p l a c e h o l d e r = " 1 2 0 . 5 " > < / d i v >
< div class = "field" > < label > 股數 * < / l a b e l > < i n p u t n a m e = " s h a r e s " t y p e = " n u m b e r " s t e p = " a n y " r e q u i r e d p l a c e h o l d e r = " 1 0 0 " > < / d i v >
< div class = "field" > < label > 進場理由 < / l a b e l > < i n p u t n a m e = " e n t r y _ r e a s o n " p l a c e h o l d e r = " 資 料 中 心 營 收 續 強 , 趨 勢 回 測 支 撐 " > < / d i v >
2026-06-03 09:33:23 +00:00
< div class = "field full" > < label > 依據的心法 ( 點選色塊 ) < / l a b e l > < d i v i d = " p r i n c i p l e C h i p s " c l a s s = " p r i n c i p l e - c h i p s " > < / d i v > < i n p u t t y p e = " h i d d e n " n a m e = " p r i n c i p l e " v a l u e = " " > < / d i v >
2026-06-03 09:21:58 +00:00
< div class = "field" > < label > 出場日期 ( 留空 = 持倉中 ) < / l a b e l > < i n p u t n a m e = " e x i t _ d a t e " t y p e = " d a t e " > < / d i v >
< div class = "field" > < label > 出場價 < / l a b e l > < i n p u t n a m e = " e x i t _ p r i c e " t y p e = " n u m b e r " s t e p = " a n y " > < / d i v >
< div class = "field full" > < label > 出場理由 < /label><input name="exit_reason" placeholder="觸發減倉條件 / 停損 / 換倉 " > < / d i v >
< div class = "field full" > < label > 心得 / 復盤筆記 < / l a b e l > < t e x t a r e a n a m e = " n o t e " p l a c e h o l d e r = " 當 初 判 斷 是 否 成 立 ? 事 後 看 哪 裡 對 、 哪 裡 錯 ? " > < / t e x t a r e a > < / d i v >
< div class = "field full" > < label class = "check-inline" > < input type = "checkbox" name = "mistake" > 這筆交易我判斷犯了錯 ( 與結果無關 ) < / l a b e l > < / d i v >
2026-06-03 09:33:23 +00:00
< div class = "field full" id = "mistakeNoteWrap" style = "display:none" > < label > 違反 / 該注意的心法 < / l a b e l > < d i v i d = " m i s t a k e C h i p s " c l a s s = " p r i n c i p l e - c h i p s " > < / d i v > < i n p u t t y p e = " h i d d e n " n a m e = " m i s t a k e _ n o t e " v a l u e = " " > < / d i v >
2026-06-03 09:21:58 +00:00
< / d i v >
< div class = "form-actions" > < button type = "button" class = "btn ghost" id = "tradeFormCancel" > 取消 < / b u t t o n > < b u t t o n t y p e = " s u b m i t " c l a s s = " b t n " > 儲 存 < / b u t t o n > < / d i v >
< / f o r m > < / d i v > ` ;
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 => ( {
2026-06-03 16:42:07 +00:00
id : p . id , label : ( LearnUI . cleanPrincipleTitle ? LearnUI . cleanPrincipleTitle ( p . title ) : p . title ) . slice ( 0 , 28 ) ,
2026-06-03 09:33:23 +00:00
} ) ) ) ;
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-04 01:35:37 +00:00
initAIWidget ( ) ;
2026-06-03 09:21:58 +00:00
setView ( parseHash ( ) ) ;