fix doc
This commit is contained in:
parent
2effe74b22
commit
6d93f23292
57
README.md
57
README.md
|
|
@ -34,17 +34,62 @@ npm start
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Emmy 投資台:四大模組
|
||||||
|
|
||||||
|
頂部分頁可切換四個視圖(用 hash 路由:`#/`、`#/learn`、`#/stock`、`#/journal`):
|
||||||
|
|
||||||
|
- **總經**:原本的 FRED 總經儀表板。
|
||||||
|
- **學習教材**:把 `emmy/` 知識庫(學習分類、案例、110 條投資心法、538 個名詞、218 家公司、單集筆記)整理成可瀏覽、可站內跳轉、可搜尋的教材,含互動式練習題庫。
|
||||||
|
- **個股工具**:輸入一個股票代號,四個子分頁共用:
|
||||||
|
- **價格走勢**:收盤線圖,可切 3 月/1 年/5 年/全部等區間。
|
||||||
|
- **財報健檢**:自動抓真實財報(SEC EDGAR 為主、Yahoo 為輔),照「財報基本功」五步驟給紅綠燈,每條檢查連回名詞/心法。
|
||||||
|
- **投資地圖**:把「投資底層邏輯」六層漏斗(總經→產業→商業模式→管理層→估值→交易紀律)做成互動判斷流程:逐層用「是/不確定/否」作答,閘門題答否該層即「出局」,每題連到對應心法,底部彙整總結論並可一鍵「存成交易紀錄」。
|
||||||
|
- **回測**:機械式策略回測(買進持有/定期定額/均線趨勢/逢大跌進場),畫策略 vs 買進持有兩條權益曲線並列出總報酬、年化、最大回撤、在場比例、勝率等統計。
|
||||||
|
- **交易復盤**:手動記錄進出與理由,自動算已實現損益、勝率、賺賠比,並依「交易/投資」「是否犯錯」「依據心法」分組復盤。資料存在本機 `data.db`。
|
||||||
|
|
||||||
|
> 個股工具與財報健檢、回測皆會把抓到的資料存進 `data.db` 快取:歷史股價日線預設 6 小時內沿用、財報季報依「軟/硬 TTL + SEC 申報探針」判斷是否需重抓,盡量節省外部 API。所有工具僅供學習,**不構成投資建議**。價格/回測資料來源以 Yahoo 為主,被限流(429)時自動改用 Nasdaq 免金鑰歷史(美股最完整)。
|
||||||
|
|
||||||
|
### 建立知識庫(學習教材的前置步驟)
|
||||||
|
|
||||||
|
學習教材與財報健檢的連結,來自 `emmy/` 的內容快照。`emmy/` 在 `web/` 之外,所以用建置腳本產生 `data/knowledge.json` 與 `data/notes.json`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:knowledge
|
||||||
|
```
|
||||||
|
|
||||||
|
> `emmy/` 內容有更新時,重跑這個指令即可。若沒先執行,學習教材分頁會提示你建立。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 專案結構
|
## 專案結構
|
||||||
|
|
||||||
```
|
```
|
||||||
index.html 前端(純 HTML/CSS/JS,向 /api/macro 取資料後渲染)
|
index.html 前端骨架 + 總經視圖(向 /api/macro 取資料後渲染)
|
||||||
server.js Express 伺服器:提供網頁 + /api/macro(代理 FRED、1 小時快取)
|
app.js 學習/個股工具/交易復盤視圖 + 主視圖路由 + Markdown 渲染 + 共用折線圖
|
||||||
lib/indicators.js 指標字典:序列代碼、中文名、分組、是否反向、解釋文字
|
app.css 新視圖的樣式(沿用總經的深色主題變數)
|
||||||
lib/fred.js 抓取 FRED / Yahoo、做 YoY/MoM 換算、產生真實 sparkline
|
server.js Express 伺服器:網頁 + 各 /api 路由(代理外部資料、快取)
|
||||||
lib/score.js 用透明公式算出健康分數、景氣燈號與 5 個訊號
|
lib/indicators.js 指標字典:序列代碼、中文名、分組、是否反向、解釋文字
|
||||||
|
lib/fred.js 抓取 FRED / Yahoo、做 YoY/MoM 換算、產生真實 sparkline
|
||||||
|
lib/score.js 用透明公式算出健康分數、景氣燈號與 5 個訊號
|
||||||
|
lib/db.js SQLite 存取:快取、序列、分數歷史、交易復盤 trades 表
|
||||||
|
lib/knowledge.js 載入 data/knowledge.json / notes.json(學習教材)
|
||||||
|
lib/fundamentals.js 抓財報(SEC EDGAR 主、Yahoo quoteSummary 輔),正規化
|
||||||
|
lib/fincheck.js 財報基本功五步驟規則引擎,輸出紅綠燈 + 連回名詞/心法
|
||||||
|
lib/marketdata.js 抓歷史股價(Yahoo v8 主、Nasdaq 免金鑰輔),給價格走勢與回測共用
|
||||||
|
lib/backtest.js 機械式策略回測純函式:buyhold/dca/sma/dip + 績效統計
|
||||||
|
lib/investmap.js 投資地圖六層漏斗設定(每層提問清單 + 原則編號,由 server 補上標題/連結)
|
||||||
|
scripts/build-knowledge.mjs 把 ../emmy 快照成 data/*.json
|
||||||
|
data/ build:knowledge 產生的知識庫快照(已 gitignore 友善)
|
||||||
```
|
```
|
||||||
|
|
||||||
資料流:`瀏覽器 → /api/macro → (持金鑰) FRED → 換算/計分 → 回傳 JSON → 渲染`
|
資料流:
|
||||||
|
- 總經:`瀏覽器 → /api/macro → (持金鑰) FRED → 換算/計分 → JSON → 渲染`
|
||||||
|
- 學習:`build:knowledge 讀 emmy/ → data/*.json → /api/knowledge、/api/note → 渲染`
|
||||||
|
- 財報:`/api/fundamentals/:symbol → EDGAR/Yahoo → fincheck 規則 → 紅綠燈 JSON`
|
||||||
|
- 價格:`/api/price/:symbol → Yahoo/Nasdaq(DB 快取)→ 收盤線圖`
|
||||||
|
- 回測:`/api/backtest/:symbol → 用快取歷史跑 backtest → 權益曲線 + 統計`
|
||||||
|
- 地圖:`/api/investmap → investmap 設定 + 知識庫原則 → 前端互動六層漏斗 → 可存成交易`
|
||||||
|
- 復盤:`/api/trades(.../stats) → SQLite trades 表 → 自動算損益與統計`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,319 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════
|
||||||
|
Emmy 投資台 — 學習教材 / 財報健檢 / 交易復盤 的樣式
|
||||||
|
沿用 index.html 既有的 CSS 變數(--bg/--surface/--card…)
|
||||||
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── 主視圖切換 tabs ── */
|
||||||
|
.view-tabs{display:flex;gap:4px;flex-wrap:wrap}
|
||||||
|
.view-tabs a{
|
||||||
|
padding:7px 16px;border-radius:8px;font-size:.9rem;font-weight:600;color:var(--text2);cursor:pointer;
|
||||||
|
transition:background .15s,color .15s;
|
||||||
|
}
|
||||||
|
.view-tabs a:hover{color:var(--text);background:rgba(77,166,255,.08)}
|
||||||
|
.view-tabs a.active{background:rgba(77,166,255,.16);color:var(--blue)}
|
||||||
|
|
||||||
|
.view[hidden]{display:none}
|
||||||
|
|
||||||
|
/* 非總經視圖時,隱藏總經的群組子導覽 */
|
||||||
|
body[data-view="macro"] #navLinks{display:flex}
|
||||||
|
body:not([data-view="macro"]) #navLinks{display:none}
|
||||||
|
|
||||||
|
/* ── 共用:頁面區塊標題 ── */
|
||||||
|
.page{margin:24px 32px 0;animation:fadeInUp .4s ease both}
|
||||||
|
.page-head{margin-bottom:18px}
|
||||||
|
.page-title{font-size:1.35rem;font-weight:700;letter-spacing:-.01em;display:flex;align-items:center;gap:10px}
|
||||||
|
.page-sub{font-size:.85rem;color:var(--text2);margin-top:6px;line-height:1.6;max-width:880px}
|
||||||
|
.disclaimer{font-size:.72rem;color:var(--text2);background:rgba(255,138,77,.08);border:1px solid rgba(255,138,77,.2);
|
||||||
|
border-radius:8px;padding:8px 14px;margin-top:14px;line-height:1.6}
|
||||||
|
|
||||||
|
@media(max-width:900px){ .page{margin:18px 16px 0} }
|
||||||
|
|
||||||
|
/* ═══════════ 學習教材 ═══════════ */
|
||||||
|
.learn-layout{display:grid;grid-template-columns:230px 1fr;gap:22px;align-items:start}
|
||||||
|
.learn-side{position:sticky;top:78px;display:flex;flex-direction:column;gap:4px}
|
||||||
|
.learn-side .side-group{font-size:.7rem;color:var(--text2);letter-spacing:.06em;margin:12px 4px 4px;text-transform:uppercase}
|
||||||
|
.learn-side a{
|
||||||
|
padding:7px 12px;border-radius:7px;font-size:.85rem;color:var(--text);cursor:pointer;transition:.15s;
|
||||||
|
display:flex;justify-content:space-between;align-items:center;gap:8px;
|
||||||
|
}
|
||||||
|
.learn-side a:hover{background:rgba(77,166,255,.08)}
|
||||||
|
.learn-side a.active{background:rgba(77,166,255,.15);color:var(--blue)}
|
||||||
|
.learn-side a .count{font-size:.68rem;color:var(--text2)}
|
||||||
|
.learn-content{min-width:0}
|
||||||
|
|
||||||
|
@media(max-width:780px){
|
||||||
|
.learn-layout{grid-template-columns:1fr}
|
||||||
|
.learn-side{position:static;flex-direction:row;flex-wrap:wrap;gap:6px;margin-bottom:14px}
|
||||||
|
.learn-side .side-group{width:100%;margin:6px 0 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 三階段課綱卡片 */
|
||||||
|
.stage{margin-bottom:24px}
|
||||||
|
.stage-title{font-size:1.05rem;font-weight:700;margin-bottom:4px;display:flex;align-items:center;gap:8px}
|
||||||
|
.stage-badge{font-size:.66rem;font-weight:700;padding:2px 9px;border-radius:20px}
|
||||||
|
.stage-desc{font-size:.82rem;color:var(--text2);margin-bottom:12px;line-height:1.6}
|
||||||
|
.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
|
||||||
|
.module-card{
|
||||||
|
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;cursor:pointer;
|
||||||
|
transition:border-color .2s,box-shadow .2s;display:flex;flex-direction:column;gap:8px;
|
||||||
|
}
|
||||||
|
.module-card:hover{border-color:rgba(77,166,255,.35);box-shadow:0 0 18px rgba(77,166,255,.06)}
|
||||||
|
.module-card .mod-name{font-size:.98rem;font-weight:700}
|
||||||
|
.module-card .mod-meta{font-size:.74rem;color:var(--text2);line-height:1.55}
|
||||||
|
.module-card .mod-tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:2px}
|
||||||
|
.chip{font-size:.68rem;color:var(--text2);background:var(--surface);border:1px solid var(--border);
|
||||||
|
border-radius:20px;padding:2px 9px;cursor:pointer;transition:.15s;white-space:nowrap}
|
||||||
|
.chip:hover{border-color:var(--blue);color:var(--blue)}
|
||||||
|
|
||||||
|
/* 速查(名詞 / 公司 / 單集)搜尋 */
|
||||||
|
.search-box{display:flex;gap:8px;margin-bottom:14px}
|
||||||
|
.search-box input{
|
||||||
|
flex:1;background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--text);
|
||||||
|
padding:10px 14px;font-size:.9rem;outline:none;transition:.15s;font-family:inherit;
|
||||||
|
}
|
||||||
|
.search-box input:focus{border-color:var(--blue)}
|
||||||
|
.glossary-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
|
||||||
|
.gloss-item{
|
||||||
|
background:var(--card);border:1px solid var(--border);border-radius:8px;padding:9px 12px;cursor:pointer;transition:.15s;
|
||||||
|
}
|
||||||
|
.gloss-item:hover{border-color:var(--blue);background:rgba(77,166,255,.06)}
|
||||||
|
.gloss-item .gi-title{font-size:.85rem;font-weight:600;color:var(--text)}
|
||||||
|
.gloss-item .gi-sub{font-size:.7rem;color:var(--text2);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.list-meta{font-size:.76rem;color:var(--text2);margin-bottom:10px}
|
||||||
|
|
||||||
|
/* Markdown 內文渲染 */
|
||||||
|
.md{font-size:.9rem;line-height:1.75;color:var(--text)}
|
||||||
|
.md h1{font-size:1.5rem;font-weight:700;margin:.2em 0 .5em}
|
||||||
|
.md h2{font-size:1.18rem;font-weight:700;margin:1.3em 0 .5em;padding-bottom:.3em;border-bottom:1px solid var(--border)}
|
||||||
|
.md h3{font-size:1.02rem;font-weight:700;margin:1.1em 0 .4em;color:var(--text)}
|
||||||
|
.md h4{font-size:.92rem;font-weight:700;margin:1em 0 .3em;color:var(--text2)}
|
||||||
|
.md p{margin:.6em 0}
|
||||||
|
.md ul,.md ol{margin:.5em 0 .5em 1.3em}
|
||||||
|
.md li{margin:.25em 0}
|
||||||
|
.md blockquote{border-left:3px solid var(--blue);background:var(--surface);margin:.8em 0;padding:.6em 1em;
|
||||||
|
color:var(--text2);border-radius:0 8px 8px 0}
|
||||||
|
.md blockquote p{margin:.2em 0}
|
||||||
|
.md code{background:var(--surface);padding:2px 6px;border-radius:4px;color:var(--yellow);font-size:.86em;
|
||||||
|
font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||||
|
.md pre{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 14px;overflow:auto;margin:.8em 0}
|
||||||
|
.md pre code{background:none;padding:0;color:var(--text2)}
|
||||||
|
.md hr{border:none;border-top:1px solid var(--border);margin:1.2em 0}
|
||||||
|
.md table{border-collapse:collapse;width:100%;margin:.9em 0;font-size:.84rem;display:block;overflow-x:auto}
|
||||||
|
.md th,.md td{border:1px solid var(--border);padding:7px 11px;text-align:left;vertical-align:top}
|
||||||
|
.md th{background:var(--surface);font-weight:600;color:var(--text);white-space:nowrap}
|
||||||
|
.md td{color:var(--text2)}
|
||||||
|
.md a{color:var(--blue)}
|
||||||
|
.md .wlink{color:var(--purple);border-bottom:1px dashed rgba(179,136,255,.4);cursor:pointer}
|
||||||
|
.md .wlink:hover{border-bottom-style:solid}
|
||||||
|
.md .wlink.dead{color:var(--text2);border-bottom-color:transparent;cursor:default}
|
||||||
|
|
||||||
|
.back-link{display:inline-flex;align-items:center;gap:6px;font-size:.82rem;color:var(--text2);cursor:pointer;margin-bottom:14px}
|
||||||
|
.back-link:hover{color:var(--blue)}
|
||||||
|
.note-frontmatter{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px}
|
||||||
|
.fm-tag{font-size:.7rem;color:var(--text2);background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:2px 10px}
|
||||||
|
|
||||||
|
/* 練習題庫 */
|
||||||
|
.quiz-q{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:10px}
|
||||||
|
.quiz-q .q-text{font-size:.9rem;line-height:1.6}
|
||||||
|
.quiz-q .q-src{font-size:.72rem;color:var(--text2);margin-top:8px;cursor:pointer}
|
||||||
|
.quiz-q .q-src:hover{color:var(--blue)}
|
||||||
|
|
||||||
|
/* ═══════════ 財報健檢 ═══════════ */
|
||||||
|
.finbox-search{display:flex;gap:8px;margin-bottom:6px;max-width:520px}
|
||||||
|
.finbox-search input{
|
||||||
|
flex:1;background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--text);
|
||||||
|
padding:11px 15px;font-size:1rem;outline:none;font-family:inherit;letter-spacing:.04em;text-transform:uppercase;
|
||||||
|
}
|
||||||
|
.finbox-search input:focus{border-color:var(--blue)}
|
||||||
|
.finbox-search button{
|
||||||
|
background:var(--blue);color:#08111d;border:none;padding:0 22px;border-radius:8px;font-weight:700;font-size:.92rem;cursor:pointer;
|
||||||
|
}
|
||||||
|
.finbox-search button:disabled{opacity:.5;cursor:wait}
|
||||||
|
.finbox-examples{font-size:.76rem;color:var(--text2);margin-bottom:18px}
|
||||||
|
.finbox-examples b{cursor:pointer;color:var(--blue);font-weight:600;margin:0 4px}
|
||||||
|
|
||||||
|
.fin-summary{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px 24px;margin-bottom:18px;
|
||||||
|
display:grid;grid-template-columns:auto 1fr;gap:24px;align-items:center}
|
||||||
|
.fin-verdict{text-align:center}
|
||||||
|
.fin-verdict .v-big{font-size:2.4rem;font-weight:800;line-height:1}
|
||||||
|
.fin-verdict .v-sub{font-size:.78rem;color:var(--text2);margin-top:4px}
|
||||||
|
.fin-lights{display:flex;gap:18px}
|
||||||
|
.fin-light{text-align:center}
|
||||||
|
.fin-light .fl-num{font-size:1.6rem;font-weight:700;line-height:1}
|
||||||
|
.fin-light .fl-lab{font-size:.72rem;color:var(--text2);margin-top:3px}
|
||||||
|
.fin-co{font-size:.82rem;color:var(--text2);margin-bottom:8px}
|
||||||
|
.fin-co b{color:var(--text);font-size:1.05rem}
|
||||||
|
.fin-fresh{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;
|
||||||
|
font-size:.74rem;color:var(--text2);margin-bottom:16px}
|
||||||
|
|
||||||
|
.fin-step{margin-bottom:18px}
|
||||||
|
.fin-step-head{display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
||||||
|
.fin-step-num{width:24px;height:24px;border-radius:7px;background:rgba(77,166,255,.15);color:var(--blue);
|
||||||
|
font-size:.78rem;font-weight:700;display:flex;align-items:center;justify-content:center}
|
||||||
|
.fin-step-title{font-size:1rem;font-weight:700}
|
||||||
|
.check-row{
|
||||||
|
background:var(--card);border:1px solid var(--border);border-radius:9px;padding:11px 14px;margin-bottom:8px;
|
||||||
|
display:grid;grid-template-columns:8px 1fr auto;gap:12px;align-items:center;border-left:3px solid var(--border);
|
||||||
|
}
|
||||||
|
.check-row.good{border-left-color:var(--green)}
|
||||||
|
.check-row.warn{border-left-color:var(--yellow)}
|
||||||
|
.check-row.bad{border-left-color:var(--red)}
|
||||||
|
.check-row.na{border-left-color:var(--text2);opacity:.7}
|
||||||
|
.check-dot{width:9px;height:9px;border-radius:50%}
|
||||||
|
.check-row.good .check-dot{background:var(--green)}
|
||||||
|
.check-row.warn .check-dot{background:var(--yellow)}
|
||||||
|
.check-row.bad .check-dot{background:var(--red)}
|
||||||
|
.check-row.na .check-dot{background:var(--text2)}
|
||||||
|
.check-main .ck-label{font-size:.88rem;font-weight:600}
|
||||||
|
.check-main .ck-note{font-size:.78rem;color:var(--text2);line-height:1.55;margin-top:3px}
|
||||||
|
.check-main .ck-links{margin-top:5px;display:flex;flex-wrap:wrap;gap:6px}
|
||||||
|
.check-main .ck-links .wlink{font-size:.72rem}
|
||||||
|
.check-val{font-size:1.05rem;font-weight:700;text-align:right;white-space:nowrap}
|
||||||
|
.check-val.good{color:var(--green)}.check-val.warn{color:var(--yellow)}.check-val.bad{color:var(--red)}.check-val.na{color:var(--text2)}
|
||||||
|
|
||||||
|
/* ═══════════ 交易復盤 ═══════════ */
|
||||||
|
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:22px}
|
||||||
|
.stat-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px}
|
||||||
|
.stat-card .st-lab{font-size:.74rem;color:var(--text2);margin-bottom:6px}
|
||||||
|
.stat-card .st-val{font-size:1.7rem;font-weight:700;line-height:1}
|
||||||
|
.stat-card .st-sub{font-size:.72rem;color:var(--text2);margin-top:4px}
|
||||||
|
|
||||||
|
.btn{background:var(--blue);color:#08111d;border:none;padding:8px 16px;border-radius:7px;font-weight:600;font-size:.85rem;cursor:pointer;transition:.15s}
|
||||||
|
.btn:hover{filter:brightness(1.08)}
|
||||||
|
.btn.ghost{background:var(--surface);border:1px solid var(--border);color:var(--text2)}
|
||||||
|
.btn.ghost:hover{border-color:var(--blue);color:var(--blue)}
|
||||||
|
.btn.danger{background:var(--surface);border:1px solid var(--border);color:var(--red)}
|
||||||
|
.btn.danger:hover{border-color:var(--red)}
|
||||||
|
.btn.sm{padding:4px 10px;font-size:.76rem}
|
||||||
|
|
||||||
|
.journal-bar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap}
|
||||||
|
.seg{display:flex;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:3px}
|
||||||
|
.seg a{padding:6px 14px;border-radius:6px;font-size:.82rem;color:var(--text2);cursor:pointer;transition:.15s}
|
||||||
|
.seg a.active{background:var(--card);color:var(--text)}
|
||||||
|
|
||||||
|
.trade-table{width:100%;border-collapse:collapse;font-size:.82rem}
|
||||||
|
.trade-table th{text-align:left;padding:9px 10px;color:var(--text2);font-weight:600;font-size:.74rem;
|
||||||
|
border-bottom:1px solid var(--border);white-space:nowrap}
|
||||||
|
.trade-table td{padding:10px;border-bottom:1px solid var(--border);vertical-align:middle}
|
||||||
|
.trade-table tr:hover td{background:rgba(77,166,255,.04)}
|
||||||
|
.t-sym{font-weight:700;color:var(--text)}
|
||||||
|
.t-sym .t-name{font-weight:400;color:var(--text2);font-size:.76rem;margin-left:5px}
|
||||||
|
.pill{font-size:.68rem;font-weight:600;padding:2px 8px;border-radius:20px;white-space:nowrap}
|
||||||
|
.pill.long{background:rgba(0,212,170,.12);color:var(--green)}
|
||||||
|
.pill.short{background:rgba(255,77,106,.12);color:var(--red)}
|
||||||
|
.pill.invest{background:rgba(77,166,255,.12);color:var(--blue)}
|
||||||
|
.pill.trade{background:rgba(179,136,255,.12);color:var(--purple)}
|
||||||
|
.pill.open{background:rgba(255,193,77,.12);color:var(--yellow)}
|
||||||
|
.pill.mistake{background:rgba(255,77,106,.14);color:var(--red)}
|
||||||
|
.pnl-pos{color:var(--green);font-weight:700}
|
||||||
|
.pnl-neg{color:var(--red);font-weight:700}
|
||||||
|
.t-actions{display:flex;gap:6px;justify-content:flex-end}
|
||||||
|
.empty-state{text-align:center;color:var(--text2);padding:50px 20px;font-size:.9rem}
|
||||||
|
|
||||||
|
.group-stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:10px}
|
||||||
|
.group-stat h4{font-size:.86rem;margin-bottom:10px;color:var(--text)}
|
||||||
|
.gs-row{display:grid;grid-template-columns:1fr auto auto auto;gap:10px;font-size:.8rem;padding:5px 0;border-top:1px solid var(--border)}
|
||||||
|
.gs-row:first-of-type{border-top:none}
|
||||||
|
.gs-row .gs-name{color:var(--text)}
|
||||||
|
.gs-row .gs-cell{color:var(--text2);text-align:right;min-width:64px}
|
||||||
|
|
||||||
|
/* Modal 表單(沿用 index.html 的 #modalOverlay 樣式,這裡補表單元素) */
|
||||||
|
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 14px;margin-top:6px}
|
||||||
|
.form-grid .full{grid-column:1/-1}
|
||||||
|
.field label{display:block;font-size:.74rem;color:var(--text2);margin-bottom:4px}
|
||||||
|
.field input,.field select,.field textarea{
|
||||||
|
width:100%;background:var(--surface);border:1px solid var(--border);border-radius:7px;color:var(--text);
|
||||||
|
padding:8px 11px;font-size:.86rem;outline:none;font-family:inherit;
|
||||||
|
}
|
||||||
|
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--blue)}
|
||||||
|
.field textarea{resize:vertical;min-height:60px}
|
||||||
|
.form-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:18px}
|
||||||
|
.check-inline{display:flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text)}
|
||||||
|
.check-inline input{width:auto}
|
||||||
|
@media(max-width:600px){ .form-grid{grid-template-columns:1fr} }
|
||||||
|
|
||||||
|
/* ═══════════ 個股工具(子分頁 / 圖表 / 投資地圖 / 回測)═══════════ */
|
||||||
|
.sub-tabs{display:flex;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:3px;margin:4px 0 18px;flex-wrap:wrap;width:fit-content}
|
||||||
|
.sub-tabs a{padding:7px 16px;border-radius:7px;font-size:.85rem;font-weight:600;color:var(--text2);cursor:pointer;transition:.15s}
|
||||||
|
.sub-tabs a:hover{color:var(--text)}
|
||||||
|
.sub-tabs a.active{background:var(--card);color:var(--blue)}
|
||||||
|
.stk-pane[hidden]{display:none}
|
||||||
|
|
||||||
|
/* 共用折線圖 */
|
||||||
|
.chart-wrap{position:relative;width:100%;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:8px}
|
||||||
|
.chart-wrap svg{display:block;width:100%;height:auto}
|
||||||
|
.chart-empty{padding:40px 0;text-align:center;color:var(--text2);font-size:.85rem}
|
||||||
|
.chart-legend{display:flex;gap:16px;font-size:.78rem;color:var(--text2);margin-bottom:6px}
|
||||||
|
.chart-legend i{display:inline-block;width:11px;height:11px;border-radius:3px;margin-right:5px;vertical-align:middle}
|
||||||
|
.chart-hover{font-size:.78rem;color:var(--text2);margin-top:6px;min-height:1.2em}
|
||||||
|
.range-btns{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:12px}
|
||||||
|
.range-btns button{background:var(--surface);border:1px solid var(--border);color:var(--text2);border-radius:7px;
|
||||||
|
padding:5px 13px;font-size:.8rem;cursor:pointer;font-family:inherit;transition:.15s}
|
||||||
|
.range-btns button:hover{border-color:var(--blue);color:var(--text)}
|
||||||
|
.range-btns button.active{background:rgba(77,166,255,.16);border-color:var(--blue);color:var(--blue);font-weight:600}
|
||||||
|
|
||||||
|
/* 投資地圖 */
|
||||||
|
.map-core{background:rgba(179,136,255,.08);border:1px solid rgba(179,136,255,.25);border-radius:10px;
|
||||||
|
padding:13px 16px;font-size:.85rem;font-weight:700;color:var(--purple);margin-bottom:14px;line-height:1.5}
|
||||||
|
.map-core span{display:block;font-weight:400;color:var(--text2);font-size:.8rem;margin-top:5px;line-height:1.6}
|
||||||
|
.map-verdict{background:var(--card);border:1px solid var(--border);border-radius:11px;padding:15px 18px;margin-bottom:16px;
|
||||||
|
border-left:4px solid var(--text2)}
|
||||||
|
.map-verdict.good{border-left-color:var(--green)}
|
||||||
|
.map-verdict.warn{border-left-color:var(--yellow)}
|
||||||
|
.map-verdict.bad{border-left-color:var(--red)}
|
||||||
|
.map-verdict .mv-lab{font-size:.74rem;color:var(--text2);margin-bottom:4px}
|
||||||
|
.map-verdict .mv-text{font-size:.96rem;font-weight:700;line-height:1.5}
|
||||||
|
.map-verdict .mv-actions{display:flex;gap:8px;margin-top:12px}
|
||||||
|
.map-layer{background:var(--card);border:1px solid var(--border);border-radius:11px;padding:15px 18px;margin-bottom:12px;
|
||||||
|
border-left:3px solid var(--border)}
|
||||||
|
.map-layer.pass{border-left-color:var(--green)}
|
||||||
|
.map-layer.watch{border-left-color:var(--yellow)}
|
||||||
|
.map-layer.out{border-left-color:var(--red)}
|
||||||
|
.ml-head{display:flex;align-items:center;gap:10px;margin-bottom:6px}
|
||||||
|
.ml-num{width:24px;height:24px;border-radius:7px;background:rgba(77,166,255,.15);color:var(--blue);
|
||||||
|
font-size:.78rem;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||||
|
.ml-title{font-size:1rem;font-weight:700;flex:1}
|
||||||
|
.ml-badge{font-size:.68rem;font-weight:700;padding:2px 10px;border-radius:20px}
|
||||||
|
.ml-badge.good{background:rgba(0,212,170,.14);color:var(--green)}
|
||||||
|
.ml-badge.warn{background:rgba(255,193,77,.14);color:var(--yellow)}
|
||||||
|
.ml-badge.bad{background:rgba(255,77,106,.14);color:var(--red)}
|
||||||
|
.ml-badge.na{background:var(--surface);color:var(--text2)}
|
||||||
|
.ml-ask{font-size:.82rem;color:var(--text);line-height:1.6}
|
||||||
|
.ml-pillar{font-size:.74rem;color:var(--text2);margin:3px 0 10px}
|
||||||
|
.map-q{border-top:1px solid var(--border);padding:10px 0}
|
||||||
|
.map-q:last-of-type{border-bottom:1px solid var(--border)}
|
||||||
|
.mq-text{font-size:.85rem;line-height:1.55;margin-bottom:7px}
|
||||||
|
.mq-text .gate{font-size:.64rem;font-weight:700;background:rgba(255,138,77,.16);color:var(--orange);
|
||||||
|
border-radius:4px;padding:1px 6px;margin-right:7px;vertical-align:middle}
|
||||||
|
.mq-ans{display:flex;gap:7px;flex-wrap:wrap}
|
||||||
|
.ans{font-size:.78rem;padding:4px 13px;border-radius:7px;border:1px solid var(--border);background:var(--surface);
|
||||||
|
color:var(--text2);cursor:pointer;transition:.15s;user-select:none}
|
||||||
|
.ans input{display:none}
|
||||||
|
.ans:hover{border-color:var(--blue)}
|
||||||
|
.ans.yes.on{background:rgba(0,212,170,.16);border-color:var(--green);color:var(--green)}
|
||||||
|
.ans.unsure.on{background:rgba(255,193,77,.16);border-color:var(--yellow);color:var(--yellow)}
|
||||||
|
.ans.no.on{background:rgba(255,77,106,.16);border-color:var(--red);color:var(--red)}
|
||||||
|
.ml-out{font-size:.72rem;color:var(--text2);margin-top:9px;font-style:italic}
|
||||||
|
.map-q .ck-links{margin-top:6px;display:flex;flex-wrap:wrap;gap:6px}
|
||||||
|
.map-q .ck-links .wlink{font-size:.72rem}
|
||||||
|
|
||||||
|
/* 回測 */
|
||||||
|
.bt-controls{display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;background:var(--card);border:1px solid var(--border);
|
||||||
|
border-radius:10px;padding:14px 16px;margin-bottom:16px}
|
||||||
|
.bt-params{display:flex;gap:12px;flex-wrap:wrap}
|
||||||
|
.bt-field{display:flex;flex-direction:column;gap:4px}
|
||||||
|
.bt-field label{font-size:.72rem;color:var(--text2)}
|
||||||
|
.bt-field select,.bt-field input{background:var(--surface);border:1px solid var(--border);border-radius:7px;color:var(--text);
|
||||||
|
padding:8px 11px;font-size:.85rem;outline:none;font-family:inherit;min-width:120px}
|
||||||
|
.bt-field input{width:100px;min-width:0}
|
||||||
|
.bt-field select:focus,.bt-field input:focus{border-color:var(--blue)}
|
||||||
|
.bt-controls .btn{align-self:flex-end}
|
||||||
|
.bt-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:14px}
|
||||||
|
.bt-stat{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px 16px}
|
||||||
|
.bt-stat .bts-title{font-size:.84rem;font-weight:700;margin-bottom:10px}
|
||||||
|
.bts-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px 14px}
|
||||||
|
.bts-grid div{display:flex;flex-direction:column;gap:2px}
|
||||||
|
.bts-grid span{font-size:.7rem;color:var(--text2)}
|
||||||
|
.bts-grid b{font-size:1.02rem;font-weight:700}
|
||||||
|
.bt-note{font-size:.76rem;color:var(--text2);margin-top:12px;line-height:1.6}
|
||||||
|
@media(max-width:680px){ .bt-stats{grid-template-columns:1fr} }
|
||||||
|
|
@ -0,0 +1,898 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Emmy 投資台 — 學習教材 / 財報健檢 / 交易復盤
|
||||||
|
// 本檔在 index.html 的內聯 script 之後載入,可使用其全域函式
|
||||||
|
// (lineChart、HEX、cssVar…),並負責主視圖切換與三個新分頁。
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const $ = (s, r = document) => r.querySelector(s);
|
||||||
|
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
async function api(path, opts) {
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw Object.assign(new Error(data.message || res.statusText), { data });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
function fmtNum(v, d = 0) {
|
||||||
|
if (v == null || isNaN(v)) return '—';
|
||||||
|
return Number(v).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||||
|
}
|
||||||
|
function fmtPct(v, d = 1) { return v == null || isNaN(v) ? '—' : (v >= 0 ? '' : '') + Number(v).toFixed(d) + '%'; }
|
||||||
|
function fmtMoney(v) {
|
||||||
|
if (v == null || isNaN(v)) return '—';
|
||||||
|
const a = Math.abs(v), s = v < 0 ? '-' : '';
|
||||||
|
if (a >= 1e12) return s + '$' + (a / 1e12).toFixed(2) + 'T';
|
||||||
|
if (a >= 1e9) return s + '$' + (a / 1e9).toFixed(2) + 'B';
|
||||||
|
if (a >= 1e6) return s + '$' + (a / 1e6).toFixed(2) + 'M';
|
||||||
|
if (a >= 1e3) return s + '$' + (a / 1e3).toFixed(1) + 'K';
|
||||||
|
return s + '$' + a.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 輕量 Markdown 渲染(支援標題/清單/表格/引用/粗體/行內碼/[[wikilink]])
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function mdInline(t) {
|
||||||
|
t = escapeHtml(t);
|
||||||
|
t = t.replace(/`([^`]+)`/g, (m, c) => '<code>' + c + '</code>');
|
||||||
|
t = t.replace(/\[\[([^\]]+)\]\]/g, (m, inner) => wlinkHTML(inner));
|
||||||
|
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||||||
|
t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
function wlinkHTML(inner) {
|
||||||
|
let [target, display] = inner.split('|');
|
||||||
|
target = (target || '').trim();
|
||||||
|
display = (display || '').trim();
|
||||||
|
if (!display) display = target.includes('#') ? target.split('#').pop() : target.split('/').pop();
|
||||||
|
return '<span class="wlink" data-link="' + escapeHtml(target) + '">' + escapeHtml(display) + '</span>';
|
||||||
|
}
|
||||||
|
function splitRow(line) {
|
||||||
|
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
|
||||||
|
}
|
||||||
|
function renderTable(header, rows) {
|
||||||
|
let h = '<table><thead><tr>' + header.map(c => '<th>' + mdInline(c) + '</th>').join('') + '</tr></thead><tbody>';
|
||||||
|
for (const r of rows) h += '<tr>' + header.map((_, j) => '<td>' + mdInline(r[j] || '') + '</td>').join('') + '</tr>';
|
||||||
|
return h + '</tbody></table>';
|
||||||
|
}
|
||||||
|
function renderListBlock(lines) {
|
||||||
|
const root = { children: [] };
|
||||||
|
const stack = [{ indent: -1, node: root }];
|
||||||
|
for (const raw of lines) {
|
||||||
|
const m = raw.match(/^(\s*)([-*]|\d+\.)\s+(.*)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
const indent = m[1].replace(/\t/g, ' ').length;
|
||||||
|
const ordered = /\d/.test(m[2]);
|
||||||
|
const item = { ordered, html: mdInline(m[3]), children: [] };
|
||||||
|
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
|
||||||
|
stack[stack.length - 1].node.children.push(item);
|
||||||
|
stack.push({ indent, node: item });
|
||||||
|
}
|
||||||
|
const emit = (node) => {
|
||||||
|
if (!node.children.length) return '';
|
||||||
|
const ordered = node.children[0].ordered;
|
||||||
|
let h = '<' + (ordered ? 'ol' : 'ul') + '>';
|
||||||
|
for (const c of node.children) h += '<li>' + c.html + emit(c) + '</li>';
|
||||||
|
return h + '</' + (ordered ? 'ol' : 'ul') + '>';
|
||||||
|
};
|
||||||
|
return emit(root);
|
||||||
|
}
|
||||||
|
function renderMarkdown(md) {
|
||||||
|
md = String(md || '').replace(/\r\n/g, '\n');
|
||||||
|
const fences = [];
|
||||||
|
md = md.replace(/```[\s\S]*?```/g, (m) => { fences.push(m); return '\u0000F' + (fences.length - 1) + '\u0000'; });
|
||||||
|
const lines = md.split('\n');
|
||||||
|
const blank = s => !s.trim();
|
||||||
|
let html = '', i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (blank(line)) { i++; continue; }
|
||||||
|
const fm = line.match(/^\u0000F(\d+)\u0000$/);
|
||||||
|
if (fm) { const code = fences[+fm[1]].replace(/^```[^\n]*\n?/, '').replace(/```\s*$/, ''); html += '<pre><code>' + escapeHtml(code) + '</code></pre>'; i++; continue; }
|
||||||
|
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
||||||
|
if (h) { const l = h[1].length; html += `<h${l}>${mdInline(h[2])}</h${l}>`; i++; continue; }
|
||||||
|
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { html += '<hr>'; i++; continue; }
|
||||||
|
if (line.includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
||||||
|
const header = splitRow(line); i += 2; const rows = [];
|
||||||
|
while (i < lines.length && lines[i].includes('|') && !blank(lines[i])) { rows.push(splitRow(lines[i])); i++; }
|
||||||
|
html += renderTable(header, rows); continue;
|
||||||
|
}
|
||||||
|
if (/^\s*>/.test(line)) { const buf = []; while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; } html += '<blockquote>' + renderMarkdown(buf.join('\n')) + '</blockquote>'; continue; }
|
||||||
|
if (/^\s*([-*]|\d+\.)\s+/.test(line)) { const buf = []; while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i])) { buf.push(lines[i]); i++; } html += renderListBlock(buf); continue; }
|
||||||
|
const buf = [];
|
||||||
|
while (i < lines.length && !blank(lines[i]) && !/^(#{1,6})\s/.test(lines[i]) && !/^\s*([-*]|\d+\.)\s+/.test(lines[i]) && !/^\s*>/.test(lines[i]) && !/^\u0000F\d+\u0000$/.test(lines[i]) && !/^(-{3,}|\*{3,}|_{3,})$/.test(lines[i].trim()) && !(lines[i].includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]))) { buf.push(lines[i]); i++; }
|
||||||
|
if (buf.length) html += '<p>' + mdInline(buf.join(' ')) + '</p>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
// 把容器內所有 [[wikilink]] 綁定成站內跳轉;無法解析的標成 dead
|
||||||
|
function bindWlinks(container) {
|
||||||
|
$$('.wlink[data-link]', container).forEach(elx => {
|
||||||
|
const t = elx.dataset.link;
|
||||||
|
const hit = (KB.linkMap && (KB.linkMap[t] || KB.linkMap[t.split('#').pop()] || KB.linkMap[t.split('/').pop()])) || null;
|
||||||
|
if (!hit) { elx.classList.add('dead'); return; }
|
||||||
|
elx.addEventListener('click', () => openNote(hit.kind, hit.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 主視圖路由
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
const VIEW_IDS = ['macro', 'learn', 'stock', 'journal'];
|
||||||
|
const inited = {};
|
||||||
|
function parseHash() { const m = location.hash.match(/^#\/(\w+)/); const v = m ? m[1] : 'macro'; return VIEW_IDS.includes(v) ? v : 'macro'; }
|
||||||
|
function setView(view) {
|
||||||
|
document.body.dataset.view = view;
|
||||||
|
VIEW_IDS.forEach(v => { const e = $('#view-' + v); if (e) e.hidden = v !== view; });
|
||||||
|
$$('#viewTabs a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
|
||||||
|
if (view === 'learn' && !inited.learn) { inited.learn = true; initLearn(); }
|
||||||
|
if (view === 'stock' && !inited.stock) { inited.stock = true; initStock(); }
|
||||||
|
if (view === 'journal' && !inited.journal) { inited.journal = true; initJournal(); }
|
||||||
|
if (view !== 'macro') window.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
$$('#viewTabs a').forEach(a => a.addEventListener('click', () => {
|
||||||
|
location.hash = a.dataset.view === 'macro' ? '#/' : '#/' + a.dataset.view;
|
||||||
|
}));
|
||||||
|
window.addEventListener('hashchange', () => setView(parseHash()));
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 知識庫資料
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
let KB = { loaded: false, linkMap: {} };
|
||||||
|
async function ensureKnowledge() {
|
||||||
|
if (KB.loaded) return KB;
|
||||||
|
KB = await api('/api/knowledge');
|
||||||
|
KB.loaded = true;
|
||||||
|
KB.linkMap = KB.linkMap || {};
|
||||||
|
return KB;
|
||||||
|
}
|
||||||
|
// 從任何視圖點連結要看的、但 initLearn 尚未建好 DOM 時,先暫存於此,由 initLearn 收尾渲染
|
||||||
|
let pendingNote = null;
|
||||||
|
// 把一篇筆記打開在「學習教材」視圖;macro/個股→切到 learn
|
||||||
|
async function openNote(kind, id) {
|
||||||
|
await ensureKnowledge();
|
||||||
|
let note = findLocalNote(kind, id);
|
||||||
|
if (!note) { try { note = await api(`/api/note/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`); } catch (e) { note = null; } }
|
||||||
|
const finalNote = note || { body: `# 找不到這篇筆記\n(${kind} / ${id})` };
|
||||||
|
if (!inited.learn) {
|
||||||
|
// 學習教材還沒初始化:暫存,切到 learn 後由 initLearn 渲染(避免被課綱總覽蓋掉)
|
||||||
|
pendingNote = finalNote;
|
||||||
|
location.hash = '#/learn';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (document.body.dataset.view !== 'learn') location.hash = '#/learn';
|
||||||
|
renderNote(finalNote);
|
||||||
|
}
|
||||||
|
function findLocalNote(kind, id) {
|
||||||
|
if (kind === 'overview') return KB.overview;
|
||||||
|
if (kind === 'principleMap') return KB.principleMap;
|
||||||
|
if (kind === 'quiz') return KB.quiz;
|
||||||
|
if (kind === 'category') return (KB.categories || []).find(c => c.id === id);
|
||||||
|
if (kind === 'case') return (KB.cases || []).find(c => c.id === id);
|
||||||
|
if (kind === 'principle') return (KB.principles || []).find(p => p.id === id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function renderNote(note) {
|
||||||
|
const content = $('#learnContent');
|
||||||
|
const fm = note.frontmatter || {};
|
||||||
|
let tags = '';
|
||||||
|
if (fm.ticker) tags += `<span class="fm-tag">代號 ${escapeHtml([].concat(fm.ticker).join(' / '))}</span>`;
|
||||||
|
if (fm.sector) tags += `<span class="fm-tag">${escapeHtml(fm.sector)}</span>`;
|
||||||
|
if (fm.category) tags += `<span class="fm-tag">${escapeHtml(fm.category)}</span>`;
|
||||||
|
if (fm.date) tags += `<span class="fm-tag">${escapeHtml(fm.date)}</span>`;
|
||||||
|
if (Array.isArray(fm.aliases) && fm.aliases.length) tags += `<span class="fm-tag">別名 ${escapeHtml(fm.aliases.join(' · '))}</span>`;
|
||||||
|
content.innerHTML =
|
||||||
|
`<span class="back-link" id="noteBack">← 返回</span>` +
|
||||||
|
(tags ? `<div class="note-frontmatter">${tags}</div>` : '') +
|
||||||
|
`<div class="md">${renderMarkdown(note.body || '')}</div>`;
|
||||||
|
bindWlinks(content);
|
||||||
|
$('#noteBack').addEventListener('click', () => showSection(LEARN.lastSection || 'overview'));
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 學習教材視圖
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
const LEARN = { lastSection: 'overview' };
|
||||||
|
function setLearnActive(section) {
|
||||||
|
$$('#learnSide a').forEach(a => a.classList.toggle('active', a.dataset.section === section));
|
||||||
|
}
|
||||||
|
async function initLearn() {
|
||||||
|
const view = $('#view-learn');
|
||||||
|
view.innerHTML = `<div class="page"><div class="empty-state"><div class="spinner" style="width:28px;height:28px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 14px;animation:spin .8s linear infinite"></div>正在載入知識庫…</div></div>`;
|
||||||
|
try { await ensureKnowledge(); } catch (e) {
|
||||||
|
view.innerHTML = `<div class="page"><div class="empty-state">知識庫尚未建立。請先在 web/ 目錄執行 <code>npm run build:knowledge</code> 產生 data/knowledge.json,再重新整理。</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const c = KB.counts || {};
|
||||||
|
view.innerHTML = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="page-title">📚 學習教材</div>
|
||||||
|
<div class="page-sub">把 Emmy 的知識整理成從零到能跟著判斷的學習路徑:三階段課綱、心法、案例、名詞與公司速查、練習題庫。點任何 <span style="color:var(--purple)">紫色連結</span> 都能跳到對應筆記。</div>
|
||||||
|
</div>
|
||||||
|
<div class="learn-layout">
|
||||||
|
<div class="learn-side" id="learnSide">
|
||||||
|
<div class="side-group">課程</div>
|
||||||
|
<a data-section="overview">課綱總覽</a>
|
||||||
|
<a data-section="principleMap">心法地圖</a>
|
||||||
|
<a data-section="quiz">練習題庫</a>
|
||||||
|
<div class="side-group">內容</div>
|
||||||
|
<a data-section="categories">學習分類 <span class="count">${(KB.categories || []).length}</span></a>
|
||||||
|
<a data-section="cases">案例講解 <span class="count">${(KB.cases || []).length}</span></a>
|
||||||
|
<a data-section="principles">投資心法 <span class="count">${(KB.principles || []).length}</span></a>
|
||||||
|
<div class="side-group">速查</div>
|
||||||
|
<a data-section="terms">名詞 <span class="count">${c.terms || 0}</span></a>
|
||||||
|
<a data-section="companies">公司 <span class="count">${c.companies || 0}</span></a>
|
||||||
|
<a data-section="episodes">單集 <span class="count">${c.episodes || 0}</span></a>
|
||||||
|
</div>
|
||||||
|
<div class="learn-content" id="learnContent"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
$$('#learnSide a').forEach(a => a.addEventListener('click', () => showSection(a.dataset.section)));
|
||||||
|
if (pendingNote) { const n = pendingNote; pendingNote = null; renderNote(n); }
|
||||||
|
else showSection('overview');
|
||||||
|
}
|
||||||
|
function showSection(section) {
|
||||||
|
LEARN.lastSection = section;
|
||||||
|
setLearnActive(section);
|
||||||
|
const content = $('#learnContent');
|
||||||
|
if (!content) return;
|
||||||
|
if (section === 'overview') return renderNote(KB.overview || { body: '# 課綱總覽\n(尚無內容)' });
|
||||||
|
if (section === 'principleMap') return renderNote(KB.principleMap || { body: '# 心法地圖\n(尚無內容)' });
|
||||||
|
if (section === 'quiz') return renderQuiz();
|
||||||
|
if (section === 'categories') return renderCardList('學習分類', KB.categories, 'category');
|
||||||
|
if (section === 'cases') return renderCardList('案例講解', KB.cases, 'case');
|
||||||
|
if (section === 'principles') return renderPrincipleList();
|
||||||
|
if (['terms', 'companies', 'episodes'].includes(section)) return renderGlossary(section);
|
||||||
|
}
|
||||||
|
function renderCardList(title, items, kind) {
|
||||||
|
const content = $('#learnContent');
|
||||||
|
const cards = (items || []).map(it => `
|
||||||
|
<div class="module-card" data-id="${escapeHtml(it.id)}">
|
||||||
|
<div class="mod-name">${escapeHtml(it.title)}</div>
|
||||||
|
${it.summary ? `<div class="mod-meta">${escapeHtml(it.summary)}</div>` : ''}
|
||||||
|
</div>`).join('');
|
||||||
|
content.innerHTML = `<div class="page-title" style="font-size:1.1rem;margin-bottom:14px">${escapeHtml(title)}</div><div class="module-grid">${cards || '<div class="empty-state">尚無內容。</div>'}</div>`;
|
||||||
|
$$('.module-card', content).forEach(el => el.addEventListener('click', () => openNote(kind, el.dataset.id)));
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
function renderPrincipleList() {
|
||||||
|
const content = $('#learnContent');
|
||||||
|
const cards = (KB.principles || []).map(p => `
|
||||||
|
<div class="module-card" data-id="${escapeHtml(p.id)}">
|
||||||
|
<div class="mod-name">${escapeHtml(p.title)}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
content.innerHTML = `<div class="page-title" style="font-size:1.1rem;margin-bottom:6px">Emmy 投資心法</div>
|
||||||
|
<div class="list-meta">共 ${(KB.principles || []).length} 條原則。完整分群與決策流程請看「心法地圖」。</div>
|
||||||
|
<div class="module-grid">${cards}</div>`;
|
||||||
|
$$('.module-card', content).forEach(el => el.addEventListener('click', () => openNote('principle', el.dataset.id)));
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
function renderGlossary(section) {
|
||||||
|
const content = $('#learnContent');
|
||||||
|
const kind = { terms: 'term', companies: 'company', episodes: 'episode' }[section];
|
||||||
|
const all = (KB.index || []).filter(x => x.kind === kind);
|
||||||
|
const title = { terms: '名詞速查', companies: '公司速查', episodes: '單集速查' }[section];
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="page-title" style="font-size:1.1rem;margin-bottom:10px">${title}</div>
|
||||||
|
<div class="search-box"><input type="text" id="glossSearch" placeholder="搜尋${title.replace('速查', '')}…(中英別名皆可)"></div>
|
||||||
|
<div class="list-meta" id="glossCount"></div>
|
||||||
|
<div class="glossary-grid" id="glossGrid"></div>`;
|
||||||
|
const grid = $('#glossGrid'), countEl = $('#glossCount');
|
||||||
|
const draw = (q) => {
|
||||||
|
q = (q || '').trim().toLowerCase();
|
||||||
|
const list = !q ? all : all.filter(x =>
|
||||||
|
x.title.toLowerCase().includes(q) ||
|
||||||
|
(x.aliases || []).some(a => a.toLowerCase().includes(q)) ||
|
||||||
|
(x.sub || '').toLowerCase().includes(q));
|
||||||
|
countEl.textContent = `${list.length} 筆${q ? `(搜尋「${q}」)` : ''}`;
|
||||||
|
grid.innerHTML = list.slice(0, 400).map(x => `
|
||||||
|
<div class="gloss-item" data-id="${escapeHtml(x.id)}">
|
||||||
|
<div class="gi-title">${escapeHtml(x.title)}</div>
|
||||||
|
${x.sub ? `<div class="gi-sub">${escapeHtml(x.sub)}</div>` : ''}
|
||||||
|
</div>`).join('') || '<div class="empty-state">找不到符合的項目。</div>';
|
||||||
|
$$('.gloss-item', grid).forEach(el => el.addEventListener('click', () => openNote(kind, el.dataset.id)));
|
||||||
|
if (list.length > 400) countEl.textContent += ',只顯示前 400 筆,請用搜尋縮小範圍。';
|
||||||
|
};
|
||||||
|
$('#glossSearch').addEventListener('input', e => draw(e.target.value));
|
||||||
|
draw('');
|
||||||
|
window.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
function renderQuiz() {
|
||||||
|
renderNote(KB.quiz || { body: '# 練習題庫\n(尚無內容)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 共用 SVG 折線圖(價格走勢 / 回測權益曲線共用,支援多條線 + hover)
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
let _chartSeq = 0;
|
||||||
|
function drawLineChart(el, series, opts = {}) {
|
||||||
|
series = (series || []).filter(s => s.points && s.points.length >= 2);
|
||||||
|
if (!series.length) { el.innerHTML = '<div class="chart-empty">資料不足,無法繪圖。</div>'; return; }
|
||||||
|
const uid = 'c' + (++_chartSeq);
|
||||||
|
const w = 760, h = opts.height || 300, padL = 60, padR = 14, padT = 16, padB = 28;
|
||||||
|
const plotW = w - padL - padR, plotH = h - padT - padB;
|
||||||
|
const n = Math.min(...series.map(s => s.points.length));
|
||||||
|
const dates = series[0].points.map(p => p.date);
|
||||||
|
const allVals = []; series.forEach(s => s.points.forEach(p => allVals.push(p.val)));
|
||||||
|
let yMin = opts.yMin != null ? opts.yMin : Math.min(...allVals);
|
||||||
|
let yMax = opts.yMax != null ? opts.yMax : Math.max(...allVals);
|
||||||
|
if (yMin === yMax) { yMin -= 1; yMax += 1; }
|
||||||
|
if (opts.yMin == null) { const p = (yMax - yMin) * 0.08; yMin -= p; yMax += p; }
|
||||||
|
const yRange = yMax - yMin || 1;
|
||||||
|
const fmt = opts.fmt || (v => fmtNum(v, opts.decimals != null ? opts.decimals : 0));
|
||||||
|
const toX = i => padL + (i / (n - 1)) * plotW;
|
||||||
|
const toY = v => padT + (1 - (v - yMin) / yRange) * plotH;
|
||||||
|
let grid = '';
|
||||||
|
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(255,255,255,.06)"/><text x="${padL - 8}" y="${(y + 3.5).toFixed(1)}" fill="#8899aa" font-size="11" text-anchor="end">${fmt(v)}</text>`; }
|
||||||
|
let xlab = ''; 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 - 9}" fill="#8899aa" font-size="10" text-anchor="middle">${(dates[idx] || '').slice(2, 7).replace('-', '/')}</text>`; }
|
||||||
|
let paths = '', dots = '';
|
||||||
|
series.forEach(s => {
|
||||||
|
const d = s.points.slice(0, n).map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(i).toFixed(1)},${toY(p.val).toFixed(1)}`).join(' ');
|
||||||
|
paths += `<path d="${d}" fill="none" stroke="${s.color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>`;
|
||||||
|
dots += `<circle class="hd" data-c="${s.color}" r="4" fill="${s.color}" stroke="#0a0e17" stroke-width="2" style="display:none"/>`;
|
||||||
|
});
|
||||||
|
const legend = series.length > 1 ? `<div class="chart-legend">${series.map(s => `<span><i style="background:${s.color}"></i>${escapeHtml(s.name)}</span>`).join('')}</div>` : '';
|
||||||
|
el.innerHTML = `${legend}<div class="chart-wrap"><svg id="${uid}" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
${grid}${xlab}${paths}
|
||||||
|
<g class="hg" style="display:none"><line class="hl" y1="${padT}" y2="${padT + plotH}" stroke="#8899aa" stroke-dasharray="3,3"/></g>
|
||||||
|
${dots}
|
||||||
|
<rect class="ha" x="${padL}" y="${padT}" width="${plotW}" height="${plotH}" fill="transparent" style="cursor:crosshair"/>
|
||||||
|
</svg><div class="chart-hover" id="${uid}h"></div></div>`;
|
||||||
|
const svg = el.querySelector('#' + uid);
|
||||||
|
const hg = svg.querySelector('.hg'), hl = svg.querySelector('.hl'), area = svg.querySelector('.ha');
|
||||||
|
const hds = $$('.hd', svg), info = el.querySelector('#' + uid + 'h');
|
||||||
|
area.addEventListener('mousemove', evt => {
|
||||||
|
const r = svg.getBoundingClientRect();
|
||||||
|
const sx = (evt.clientX - r.left) * (w / r.width);
|
||||||
|
let i = Math.round(((sx - padL) / plotW) * (n - 1));
|
||||||
|
i = Math.max(0, Math.min(n - 1, i));
|
||||||
|
const x = toX(i);
|
||||||
|
hg.style.display = ''; hl.setAttribute('x1', x); hl.setAttribute('x2', x);
|
||||||
|
hds.forEach((dot, k) => { const p = series[k].points[i]; if (!p) return; dot.style.display = ''; dot.setAttribute('cx', x); dot.setAttribute('cy', toY(p.val)); });
|
||||||
|
info.style.display = 'block';
|
||||||
|
info.innerHTML = `<b>${dates[i]}</b> ` + series.map(s => `<span style="color:${s.color}">${series.length > 1 ? escapeHtml(s.name) + ' ' : ''}${fmt(s.points[i].val)}</span>`).join(' ');
|
||||||
|
});
|
||||||
|
area.addEventListener('mouseleave', () => { hg.style.display = 'none'; hds.forEach(d => d.style.display = 'none'); info.style.display = 'none'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 個股工具視圖(共用代號:價格走勢 / 財報健檢 / 投資地圖 / 回測)
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
const STOCK = { symbol: '', sub: 'price', priceRange: '1y', rendered: {}, mapAnswers: {}, mapCfg: null };
|
||||||
|
const SUBS = ['price', 'finbox', 'map', 'backtest'];
|
||||||
|
function initStock() {
|
||||||
|
const view = $('#view-stock');
|
||||||
|
view.innerHTML = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="page-title">📈 個股工具</div>
|
||||||
|
<div class="page-sub">輸入一檔股票代號,所有工具一次到位:價格走勢、<span class="wlink" data-link="學習分類/財報基本功">財報</span>健檢、用 Emmy 六層漏斗的<b>投資地圖</b>判斷該不該進場、以及策略<b>回測</b>。資料皆會存資料庫快取以節省 API。</div>
|
||||||
|
</div>
|
||||||
|
<div class="finbox-search">
|
||||||
|
<input type="text" id="stkSym" placeholder="輸入代號,例如 NVDA(美股最完整)" autocomplete="off">
|
||||||
|
<button id="stkGo">查詢</button>
|
||||||
|
</div>
|
||||||
|
<div class="finbox-examples">範例:<b data-sym="NVDA">NVDA</b><b data-sym="AMD">AMD</b><b data-sym="MSFT">MSFT</b><b data-sym="AVGO">AVGO</b><b data-sym="AAPL">AAPL</b></div>
|
||||||
|
<div class="sub-tabs" id="stkSub">
|
||||||
|
<a data-sub="price" class="active">價格走勢</a>
|
||||||
|
<a data-sub="finbox">財報健檢</a>
|
||||||
|
<a data-sub="map">投資地圖</a>
|
||||||
|
<a data-sub="backtest">回測</a>
|
||||||
|
</div>
|
||||||
|
<div id="stkBody">
|
||||||
|
<div class="stk-pane" id="pane-price"></div>
|
||||||
|
<div class="stk-pane" id="pane-finbox" hidden></div>
|
||||||
|
<div class="stk-pane" id="pane-map" hidden></div>
|
||||||
|
<div class="stk-pane" id="pane-backtest" hidden></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
ensureKnowledge().then(() => bindWlinks(view)).catch(() => {});
|
||||||
|
const go = () => setStockSymbol($('#stkSym').value);
|
||||||
|
$('#stkGo').addEventListener('click', go);
|
||||||
|
$('#stkSym').addEventListener('keydown', e => { if (e.key === 'Enter') go(); });
|
||||||
|
$$('.finbox-examples b', view).forEach(b => b.addEventListener('click', () => { $('#stkSym').value = b.dataset.sym; go(); }));
|
||||||
|
$$('#stkSub a').forEach(a => a.addEventListener('click', () => setSub(a.dataset.sub)));
|
||||||
|
setSub('map'); // 投資地圖不需代號也能先看判斷流程
|
||||||
|
}
|
||||||
|
function setStockSymbol(sym) {
|
||||||
|
sym = (sym || '').trim().toUpperCase();
|
||||||
|
if (!sym) return;
|
||||||
|
STOCK.symbol = sym;
|
||||||
|
STOCK.rendered = {}; // 換股票 → 各分頁重抓
|
||||||
|
$('#stkSym').value = sym;
|
||||||
|
if (STOCK.sub === 'map') setSub('price'); // 輸入代號後預設先看價格
|
||||||
|
else renderSub(STOCK.sub);
|
||||||
|
}
|
||||||
|
function setSub(sub) {
|
||||||
|
if (!SUBS.includes(sub)) sub = 'price';
|
||||||
|
STOCK.sub = sub;
|
||||||
|
$$('#stkSub a').forEach(a => a.classList.toggle('active', a.dataset.sub === sub));
|
||||||
|
SUBS.forEach(s => { const p = $('#pane-' + s); if (p) p.hidden = s !== sub; });
|
||||||
|
renderSub(sub);
|
||||||
|
}
|
||||||
|
function needSymbol(pane) {
|
||||||
|
if (STOCK.symbol) return false;
|
||||||
|
pane.innerHTML = '<div class="empty-state">請先在上方輸入股票代號。</div>';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function renderSub(sub) {
|
||||||
|
if (sub === 'price') return renderPrice();
|
||||||
|
if (sub === 'finbox') return renderFinboxPane();
|
||||||
|
if (sub === 'map') return renderMap();
|
||||||
|
if (sub === 'backtest') return renderBacktestPane();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 價格走勢 ──
|
||||||
|
const PRICE_RANGES = [['3mo', '3月'], ['6mo', '6月'], ['1y', '1年'], ['2y', '2年'], ['5y', '5年'], ['max', '全部']];
|
||||||
|
async function renderPrice(force) {
|
||||||
|
const pane = $('#pane-price');
|
||||||
|
if (needSymbol(pane)) return;
|
||||||
|
if (!force && STOCK.rendered.price === STOCK.symbol + ':' + STOCK.priceRange) return;
|
||||||
|
pane.innerHTML = `
|
||||||
|
<div class="range-btns" id="priceRange">${PRICE_RANGES.map(r => `<button data-r="${r[0]}" class="${r[0] === STOCK.priceRange ? 'active' : ''}">${r[1]}</button>`).join('')}</div>
|
||||||
|
<div id="priceHead" class="fin-co"></div>
|
||||||
|
<div id="priceChart"><div class="chart-empty">載入中…</div></div>`;
|
||||||
|
$$('#priceRange button', pane).forEach(b => b.addEventListener('click', () => { STOCK.priceRange = b.dataset.r; renderPrice(true); }));
|
||||||
|
try {
|
||||||
|
const d = await api(`/api/price/${encodeURIComponent(STOCK.symbol)}?range=${STOCK.priceRange}&interval=1d`);
|
||||||
|
STOCK.rendered.price = STOCK.symbol + ':' + STOCK.priceRange;
|
||||||
|
const pts = d.points.map(p => ({ date: p.date, val: p.close }));
|
||||||
|
const first = pts[0].val, last = pts[pts.length - 1].val;
|
||||||
|
const chg = (last / first - 1) * 100;
|
||||||
|
const chgCls = chg >= 0 ? 'pnl-pos' : 'pnl-neg';
|
||||||
|
$('#priceHead').innerHTML = `<b>${escapeHtml(d.name || d.symbol)}</b> ${escapeHtml(d.symbol)} · 收盤 ${escapeHtml(d.currency || '')} ${fmtNum(last, 2)} · 此區間 <span class="${chgCls}">${chg >= 0 ? '+' : ''}${chg.toFixed(1)}%</span>${d.cached ? ' · <span style="color:var(--text2);font-size:.8rem">快取</span>' : ''}`;
|
||||||
|
drawLineChart($('#priceChart'), [{ name: d.symbol, color: HEX.blue, points: pts }], { fmt: v => fmtNum(v, 2) });
|
||||||
|
} catch (e) {
|
||||||
|
pane.querySelector('#priceChart').innerHTML = `<div class="empty-state">無法取得 ${escapeHtml(STOCK.symbol)} 的價格:${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 財報健檢 ──
|
||||||
|
function renderFinboxPane() {
|
||||||
|
const pane = $('#pane-finbox');
|
||||||
|
if (needSymbol(pane)) return;
|
||||||
|
if (STOCK.rendered.finbox === STOCK.symbol) return;
|
||||||
|
pane.innerHTML = '<div id="finResult"></div>';
|
||||||
|
runFincheck(STOCK.symbol);
|
||||||
|
}
|
||||||
|
async function runFincheck(sym, fresh) {
|
||||||
|
sym = (sym || STOCK.symbol || '').trim().toUpperCase();
|
||||||
|
const out = $('#finResult');
|
||||||
|
if (!out) return;
|
||||||
|
if (!sym) { out.innerHTML = '<div class="empty-state">請先輸入股票代號。</div>'; return; }
|
||||||
|
out.innerHTML = `<div class="empty-state"><div class="spinner" style="width:28px;height:28px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 14px;animation:spin .8s linear infinite"></div>正在${fresh ? '重新抓取' : '查詢'} ${escapeHtml(sym)} 的財報並健檢…</div>`;
|
||||||
|
try {
|
||||||
|
const d = await api('/api/fundamentals/' + encodeURIComponent(sym) + (fresh ? '?fresh=1' : ''));
|
||||||
|
STOCK.rendered.finbox = sym;
|
||||||
|
renderFincheck(d);
|
||||||
|
} catch (e) {
|
||||||
|
out.innerHTML = `<div class="empty-state">無法取得 ${escapeHtml(sym)} 的財報:${escapeHtml((e.data && e.data.message) || e.message || '未知錯誤')}<br><span style="font-size:.8rem">可試試美股代號(如 NVDA、AMD、MSFT)。</span></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderFincheck(d) {
|
||||||
|
const out = $('#finResult');
|
||||||
|
if (!out) return;
|
||||||
|
const r = d.report || {};
|
||||||
|
const sum = r.summary || {};
|
||||||
|
const vColor = { good: 'var(--green)', warn: 'var(--yellow)', bad: 'var(--red)' }[sum.verdictColor] || 'var(--text)';
|
||||||
|
const steps = (r.steps || []).map(st => `
|
||||||
|
<div class="fin-step">
|
||||||
|
<div class="fin-step-head"><div class="fin-step-num">${st.num}</div><div class="fin-step-title">${escapeHtml(st.title)}</div></div>
|
||||||
|
${(st.checks || []).map(ck => checkRowHTML(ck)).join('')}
|
||||||
|
</div>`).join('');
|
||||||
|
const caveats = (r.caveats || []).map(c => `<div class="disclaimer">${mdLinks(c.text, c.links)}</div>`).join('');
|
||||||
|
const fetched = d._fetchedAt ? new Date(d._fetchedAt).toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : null;
|
||||||
|
const freshNote = d.cached
|
||||||
|
? `已存資料庫的快取${fetched ? `,抓取於 ${fetched}` : ''}${d._latestForm ? `(依最新申報 ${escapeHtml(d._latestForm)})` : ''}`
|
||||||
|
: `剛從來源抓取${fetched ? `(${fetched})` : ''}`;
|
||||||
|
const staleNote = d.stale ? '<span style="color:var(--orange)"> · 即時更新失敗,先顯示先前存的資料</span>' : '';
|
||||||
|
out.innerHTML = `
|
||||||
|
<div class="fin-co"><b>${escapeHtml(d.name || d.symbol)}</b> ${escapeHtml(d.symbol)}${d.price != null ? ` · 股價 $${fmtNum(d.price, 2)}` : ''} · 資料來源 ${escapeHtml(d.source || '—')}${d.asOf ? ` · 最新季別 ${escapeHtml(d.asOf)}` : ''}</div>
|
||||||
|
<div class="fin-fresh"><span>${freshNote}${staleNote}</span><button class="btn ghost sm" id="finRefresh">↻ 重新抓取</button></div>
|
||||||
|
<div class="fin-summary">
|
||||||
|
<div class="fin-verdict"><div class="v-big" style="color:${vColor}">${escapeHtml(sum.verdict || '—')}</div><div class="v-sub">${(sum.good || 0) + (sum.warn || 0) + (sum.bad || 0)} 項檢查</div></div>
|
||||||
|
<div class="fin-lights">
|
||||||
|
<div class="fin-light"><div class="fl-num" style="color:var(--green)">${sum.good || 0}</div><div class="fl-lab">綠燈 通過</div></div>
|
||||||
|
<div class="fin-light"><div class="fl-num" style="color:var(--yellow)">${sum.warn || 0}</div><div class="fl-lab">黃燈 留意</div></div>
|
||||||
|
<div class="fin-light"><div class="fl-num" style="color:var(--red)">${sum.bad || 0}</div><div class="fl-lab">紅燈 警訊</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${steps}
|
||||||
|
${caveats}`;
|
||||||
|
bindWlinks(out);
|
||||||
|
const rb = $('#finRefresh');
|
||||||
|
if (rb) rb.addEventListener('click', () => runFincheck(d.symbol, true));
|
||||||
|
}
|
||||||
|
function checkRowHTML(ck) {
|
||||||
|
const links = (ck.links || []).map(l => `<span class="wlink" data-link="${escapeHtml(l.target)}">${escapeHtml(l.label)}</span>`).join('');
|
||||||
|
return `
|
||||||
|
<div class="check-row ${ck.status}">
|
||||||
|
<span class="check-dot"></span>
|
||||||
|
<div class="check-main">
|
||||||
|
<div class="ck-label">${escapeHtml(ck.label)}</div>
|
||||||
|
${ck.note ? `<div class="ck-note">${escapeHtml(ck.note)}</div>` : ''}
|
||||||
|
${links ? `<div class="ck-links">${links}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="check-val ${ck.status}">${escapeHtml(ck.value != null ? ck.value : '—')}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
// 把 {text, links:[{target,label}]} 的 [label] 佔位轉成 wlink(links 依序替換)
|
||||||
|
function mdLinks(text, links) {
|
||||||
|
let i = 0;
|
||||||
|
return escapeHtml(text).replace(/\{link\}/g, () => {
|
||||||
|
const l = (links || [])[i++]; if (!l) return '';
|
||||||
|
return `<span class="wlink" data-link="${escapeHtml(l.target)}">${escapeHtml(l.label)}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 投資地圖(互動六層漏斗)──
|
||||||
|
const ANS = [['yes', '是'], ['unsure', '不確定'], ['no', '否']];
|
||||||
|
function layerStatus(L) {
|
||||||
|
const ans = L.questions.map((_, qi) => STOCK.mapAnswers[L.key + ':' + qi]);
|
||||||
|
const answered = ans.filter(Boolean);
|
||||||
|
const gateNo = L.questions.some((q, qi) => q.gate && ans[qi] === 'no');
|
||||||
|
if (gateNo) return 'out';
|
||||||
|
if (!answered.length) return 'pending';
|
||||||
|
if (ans.every(a => a === 'yes')) return 'pass';
|
||||||
|
return 'watch';
|
||||||
|
}
|
||||||
|
const ST_META = {
|
||||||
|
pass: { lab: '通過', cls: 'good' }, watch: { lab: '待確認', cls: 'warn' },
|
||||||
|
out: { lab: '出局', cls: 'bad' }, pending: { lab: '未評估', cls: 'na' },
|
||||||
|
};
|
||||||
|
async function renderMap() {
|
||||||
|
const pane = $('#pane-map');
|
||||||
|
pane.innerHTML = '<div class="empty-state"><div class="spinner" style="width:26px;height:26px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 12px;animation:spin .8s linear infinite"></div>載入投資地圖…</div>';
|
||||||
|
if (!STOCK.mapCfg) {
|
||||||
|
try { STOCK.mapCfg = await api('/api/investmap'); await ensureKnowledge(); }
|
||||||
|
catch (e) { pane.innerHTML = `<div class="empty-state">載入投資地圖失敗:${escapeHtml(e.message || '')}</div>`; return; }
|
||||||
|
}
|
||||||
|
drawMap();
|
||||||
|
}
|
||||||
|
function drawMap() {
|
||||||
|
const pane = $('#pane-map');
|
||||||
|
const cfg = STOCK.mapCfg;
|
||||||
|
const target = STOCK.symbol ? `<b>${escapeHtml(STOCK.symbol)}</b>` : '這檔標的';
|
||||||
|
let firstOut = -1;
|
||||||
|
const layersHTML = cfg.layers.map((L, idx) => {
|
||||||
|
const st = layerStatus(L);
|
||||||
|
if (st === 'out' && firstOut < 0) firstOut = idx;
|
||||||
|
const meta = ST_META[st];
|
||||||
|
const qs = L.questions.map((q, qi) => {
|
||||||
|
const cur = STOCK.mapAnswers[L.key + ':' + qi];
|
||||||
|
const radios = ANS.map(([v, lab]) => `<label class="ans ${v} ${cur === v ? 'on' : ''}"><input type="radio" name="${L.key}_${qi}" value="${v}" ${cur === v ? 'checked' : ''}>${lab}</label>`).join('');
|
||||||
|
const links = (q.principles || []).map(p => `<span class="wlink ${p.id ? '' : 'dead'}" ${p.id ? `data-pid="${escapeHtml(p.id)}"` : ''}>${escapeHtml(p.title)}</span>`).join('');
|
||||||
|
return `<div class="map-q">
|
||||||
|
<div class="mq-text">${q.gate ? '<span class="gate">閘門</span>' : ''}${escapeHtml(q.q)}</div>
|
||||||
|
<div class="mq-ans" data-layer="${L.key}" data-qi="${qi}">${radios}</div>
|
||||||
|
${links ? `<div class="ck-links">${links}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
return `<div class="map-layer ${st}">
|
||||||
|
<div class="ml-head"><div class="ml-num">${idx + 1}</div><div class="ml-title">${escapeHtml(L.title)}</div><span class="ml-badge ${meta.cls}">${meta.lab}</span></div>
|
||||||
|
<div class="ml-ask">${escapeHtml(L.ask)}</div>
|
||||||
|
<div class="ml-pillar">${escapeHtml(L.pillar)}</div>
|
||||||
|
${qs}
|
||||||
|
<div class="ml-out">出局條件:${escapeHtml(L.out)}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// 彙整結論
|
||||||
|
const statuses = cfg.layers.map(layerStatus);
|
||||||
|
const anyAnswered = Object.keys(STOCK.mapAnswers).length > 0;
|
||||||
|
let verdict, vcls;
|
||||||
|
if (firstOut >= 0) { verdict = `不建議進場:第 ${firstOut + 1} 層「${cfg.layers[firstOut].title}」出局,依漏斗原則應停手。`; vcls = 'bad'; }
|
||||||
|
else if (statuses.every(s => s === 'pass')) { verdict = '六層皆通過,可進入交易計畫(記得設好減倉/停損規則與底倉)。'; vcls = 'good'; }
|
||||||
|
else if (anyAnswered) { verdict = '初步可行,但仍有「待確認」項目——把不確定的補齊再決定。'; vcls = 'warn'; }
|
||||||
|
else { verdict = '逐層回答下面的提問,系統會即時告訴你哪一層卡關。'; vcls = 'na'; }
|
||||||
|
|
||||||
|
pane.innerHTML = `
|
||||||
|
<div class="map-core">🧭 下單前的核心提問<br><span>${escapeHtml(cfg.coreQuestion)}</span></div>
|
||||||
|
<div class="map-verdict ${vcls}"><div class="mv-lab">${target} 的判斷</div><div class="mv-text">${verdict}</div>
|
||||||
|
<div class="mv-actions"><button class="btn ghost sm" id="mapReset">重設</button>${STOCK.symbol ? '<button class="btn sm" id="mapSave">存成交易紀錄</button>' : ''}</div>
|
||||||
|
</div>
|
||||||
|
${layersHTML}
|
||||||
|
<div class="disclaimer">這是把 Emmy「<span class="wlink" data-link="學習分類/投資底層邏輯">投資底層邏輯</span>」六層漏斗變成的自我檢查工具,幫你結構化判斷,<b>不構成投資建議</b>。任何一層出局就停手,是漏斗的精神。</div>`;
|
||||||
|
|
||||||
|
// 綁定:作答(即時重繪)、原則連結、按鈕
|
||||||
|
$$('.mq-ans', pane).forEach(box => {
|
||||||
|
$$('input[type=radio]', box).forEach(r => r.addEventListener('change', () => {
|
||||||
|
STOCK.mapAnswers[box.dataset.layer + ':' + box.dataset.qi] = r.value;
|
||||||
|
drawMap();
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
$$('.wlink[data-pid]', pane).forEach(el => el.addEventListener('click', () => openNote('principle', el.dataset.pid)));
|
||||||
|
bindWlinks(pane);
|
||||||
|
const rs = $('#mapReset'); if (rs) rs.addEventListener('click', () => { STOCK.mapAnswers = {}; drawMap(); });
|
||||||
|
const sv = $('#mapSave'); if (sv) sv.addEventListener('click', saveMapToJournal);
|
||||||
|
}
|
||||||
|
function saveMapToJournal() {
|
||||||
|
const cfg = STOCK.mapCfg;
|
||||||
|
const statuses = cfg.layers.map((L, i) => ({ i, key: L.key, title: L.title, st: layerStatus(L) }));
|
||||||
|
const firstOut = statuses.find(s => s.st === 'out');
|
||||||
|
const verdict = firstOut ? `投資地圖:第${firstOut.i + 1}層「${firstOut.title}」出局`
|
||||||
|
: (statuses.every(s => s.st === 'pass') ? '投資地圖:六層皆通過' : '投資地圖:初步可行、仍有待確認');
|
||||||
|
const noteLines = statuses.map(s => `${s.i + 1}.${s.title}:${ST_META[s.st].lab}`).join('|');
|
||||||
|
// 找原則五十四(三面向判斷交易)當預設依據
|
||||||
|
let principle = '';
|
||||||
|
for (const L of cfg.layers) for (const q of L.questions) for (const p of (q.principles || [])) if (p.num === 54 && p.id) principle = 'Emmy 投資心法#' + p.id;
|
||||||
|
openTradeForm({ symbol: STOCK.symbol, entry_reason: verdict, note: '六層漏斗評估:' + noteLines, principle });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 回測 ──
|
||||||
|
const BT_STRATS = {
|
||||||
|
buyhold: { label: '買進持有(基準)', params: [] },
|
||||||
|
dca: { label: '定期定額(每月)', params: [{ key: 'monthly', label: '每月投入', def: 1000 }] },
|
||||||
|
sma: { label: '均線趨勢(短>長在場)', params: [{ key: 'short', label: '短均(日)', def: 50 }, { key: 'long', label: '長均(日)', def: 200 }] },
|
||||||
|
dip: { label: '逢大跌進場(回落%後買進)', params: [{ key: 'drop', label: '距高點回落%', def: 15 }] },
|
||||||
|
};
|
||||||
|
const BT_RANGES = [['1y', '1年'], ['2y', '2年'], ['5y', '5年'], ['10y', '10年'], ['max', '全部']];
|
||||||
|
function renderBacktestPane() {
|
||||||
|
const pane = $('#pane-backtest');
|
||||||
|
if (needSymbol(pane)) return;
|
||||||
|
if (STOCK.rendered.backtest === STOCK.symbol) return;
|
||||||
|
STOCK.rendered.backtest = STOCK.symbol;
|
||||||
|
if (!STOCK.bt) STOCK.bt = { strategy: 'sma', range: '5y', params: {} };
|
||||||
|
pane.innerHTML = `
|
||||||
|
<div class="bt-controls">
|
||||||
|
<div class="bt-field"><label>策略</label><select id="btStrat">${Object.entries(BT_STRATS).map(([k, v]) => `<option value="${k}" ${k === STOCK.bt.strategy ? 'selected' : ''}>${v.label}</option>`).join('')}</select></div>
|
||||||
|
<div class="bt-field"><label>期間</label><select id="btRange">${BT_RANGES.map(r => `<option value="${r[0]}" ${r[0] === STOCK.bt.range ? 'selected' : ''}>${r[1]}</option>`).join('')}</select></div>
|
||||||
|
<div id="btParams" class="bt-params"></div>
|
||||||
|
<button class="btn" id="btRun">跑回測</button>
|
||||||
|
</div>
|
||||||
|
<div id="btResult"><div class="empty-state">選好策略與期間,按「跑回測」。以還原股價、初始資金 $10,000 模擬。</div></div>`;
|
||||||
|
const drawParams = () => {
|
||||||
|
const s = BT_STRATS[$('#btStrat').value];
|
||||||
|
$('#btParams').innerHTML = s.params.map(p => `<div class="bt-field"><label>${escapeHtml(p.label)}</label><input type="number" step="any" data-pk="${p.key}" value="${STOCK.bt.params[p.key] != null ? STOCK.bt.params[p.key] : p.def}"></div>`).join('');
|
||||||
|
};
|
||||||
|
$('#btStrat').addEventListener('change', drawParams);
|
||||||
|
drawParams();
|
||||||
|
$('#btRun').addEventListener('click', runBacktestUI);
|
||||||
|
}
|
||||||
|
async function runBacktestUI() {
|
||||||
|
STOCK.bt.strategy = $('#btStrat').value;
|
||||||
|
STOCK.bt.range = $('#btRange').value;
|
||||||
|
const params = {}; $$('#btParams input').forEach(i => params[i.dataset.pk] = i.value); STOCK.bt.params = params;
|
||||||
|
const out = $('#btResult');
|
||||||
|
out.innerHTML = `<div class="empty-state"><div class="spinner" style="width:26px;height:26px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 12px;animation:spin .8s linear infinite"></div>回測中…</div>`;
|
||||||
|
const qs = new URLSearchParams({ strategy: STOCK.bt.strategy, range: STOCK.bt.range, ...params });
|
||||||
|
try {
|
||||||
|
const d = await api(`/api/backtest/${encodeURIComponent(STOCK.symbol)}?${qs}`);
|
||||||
|
renderBacktest(d);
|
||||||
|
} catch (e) {
|
||||||
|
out.innerHTML = `<div class="empty-state">回測失敗:${escapeHtml((e.data && e.data.message) || e.message || '')}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderBacktest(d) {
|
||||||
|
const out = $('#btResult');
|
||||||
|
const money = v => '$' + fmtNum(v, 0);
|
||||||
|
const series = [{ name: d.strategyLabel, color: HEX.blue, points: d.equity }];
|
||||||
|
if (d.benchmark) series.push({ name: '買進持有', color: HEX.text2, points: d.benchmark });
|
||||||
|
const statCard = (title, s, color) => s ? `
|
||||||
|
<div class="bt-stat" style="border-top:3px solid ${color}">
|
||||||
|
<div class="bts-title">${escapeHtml(title)}</div>
|
||||||
|
<div class="bts-grid">
|
||||||
|
<div><span>期末價值</span><b>${money(s.finalValue)}</b></div>
|
||||||
|
<div><span>總報酬</span><b class="${s.totalReturn >= 0 ? 'pnl-pos' : 'pnl-neg'}">${s.totalReturn >= 0 ? '+' : ''}${s.totalReturn.toFixed(1)}%</b></div>
|
||||||
|
<div><span>年化(CAGR)</span><b class="${s.cagr >= 0 ? 'pnl-pos' : 'pnl-neg'}">${s.cagr >= 0 ? '+' : ''}${s.cagr.toFixed(1)}%</b></div>
|
||||||
|
<div><span>最大回撤</span><b class="pnl-neg">-${s.maxDrawdown.toFixed(1)}%</b></div>
|
||||||
|
<div><span>在場比例</span><b>${s.exposure.toFixed(0)}%</b></div>
|
||||||
|
<div><span>${s.winRate != null ? '勝率' : '進場次數'}</span><b>${s.winRate != null ? s.winRate.toFixed(0) + '%(' + s.trades + '次)' : s.trades + ' 次'}</b></div>
|
||||||
|
</div>
|
||||||
|
</div>` : '';
|
||||||
|
out.innerHTML = `
|
||||||
|
<div class="fin-co"><b>${escapeHtml(d.name || d.symbol)}</b> ${escapeHtml(d.symbol)} · ${escapeHtml(d.strategyLabel)} · ${escapeHtml(d.from)} ~ ${escapeHtml(d.to)}${d.cached ? ' · <span style="color:var(--text2);font-size:.8rem">快取</span>' : ''}</div>
|
||||||
|
<div id="btChart"></div>
|
||||||
|
<div class="bt-stats">${statCard(d.strategyLabel, d.stats, HEX.blue)}${statCard('買進持有', d.benchStats, HEX.text2)}</div>
|
||||||
|
${d.note ? `<div class="bt-note">${escapeHtml(d.note)}</div>` : ''}
|
||||||
|
<div class="disclaimer">回測以歷史還原股價模擬、未計交易成本與稅,且<b>過去績效不代表未來</b>。這是用來理解策略行為(如趨勢進出 vs 一直持有)的學習工具,不構成投資建議。對照 <span class="wlink" data-link="Emmy 投資心法#原則十三:長期趨勢跌就買">長期趨勢跌就買</span>、<span class="wlink" data-link="Emmy 投資心法#原則五十九:觸發式減倉">觸發式減倉</span>。</div>`;
|
||||||
|
drawLineChart($('#btChart'), series, { fmt: money });
|
||||||
|
bindWlinks(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 交易復盤視圖
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
const JOURNAL = { tab: 'all', trades: [], stats: null };
|
||||||
|
function initJournal() {
|
||||||
|
const view = $('#view-journal');
|
||||||
|
view.innerHTML = `
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="page-title">📓 交易復盤</div>
|
||||||
|
<div class="page-sub">記錄每一筆進出與理由,自動算盈虧、勝率與賺賠比。重點不是「賺或賠」,而是<b>當初的判斷依據是否成立</b>——標記犯錯與依據的心法,定期回頭復盤。對應 <span class="wlink" data-link="學習分類/交易與資金管理">交易與資金管理</span>。</div>
|
||||||
|
</div>
|
||||||
|
<div id="journalStats"></div>
|
||||||
|
<div class="journal-bar">
|
||||||
|
<div class="seg" id="journalSeg">
|
||||||
|
<a data-tab="all" class="active">全部</a>
|
||||||
|
<a data-tab="open">持倉中</a>
|
||||||
|
<a data-tab="closed">已平倉</a>
|
||||||
|
<a data-tab="review">復盤分析</a>
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="addTradeBtn">+ 新增交易</button>
|
||||||
|
</div>
|
||||||
|
<div id="journalBody"></div>
|
||||||
|
</div>`;
|
||||||
|
ensureKnowledge().then(() => bindWlinks(view)).catch(() => {});
|
||||||
|
$$('#journalSeg a').forEach(a => a.addEventListener('click', () => { JOURNAL.tab = a.dataset.tab; $$('#journalSeg a').forEach(x => x.classList.toggle('active', x === a)); renderJournalBody(); }));
|
||||||
|
$('#addTradeBtn').addEventListener('click', () => openTradeForm());
|
||||||
|
loadTrades();
|
||||||
|
}
|
||||||
|
async function loadTrades() {
|
||||||
|
try {
|
||||||
|
const [t, s] = await Promise.all([api('/api/trades'), api('/api/trades/stats')]);
|
||||||
|
JOURNAL.trades = t.trades || [];
|
||||||
|
JOURNAL.stats = s;
|
||||||
|
renderJournalStats();
|
||||||
|
renderJournalBody();
|
||||||
|
} catch (e) {
|
||||||
|
const b = $('#journalBody'); if (b) b.innerHTML = `<div class="empty-state">載入交易紀錄失敗:${escapeHtml(e.message || '')}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderJournalStats() {
|
||||||
|
const el = $('#journalStats'); if (!el) return;
|
||||||
|
const s = JOURNAL.stats || {};
|
||||||
|
const pnlCls = (s.totalPnl || 0) >= 0 ? 'pnl-pos' : 'pnl-neg';
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-card"><div class="st-lab">已實現損益</div><div class="st-val ${pnlCls}">${s.totalPnl != null ? fmtMoney(s.totalPnl) : '—'}</div><div class="st-sub">${s.closed || 0} 筆已平倉 · ${s.open || 0} 筆持倉</div></div>
|
||||||
|
<div class="stat-card"><div class="st-lab">勝率</div><div class="st-val">${s.winRate != null ? s.winRate.toFixed(0) + '%' : '—'}</div><div class="st-sub">${s.wins || 0} 勝 / ${s.losses || 0} 敗</div></div>
|
||||||
|
<div class="stat-card"><div class="st-lab">賺賠比 (Payoff)</div><div class="st-val">${s.payoff != null ? s.payoff.toFixed(2) : '—'}</div><div class="st-sub">平均賺 ${s.avgWin != null ? fmtMoney(s.avgWin) : '—'} / 賠 ${s.avgLoss != null ? fmtMoney(Math.abs(s.avgLoss)) : '—'}</div></div>
|
||||||
|
<div class="stat-card"><div class="st-lab">紀律提醒</div><div class="st-val" style="font-size:1rem;font-weight:600;line-height:1.4">六成看對<br>就夠賺錢</div><div class="st-sub">勝率不必高,賺賠比是關鍵</div></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function renderJournalBody() {
|
||||||
|
const body = $('#journalBody');
|
||||||
|
if (!body) return;
|
||||||
|
if (JOURNAL.tab === 'review') return renderReview();
|
||||||
|
let list = JOURNAL.trades.slice();
|
||||||
|
if (JOURNAL.tab === 'open') list = list.filter(t => !t.closed);
|
||||||
|
if (JOURNAL.tab === 'closed') list = list.filter(t => t.closed);
|
||||||
|
if (!list.length) { body.innerHTML = `<div class="empty-state">${JOURNAL.trades.length ? '此分類沒有交易。' : '還沒有任何交易紀錄。點右上角「+ 新增交易」開始記錄你的第一筆。'}</div>`; return; }
|
||||||
|
const rows = list.map(t => {
|
||||||
|
const dirPill = `<span class="pill ${t.direction === 'short' ? 'short' : 'long'}">${t.direction === 'short' ? '做空' : '做多'}</span>`;
|
||||||
|
const kindPill = t.kind ? `<span class="pill ${t.kind === '投資' ? 'invest' : 'trade'}">${escapeHtml(t.kind)}</span>` : '';
|
||||||
|
const statusPill = t.closed ? '' : '<span class="pill open">持倉</span>';
|
||||||
|
const mistakePill = t.mistake ? '<span class="pill mistake">犯錯</span>' : '';
|
||||||
|
const pnl = t.closed ? `<span class="${t.pnl >= 0 ? 'pnl-pos' : 'pnl-neg'}">${fmtMoney(t.pnl)}<br><span style="font-size:.74rem;font-weight:400">${t.pnl_pct >= 0 ? '+' : ''}${t.pnl_pct != null ? t.pnl_pct.toFixed(1) : '—'}%</span></span>` : '<span style="color:var(--text2)">—</span>';
|
||||||
|
return `<tr>
|
||||||
|
<td><span class="t-sym">${escapeHtml(t.symbol)}${t.name ? `<span class="t-name">${escapeHtml(t.name)}</span>` : ''}</span></td>
|
||||||
|
<td>${dirPill} ${kindPill} ${statusPill} ${mistakePill}</td>
|
||||||
|
<td>${escapeHtml(t.entry_date || '—')}<br><span style="color:var(--text2);font-size:.76rem">$${fmtNum(t.entry_price, 2)} × ${fmtNum(t.shares, 0)}</span></td>
|
||||||
|
<td>${t.closed ? escapeHtml(t.exit_date || '—') + `<br><span style="color:var(--text2);font-size:.76rem">$${fmtNum(t.exit_price, 2)}</span>` : '<span style="color:var(--text2)">—</span>'}</td>
|
||||||
|
<td>${pnl}</td>
|
||||||
|
<td style="max-width:200px;color:var(--text2);font-size:.78rem">${escapeHtml(t.entry_reason || '')}${t.principle ? `<br><span class="wlink" data-link="${escapeHtml(t.principle)}" style="font-size:.74rem">依據:${escapeHtml(t.principle.split('#').pop())}</span>` : ''}</td>
|
||||||
|
<td class="t-actions"><button class="btn ghost sm" data-edit="${t.id}">編輯</button><button class="btn danger sm" data-del="${t.id}">刪</button></td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
body.innerHTML = `<table class="trade-table">
|
||||||
|
<thead><tr><th>標的</th><th>類型</th><th>進場</th><th>出場</th><th>已實現損益</th><th>理由 / 依據</th><th></th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody></table>`;
|
||||||
|
bindWlinks(body);
|
||||||
|
$$('[data-edit]', body).forEach(b => b.addEventListener('click', () => openTradeForm(JOURNAL.trades.find(t => t.id == b.dataset.edit))));
|
||||||
|
$$('[data-del]', body).forEach(b => b.addEventListener('click', () => deleteTrade(b.dataset.del)));
|
||||||
|
}
|
||||||
|
function renderReview() {
|
||||||
|
const s = JOURNAL.stats || {};
|
||||||
|
const groupHTML = (title, rows, note) => {
|
||||||
|
if (!rows || !rows.length) return '';
|
||||||
|
return `<div class="group-stat"><h4>${escapeHtml(title)}${note ? ` <span style="color:var(--text2);font-weight:400;font-size:.76rem">${escapeHtml(note)}</span>` : ''}</h4>
|
||||||
|
<div class="gs-row" style="color:var(--text2);font-size:.72rem"><span class="gs-name"></span><span class="gs-cell">筆數</span><span class="gs-cell">勝率</span><span class="gs-cell">損益</span></div>
|
||||||
|
${rows.map(r => `<div class="gs-row"><span class="gs-name">${escapeHtml(r.key)}</span><span class="gs-cell">${r.count}</span><span class="gs-cell">${r.winRate != null ? r.winRate.toFixed(0) + '%' : '—'}</span><span class="gs-cell ${r.pnl >= 0 ? 'pnl-pos' : 'pnl-neg'}">${fmtMoney(r.pnl)}</span></div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
const body = $('#journalBody');
|
||||||
|
if (!body) return;
|
||||||
|
if (!s.closed) { body.innerHTML = '<div class="empty-state">還沒有已平倉的交易可供復盤。先記錄並平倉幾筆交易,這裡就會出現分析。</div>'; return; }
|
||||||
|
body.innerHTML = `
|
||||||
|
${groupHTML('依「交易 vs 投資」', s.byKind)}
|
||||||
|
${groupHTML('依「是否犯錯」', s.byMistake, '結果論陷阱:賺錢不代表判斷對,賠錢不代表判斷錯')}
|
||||||
|
${groupHTML('依「依據的心法」', s.byPrinciple)}
|
||||||
|
<div class="disclaimer">復盤重點:找出「賠錢但判斷正確(可接受)」與「賺錢但其實犯錯(運氣)」的交易。對照 <span class="wlink" data-link="Emmy 投資心法#原則九十六:結果論陷阱(Outcome Bias)">結果論陷阱</span>、<span class="wlink" data-link="Emmy 投資心法#原則六十二:賣弱留強">賣弱留強</span>、<span class="wlink" data-link="Emmy 投資心法#原則五十九:觸發式減倉">觸發式減倉</span>。</div>`;
|
||||||
|
bindWlinks(body);
|
||||||
|
}
|
||||||
|
async function deleteTrade(id) {
|
||||||
|
if (!confirm('確定刪除這筆交易紀錄?')) return;
|
||||||
|
try { await api('/api/trades/' + id, { method: 'DELETE' }); await loadTrades(); }
|
||||||
|
catch (e) { alert('刪除失敗:' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 交易表單 Modal ──
|
||||||
|
function ensureTradeModal() {
|
||||||
|
if ($('#tradeModal')) return;
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.id = 'tradeModal';
|
||||||
|
div.className = 'view'; // reuse nothing; styled inline below
|
||||||
|
div.style.cssText = 'position:fixed;inset:0;z-index:600;background:rgba(4,8,14,.72);backdrop-filter:blur(3px);display:none;align-items:center;justify-content:center;padding:20px';
|
||||||
|
div.innerHTML = `<div class="modal-panel" style="width:min(640px,100%)">
|
||||||
|
<div class="modal-head"><div class="modal-title" id="tradeFormTitle">新增交易</div><button class="modal-close" id="tradeFormClose">✕</button></div>
|
||||||
|
<form id="tradeForm"><div class="form-grid">
|
||||||
|
<div class="field"><label>股票代號 *</label><input name="symbol" required placeholder="NVDA"></div>
|
||||||
|
<div class="field"><label>名稱</label><input name="name" placeholder="輝達"></div>
|
||||||
|
<div class="field"><label>方向</label><select name="direction"><option value="long">做多 Long</option><option value="short">做空 Short</option></select></div>
|
||||||
|
<div class="field"><label>交易 / 投資</label><select name="kind"><option value="投資">投資(看基本面與趨勢)</option><option value="交易">交易(看情緒與資金)</option></select></div>
|
||||||
|
<div class="field"><label>進場日期 *</label><input name="entry_date" type="date" required></div>
|
||||||
|
<div class="field"><label>進場價 *</label><input name="entry_price" type="number" step="any" required placeholder="120.5"></div>
|
||||||
|
<div class="field"><label>股數 *</label><input name="shares" type="number" step="any" required placeholder="100"></div>
|
||||||
|
<div class="field"><label>進場理由</label><input name="entry_reason" placeholder="資料中心營收續強,趨勢回測支撐"></div>
|
||||||
|
<div class="field full"><label>依據的心法</label><select name="principle"><option value="">(不指定)</option></select></div>
|
||||||
|
<div class="field"><label>出場日期(留空=持倉中)</label><input name="exit_date" type="date"></div>
|
||||||
|
<div class="field"><label>出場價</label><input name="exit_price" type="number" step="any"></div>
|
||||||
|
<div class="field full"><label>出場理由</label><input name="exit_reason" placeholder="觸發減倉條件 / 停損 / 換倉"></div>
|
||||||
|
<div class="field full"><label>心得 / 復盤筆記</label><textarea name="note" placeholder="當初判斷是否成立?事後看哪裡對、哪裡錯?"></textarea></div>
|
||||||
|
<div class="field full"><label class="check-inline"><input type="checkbox" name="mistake"> 這筆交易我判斷犯了錯(與結果無關)</label></div>
|
||||||
|
<div class="field full" id="mistakeNoteWrap" style="display:none"><label>違反 / 該注意的心法</label><select name="mistake_note"><option value="">(不指定)</option></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions"><button type="button" class="btn ghost" id="tradeFormCancel">取消</button><button type="submit" class="btn">儲存</button></div>
|
||||||
|
</form></div>`;
|
||||||
|
document.body.appendChild(div);
|
||||||
|
$('#tradeFormClose').addEventListener('click', closeTradeForm);
|
||||||
|
$('#tradeFormCancel').addEventListener('click', closeTradeForm);
|
||||||
|
div.addEventListener('click', e => { if (e.target === div) closeTradeForm(); });
|
||||||
|
$('#tradeForm [name=mistake]').addEventListener('change', e => { $('#mistakeNoteWrap').style.display = e.target.checked ? '' : 'none'; });
|
||||||
|
$('#tradeForm').addEventListener('submit', submitTradeForm);
|
||||||
|
}
|
||||||
|
function principleOptions(selected) {
|
||||||
|
const ps = (KB.principles || []);
|
||||||
|
return '<option value="">(不指定)</option>' + ps.map(p =>
|
||||||
|
`<option value="${escapeHtml('Emmy 投資心法#' + p.id)}" ${('Emmy 投資心法#' + p.id) === selected ? 'selected' : ''}>${escapeHtml(p.title)}</option>`).join('');
|
||||||
|
}
|
||||||
|
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 : '';
|
||||||
|
f.principle.innerHTML = principleOptions(trade ? trade.principle : '');
|
||||||
|
f.mistake_note.innerHTML = principleOptions(trade ? trade.mistake_note : '');
|
||||||
|
if (trade) {
|
||||||
|
['symbol', 'name', 'direction', 'kind', '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.mistake.checked = !!trade.mistake;
|
||||||
|
f.principle.value = trade.principle || '';
|
||||||
|
f.mistake_note.value = trade.mistake_note || '';
|
||||||
|
$('#mistakeNoteWrap').style.display = trade.mistake ? '' : 'none';
|
||||||
|
}
|
||||||
|
$('#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 內聯負責載入)
|
||||||
|
setView(parseHash());
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
41
index.html
41
index.html
|
|
@ -3,7 +3,8 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MacroScope — 總經指標儀表板</title>
|
<title>Emmy 投資台 — 學習 · 財報健檢 · 交易復盤</title>
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
<style>
|
<style>
|
||||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
:root{
|
:root{
|
||||||
|
|
@ -236,21 +237,32 @@ a{color:var(--blue);text-decoration:none}
|
||||||
<!-- ─── Header ─── -->
|
<!-- ─── Header ─── -->
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<div class="logo-icon">M</div>
|
<div class="logo-icon">E</div>
|
||||||
MacroScope
|
Emmy 投資台
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav-links" id="navLinks"></nav>
|
<nav class="view-tabs" id="viewTabs">
|
||||||
|
<a data-view="macro" class="active">總經</a>
|
||||||
|
<a data-view="learn">學習教材</a>
|
||||||
|
<a data-view="stock">個股工具</a>
|
||||||
|
<a data-view="journal">交易復盤</a>
|
||||||
|
</nav>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<nav class="nav-links" id="navLinks"></nav>
|
||||||
<span class="last-updated" id="lastUpdated"></span>
|
<span class="last-updated" id="lastUpdated"></span>
|
||||||
<button class="refresh-btn" id="refreshBtn" title="重新抓取最新資料">↻ 更新</button>
|
<button class="refresh-btn" id="refreshBtn" title="重新抓取最新資料">↻ 更新</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="main">
|
<main id="main">
|
||||||
<div class="state" id="loadingState">
|
<section class="view" id="view-macro">
|
||||||
<div class="spinner"></div>
|
<div class="state" id="loadingState">
|
||||||
正在抓取真實總經資料…
|
<div class="spinner"></div>
|
||||||
</div>
|
正在抓取真實總經資料…
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="view" id="view-learn" hidden></section>
|
||||||
|
<section class="view" id="view-stock" hidden></section>
|
||||||
|
<section class="view" id="view-journal" hidden></section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- 浮動說明框 -->
|
<!-- 浮動說明框 -->
|
||||||
|
|
@ -416,7 +428,7 @@ function guideHTML(){
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(data){
|
function render(data){
|
||||||
const main=document.getElementById('main');
|
const main=document.getElementById('view-macro');
|
||||||
const scoreColor=cssVar(data.regime?data.regime.colorKey:'yellow');
|
const scoreColor=cssVar(data.regime?data.regime.colorKey:'yellow');
|
||||||
|
|
||||||
// 訊號 pills
|
// 訊號 pills
|
||||||
|
|
@ -623,7 +635,8 @@ let CHARTGEO={};
|
||||||
const RANGES=[['1m','1個月'],['6m','6個月'],['1y','1年'],['5y','5年'],['10y','10年'],['max','全部']];
|
const RANGES=[['1m','1個月'],['6m','6個月'],['1y','1年'],['5y','5年'],['10y','10年'],['max','全部']];
|
||||||
|
|
||||||
function openModal(key,range){
|
function openModal(key,range){
|
||||||
MODAL={key,range:range||'1y',isScore:false};
|
// 預設開啟即顯示「全部」長期走勢,直接看到十年以上歷史與事件標記
|
||||||
|
MODAL={key,range:range||'max',isScore:false};
|
||||||
const meta=CARD_META[key]||{label:key};
|
const meta=CARD_META[key]||{label:key};
|
||||||
document.getElementById('modalTitle').innerHTML=`${meta.label}<span class="en">${meta.labelEn||''}</span>`;
|
document.getElementById('modalTitle').innerHTML=`${meta.label}<span class="en">${meta.labelEn||''}</span>`;
|
||||||
const now=document.getElementById('modalNow');
|
const now=document.getElementById('modalNow');
|
||||||
|
|
@ -665,6 +678,9 @@ async function loadSeries(){
|
||||||
const color=HEX[(CARD_META[MODAL.key]&&CARD_META[MODAL.key].colorKey)||'blue']||HEX.blue;
|
const color=HEX[(CARD_META[MODAL.key]&&CARD_META[MODAL.key].colorKey)||'blue']||HEX.blue;
|
||||||
wrap.innerHTML=lineChart(data.points,{format:data.format,decimals:data.decimals,color,events:EVENTS});
|
wrap.innerHTML=lineChart(data.points,{format:data.format,decimals:data.decimals,color,events:EVENTS});
|
||||||
bindChartHover(data.points,{format:data.format,decimals:data.decimals});
|
bindChartHover(data.points,{format:data.format,decimals:data.decimals});
|
||||||
|
// 底部標出此指標實際的資料起訖,讓人一眼知道能回看多久
|
||||||
|
const sp=data.points,src=(TIPS[MODAL.key]&&TIPS[MODAL.key].tip&&TIPS[MODAL.key].tip.source)||'';
|
||||||
|
document.getElementById('modalFoot').textContent=`資料區間 ${sp[0].date} ~ ${sp[sp.length-1].date} ${src}`;
|
||||||
}catch(e){wrap.innerHTML='<div class="chart-empty">載入失敗。</div>';}
|
}catch(e){wrap.innerHTML='<div class="chart-empty">載入失敗。</div>';}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -791,7 +807,7 @@ function bindChartHover(points,opts){
|
||||||
// 載入資料
|
// 載入資料
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
async function load(fresh){
|
async function load(fresh){
|
||||||
const main=document.getElementById('main');
|
const main=document.getElementById('view-macro');
|
||||||
main.innerHTML=`<div class="state"><div class="spinner"></div>正在抓取真實總經資料…</div>`;
|
main.innerHTML=`<div class="state"><div class="spinner"></div>正在抓取真實總經資料…</div>`;
|
||||||
try{
|
try{
|
||||||
const [res,evRes]=await Promise.all([
|
const [res,evRes]=await Promise.all([
|
||||||
|
|
@ -808,7 +824,7 @@ async function load(fresh){
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderError(data){
|
function renderError(data){
|
||||||
const main=document.getElementById('main');
|
const main=document.getElementById('view-macro');
|
||||||
if(data&&data.error==='missing_api_key'){
|
if(data&&data.error==='missing_api_key'){
|
||||||
main.innerHTML=`<div class="state"><div class="err-box">
|
main.innerHTML=`<div class="state"><div class="err-box">
|
||||||
<h2>還差一步:設定免費的 FRED 金鑰</h2>
|
<h2>還差一步:設定免費的 FRED 金鑰</h2>
|
||||||
|
|
@ -834,5 +850,6 @@ function renderError(data){
|
||||||
document.getElementById('refreshBtn').addEventListener('click',()=>load(true));
|
document.getElementById('refreshBtn').addEventListener('click',()=>load(true));
|
||||||
document.addEventListener('DOMContentLoaded',()=>load(false));
|
document.addEventListener('DOMContentLoaded',()=>load(false));
|
||||||
</script>
|
</script>
|
||||||
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// backtest.js — 機械式策略回測(純函式,吃 marketdata 的歷史點)
|
||||||
|
// 策略:
|
||||||
|
// buyhold 買進持有(基準)
|
||||||
|
// dca 定期定額(每月投入固定金額)
|
||||||
|
// sma 均線趨勢(短均 > 長均在場、否則空手)
|
||||||
|
// dip 逢大跌進場(距歷史高點回落 X% 才買進後續抱)
|
||||||
|
// 皆以 adjclose(還原股價)計算,初始資金 10,000。
|
||||||
|
// 輸出權益曲線與統計(總報酬、年化、最大回撤、在場比例、交易次數、勝率),
|
||||||
|
// 並附「買進持有」基準供對照。本工具僅供學習,過去績效不代表未來。
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
const CAPITAL = 10000;
|
||||||
|
|
||||||
|
export const STRATEGIES = {
|
||||||
|
buyhold: { label: '買進持有', params: [] },
|
||||||
|
dca: { label: '定期定額(每月)', params: [{ key: 'monthly', label: '每月投入', def: 1000, min: 1 }] },
|
||||||
|
sma: { label: '均線趨勢(短>長在場)', params: [
|
||||||
|
{ key: 'short', label: '短均線(日)', def: 50, min: 2 },
|
||||||
|
{ key: 'long', label: '長均線(日)', def: 200, min: 3 },
|
||||||
|
] },
|
||||||
|
dip: { label: '逢大跌進場(回落%後買進)', params: [
|
||||||
|
{ key: 'drop', label: '距高點回落%', def: 15, min: 1 },
|
||||||
|
] },
|
||||||
|
};
|
||||||
|
|
||||||
|
function sma(vals, i, n) {
|
||||||
|
if (i + 1 < n) return null;
|
||||||
|
let s = 0; for (let k = i - n + 1; k <= i; k++) s += vals[k];
|
||||||
|
return s / n;
|
||||||
|
}
|
||||||
|
function yearsBetween(a, b) { return Math.max((new Date(b) - new Date(a)) / (365.25 * 86400000), 1 / 365.25); }
|
||||||
|
function maxDrawdown(curve) {
|
||||||
|
let peak = -Infinity, mdd = 0;
|
||||||
|
for (const p of curve) { if (p.val > peak) peak = p.val; const dd = (p.val - peak) / peak; if (dd < mdd) mdd = dd; }
|
||||||
|
return -mdd * 100; // 正的百分比
|
||||||
|
}
|
||||||
|
function statsFrom(curve, { trades = 1, roundTrips = [], inDays = null } = {}) {
|
||||||
|
const first = curve[0].val, last = curve[curve.length - 1].val;
|
||||||
|
const yrs = yearsBetween(curve[0].date, curve[curve.length - 1].date);
|
||||||
|
const totalReturn = (last / first - 1) * 100;
|
||||||
|
const cagr = (Math.pow(last / first, 1 / yrs) - 1) * 100;
|
||||||
|
const wins = roundTrips.filter(r => r > 0).length;
|
||||||
|
return {
|
||||||
|
finalValue: last,
|
||||||
|
totalReturn,
|
||||||
|
cagr,
|
||||||
|
maxDrawdown: maxDrawdown(curve),
|
||||||
|
trades,
|
||||||
|
winRate: roundTrips.length ? (wins / roundTrips.length) * 100 : null,
|
||||||
|
exposure: inDays != null ? (inDays / curve.length) * 100 : 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 買進持有:期初一次投入 CAPITAL
|
||||||
|
function buyHold(points) {
|
||||||
|
const a0 = points[0].adjclose;
|
||||||
|
return points.map(p => ({ date: p.date, val: CAPITAL * (p.adjclose / a0) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定期定額:每月第一個交易日投入固定金額;基準=同總額在期初一次買進
|
||||||
|
function runDca(points, monthly) {
|
||||||
|
let shares = 0, invested = 0, lastMonth = '';
|
||||||
|
const equity = [];
|
||||||
|
for (const p of points) {
|
||||||
|
const m = p.date.slice(0, 7);
|
||||||
|
if (m !== lastMonth) { shares += monthly / p.adjclose; invested += monthly; lastMonth = m; }
|
||||||
|
equity.push({ date: p.date, val: shares * p.adjclose, invested });
|
||||||
|
}
|
||||||
|
// 正規化成「以總投入為基底」:把曲線換成相對總投入的價值,期初基底=首次投入
|
||||||
|
const totalInvested = invested;
|
||||||
|
const a0 = points[0].adjclose;
|
||||||
|
const benchmark = points.map(p => ({ date: p.date, val: totalInvested * (p.adjclose / a0) }));
|
||||||
|
// DCA 報酬以總投入為分母
|
||||||
|
const last = equity[equity.length - 1].val;
|
||||||
|
const yrs = yearsBetween(points[0].date, points[points.length - 1].date);
|
||||||
|
const stats = {
|
||||||
|
finalValue: last,
|
||||||
|
totalReturn: (last / totalInvested - 1) * 100,
|
||||||
|
cagr: (Math.pow(Math.max(last / totalInvested, 0.0001), 1 / yrs) - 1) * 100,
|
||||||
|
maxDrawdown: maxDrawdown(equity.map(e => ({ val: e.val, date: e.date }))),
|
||||||
|
trades: equity.filter((e, i) => i === 0 || e.invested !== equity[i - 1].invested).length,
|
||||||
|
winRate: null,
|
||||||
|
exposure: 100,
|
||||||
|
invested: totalInvested,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
equity: equity.map(e => ({ date: e.date, val: e.val })),
|
||||||
|
benchmark,
|
||||||
|
stats,
|
||||||
|
benchStats: statsFrom(benchmark),
|
||||||
|
note: `共投入 ${stats.trades} 期、合計 ${Math.round(totalInvested).toLocaleString()} 元;基準線為「同總額在期初一次買進」。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在場/空手型策略的共用模擬器:signal[i] = 是否該在場
|
||||||
|
function simulateSignal(points, signal) {
|
||||||
|
let cash = CAPITAL, shares = 0, holding = false, buyPrice = 0, inDays = 0, trades = 0;
|
||||||
|
const roundTrips = [];
|
||||||
|
const equity = [];
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const px = points[i].adjclose;
|
||||||
|
const want = signal[i];
|
||||||
|
if (want && !holding) { shares = cash / px; cash = 0; holding = true; buyPrice = px; trades++; }
|
||||||
|
else if (!want && holding) { cash = shares * px; shares = 0; holding = false; roundTrips.push((px / buyPrice - 1)); }
|
||||||
|
if (holding) inDays++;
|
||||||
|
equity.push({ date: points[i].date, val: holding ? shares * px : cash });
|
||||||
|
}
|
||||||
|
return { equity, trades, roundTrips, inDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSma(points, short, long) {
|
||||||
|
short = Math.max(2, Math.round(short)); long = Math.max(short + 1, Math.round(long));
|
||||||
|
const vals = points.map(p => p.adjclose);
|
||||||
|
const signal = points.map((_, i) => {
|
||||||
|
const s = sma(vals, i, short), l = sma(vals, i, long);
|
||||||
|
return (s != null && l != null) ? s > l : false;
|
||||||
|
});
|
||||||
|
const sim = simulateSignal(points, signal);
|
||||||
|
return {
|
||||||
|
equity: sim.equity,
|
||||||
|
benchmark: buyHold(points),
|
||||||
|
stats: statsFrom(sim.equity, { trades: sim.trades, roundTrips: sim.roundTrips, inDays: sim.inDays }),
|
||||||
|
benchStats: statsFrom(buyHold(points)),
|
||||||
|
note: `${short}/${long} 日均線:短均上穿長均才在場,否則空手。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDip(points, dropPct) {
|
||||||
|
const drop = Math.max(1, dropPct) / 100;
|
||||||
|
let peak = -Infinity, entered = false;
|
||||||
|
const signal = points.map(p => {
|
||||||
|
if (p.adjclose > peak) peak = p.adjclose;
|
||||||
|
if (!entered && (p.adjclose / peak - 1) <= -drop) entered = true; // 一旦回落達標即進場,之後續抱
|
||||||
|
return entered;
|
||||||
|
});
|
||||||
|
const sim = simulateSignal(points, signal);
|
||||||
|
return {
|
||||||
|
equity: sim.equity,
|
||||||
|
benchmark: buyHold(points),
|
||||||
|
stats: statsFrom(sim.equity, { trades: sim.trades, roundTrips: sim.roundTrips, inDays: sim.inDays }),
|
||||||
|
benchStats: statsFrom(buyHold(points)),
|
||||||
|
note: `先空手等待,距歷史高點回落達 ${dropPct}% 才一次買進並續抱到期末(對照「直接買進持有」)。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主入口:points 需含 adjclose;params 為策略參數
|
||||||
|
export function runBacktest(points, { strategy = 'buyhold', monthly, short, long, drop } = {}) {
|
||||||
|
if (!Array.isArray(points) || points.length < 3) throw new Error('歷史資料不足以回測');
|
||||||
|
const meta = STRATEGIES[strategy] || STRATEGIES.buyhold;
|
||||||
|
let out;
|
||||||
|
if (strategy === 'dca') out = runDca(points, monthly > 0 ? monthly : 1000);
|
||||||
|
else if (strategy === 'sma') out = runSma(points, short || 50, long || 200);
|
||||||
|
else if (strategy === 'dip') out = runDip(points, drop || 15);
|
||||||
|
else {
|
||||||
|
const eq = buyHold(points);
|
||||||
|
out = { equity: eq, benchmark: null, stats: statsFrom(eq), benchStats: null, note: '期初一次投入並持有到期末。' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
strategy, strategyLabel: meta.label,
|
||||||
|
from: points[0].date, to: points[points.length - 1].date,
|
||||||
|
capital: CAPITAL,
|
||||||
|
...out,
|
||||||
|
};
|
||||||
|
}
|
||||||
135
lib/db.js
135
lib/db.js
|
|
@ -32,6 +32,26 @@ db.exec(`
|
||||||
score INTEGER NOT NULL,
|
score INTEGER NOT NULL,
|
||||||
regime TEXT
|
regime TEXT
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS trades (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
direction TEXT NOT NULL DEFAULT 'long',
|
||||||
|
kind TEXT,
|
||||||
|
entry_date TEXT,
|
||||||
|
entry_price REAL,
|
||||||
|
shares REAL,
|
||||||
|
exit_date TEXT,
|
||||||
|
exit_price REAL,
|
||||||
|
entry_reason TEXT,
|
||||||
|
exit_reason TEXT,
|
||||||
|
principle TEXT,
|
||||||
|
mistake INTEGER DEFAULT 0,
|
||||||
|
mistake_note TEXT,
|
||||||
|
note TEXT,
|
||||||
|
created_at INTEGER,
|
||||||
|
updated_at INTEGER
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// ─── 整包結果的持久化快取 ───
|
// ─── 整包結果的持久化快取 ───
|
||||||
|
|
@ -79,3 +99,118 @@ export function saveScoreSnapshot(score, regimeLabel) {
|
||||||
export function getScoreHistory() {
|
export function getScoreHistory() {
|
||||||
return db.prepare('SELECT date, score, regime FROM score_history ORDER BY date ASC').all();
|
return db.prepare('SELECT date, score, regime FROM score_history ORDER BY date ASC').all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 通用 JSON 快取(給財報健檢等,沿用 cache 表,含 TTL)───
|
||||||
|
export function putCachedJSON(key, value) {
|
||||||
|
db.prepare('INSERT OR REPLACE INTO cache (key, payload, updated_at) VALUES (?, ?, ?)')
|
||||||
|
.run(key, JSON.stringify(value), Date.now());
|
||||||
|
}
|
||||||
|
export function getCachedJSON(key, ttlMs) {
|
||||||
|
const row = db.prepare('SELECT payload, updated_at FROM cache WHERE key = ?').get(key);
|
||||||
|
if (!row) return null;
|
||||||
|
if (ttlMs != null && Date.now() - row.updated_at > ttlMs) return null;
|
||||||
|
try { return JSON.parse(row.payload); } catch { return null; }
|
||||||
|
}
|
||||||
|
// 取出快取連同寫入時間(不做 TTL 判斷,由呼叫端決定新鮮度策略)
|
||||||
|
export function getCachedEntry(key) {
|
||||||
|
const row = db.prepare('SELECT payload, updated_at FROM cache WHERE key = ?').get(key);
|
||||||
|
if (!row) return null;
|
||||||
|
try { return { value: JSON.parse(row.payload), updatedAt: row.updated_at }; } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 交易復盤 ───
|
||||||
|
const TRADE_FIELDS = ['symbol', 'name', 'direction', 'kind', 'entry_date', 'entry_price', 'shares',
|
||||||
|
'exit_date', 'exit_price', 'entry_reason', 'exit_reason', 'principle', 'mistake', 'mistake_note', 'note'];
|
||||||
|
|
||||||
|
// 已實現損益 / 報酬率 / 持有天數(多空與否、是否平倉皆處理)
|
||||||
|
function computeTrade(t) {
|
||||||
|
const closed = t.exit_date != null && t.exit_price != null && t.entry_price != null;
|
||||||
|
const out = { ...t, closed };
|
||||||
|
out.cost = t.entry_price != null && t.shares != null ? t.entry_price * t.shares : null;
|
||||||
|
if (closed) {
|
||||||
|
const per = t.direction === 'short' ? (t.entry_price - t.exit_price) : (t.exit_price - t.entry_price);
|
||||||
|
out.pnl = per * t.shares;
|
||||||
|
const base = t.direction === 'short' ? t.exit_price : t.entry_price;
|
||||||
|
out.pnl_pct = base ? (per / t.entry_price) * 100 : null;
|
||||||
|
if (t.entry_date && t.exit_date) {
|
||||||
|
out.hold_days = Math.max(0, Math.round((new Date(t.exit_date) - new Date(t.entry_date)) / 86400000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTrades() {
|
||||||
|
const rows = db.prepare('SELECT * FROM trades ORDER BY COALESCE(exit_date, entry_date) DESC, id DESC').all();
|
||||||
|
return rows.map(computeTrade);
|
||||||
|
}
|
||||||
|
export function getTrade(id) {
|
||||||
|
const row = db.prepare('SELECT * FROM trades WHERE id = ?').get(id);
|
||||||
|
return row ? computeTrade(row) : null;
|
||||||
|
}
|
||||||
|
export function insertTrade(body) {
|
||||||
|
if (!body.symbol) throw new Error('缺少股票代號 symbol');
|
||||||
|
const now = Date.now();
|
||||||
|
const vals = TRADE_FIELDS.map(f => normField(f, body[f]));
|
||||||
|
const sql = `INSERT INTO trades (${TRADE_FIELDS.join(',')}, created_at, updated_at)
|
||||||
|
VALUES (${TRADE_FIELDS.map(() => '?').join(',')}, ?, ?)`;
|
||||||
|
const info = db.prepare(sql).run(...vals, now, now);
|
||||||
|
return getTrade(Number(info.lastInsertRowid));
|
||||||
|
}
|
||||||
|
export function updateTrade(id, body) {
|
||||||
|
const existing = db.prepare('SELECT id FROM trades WHERE id = ?').get(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
const vals = TRADE_FIELDS.map(f => normField(f, body[f]));
|
||||||
|
const sql = `UPDATE trades SET ${TRADE_FIELDS.map(f => f + '=?').join(',')}, updated_at=? WHERE id=?`;
|
||||||
|
db.prepare(sql).run(...vals, Date.now(), id);
|
||||||
|
return getTrade(id);
|
||||||
|
}
|
||||||
|
export function deleteTrade(id) {
|
||||||
|
db.prepare('DELETE FROM trades WHERE id = ?').run(id);
|
||||||
|
}
|
||||||
|
function normField(f, v) {
|
||||||
|
if (v === undefined) v = null;
|
||||||
|
if (['entry_price', 'shares', 'exit_price'].includes(f)) return v === '' || v == null ? null : Number(v);
|
||||||
|
if (f === 'mistake') return v ? 1 : 0;
|
||||||
|
if (['exit_date', 'name', 'kind', 'entry_reason', 'exit_reason', 'principle', 'mistake_note', 'note'].includes(f))
|
||||||
|
return v === '' ? null : v;
|
||||||
|
if (f === 'direction') return v === 'short' ? 'short' : 'long';
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 復盤統計:勝率、賺賠比,並依「交易/投資」「是否犯錯」「依據心法」分組
|
||||||
|
export function tradeStats() {
|
||||||
|
const all = listTrades();
|
||||||
|
const closed = all.filter(t => t.closed);
|
||||||
|
const wins = closed.filter(t => t.pnl > 0);
|
||||||
|
const losses = closed.filter(t => t.pnl < 0);
|
||||||
|
const sum = arr => arr.reduce((a, t) => a + (t.pnl || 0), 0);
|
||||||
|
const avgWin = wins.length ? sum(wins) / wins.length : null;
|
||||||
|
const avgLoss = losses.length ? sum(losses) / losses.length : null;
|
||||||
|
const group = (keyFn) => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const t of closed) {
|
||||||
|
const key = keyFn(t); if (key == null || key === '') continue;
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key).push(t);
|
||||||
|
}
|
||||||
|
return [...map.entries()].map(([key, arr]) => ({
|
||||||
|
key,
|
||||||
|
count: arr.length,
|
||||||
|
winRate: arr.length ? (arr.filter(t => t.pnl > 0).length / arr.length) * 100 : null,
|
||||||
|
pnl: sum(arr),
|
||||||
|
})).sort((a, b) => b.count - a.count);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
closed: closed.length,
|
||||||
|
open: all.length - closed.length,
|
||||||
|
wins: wins.length,
|
||||||
|
losses: losses.length,
|
||||||
|
winRate: closed.length ? (wins.length / closed.length) * 100 : null,
|
||||||
|
totalPnl: sum(closed),
|
||||||
|
avgWin, avgLoss,
|
||||||
|
payoff: avgWin != null && avgLoss ? avgWin / Math.abs(avgLoss) : null,
|
||||||
|
byKind: group(t => t.kind),
|
||||||
|
byMistake: group(t => (t.mistake ? '有犯錯' : '無犯錯')),
|
||||||
|
byPrinciple: group(t => t.principle ? t.principle.split('#').pop() : null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// fincheck.js — 財報健檢規則引擎
|
||||||
|
// 輸入 fundamentals.js 正規化後的財報,照 Emmy「財報基本功」五步驟
|
||||||
|
// 產出紅/黃/綠燈 + 白話提醒,每條檢查連回對應名詞/心法/案例。
|
||||||
|
// 純規則、不抓網路;連結目標為 knowledge linkMap 的鍵,前端負責跳轉。
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const L = {
|
||||||
|
營收: { target: '名詞/營收', label: '營收' },
|
||||||
|
YoY: { target: '名詞/YoY', label: 'YoY' },
|
||||||
|
QoQ: { target: '名詞/QoQ', label: 'QoQ' },
|
||||||
|
毛利率: { target: '名詞/毛利率', label: '毛利率' },
|
||||||
|
EPS: { target: '名詞/EPS', label: 'EPS' },
|
||||||
|
淨利: { target: '名詞/淨利', label: '淨利' },
|
||||||
|
Capex: { target: '名詞/Capex', label: 'Capex' },
|
||||||
|
現金流: { target: '名詞/現金流量表', label: '現金流量表' },
|
||||||
|
負債率: { target: '名詞/負債率', label: '負債率' },
|
||||||
|
資產負債表: { target: '名詞/資產負債表', label: '資產負債表' },
|
||||||
|
NonGAAP: { target: '名詞/Non-GAAP', label: 'Non-GAAP' },
|
||||||
|
財測: { target: '名詞/財測', label: '財測' },
|
||||||
|
進攻Capex: { target: 'Emmy 投資心法#原則三:進攻型Capex', label: '原則三:進攻型Capex' },
|
||||||
|
定價權: { target: 'Emmy 投資心法#原則四:供給瓶頸定價權', label: '原則四:供給瓶頸定價權' },
|
||||||
|
毛利溫度計: { target: 'Emmy 投資心法#原則四十七:毛利率是規模化的溫度計', label: '原則四十七:毛利率是規模化的溫度計' },
|
||||||
|
財測重要: { target: 'Emmy 投資心法#原則十七:財測比財報重要', label: '原則十七:財測比財報重要' },
|
||||||
|
總經優先: { target: 'Emmy 投資心法#原則五十:總經大於產業大於個股', label: '原則五十:總經>產業>個股' },
|
||||||
|
財報基本功: { target: '學習分類/財報基本功', label: '財報基本功' },
|
||||||
|
capex案例: { target: '案例講解/AI四巨頭Capex怎麼看', label: 'AI四巨頭Capex怎麼看' },
|
||||||
|
財報案例: { target: '案例講解/NVIDIA財報怎麼看', label: 'NVIDIA財報怎麼看' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const signed = (v, d = 0, suf = '%') => v == null || isNaN(v) ? '—' : (v >= 0 ? '+' : '') + v.toFixed(d) + suf;
|
||||||
|
const na = (id, step, label, note, links) => ({ id, step, label, status: 'na', value: '無資料', note, links: links || [] });
|
||||||
|
|
||||||
|
function yoy(cur, prev) { return (cur != null && prev) ? ((cur - prev) / Math.abs(prev)) * 100 : null; }
|
||||||
|
// 找最接近「一年前」的那一季(容忍 ±70 天),避免 EDGAR 不單獨揭露 Q4 造成的索引錯位
|
||||||
|
function yearAgo(q, ref) {
|
||||||
|
if (!ref || !ref.end) return null;
|
||||||
|
const target = new Date(ref.end); target.setUTCFullYear(target.getUTCFullYear() - 1);
|
||||||
|
let best = null, bd = Infinity;
|
||||||
|
for (let i = 1; i < q.length; i++) {
|
||||||
|
if (!q[i].end) continue;
|
||||||
|
const d = Math.abs(new Date(q[i].end) - target) / 86400000;
|
||||||
|
if (d < bd) { bd = d; best = q[i]; }
|
||||||
|
}
|
||||||
|
return bd <= 70 ? best : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReport(f) {
|
||||||
|
const q = f.quarters || [];
|
||||||
|
const a = f.annual || [];
|
||||||
|
const bal = f.balance || {};
|
||||||
|
const checks = []; // {step}
|
||||||
|
const add = c => checks.push(c);
|
||||||
|
const cur = q[0] || null;
|
||||||
|
const ya = cur ? yearAgo(q, cur) : null;
|
||||||
|
|
||||||
|
// ── 步驟 1:營收成長 ──
|
||||||
|
if (cur && ya && cur.revenue != null && ya.revenue != null) {
|
||||||
|
const g = yoy(cur.revenue, ya.revenue);
|
||||||
|
add({ id: 'rev_yoy', step: 1, label: `營收年增 YoY(${cur.label} vs ${ya.label})`, value: signed(g, 0),
|
||||||
|
status: g >= 15 ? 'good' : g >= 0 ? 'warn' : 'bad',
|
||||||
|
note: g >= 15 ? '營收年增強勁,需求結構性成長。' : g >= 0 ? '營收仍在成長但動能偏緩,留意是否放慢。' : '營收較去年同期衰退,需求或競爭出問題。',
|
||||||
|
links: [L.營收, L.YoY] });
|
||||||
|
} else if (a.length >= 2 && a[0].revenue != null && a[1].revenue != null) {
|
||||||
|
const g = yoy(a[0].revenue, a[1].revenue);
|
||||||
|
add({ id: 'rev_yoy', step: 1, label: `營收年增(${a[1].label}→${a[0].label},年度)`, value: signed(g, 0),
|
||||||
|
status: g >= 15 ? 'good' : g >= 0 ? 'warn' : 'bad',
|
||||||
|
note: '季資料不足,改用年度營收比較。', links: [L.營收, L.YoY] });
|
||||||
|
} else add(na('rev_yoy', 1, '營收年增 YoY', '財報期數不足,無法計算年增。', [L.營收]));
|
||||||
|
|
||||||
|
if (q.length >= 2 && cur.revenue != null && q[1].revenue != null) {
|
||||||
|
const g = yoy(cur.revenue, q[1].revenue);
|
||||||
|
add({ id: 'rev_qoq', step: 1, label: `營收環比 QoQ(${cur.label} vs ${q[1].label})`, value: signed(g, 0),
|
||||||
|
status: g >= 0 ? 'good' : g >= -10 ? 'warn' : 'bad',
|
||||||
|
note: '看短期動能;高成長股最怕增速放慢。注意產業淡旺季與會計年度。', links: [L.QoQ] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 步驟 2:毛利率與獲利品質 ──
|
||||||
|
const gm = q.find(p => p.grossMargin != null)?.grossMargin ?? a.find(p => p.grossMargin != null)?.grossMargin;
|
||||||
|
if (gm != null) {
|
||||||
|
add({ id: 'gm_level', step: 2, label: '毛利率水準', value: gm.toFixed(1) + '%',
|
||||||
|
status: gm >= 50 ? 'good' : gm >= 25 ? 'warn' : 'bad',
|
||||||
|
note: gm >= 50 ? '毛利率高,通常代表定價權、技術門檻或供給瓶頸。' : gm >= 25 ? '毛利率中等,視產業而定(硬體偏低、軟體偏高)。' : '毛利率偏低,定價權或成本結構需留意。',
|
||||||
|
links: [L.毛利率, L.定價權] });
|
||||||
|
if (cur && ya && cur.grossMargin != null && ya.grossMargin != null) {
|
||||||
|
const d = cur.grossMargin - ya.grossMargin;
|
||||||
|
add({ id: 'gm_trend', step: 2, label: '毛利率趨勢(年比)', value: signed(d, 1, 'pp'),
|
||||||
|
status: d >= 0.5 ? 'good' : d >= -1 ? 'warn' : 'bad',
|
||||||
|
note: d >= 0.5 ? '毛利率走升,是規模化/產品組合優化的訊號。' : d >= -1 ? '毛利率大致持平。' : '毛利率下滑,留意是否降價、成本上升或組合變差。',
|
||||||
|
links: [L.毛利溫度計] });
|
||||||
|
}
|
||||||
|
} else add(na('gm_level', 2, '毛利率', '此來源未提供毛利資料(部分公司不揭露銷貨成本)。', [L.毛利率]));
|
||||||
|
|
||||||
|
if (cur && ya && cur.operatingMargin != null && ya.operatingMargin != null) {
|
||||||
|
const d = cur.operatingMargin - ya.operatingMargin;
|
||||||
|
add({ id: 'om_trend', step: 2, label: '營業利潤率趨勢(年比)', value: signed(d, 1, 'pp'),
|
||||||
|
status: d >= 0 ? 'good' : d >= -2 ? 'warn' : 'bad',
|
||||||
|
note: '營收成長率高於淨利成長時,常代表營運槓桿開始發酵。', links: [L.淨利] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 步驟 3:EPS 與獲利 ──
|
||||||
|
if (cur && ya && cur.eps != null && ya.eps != null) {
|
||||||
|
const g = yoy(cur.eps, ya.eps);
|
||||||
|
add({ id: 'eps_yoy', step: 3, label: `EPS 年增(${cur.label} vs ${ya.label})`, value: signed(g, 0),
|
||||||
|
status: g >= 15 ? 'good' : g >= 0 ? 'warn' : 'bad',
|
||||||
|
note: f.source === 'Yahoo Finance' ? 'EPS 以淨利÷流通股數估算,僅供概略參考。' : 'EPS 取自申報的稀釋每股盈餘。',
|
||||||
|
links: [L.EPS] });
|
||||||
|
} else add(na('eps_yoy', 3, 'EPS 年增', 'EPS 期數不足或來源未提供。', [L.EPS]));
|
||||||
|
|
||||||
|
const nm = q.find(p => p.netMargin != null)?.netMargin;
|
||||||
|
if (nm != null) {
|
||||||
|
add({ id: 'net_margin', step: 3, label: '淨利率', value: nm.toFixed(1) + '%',
|
||||||
|
status: nm >= 15 ? 'good' : nm > 0 ? 'warn' : 'bad',
|
||||||
|
note: nm > 0 ? '最後真正落袋的獲利佔營收比例。' : '目前淨利為負,須看是投入期還是結構性虧損。',
|
||||||
|
links: [L.淨利] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 步驟 4:Capex 與現金流 ──
|
||||||
|
const ay = a.find(p => p.capex != null && p.netIncome != null);
|
||||||
|
if (ay) {
|
||||||
|
const burn = Math.abs(ay.capex) / Math.abs(ay.netIncome);
|
||||||
|
const profitable = ay.netIncome > 0;
|
||||||
|
add({ id: 'capex_burn', step: 4, label: `燒錢倍數 Capex ÷ 淨利(${ay.label})`, value: profitable ? burn.toFixed(2) + 'x' : '淨利為負',
|
||||||
|
status: !profitable ? 'bad' : burn <= 0.6 ? 'good' : burn <= 1 ? 'warn' : 'bad',
|
||||||
|
note: !profitable ? '淨利為負時的高 Capex 風險較大。' : burn <= 0.6 ? 'Capex 遠低於獲利,擴張遊刃有餘。' : burn <= 1 ? 'Capex 接近全年獲利,留意現金消耗。' : 'Capex 高於獲利=在燒錢擴張,須判斷是「進攻型」還是壓力。',
|
||||||
|
links: [L.Capex, L.進攻Capex, L.capex案例] });
|
||||||
|
} else add(na('capex_burn', 4, '燒錢倍數 Capex÷淨利', '年度 Capex 或淨利資料不足。', [L.Capex, L.進攻Capex]));
|
||||||
|
|
||||||
|
const ocfY = a.find(p => p.ocf != null);
|
||||||
|
if (ocfY) {
|
||||||
|
add({ id: 'ocf', step: 4, label: `營業現金流(${ocfY.label})`, value: fmtMoneyShort(ocfY.ocf),
|
||||||
|
status: ocfY.ocf > 0 ? 'good' : 'bad',
|
||||||
|
note: ocfY.ocf > 0 ? '本業真的有現金流入,不只是帳面獲利。' : '營業現金流為負,獲利品質要打問號。',
|
||||||
|
links: [L.現金流] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 步驟 5:資產負債 / 槓桿 ──
|
||||||
|
if (bal.debtToAssets != null) {
|
||||||
|
add({ id: 'leverage', step: 5, label: '負債率(總負債 ÷ 總資產)', value: bal.debtToAssets.toFixed(0) + '%',
|
||||||
|
status: bal.debtToAssets < 50 ? 'good' : bal.debtToAssets <= 70 ? 'warn' : 'bad',
|
||||||
|
note: bal.debtToAssets < 50 ? '負債比例健康,高利率環境下抗風險能力較強。' : bal.debtToAssets <= 70 ? '負債比例中等,留意利息負擔。' : '負債比例偏高,升息環境下風險較大。',
|
||||||
|
links: [L.負債率, L.資產負債表] });
|
||||||
|
} else add(na('leverage', 5, '負債率', '資產負債資料不足。', [L.資產負債表]));
|
||||||
|
|
||||||
|
// ── 統計 + 結論 ──
|
||||||
|
const cnt = { good: 0, warn: 0, bad: 0, na: 0 };
|
||||||
|
for (const c of checks) cnt[c.status]++;
|
||||||
|
let verdict, verdictColor;
|
||||||
|
if (cnt.good + cnt.warn + cnt.bad === 0) { verdict = '資料不足'; verdictColor = 'warn'; }
|
||||||
|
else if (cnt.bad >= 3) { verdict = '偏弱'; verdictColor = 'bad'; }
|
||||||
|
else if (cnt.bad >= 1) { verdict = '留意'; verdictColor = 'warn'; }
|
||||||
|
else if (cnt.warn >= 3) { verdict = '尚可'; verdictColor = 'warn'; }
|
||||||
|
else { verdict = '穩健'; verdictColor = 'good'; }
|
||||||
|
|
||||||
|
const STEP_TITLES = ['', '營收成長', '毛利率與獲利品質', 'EPS 與獲利', 'Capex 與現金流', '資產負債與槓桿'];
|
||||||
|
const steps = [1, 2, 3, 4, 5].map(n => ({ num: n, title: STEP_TITLES[n], checks: checks.filter(c => c.step === n) }));
|
||||||
|
|
||||||
|
const caveats = [
|
||||||
|
{ text: '財報只是判斷的一面。Emmy 強調 {link},下任何個股判斷前先看天氣(總經與大盤水位)。同一份財報在多頭與空頭環境,市場解讀可能完全不同。',
|
||||||
|
links: [L.總經優先] },
|
||||||
|
{ text: '本工具使用申報的 GAAP 數字;看科技股時市場常用 {link},記得對照差異與是否每季都有「一次性費用」。財報也別只看好壞,要和 {link}(市場預期)一起看。',
|
||||||
|
links: [L.NonGAAP, L.財測] },
|
||||||
|
{ text: '想學完整看財報的方法,回到 {link} 與 {link}。',
|
||||||
|
links: [L.財報基本功, L.財報案例] },
|
||||||
|
];
|
||||||
|
|
||||||
|
return { summary: { ...cnt, verdict, verdictColor }, steps, caveats };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMoneyShort(v) {
|
||||||
|
if (v == null || isNaN(v)) return '—';
|
||||||
|
const x = Math.abs(v), s = v < 0 ? '-' : '';
|
||||||
|
if (x >= 1e12) return s + '$' + (x / 1e12).toFixed(2) + 'T';
|
||||||
|
if (x >= 1e9) return s + '$' + (x / 1e9).toFixed(2) + 'B';
|
||||||
|
if (x >= 1e6) return s + '$' + (x / 1e6).toFixed(2) + 'M';
|
||||||
|
return s + '$' + x.toFixed(0);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// fundamentals.js — server 端抓取公司財報(金鑰不外洩、無需付費)
|
||||||
|
// 主來源:Yahoo quoteSummary(季/年 損益、現金流、資產負債 + 估值)
|
||||||
|
// 後備: SEC EDGAR companyfacts(美股,官方、免金鑰)
|
||||||
|
// 皆正規化成同一形狀供 fincheck.js 使用。
|
||||||
|
// 沿用 fred.js 的 server 端 fetch + User-Agent 模式。
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36';
|
||||||
|
const SEC_UA = 'EmmyInvestDashboard/1.0 (personal learning tool; contact@example.com)';
|
||||||
|
|
||||||
|
async function jget(url, headers = {}, ms = 9000) {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), ms);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': UA, ...headers }, signal: ctrl.signal });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = (x) => (x && typeof x === 'object' && 'raw' in x) ? x.raw : (typeof x === 'number' ? x : null);
|
||||||
|
// 用結束年月當季標籤(避免不同公司會計年度造成的「第幾季」混淆)
|
||||||
|
function quarterLabel(endISO) {
|
||||||
|
const d = new Date(endISO); if (isNaN(d)) return String(endISO || '');
|
||||||
|
return `${d.getUTCFullYear()}/${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
const pct = (a, b) => (a != null && b) ? (a / b) * 100 : null;
|
||||||
|
function enrich(p) {
|
||||||
|
p.grossMargin = pct(p.grossProfit, p.revenue);
|
||||||
|
p.operatingMargin = pct(p.operatingIncome, p.revenue);
|
||||||
|
p.netMargin = pct(p.netIncome, p.revenue);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 現價(Yahoo chart v8,免 crumb;query1 失敗改 query2)───
|
||||||
|
async function getPrice(symbol) {
|
||||||
|
for (const host of ['query1', 'query2']) {
|
||||||
|
try {
|
||||||
|
const d = await jget(`https://${host}.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?range=5d&interval=1d`);
|
||||||
|
const r = d?.chart?.result?.[0];
|
||||||
|
if (!r) continue;
|
||||||
|
let p = r.meta?.regularMarketPrice;
|
||||||
|
if (p == null) { const cl = (r.indicators?.quote?.[0]?.close || []).filter(x => x != null); p = cl.length ? cl[cl.length - 1] : null; }
|
||||||
|
return { price: p != null ? p : null, name: r.meta?.shortName || r.meta?.longName || null, currency: r.meta?.currency || null };
|
||||||
|
} catch { /* try next host */ }
|
||||||
|
}
|
||||||
|
return { price: null, name: null, currency: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Yahoo quoteSummary(需 cookie + crumb)───
|
||||||
|
let _auth = { cookie: null, crumb: null, at: 0 };
|
||||||
|
async function yahooAuth() {
|
||||||
|
if (_auth.crumb && Date.now() - _auth.at < 3600e3) return _auth;
|
||||||
|
const r1 = await fetch('https://fc.yahoo.com/', { headers: { 'User-Agent': UA } }).catch(() => null);
|
||||||
|
const cookie = (r1 && (r1.headers.get('set-cookie') || '')).split(';')[0] || '';
|
||||||
|
const r2 = await fetch('https://query2.finance.yahoo.com/v1/test/getcrumb', { headers: { 'User-Agent': UA, Cookie: cookie } });
|
||||||
|
const crumb = (await r2.text()).trim();
|
||||||
|
if (!crumb || crumb.includes('<')) throw new Error('無法取得 Yahoo crumb');
|
||||||
|
_auth = { cookie, crumb, at: Date.now() };
|
||||||
|
return _auth;
|
||||||
|
}
|
||||||
|
async function fetchYahoo(symbol) {
|
||||||
|
const { cookie, crumb } = await yahooAuth();
|
||||||
|
const mods = [
|
||||||
|
'price', 'summaryDetail', 'defaultKeyStatistics', 'financialData',
|
||||||
|
'incomeStatementHistory', 'incomeStatementHistoryQuarterly',
|
||||||
|
'cashflowStatementHistory', 'cashflowStatementHistoryQuarterly',
|
||||||
|
'balanceSheetHistoryQuarterly',
|
||||||
|
].join('%2C');
|
||||||
|
const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?modules=${mods}&crumb=${encodeURIComponent(crumb)}`;
|
||||||
|
const d = await jget(url, { Cookie: cookie });
|
||||||
|
const r = d?.quoteSummary?.result?.[0];
|
||||||
|
if (!r) throw new Error('Yahoo 無資料');
|
||||||
|
|
||||||
|
const incQ = r.incomeStatementHistoryQuarterly?.incomeStatementHistory || [];
|
||||||
|
const cfQ = r.cashflowStatementHistoryQuarterly?.cashflowStatements || [];
|
||||||
|
const incY = r.incomeStatementHistory?.incomeStatementHistory || [];
|
||||||
|
const cfY = r.cashflowStatementHistory?.cashflowStatements || [];
|
||||||
|
const bsQ = r.balanceSheetHistoryQuarterly?.balanceSheetStatements || [];
|
||||||
|
const shares = num(r.defaultKeyStatistics?.sharesOutstanding);
|
||||||
|
|
||||||
|
const cfByDate = {};
|
||||||
|
for (const c of cfQ) cfByDate[num(c.endDate)] = c;
|
||||||
|
const quarters = incQ.map(s => {
|
||||||
|
const end = num(s.endDate);
|
||||||
|
const cf = cfByDate[end] || {};
|
||||||
|
const netIncome = num(s.netIncome);
|
||||||
|
return enrich({
|
||||||
|
end: end ? new Date(end * 1000).toISOString().slice(0, 10) : null,
|
||||||
|
label: end ? quarterLabel(end * 1000) : '',
|
||||||
|
revenue: num(s.totalRevenue),
|
||||||
|
grossProfit: num(s.grossProfit),
|
||||||
|
operatingIncome: num(s.operatingIncome),
|
||||||
|
netIncome,
|
||||||
|
eps: (netIncome != null && shares) ? netIncome / shares : null,
|
||||||
|
capex: num(cf.capitalExpenditures),
|
||||||
|
ocf: num(cf.totalCashFromOperatingActivities),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfYByDate = {};
|
||||||
|
for (const c of cfY) cfYByDate[num(c.endDate)] = c;
|
||||||
|
const annual = incY.map(s => {
|
||||||
|
const end = num(s.endDate);
|
||||||
|
const cf = cfYByDate[end] || {};
|
||||||
|
return enrich({
|
||||||
|
end: end ? new Date(end * 1000).toISOString().slice(0, 10) : null,
|
||||||
|
label: end ? String(new Date(end * 1000).getUTCFullYear()) : '',
|
||||||
|
revenue: num(s.totalRevenue),
|
||||||
|
grossProfit: num(s.grossProfit),
|
||||||
|
netIncome: num(s.netIncome),
|
||||||
|
capex: num(cf.capitalExpenditures),
|
||||||
|
ocf: num(cf.totalCashFromOperatingActivities),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const bs = bsQ[0] || {};
|
||||||
|
const totalAssets = num(bs.totalAssets);
|
||||||
|
const totalLiabilities = num(bs.totalLiab);
|
||||||
|
const balance = {
|
||||||
|
end: num(bs.endDate) ? new Date(num(bs.endDate) * 1000).toISOString().slice(0, 10) : null,
|
||||||
|
totalAssets, totalLiabilities,
|
||||||
|
cash: num(bs.cash),
|
||||||
|
totalDebt: (num(bs.shortLongTermDebt) || 0) + (num(bs.longTermDebt) || 0) || null,
|
||||||
|
debtToAssets: pct(totalLiabilities, totalAssets),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: 'Yahoo Finance',
|
||||||
|
name: num(r.price?.shortName) || r.price?.shortName || r.price?.longName || symbol,
|
||||||
|
currency: r.price?.currency || null,
|
||||||
|
peTrailing: num(r.summaryDetail?.trailingPE),
|
||||||
|
marketCap: num(r.price?.marketCap),
|
||||||
|
quarters, annual, balance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SEC EDGAR companyfacts(美股後備)───
|
||||||
|
let _tickerMap = null;
|
||||||
|
async function tickerToCik(symbol) {
|
||||||
|
if (!_tickerMap) {
|
||||||
|
const d = await jget('https://www.sec.gov/files/company_tickers.json', { 'User-Agent': SEC_UA });
|
||||||
|
_tickerMap = {};
|
||||||
|
for (const k of Object.keys(d)) _tickerMap[String(d[k].ticker).toUpperCase()] = { cik: String(d[k].cik_str).padStart(10, '0'), name: d[k].title };
|
||||||
|
}
|
||||||
|
return _tickerMap[symbol] || null;
|
||||||
|
}
|
||||||
|
// 公司常會在不同年度換 XBRL 概念名(如營收 tag 變更),因此把所有候選概念
|
||||||
|
// 的序列合併成一條時間線,再用 durationSeries / instantLatest 去重取最新。
|
||||||
|
function mergeUnits(facts, names, unit = 'USD') {
|
||||||
|
let out = [];
|
||||||
|
for (const n of names) { const u = facts[n]?.units?.[unit]; if (u) out = out.concat(u); }
|
||||||
|
return out.length ? out : null;
|
||||||
|
}
|
||||||
|
function pickShares(facts, names) {
|
||||||
|
let out = [];
|
||||||
|
for (const n of names) { const u = facts[n]?.units?.['USD/shares'] || facts[n]?.units?.shares; if (u) out = out.concat(u); }
|
||||||
|
return out.length ? out : null;
|
||||||
|
}
|
||||||
|
// 取「期間型」概念的最近 N 期(quarterly ≈ 80-100 天 / annual ≈ 350-380 天)
|
||||||
|
function durationSeries(units, kind) {
|
||||||
|
if (!units) return [];
|
||||||
|
const lo = kind === 'q' ? 80 : 330, hi = kind === 'q' ? 100 : 380;
|
||||||
|
const byEnd = {};
|
||||||
|
for (const e of units) {
|
||||||
|
if (!e.start || !e.end) continue;
|
||||||
|
const days = (new Date(e.end) - new Date(e.start)) / 86400000;
|
||||||
|
if (days < lo || days > hi) continue;
|
||||||
|
// 同一 end 取較新申報(10-Q/10-K)
|
||||||
|
if (!byEnd[e.end] || (e.filed || '') > (byEnd[e.end].filed || '')) byEnd[e.end] = e;
|
||||||
|
}
|
||||||
|
return Object.values(byEnd).sort((a, b) => (a.end < b.end ? 1 : -1));
|
||||||
|
}
|
||||||
|
function instantLatest(units) {
|
||||||
|
if (!units) return null;
|
||||||
|
const sorted = units.filter(e => e.end).sort((a, b) => (a.end < b.end ? 1 : -1));
|
||||||
|
return sorted[0] || null;
|
||||||
|
}
|
||||||
|
async function fetchEdgar(symbol) {
|
||||||
|
const hit = await tickerToCik(symbol);
|
||||||
|
if (!hit) throw new Error('SEC EDGAR 查無此美股代號');
|
||||||
|
const cf = await jget(`https://data.sec.gov/api/xbrl/companyfacts/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA });
|
||||||
|
const g = cf.facts?.['us-gaap'] || {};
|
||||||
|
const REV = ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'RevenueFromContractWithCustomerIncludingAssessedTax', 'SalesRevenueNet', 'SalesRevenueGoodsNet'];
|
||||||
|
const revU = mergeUnits(g, REV);
|
||||||
|
const gpU = mergeUnits(g, ['GrossProfit']);
|
||||||
|
const oiU = mergeUnits(g, ['OperatingIncomeLoss']);
|
||||||
|
const niU = mergeUnits(g, ['NetIncomeLoss']);
|
||||||
|
const epsU = pickShares(g, ['EarningsPerShareDiluted', 'EarningsPerShareBasic']);
|
||||||
|
const capU = mergeUnits(g, ['PaymentsToAcquirePropertyPlantAndEquipment', 'PaymentsToAcquireProductiveAssets']);
|
||||||
|
const ocfU = mergeUnits(g, ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations']);
|
||||||
|
|
||||||
|
const mapByEnd = (series) => { const m = {}; for (const e of series) m[e.end] = e.val; return m; };
|
||||||
|
const revQ = durationSeries(revU, 'q'), gpQ = durationSeries(gpU, 'q'), oiQ = durationSeries(oiU, 'q'), niQ = durationSeries(niU, 'q'), epsQ = durationSeries(epsU, 'q');
|
||||||
|
const gpM = mapByEnd(gpQ), oiM = mapByEnd(oiQ), niM = mapByEnd(niQ), epsM = mapByEnd(epsQ);
|
||||||
|
const quarters = revQ.slice(0, 8).map(e => enrich({
|
||||||
|
end: e.end, label: quarterLabel(e.end),
|
||||||
|
revenue: e.val, grossProfit: gpM[e.end] ?? null, operatingIncome: oiM[e.end] ?? null,
|
||||||
|
netIncome: niM[e.end] ?? null, eps: epsM[e.end] ?? null, capex: null, ocf: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const revY = durationSeries(revU, 'y'), niY = durationSeries(niU, 'y'), capY = durationSeries(capU, 'y'), ocfY = durationSeries(ocfU, 'y'), gpY = durationSeries(gpU, 'y');
|
||||||
|
const niYM = mapByEnd(niY), capYM = mapByEnd(capY), ocfYM = mapByEnd(ocfY), gpYM = mapByEnd(gpY);
|
||||||
|
const annual = revY.slice(0, 5).map(e => enrich({
|
||||||
|
end: e.end, label: String(new Date(e.end).getUTCFullYear()),
|
||||||
|
revenue: e.val, grossProfit: gpYM[e.end] ?? null, netIncome: niYM[e.end] ?? null,
|
||||||
|
capex: capYM[e.end] != null ? -Math.abs(capYM[e.end]) : null, ocf: ocfYM[e.end] ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const assetsE = instantLatest(mergeUnits(g, ['Assets']));
|
||||||
|
const liabE = instantLatest(mergeUnits(g, ['Liabilities']));
|
||||||
|
const cashE = instantLatest(mergeUnits(g, ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents']));
|
||||||
|
const balance = {
|
||||||
|
end: assetsE?.end || null,
|
||||||
|
totalAssets: assetsE?.val ?? null,
|
||||||
|
totalLiabilities: liabE?.val ?? null,
|
||||||
|
cash: cashE?.val ?? null,
|
||||||
|
totalDebt: null,
|
||||||
|
debtToAssets: pct(liabE?.val, assetsE?.val),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!quarters.length && !annual.length) throw new Error('EDGAR 無 us-gaap 財報資料(可能為以 IFRS 申報的外國發行人,建議改查其美股同業)');
|
||||||
|
return { source: 'SEC EDGAR', name: cf.entityName || hit.name || symbol, currency: 'USD', peTrailing: null, marketCap: null, quarters, annual, balance };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 輕量「是否有新財報」探針(美股;只抓 submissions,比 companyfacts 小很多)───
|
||||||
|
// 回傳最近一筆財報類申報的識別碼,用來判斷自上次抓取後是否出現新財報。
|
||||||
|
export async function getLatestFilingInfo(symbol) {
|
||||||
|
const hit = await tickerToCik(symbol);
|
||||||
|
if (!hit) return null;
|
||||||
|
const d = await jget(`https://data.sec.gov/submissions/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA });
|
||||||
|
const f = d.filings?.recent;
|
||||||
|
if (!f || !Array.isArray(f.form)) return null;
|
||||||
|
const FORMS = new Set(['10-Q', '10-K', '20-F', '40-F', '6-K']);
|
||||||
|
for (let i = 0; i < f.form.length; i++) {
|
||||||
|
if (FORMS.has(f.form[i])) return { accn: f.accessionNumber?.[i] || null, form: f.form[i], filingDate: f.filingDate?.[i] || null };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 對外:取得正規化財報(價格 + 兩來源擇優)───
|
||||||
|
export async function getFundamentals(symbol) {
|
||||||
|
const priceInfo = await getPrice(symbol);
|
||||||
|
let data = null, errs = [];
|
||||||
|
try {
|
||||||
|
data = await fetchYahoo(symbol);
|
||||||
|
if (!data.quarters?.length && !data.annual?.length) { errs.push('Yahoo 無財報期間'); data = null; }
|
||||||
|
} catch (e) { errs.push('Yahoo: ' + (e?.message || e)); }
|
||||||
|
if (!data) {
|
||||||
|
try { data = await fetchEdgar(symbol); }
|
||||||
|
catch (e) { errs.push('EDGAR: ' + (e?.message || e)); }
|
||||||
|
}
|
||||||
|
if (!data) throw new Error('兩個來源都取不到財報(' + errs.join(';') + ')');
|
||||||
|
|
||||||
|
const asOf = data.quarters?.[0]?.label || data.annual?.[0]?.label || null;
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
name: data.name || priceInfo.name || symbol,
|
||||||
|
currency: data.currency || priceInfo.currency || 'USD',
|
||||||
|
source: data.source,
|
||||||
|
asOf,
|
||||||
|
price: priceInfo.price,
|
||||||
|
peTrailing: data.peTrailing ?? null,
|
||||||
|
marketCap: data.marketCap ?? null,
|
||||||
|
quarters: data.quarters || [],
|
||||||
|
annual: data.annual || [],
|
||||||
|
balance: data.balance || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// investmap.js — 互動式「投資地圖」六層漏斗設定
|
||||||
|
// 內容整理自 emmy/emmy/學習分類/投資底層邏輯.md 的六層漏斗與提問清單。
|
||||||
|
// 每一層是一道篩子,任一「閘門題(gate)」答否 → 該層出局、後面先停。
|
||||||
|
// 問題上的 principles 為原則編號,server 端會補上標題與 note id 供前端連結。
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export const CORE_QUESTION =
|
||||||
|
'市場現在相信什麼(已 price 進去的共識)?我相信而市場還沒相信的點是什麼?看錯了我會不會死?答不出「市場還沒信的點」就只是追價。';
|
||||||
|
|
||||||
|
const LAYERS = [
|
||||||
|
{
|
||||||
|
key: 'macro', title: '總經水位', ask: '現在是滿倉、半倉還是減倉的環境?覆巢之下無完卵。',
|
||||||
|
pillar: '柱二:這波是結構性還非結構性?',
|
||||||
|
out: '總經明確轉空 → 降到低水位,後面幾層先不看。',
|
||||||
|
questions: [
|
||||||
|
{ q: '利率處於升息結束的高原期或初升段,而非「降息=衰退確認」的環境?', gate: true, principles: [2, 55] },
|
||||||
|
{ q: '即時數據(Truflation、訂單、貨運)與官方數據一致、沒有惡化?', principles: [24] },
|
||||||
|
{ q: '目前的下跌是「非結構性」恐慌(可加碼),而非結構性崩壞?', gate: true, principles: [101, 50] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'industry', title: '產業結構', ask: '這產業未來 3-5 年是結構性成長、循環、還是結構性衰退?',
|
||||||
|
pillar: '柱二:供給端還是需求端決定價格?',
|
||||||
|
out: '結構性衰退產業 → 再便宜也避開。',
|
||||||
|
questions: [
|
||||||
|
{ q: '這是長期不可逆的結構性成長/長多趨勢?', gate: true, principles: [13] },
|
||||||
|
{ q: '價格由「供給瓶頸」撐住,而非「需求結構性消失」?', gate: true, principles: [4, 6] },
|
||||||
|
{ q: '近期營收回升來自終端需求,而非只是補庫存?', principles: [14] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'moat', title: '商業模式(真假護城河)', ask: '這家公司「贏」的理由能撐多久?',
|
||||||
|
pillar: '柱二:護城河可不可複製。',
|
||||||
|
out: '護城河靠剝削/補貼撐 → 不碰。',
|
||||||
|
questions: [
|
||||||
|
{ q: '是生態/技術/資本型護城河,而非掠奪補貼型?', gate: true, principles: [12, 16] },
|
||||||
|
{ q: '若有毀滅性價格戰,它是資本最深的贏家?', principles: [15] },
|
||||||
|
{ q: '能把技術真正變現(賣得出去),而非只有技術?', principles: [66] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'management', title: '管理層(人對不對)', ask: '買股票就是買管理層;前三層決定局好不好打,這層決定派誰上場。',
|
||||||
|
pillar: '避開結果論:看當下決策品質 + 兌現紀錄。',
|
||||||
|
out: '判斷力差或常財測跳票 → 給折價甚至避開。',
|
||||||
|
questions: [
|
||||||
|
{ q: '重大決策用「當下資訊」看邏輯站得住(非事後諸葛)?', gate: true, principles: [96, 83] },
|
||||||
|
{ q: '過去財測穩健、說到做到(信任溢價而非折價)?', principles: [17, 103] },
|
||||||
|
{ q: '研發/資本投入誠實反映他真正相信的方向?', principles: [46] },
|
||||||
|
{ q: 'CEO 公開行為與認股條款透露對自己有信心?', principles: [93, 71] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'valuation', title: '估值 / 財報(價格對不對)', ask: '好公司 ≠ 好價格。現在買,賠率划算嗎?(可搭配「財報健檢」)',
|
||||||
|
pillar: '柱三前哨:別在脆弱估值上重壓。',
|
||||||
|
out: '好公司但價格爛 → 等更好的賠率。',
|
||||||
|
questions: [
|
||||||
|
{ q: '財測(未來)向上,而不是只看過去財報?', gate: true, principles: [17] },
|
||||||
|
{ q: '本益比沒有高到「一次不如預期就重摔」?', principles: [79] },
|
||||||
|
{ q: '同產業裡沒有更便宜的選擇正在吸走資金?', principles: [102] },
|
||||||
|
{ q: '毛利率/營收獲利型態揭露規模化或營運槓桿?', principles: [47, 91] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'discipline', title: '交易紀律(怎麼進出)', ask: '怎麼進、怎麼加、看錯怎麼退、永遠留多少現金?=柱三落地。',
|
||||||
|
pillar: '柱三:看錯時還活著,等對的幾次兌現。',
|
||||||
|
out: '沒有事前規則 → 別憑情緒進場。',
|
||||||
|
questions: [
|
||||||
|
{ q: '總經、產業、公司三面向都支持才動手?', gate: true, principles: [54] },
|
||||||
|
{ q: '減倉/停損規則「事前」就設好、能機械執行?', principles: [59] },
|
||||||
|
{ q: '有留底倉、分散到不被單一判斷錯誤打死?', principles: [97, 89] },
|
||||||
|
{ q: '計畫好「多頭讓利潤奔跑、空頭虧一半就斷」?', principles: [63] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 由 server 呼叫:用知識庫把 principles(編號) 補成 {num,title,id}
|
||||||
|
export function getInvestMap(principlesByNum) {
|
||||||
|
const enrichOne = (n) => {
|
||||||
|
const p = principlesByNum[n];
|
||||||
|
return p ? { num: n, title: p.title, id: p.id } : { num: n, title: `原則 ${n}`, id: null };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
coreQuestion: CORE_QUESTION,
|
||||||
|
layers: LAYERS.map(L => ({
|
||||||
|
key: L.key, title: L.title, ask: L.ask, pillar: L.pillar, out: L.out,
|
||||||
|
questions: L.questions.map(q => ({ q: q.q, gate: !!q.gate, principles: (q.principles || []).map(enrichOne) })),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 知識庫載入器
|
||||||
|
// 讀 data/knowledge.json(課綱輕量包)與 data/notes.json(全文)
|
||||||
|
// 進記憶體。由 scripts/build-knowledge.mjs 從 ../emmy 產生。
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DATA_DIR = path.join(__dirname, '..', 'data');
|
||||||
|
const KNOWLEDGE_PATH = path.join(DATA_DIR, 'knowledge.json');
|
||||||
|
const NOTES_PATH = path.join(DATA_DIR, 'notes.json');
|
||||||
|
|
||||||
|
let _knowledge = null;
|
||||||
|
let _notes = null;
|
||||||
|
|
||||||
|
export function knowledgeReady() {
|
||||||
|
return fs.existsSync(KNOWLEDGE_PATH) && fs.existsSync(NOTES_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKnowledge() {
|
||||||
|
if (_knowledge) return _knowledge;
|
||||||
|
if (!fs.existsSync(KNOWLEDGE_PATH)) return null;
|
||||||
|
_knowledge = JSON.parse(fs.readFileSync(KNOWLEDGE_PATH, 'utf8'));
|
||||||
|
return _knowledge;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNote(kind, id) {
|
||||||
|
if (!_notes) {
|
||||||
|
if (!fs.existsSync(NOTES_PATH)) return null;
|
||||||
|
_notes = JSON.parse(fs.readFileSync(NOTES_PATH, 'utf8'));
|
||||||
|
}
|
||||||
|
return _notes[`${kind}:${id}`] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 給 fincheck 用:心法/名詞/案例的 linkMap,方便把檢查結果連回筆記
|
||||||
|
export function getLinkMap() {
|
||||||
|
const k = getKnowledge();
|
||||||
|
return (k && k.linkMap) || {};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// marketdata.js — server 端抓取歷史股價(給「價格走勢」與「回測」共用)
|
||||||
|
// 來源:Yahoo v8 chart(免 crumb、公開),含還原股價(adjclose)。
|
||||||
|
// 只負責抓取+正規化;快取由 server.js 以 DB 處理(節省 API)。
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36';
|
||||||
|
|
||||||
|
export const RANGES = ['3mo', '6mo', '1y', '2y', '5y', '10y', 'max'];
|
||||||
|
export const INTERVALS = ['1d', '1wk', '1mo'];
|
||||||
|
|
||||||
|
async function jget(url, ms = 12000) {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), ms);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': UA, Accept: 'application/json,text/plain,*/*', 'Accept-Language': 'en-US,en;q=0.9' },
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
async function jgetH(url, headers, ms = 12000) {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), ms);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': UA, ...headers }, signal: ctrl.signal });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// range → 起始日期門檻
|
||||||
|
const RANGE_MONTHS = { '3mo': 3, '6mo': 6, '1y': 12, '2y': 24, '5y': 60, '10y': 120, max: null };
|
||||||
|
function cutoffDate(range) {
|
||||||
|
const m = RANGE_MONTHS[range];
|
||||||
|
if (m == null) return '1980-01-01';
|
||||||
|
const d = new Date(); d.setMonth(d.getMonth() - m);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nasdaq 免金鑰每日歷史後備(Yahoo 429 時用,美股最完整)。
|
||||||
|
async function fetchNasdaq(symbol, range) {
|
||||||
|
const from = cutoffDate(range), to = new Date().toISOString().slice(0, 10);
|
||||||
|
const H = { Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/' };
|
||||||
|
for (const assetclass of ['stocks', 'etf']) {
|
||||||
|
let j;
|
||||||
|
try { j = await jgetH(`https://api.nasdaq.com/api/quote/${encodeURIComponent(symbol)}/chart?assetclass=${assetclass}&fromdate=${from}&todate=${to}`, H); }
|
||||||
|
catch { continue; }
|
||||||
|
const chart = j?.data?.chart;
|
||||||
|
if (!Array.isArray(chart) || chart.length < 2) continue;
|
||||||
|
const points = chart
|
||||||
|
.map(c => ({ date: new Date(c.x).toISOString().slice(0, 10), close: c.y, adjclose: c.y }))
|
||||||
|
.filter(p => p.close != null);
|
||||||
|
if (points.length >= 2) {
|
||||||
|
return { symbol, name: j.data.company || null, currency: 'USD', range, interval: '1d', points, source: 'Nasdaq' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回傳 { symbol, name, currency, points:[{date:'YYYY-MM-DD', close, adjclose}] }
|
||||||
|
export async function getHistory(symbol, range = '5y', interval = '1d') {
|
||||||
|
if (!RANGES.includes(range)) range = '5y';
|
||||||
|
if (!INTERVALS.includes(interval)) interval = '1d';
|
||||||
|
let lastErr = null;
|
||||||
|
for (const host of ['query1', 'query2']) {
|
||||||
|
try {
|
||||||
|
const url = `https://${host}.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}`
|
||||||
|
+ `?range=${range}&interval=${interval}&includeAdjustedClose=true`;
|
||||||
|
const d = await jget(url);
|
||||||
|
const r = d?.chart?.result?.[0];
|
||||||
|
if (!r || !Array.isArray(r.timestamp)) { lastErr = new Error('Yahoo 無歷史資料'); continue; }
|
||||||
|
const ts = r.timestamp;
|
||||||
|
const close = r.indicators?.quote?.[0]?.close || [];
|
||||||
|
const adj = r.indicators?.adjclose?.[0]?.adjclose || [];
|
||||||
|
const points = [];
|
||||||
|
for (let i = 0; i < ts.length; i++) {
|
||||||
|
const c = close[i];
|
||||||
|
if (c == null) continue; // 跳過缺值(停牌/未成交)
|
||||||
|
const a = (adj[i] != null) ? adj[i] : c;
|
||||||
|
points.push({ date: new Date(ts[i] * 1000).toISOString().slice(0, 10), close: c, adjclose: a });
|
||||||
|
}
|
||||||
|
if (points.length < 2) { lastErr = new Error('歷史資料點過少'); continue; }
|
||||||
|
return {
|
||||||
|
symbol: r.meta?.symbol || symbol,
|
||||||
|
name: r.meta?.shortName || r.meta?.longName || null,
|
||||||
|
currency: r.meta?.currency || null,
|
||||||
|
range, interval, source: 'Yahoo Finance',
|
||||||
|
points,
|
||||||
|
};
|
||||||
|
} catch (e) { lastErr = e; }
|
||||||
|
}
|
||||||
|
// Yahoo 失敗(常見 429)→ 改用 Nasdaq 免金鑰歷史
|
||||||
|
const fallback = await fetchNasdaq(symbol, range).catch(() => null);
|
||||||
|
if (fallback) return fallback;
|
||||||
|
throw lastErr || new Error('無法取得歷史股價');
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --disable-warning=ExperimentalWarning server.js",
|
"start": "node --disable-warning=ExperimentalWarning server.js",
|
||||||
"dev": "node --disable-warning=ExperimentalWarning --watch server.js"
|
"dev": "node --disable-warning=ExperimentalWarning --watch server.js",
|
||||||
|
"build:knowledge": "node scripts/build-knowledge.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// build-knowledge.mjs
|
||||||
|
// 把 ../emmy/emmy 的 Obsidian 知識庫快照成兩個 JSON:
|
||||||
|
// - data/knowledge.json : 課綱/心法/案例/分類 全文 + 名詞/公司/單集的輕量索引 + linkMap
|
||||||
|
// - data/notes.json : 所有筆記全文(key = `${kind}:${id}`),給 /api/note 即時查單篇
|
||||||
|
// emmy/ 在 web/ 之外,因此用建置腳本快照,不在執行期讀檔。
|
||||||
|
// 用法:cd web && npm run build:knowledge
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const EMMY = path.resolve(__dirname, '..', '..', 'emmy', 'emmy');
|
||||||
|
const OUT_DIR = path.resolve(__dirname, '..', 'data');
|
||||||
|
|
||||||
|
if (!fs.existsSync(EMMY)) {
|
||||||
|
console.error(`找不到知識庫資料夾:${EMMY}\n請確認 emmy/emmy 與 web/ 在同一層。`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 小工具 ──
|
||||||
|
function parseFrontmatter(raw) {
|
||||||
|
if (!raw.startsWith('---')) return { fm: {}, body: raw };
|
||||||
|
const end = raw.indexOf('\n---', 3);
|
||||||
|
if (end < 0) return { fm: {}, body: raw };
|
||||||
|
const fmText = raw.slice(3, end).trim();
|
||||||
|
const body = raw.slice(end + 4).replace(/^\s*\n/, '');
|
||||||
|
const fm = {};
|
||||||
|
for (const line of fmText.split('\n')) {
|
||||||
|
const m = line.match(/^([\w\u4e00-\u9fff]+):\s*(.*)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
let [, k, v] = m; v = v.trim();
|
||||||
|
if (v.startsWith('[') && v.endsWith(']')) v = v.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
else v = v.replace(/^["']|["']$/g, '');
|
||||||
|
fm[k] = v;
|
||||||
|
}
|
||||||
|
return { fm, body };
|
||||||
|
}
|
||||||
|
const firstHeading = (body) => { const m = body.match(/^#\s+(.+)$/m); return m ? m[1].trim() : null; };
|
||||||
|
function summarize(body) {
|
||||||
|
for (let l of body.split('\n')) {
|
||||||
|
l = l.trim();
|
||||||
|
if (!l || /^#/.test(l) || /^[-|]/.test(l) || /^type:/.test(l)) continue;
|
||||||
|
if (l.startsWith('>')) l = l.replace(/^>\s?/, '');
|
||||||
|
l = l.replace(/\[\[([^\]|]+)(\|[^\]]+)?\]\]/g, '$1').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/[*`#]/g, '').trim();
|
||||||
|
if (l.length > 4) return l.slice(0, 90);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function readDir(sub) {
|
||||||
|
const dir = path.join(EMMY, sub);
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs.readdirSync(dir)
|
||||||
|
.filter(f => f.endsWith('.md') && !f.endsWith('.bak') && !f.startsWith('.') && !f.startsWith('_'))
|
||||||
|
.map(f => {
|
||||||
|
const full = path.join(dir, f);
|
||||||
|
if (!fs.statSync(full).isFile()) return null;
|
||||||
|
const raw = fs.readFileSync(full, 'utf8');
|
||||||
|
if (!raw.trim()) return null;
|
||||||
|
const id = f.replace(/\.md$/, '');
|
||||||
|
const { fm, body } = parseFrontmatter(raw);
|
||||||
|
return { id, title: firstHeading(body) || id, fm, body };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 累積器 ──
|
||||||
|
const linkMap = {};
|
||||||
|
const notes = {};
|
||||||
|
const setLink = (key, val, overwrite = true) => { if (key && (overwrite || !linkMap[key])) linkMap[key] = val; };
|
||||||
|
const addNote = (kind, n) => {
|
||||||
|
notes[`${kind}:${n.id}`] = { kind, id: n.id, title: n.title, frontmatter: n.fm || {}, body: n.body };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 1. 學習分類(含 總覽 / 心法地圖 / 練習題庫)──
|
||||||
|
const SPECIAL = { '總覽': 'overview', '心法地圖': 'principleMap', '練習題庫': 'quiz' };
|
||||||
|
let overview = null, principleMap = null, quiz = null;
|
||||||
|
const categories = [];
|
||||||
|
for (const n of readDir('學習分類')) {
|
||||||
|
const node = { id: n.id, title: n.title, body: n.body, summary: summarize(n.body), frontmatter: n.fm };
|
||||||
|
const special = SPECIAL[n.id];
|
||||||
|
if (special === 'overview') { overview = node; setLink('學習分類/總覽', { kind: 'overview', id: n.id, title: n.title }); }
|
||||||
|
else if (special === 'principleMap') { principleMap = node; setLink('學習分類/心法地圖', { kind: 'principleMap', id: n.id, title: n.title }); }
|
||||||
|
else if (special === 'quiz') { quiz = node; setLink('學習分類/練習題庫', { kind: 'quiz', id: n.id, title: n.title }); }
|
||||||
|
else categories.push(node);
|
||||||
|
const kind = special || 'category';
|
||||||
|
setLink(`學習分類/${n.id}`, { kind, id: n.id, title: n.title });
|
||||||
|
setLink(n.id, { kind, id: n.id, title: n.title }, false);
|
||||||
|
addNote(kind, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. 案例講解 ──
|
||||||
|
const cases = [];
|
||||||
|
for (const n of readDir('案例講解')) {
|
||||||
|
cases.push({ id: n.id, title: n.title, body: n.body, summary: summarize(n.body), frontmatter: n.fm });
|
||||||
|
setLink(`案例講解/${n.id}`, { kind: 'case', id: n.id, title: n.title });
|
||||||
|
setLink(n.id, { kind: 'case', id: n.id, title: n.title }, false);
|
||||||
|
addNote('case', n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. 投資心法(單檔切成多條原則)──
|
||||||
|
const principles = [];
|
||||||
|
{
|
||||||
|
const file = path.join(EMMY, 'Emmy 投資心法.md');
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
||||||
|
let cur = null;
|
||||||
|
const push = () => { if (cur) { cur.body = cur._lines.join('\n').trim(); delete cur._lines; principles.push(cur); } };
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.match(/^##\s+(原則.+?)\s*$/);
|
||||||
|
if (m) { push(); cur = { id: m[1].trim(), title: m[1].trim(), _lines: ['# ' + m[1].trim()] }; }
|
||||||
|
else if (cur) cur._lines.push(line);
|
||||||
|
}
|
||||||
|
push();
|
||||||
|
principles.forEach((p, i) => {
|
||||||
|
p.num = i + 1;
|
||||||
|
setLink(`Emmy 投資心法#${p.id}`, { kind: 'principle', id: p.id, title: p.title });
|
||||||
|
setLink(p.id, { kind: 'principle', id: p.id, title: p.title }, false);
|
||||||
|
addNote('principle', { id: p.id, title: p.title, body: p.body, fm: {} });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. 名詞 / 公司 / 單集(輕量索引 + 全文存 notes)──
|
||||||
|
const index = [];
|
||||||
|
for (const n of readDir('名詞')) {
|
||||||
|
const aliases = Array.isArray(n.fm.aliases) ? n.fm.aliases : [];
|
||||||
|
index.push({ kind: 'term', id: n.id, title: n.title, aliases, sub: n.fm.category || '' });
|
||||||
|
setLink(`名詞/${n.id}`, { kind: 'term', id: n.id, title: n.title });
|
||||||
|
setLink(n.id, { kind: 'term', id: n.id, title: n.title }, false);
|
||||||
|
aliases.forEach(a => setLink(a, { kind: 'term', id: n.id, title: n.title }, false));
|
||||||
|
addNote('term', n);
|
||||||
|
}
|
||||||
|
for (const n of readDir('公司')) {
|
||||||
|
const ticker = Array.isArray(n.fm.ticker) ? n.fm.ticker.join(' / ') : (n.fm.ticker || '');
|
||||||
|
index.push({ kind: 'company', id: n.id, title: n.title, aliases: ticker ? [ticker] : [], sub: [n.fm.sector, ticker].filter(Boolean).join(' · ') });
|
||||||
|
setLink(`公司/${n.id}`, { kind: 'company', id: n.id, title: n.title });
|
||||||
|
setLink(n.id, { kind: 'company', id: n.id, title: n.title }, false);
|
||||||
|
addNote('company', n);
|
||||||
|
}
|
||||||
|
for (const n of readDir('單集')) {
|
||||||
|
index.push({ kind: 'episode', id: n.id, title: n.title, aliases: n.fm.episode ? [n.fm.episode] : [], sub: n.fm.date || '' });
|
||||||
|
setLink(`單集/${n.id}`, { kind: 'episode', id: n.id, title: n.title });
|
||||||
|
setLink(n.id, { kind: 'episode', id: n.id, title: n.title }, false);
|
||||||
|
if (n.fm.episode) setLink(n.fm.episode, { kind: 'episode', id: n.id, title: n.title }, false);
|
||||||
|
addNote('episode', n);
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
terms: index.filter(x => x.kind === 'term').length,
|
||||||
|
companies: index.filter(x => x.kind === 'company').length,
|
||||||
|
episodes: index.filter(x => x.kind === 'episode').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const knowledge = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
overview, principleMap, quiz,
|
||||||
|
categories, cases, principles,
|
||||||
|
index, counts, linkMap,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(OUT_DIR, 'knowledge.json'), JSON.stringify(knowledge));
|
||||||
|
fs.writeFileSync(path.join(OUT_DIR, 'notes.json'), JSON.stringify(notes));
|
||||||
|
|
||||||
|
console.log('知識庫建置完成:');
|
||||||
|
console.log(` 學習分類 ${categories.length} 案例 ${cases.length} 心法 ${principles.length}`);
|
||||||
|
console.log(` 名詞 ${counts.terms} 公司 ${counts.companies} 單集 ${counts.episodes}`);
|
||||||
|
console.log(` linkMap ${Object.keys(linkMap).length} 個鍵 notes ${Object.keys(notes).length} 篇`);
|
||||||
|
console.log(` 輸出 → ${path.relative(process.cwd(), path.join(OUT_DIR, 'knowledge.json'))}, notes.json`);
|
||||||
152
server.js
152
server.js
|
|
@ -19,12 +19,29 @@ import { EVENTS, EPISODES } from './lib/events.js';
|
||||||
import {
|
import {
|
||||||
savePayload, loadPayload, saveSeries, getSeries,
|
savePayload, loadPayload, saveSeries, getSeries,
|
||||||
saveScoreSnapshot, getScoreHistory,
|
saveScoreSnapshot, getScoreHistory,
|
||||||
|
listTrades, getTrade, insertTrade, updateTrade, deleteTrade, tradeStats,
|
||||||
|
getCachedJSON, putCachedJSON, getCachedEntry,
|
||||||
} from './lib/db.js';
|
} from './lib/db.js';
|
||||||
|
import { getKnowledge, getNote, knowledgeReady } from './lib/knowledge.js';
|
||||||
|
import { getFundamentals, getLatestFilingInfo } from './lib/fundamentals.js';
|
||||||
|
import { buildReport } from './lib/fincheck.js';
|
||||||
|
import { getHistory, RANGES, INTERVALS } from './lib/marketdata.js';
|
||||||
|
import { runBacktest, STRATEGIES } from './lib/backtest.js';
|
||||||
|
import { getInvestMap } from './lib/investmap.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const CACHE_TTL_MS = (Number(process.env.CACHE_TTL_SECONDS) || 3600) * 1000;
|
const CACHE_TTL_MS = (Number(process.env.CACHE_TTL_SECONDS) || 3600) * 1000;
|
||||||
|
// 財報變動頻率低(季報),因此長期存資料庫、盡量沿用:
|
||||||
|
// FUND_SOFT_MS 內直接用快取、完全不連網;超過才用輕量探針查 SEC 是否有新財報,
|
||||||
|
// 沒有新財報就續用快取(只更新檢查時間),有新財報或探針失敗且超過 FUND_HARD_MS 才重抓。
|
||||||
|
const FUND_SOFT_MS = (Number(process.env.FUND_SOFT_HOURS) || 12) * 3600 * 1000;
|
||||||
|
const FUND_HARD_MS = (Number(process.env.FUND_HARD_DAYS) || 3) * 24 * 3600 * 1000;
|
||||||
|
// 歷史股價快取:日線 6 小時內沿用、週/月線 1 天內沿用(節省 API)
|
||||||
|
const HIST_TTL_MS = (Number(process.env.HIST_SOFT_HOURS) || 6) * 3600 * 1000;
|
||||||
|
const SYMBOL_RE = /^[A-Z0-9.\-]{1,12}$/;
|
||||||
const hasKey = process.env.FRED_API_KEY && process.env.FRED_API_KEY !== 'your_fred_api_key_here';
|
const hasKey = process.env.FRED_API_KEY && process.env.FRED_API_KEY !== 'your_fred_api_key_here';
|
||||||
|
|
||||||
// 記憶體快取(開機時會用 DB 內容預先填入)
|
// 記憶體快取(開機時會用 DB 內容預先填入)
|
||||||
|
|
@ -111,7 +128,140 @@ app.get('/api/score-history', (req, res) => {
|
||||||
res.json({ points: getScoreHistory() });
|
res.json({ points: getScoreHistory() });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/health', (req, res) => res.json({ ok: true }));
|
// ─── 學習教材:知識庫 ───
|
||||||
|
app.get('/api/knowledge', (req, res) => {
|
||||||
|
const k = getKnowledge();
|
||||||
|
if (!k) return res.status(503).json({ error: 'knowledge_not_built', message: '知識庫尚未建立,請先執行 npm run build:knowledge。' });
|
||||||
|
res.json(k);
|
||||||
|
});
|
||||||
|
app.get('/api/note/:kind/:id', (req, res) => {
|
||||||
|
const note = getNote(req.params.kind, req.params.id);
|
||||||
|
if (!note) return res.status(404).json({ error: 'note_not_found', message: '找不到這篇筆記。' });
|
||||||
|
res.json(note);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 財報健檢 ───
|
||||||
|
app.get('/api/fundamentals/:symbol', async (req, res) => {
|
||||||
|
const symbol = String(req.params.symbol || '').trim().toUpperCase();
|
||||||
|
if (!/^[A-Z0-9.\-]{1,12}$/.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
|
||||||
|
const cacheKey = 'fund:' + symbol;
|
||||||
|
const fresh = req.query.fresh === '1';
|
||||||
|
const entry = getCachedEntry(cacheKey); // { value, updatedAt } | null
|
||||||
|
try {
|
||||||
|
if (!fresh && entry) {
|
||||||
|
const age = Date.now() - entry.updatedAt;
|
||||||
|
// 1) 還很新 → 直接用快取,完全不連網
|
||||||
|
if (age < FUND_SOFT_MS) return res.json({ ...entry.value, cached: true });
|
||||||
|
// 2) 稍舊 → 用輕量探針確認 SEC 是否有新財報
|
||||||
|
const probe = await getLatestFilingInfo(symbol).catch(() => null);
|
||||||
|
const known = entry.value._latestFiling;
|
||||||
|
const noUpdate = probe ? (known && probe.accn === known) : (age <= FUND_HARD_MS);
|
||||||
|
if (noUpdate) {
|
||||||
|
// 沒有新財報(或暫時無法判斷但還沒到硬上限)→ 續用快取,只更新「檢查時間」
|
||||||
|
const v = { ...entry.value, _checkedAt: Date.now() };
|
||||||
|
putCachedJSON(cacheKey, v); // 更新 updated_at,避免短時間內重複探針
|
||||||
|
return res.json({ ...v, cached: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3) 首次、或偵測到新財報、或使用者手動更新 → 真正抓取
|
||||||
|
const fundamentals = await getFundamentals(symbol);
|
||||||
|
const report = buildReport(fundamentals);
|
||||||
|
const probe = await getLatestFilingInfo(symbol).catch(() => null);
|
||||||
|
const now = Date.now();
|
||||||
|
const payload = {
|
||||||
|
symbol: fundamentals.symbol, name: fundamentals.name, source: fundamentals.source,
|
||||||
|
currency: fundamentals.currency, asOf: fundamentals.asOf, price: fundamentals.price, report,
|
||||||
|
_fetchedAt: now, _checkedAt: now,
|
||||||
|
_latestFiling: probe ? probe.accn : null, _latestForm: probe ? probe.form : null,
|
||||||
|
};
|
||||||
|
putCachedJSON(cacheKey, payload);
|
||||||
|
res.json({ ...payload, cached: false });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[api/fundamentals]', symbol, err?.message || err);
|
||||||
|
// 抓取失敗但有舊資料 → 回舊資料(標記 stale),不讓使用者卡住、也避免一直重試燒 API
|
||||||
|
if (entry) return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
|
||||||
|
res.status(502).json({ error: 'fundamentals_failed', message: String(err?.message || err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 歷史股價(價格走勢 + 回測共用,持久 DB 快取)───
|
||||||
|
async function getHistoryCached(symbol, range, interval, fresh) {
|
||||||
|
const key = `hist:${symbol}:${range}:${interval}`;
|
||||||
|
const ttl = interval === '1d' ? HIST_TTL_MS : 24 * 3600 * 1000;
|
||||||
|
const entry = getCachedEntry(key);
|
||||||
|
if (!fresh && entry && Date.now() - entry.updatedAt < ttl) return { ...entry.value, cached: true };
|
||||||
|
try {
|
||||||
|
const hist = await getHistory(symbol, range, interval);
|
||||||
|
const payload = { ...hist, _fetchedAt: Date.now() };
|
||||||
|
putCachedJSON(key, payload);
|
||||||
|
return { ...payload, cached: false };
|
||||||
|
} catch (err) {
|
||||||
|
if (entry) return { ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/price/:symbol', async (req, res) => {
|
||||||
|
const symbol = String(req.params.symbol || '').trim().toUpperCase();
|
||||||
|
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
|
||||||
|
const range = RANGES.includes(req.query.range) ? req.query.range : '5y';
|
||||||
|
const interval = INTERVALS.includes(req.query.interval) ? req.query.interval : '1d';
|
||||||
|
try {
|
||||||
|
const h = await getHistoryCached(symbol, range, interval, req.query.fresh === '1');
|
||||||
|
res.json(h);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[api/price]', symbol, err?.message || err);
|
||||||
|
res.status(502).json({ error: 'price_failed', message: String(err?.message || err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/backtest/:symbol', async (req, res) => {
|
||||||
|
const symbol = String(req.params.symbol || '').trim().toUpperCase();
|
||||||
|
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
|
||||||
|
const strategy = STRATEGIES[req.query.strategy] ? req.query.strategy : 'buyhold';
|
||||||
|
const range = RANGES.includes(req.query.range) ? req.query.range : '5y';
|
||||||
|
const numQ = (k) => (req.query[k] != null && req.query[k] !== '') ? Number(req.query[k]) : undefined;
|
||||||
|
try {
|
||||||
|
const h = await getHistoryCached(symbol, range, '1d', false);
|
||||||
|
const result = runBacktest(h.points, {
|
||||||
|
strategy, monthly: numQ('monthly'), short: numQ('short'), long: numQ('long'), drop: numQ('drop'),
|
||||||
|
});
|
||||||
|
res.json({ symbol, name: h.name, currency: h.currency, range, cached: h.cached, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[api/backtest]', symbol, err?.message || err);
|
||||||
|
res.status(502).json({ error: 'backtest_failed', message: String(err?.message || err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/investmap', (req, res) => {
|
||||||
|
try {
|
||||||
|
const k = getKnowledge();
|
||||||
|
const byNum = {};
|
||||||
|
for (const p of (k?.principles || [])) byNum[p.num] = { title: p.title, id: p.id };
|
||||||
|
res.json(getInvestMap(byNum));
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'investmap_failed', message: String(err?.message || err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 交易復盤 ───
|
||||||
|
app.get('/api/trades', (req, res) => res.json({ trades: listTrades() }));
|
||||||
|
app.get('/api/trades/stats', (req, res) => res.json(tradeStats()));
|
||||||
|
app.post('/api/trades', (req, res) => {
|
||||||
|
try { res.json(insertTrade(req.body || {})); }
|
||||||
|
catch (e) { res.status(400).json({ error: 'bad_trade', message: String(e?.message || e) }); }
|
||||||
|
});
|
||||||
|
app.put('/api/trades/:id', (req, res) => {
|
||||||
|
const row = updateTrade(Number(req.params.id), req.body || {});
|
||||||
|
if (!row) return res.status(404).json({ error: 'not_found', message: '查無此交易。' });
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
app.delete('/api/trades/:id', (req, res) => {
|
||||||
|
deleteTrade(Number(req.params.id));
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => res.json({ ok: true, knowledge: knowledgeReady() }));
|
||||||
app.use(express.static(__dirname));
|
app.use(express.static(__dirname));
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue