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