first commit
This commit is contained in:
commit
2effe74b22
|
|
@ -0,0 +1,9 @@
|
||||||
|
# 把這個檔案複製成 .env,然後填入你自己的 FRED 金鑰
|
||||||
|
# 申請(免費、約 1 分鐘):https://fred.stlouisfed.org/docs/api/api_key.html
|
||||||
|
FRED_API_KEY=your_fred_api_key_here
|
||||||
|
|
||||||
|
# 伺服器埠號(可不改)
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# 後端快取秒數(預設 1 小時)。FRED 資料更新慢,不需頻繁抓取
|
||||||
|
CACHE_TTL_SECONDS=3600
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
data.db
|
||||||
|
data.db-*
|
||||||
|
.gstack/
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
# MacroScope — 總經指標儀表板
|
||||||
|
|
||||||
|
一個給初學者看的美國總體經濟儀表板。資料來自美國聖路易聯儲的 **FRED**(免費、公開),
|
||||||
|
全中文介面、每張卡片都有白話解釋,並用透明公式算出「總經健康分數」。
|
||||||
|
|
||||||
|
> 為什麼需要一個後端?FRED 官方 API 不允許瀏覽器直接呼叫(沒有 CORS),而且金鑰不能放在前端外洩。
|
||||||
|
> 所以這裡用一支很小的 Node 伺服器當「代理」:金鑰只留在伺服器,瀏覽器只跟自己的 `/api/macro` 溝通。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三步驟啟動
|
||||||
|
|
||||||
|
### 1. 申請免費的 FRED 金鑰(約 1 分鐘)
|
||||||
|
到 <https://fred.stlouisfed.org/docs/api/api_key.html> 註冊帳號,取得一組 32 碼的金鑰。免費、即時核發。
|
||||||
|
|
||||||
|
### 2. 設定金鑰
|
||||||
|
把範例檔複製成 `.env`,填入你的金鑰:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# 然後編輯 .env,把 FRED_API_KEY 換成你的金鑰
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 安裝並啟動
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
看到 `MacroScope 已啟動 → http://localhost:3000` 後,用瀏覽器打開該網址即可。
|
||||||
|
|
||||||
|
> 還沒設定金鑰也能啟動,畫面會直接顯示設定教學,照著做即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 專案結構
|
||||||
|
|
||||||
|
```
|
||||||
|
index.html 前端(純 HTML/CSS/JS,向 /api/macro 取資料後渲染)
|
||||||
|
server.js Express 伺服器:提供網頁 + /api/macro(代理 FRED、1 小時快取)
|
||||||
|
lib/indicators.js 指標字典:序列代碼、中文名、分組、是否反向、解釋文字
|
||||||
|
lib/fred.js 抓取 FRED / Yahoo、做 YoY/MoM 換算、產生真實 sparkline
|
||||||
|
lib/score.js 用透明公式算出健康分數、景氣燈號與 5 個訊號
|
||||||
|
```
|
||||||
|
|
||||||
|
資料流:`瀏覽器 → /api/macro → (持金鑰) FRED → 換算/計分 → 回傳 JSON → 渲染`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 指標與資料來源
|
||||||
|
|
||||||
|
絕大多數指標直接對應免費的 FRED 序列(利率、通膨、就業、成長、貨幣信用等)。
|
||||||
|
黃金因 FRED 無良好日線來源,改用 Yahoo Finance 期貨報價(伺服器端呼叫、免金鑰)。
|
||||||
|
|
||||||
|
### 免費替代指標(畫面上會標示「替代」)
|
||||||
|
有少數指標屬於付費/專有資料,無法免費取得,因此用公認的免費等價指標替代,並在卡片上明確標示:
|
||||||
|
|
||||||
|
- **ISM 製造業 PMI** → 費城聯儲製造業景氣指數(`GACDFSA066MSFRBPHI`),大於 0 為擴張
|
||||||
|
- **世界大型企業聯合會 消費者信心 CCI** → 密西根大學消費者信心指數(`UMCSENT`)
|
||||||
|
- **領先指標 LEI** → 紐約聯儲殖利率曲線衰退機率模型(`RECPROUSM156N`)
|
||||||
|
|
||||||
|
此外加入「工業生產年增(`INDPRO`)」作為實體經濟的補充指標。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 總經健康分數怎麼算?
|
||||||
|
|
||||||
|
從 50 分(中性)出發,依殖利率曲線、衰退機率、通膨、就業、信用利差、金融條件、製造業、
|
||||||
|
成長、波動率等規則加減分,最後限制在 0–100。每一條規則都會列在分數的「?」說明裡,
|
||||||
|
方向中性的指標(如美元、油價、股市本身)不計入分數,只作為參考。
|
||||||
|
|
||||||
|
- 65 分以上:景氣穩健
|
||||||
|
- 50–64:溫和成長
|
||||||
|
- 35–49:景氣放緩
|
||||||
|
- 35 以下:衰退風險高
|
||||||
|
|
||||||
|
> 這是教學用的簡化模型,**不構成任何投資建議**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常見問題
|
||||||
|
|
||||||
|
- **畫面顯示「設定 FRED 金鑰」?** 代表 `.env` 還沒設定或金鑰錯誤,照畫面步驟做即可。
|
||||||
|
- **某些卡片顯示抓取失敗?** 個別序列偶爾延遲或維護,其餘仍是真實資料;按右上角「↻ 更新」可重抓。
|
||||||
|
- **資料多久更新?** 後端快取 1 小時;FRED 多數指標本身就是每日/每週/每月更新。
|
||||||
|
|
@ -0,0 +1,838 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MacroScope — 總經指標儀表板</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
:root{
|
||||||
|
--bg: #0a0e17;
|
||||||
|
--surface: #111822;
|
||||||
|
--card: #161f2e;
|
||||||
|
--border: #1e2a3a;
|
||||||
|
--text: #e8ecf1;
|
||||||
|
--text2: #8899aa;
|
||||||
|
--green: #00d4aa;
|
||||||
|
--red: #ff4d6a;
|
||||||
|
--yellow: #ffc14d;
|
||||||
|
--blue: #4da6ff;
|
||||||
|
--purple: #b388ff;
|
||||||
|
--orange: #ff8a4d;
|
||||||
|
--radius: 10px;
|
||||||
|
--shadow: 0 2px 12px rgba(0,0,0,.35);
|
||||||
|
}
|
||||||
|
html{font-size:14px;background:var(--bg);color:var(--text);font-family:'Inter',system-ui,-apple-system,"PingFang TC","Noto Sans TC",sans-serif;-webkit-font-smoothing:antialiased}
|
||||||
|
body{min-height:100vh;padding:0 0 60px}
|
||||||
|
a{color:var(--blue);text-decoration:none}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.header{
|
||||||
|
position:sticky;top:0;z-index:100;
|
||||||
|
background:rgba(10,14,23,.82);
|
||||||
|
backdrop-filter:blur(12px);
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
padding:14px 32px;
|
||||||
|
display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;
|
||||||
|
}
|
||||||
|
.logo{display:flex;align-items:center;gap:10px;font-size:1.25rem;font-weight:700;letter-spacing:-.02em}
|
||||||
|
.logo-icon{width:28px;height:28px;border-radius:6px;background:linear-gradient(135deg,var(--blue),var(--purple));display:flex;align-items:center;justify-content:center;font-size:.85rem}
|
||||||
|
.header-right{display:flex;align-items:center;gap:16px}
|
||||||
|
.last-updated{font-size:.8rem;color:var(--text2)}
|
||||||
|
.nav-links{display:flex;gap:6px;flex-wrap:wrap}
|
||||||
|
.nav-links a{
|
||||||
|
padding:6px 14px;border-radius:6px;font-size:.85rem;font-weight:500;color:var(--text);cursor:pointer;
|
||||||
|
transition:background .15s,color .15s;
|
||||||
|
}
|
||||||
|
.nav-links a.active{background:rgba(77,166,255,.15);color:var(--blue)}
|
||||||
|
.nav-links a:hover{background:rgba(77,166,255,.08)}
|
||||||
|
.refresh-btn{
|
||||||
|
background:var(--surface);border:1px solid var(--border);color:var(--text2);
|
||||||
|
padding:6px 12px;border-radius:6px;font-size:.8rem;cursor:pointer;transition:.15s;
|
||||||
|
}
|
||||||
|
.refresh-btn:hover{border-color:var(--blue);color:var(--blue)}
|
||||||
|
|
||||||
|
/* ── 導讀條(如何閱讀)── */
|
||||||
|
.guide{
|
||||||
|
margin:20px 32px 0;background:var(--surface);border:1px solid var(--border);
|
||||||
|
border-radius:var(--radius);padding:14px 20px;
|
||||||
|
}
|
||||||
|
.guide-title{font-size:.85rem;font-weight:600;margin-bottom:10px;display:flex;align-items:center;gap:8px}
|
||||||
|
.guide-title .tag{font-size:.68rem;font-weight:600;color:var(--blue);background:rgba(77,166,255,.12);padding:2px 8px;border-radius:20px}
|
||||||
|
.guide-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px 24px;font-size:.78rem;color:var(--text2);line-height:1.55}
|
||||||
|
.guide-grid b{color:var(--text);font-weight:600}
|
||||||
|
.legend-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px;vertical-align:middle}
|
||||||
|
|
||||||
|
/* ── Macro Signal Bar ── */
|
||||||
|
.signal-bar{
|
||||||
|
margin:20px 32px 0;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||||
|
padding:20px 28px;display:grid;grid-template-columns:1fr 2.5fr 1fr;gap:24px;align-items:center;
|
||||||
|
}
|
||||||
|
.signal-score{text-align:center;position:relative}
|
||||||
|
.signal-score .label{font-size:.75rem;color:var(--text2);letter-spacing:.04em;margin-bottom:4px;display:flex;align-items:center;justify-content:center;gap:6px}
|
||||||
|
.signal-score .value{font-size:2.8rem;font-weight:700;line-height:1}
|
||||||
|
.signal-score .sublabel{font-size:.75rem;color:var(--text2);margin-top:4px}
|
||||||
|
.signal-details{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}
|
||||||
|
.signal-pill{padding:10px 14px;border-radius:8px;text-align:center;background:var(--surface);border:1px solid var(--border)}
|
||||||
|
.signal-pill .pill-label{font-size:.7rem;color:var(--text2);letter-spacing:.02em;margin-bottom:4px}
|
||||||
|
.signal-pill .pill-value{font-size:1rem;font-weight:600}
|
||||||
|
.signal-regime{text-align:center}
|
||||||
|
.signal-regime .label{font-size:.75rem;color:var(--text2);letter-spacing:.04em;margin-bottom:8px}
|
||||||
|
.regime-badge{display:inline-block;padding:8px 20px;border-radius:20px;font-weight:700;font-size:.9rem;letter-spacing:.02em}
|
||||||
|
|
||||||
|
/* ── Section ── */
|
||||||
|
.section{margin:28px 32px 0}
|
||||||
|
.section-header{display:flex;align-items:center;gap:10px;margin-bottom:6px}
|
||||||
|
.section-icon{width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:.85rem}
|
||||||
|
.section-title{font-size:1.05rem;font-weight:600;letter-spacing:-.01em}
|
||||||
|
.section-subtitle{font-size:.75rem;color:var(--text2);margin-left:8px}
|
||||||
|
.section-intro{font-size:.82rem;color:var(--text2);line-height:1.65;margin:0 0 14px 38px;max-width:880px}
|
||||||
|
|
||||||
|
/* ── Card Grid ── */
|
||||||
|
.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px}
|
||||||
|
.card{
|
||||||
|
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||||
|
padding:18px 20px;transition:border-color .2s,box-shadow .2s;position:relative;
|
||||||
|
display:flex;flex-direction:column;
|
||||||
|
}
|
||||||
|
.card:hover{border-color:rgba(77,166,255,.3);box-shadow:0 0 20px rgba(77,166,255,.06)}
|
||||||
|
.card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px}
|
||||||
|
.card-labels{display:flex;flex-direction:column;gap:1px;min-width:0}
|
||||||
|
.card-label{font-size:.82rem;color:var(--text);font-weight:600;line-height:1.3}
|
||||||
|
.card-label-en{font-size:.66rem;color:var(--text2);letter-spacing:.02em}
|
||||||
|
.card-top-right{display:flex;align-items:center;gap:6px;flex-shrink:0}
|
||||||
|
.badge{padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;white-space:nowrap}
|
||||||
|
.badge.good{background:rgba(0,212,170,.12);color:var(--green)}
|
||||||
|
.badge.bad{background:rgba(255,77,106,.12);color:var(--red)}
|
||||||
|
.badge.neutral{background:rgba(136,153,170,.14);color:var(--text2)}
|
||||||
|
.info-btn{
|
||||||
|
width:18px;height:18px;border-radius:50%;border:1px solid var(--border);background:var(--surface);
|
||||||
|
color:var(--text2);font-size:.7rem;line-height:1;cursor:help;flex-shrink:0;
|
||||||
|
display:flex;align-items:center;justify-content:center;transition:.15s;font-weight:700;
|
||||||
|
}
|
||||||
|
.info-btn:hover,.info-btn:focus{border-color:var(--blue);color:var(--blue);outline:none}
|
||||||
|
.sub-tag{font-size:.62rem;color:var(--orange);background:rgba(255,138,77,.12);padding:1px 6px;border-radius:4px;align-self:flex-start;margin-bottom:6px}
|
||||||
|
.card-value{font-size:1.75rem;font-weight:700;line-height:1.1;margin-bottom:2px}
|
||||||
|
.card-change{font-size:.75rem;font-weight:500;margin-bottom:10px;min-height:1em}
|
||||||
|
.card-sparkline{height:36px;margin-top:auto;opacity:.9}
|
||||||
|
.card-meta{display:flex;justify-content:space-between;gap:8px;font-size:.66rem;color:var(--text2);margin-top:8px}
|
||||||
|
|
||||||
|
/* ── Yield Curve (wide card) ── */
|
||||||
|
.card.wide{grid-column:span 2}
|
||||||
|
.c-green{color:var(--green)}.c-red{color:var(--red)}.c-yellow{color:var(--yellow)}.c-blue{color:var(--blue)}.c-purple{color:var(--purple)}.c-orange{color:var(--orange)}.c-text{color:var(--text)}.c-text2{color:var(--text2)}
|
||||||
|
|
||||||
|
/* ── Tooltip(多行解釋)── */
|
||||||
|
#tooltip{
|
||||||
|
position:fixed;z-index:1000;max-width:300px;background:#18222f;border:1px solid var(--border);
|
||||||
|
border-radius:8px;padding:12px 14px;box-shadow:var(--shadow);font-size:.76rem;line-height:1.6;
|
||||||
|
color:var(--text2);opacity:0;pointer-events:none;transition:opacity .12s;
|
||||||
|
}
|
||||||
|
#tooltip.show{opacity:1}
|
||||||
|
#tooltip .tip-title{color:var(--text);font-weight:700;font-size:.82rem;margin-bottom:8px}
|
||||||
|
#tooltip .tip-row{margin-bottom:7px}
|
||||||
|
#tooltip .tip-row:last-child{margin-bottom:0}
|
||||||
|
#tooltip .tip-k{color:var(--blue);font-weight:600;margin-right:4px}
|
||||||
|
#tooltip .tip-foot{margin-top:9px;padding-top:8px;border-top:1px solid var(--border);font-size:.68rem;color:var(--text2);display:flex;justify-content:space-between;gap:10px}
|
||||||
|
#tooltip .tip-break{display:flex;justify-content:space-between;gap:12px;margin-bottom:4px}
|
||||||
|
#tooltip .tip-break .d-pos{color:var(--green)}#tooltip .tip-break .d-neg{color:var(--red)}
|
||||||
|
|
||||||
|
/* ── 載入 / 錯誤 ── */
|
||||||
|
.state{margin:60px 32px;text-align:center;color:var(--text2)}
|
||||||
|
.state h2{color:var(--text);font-size:1.1rem;margin-bottom:10px}
|
||||||
|
.state .spinner{width:34px;height:34px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 18px;animation:spin .8s linear infinite}
|
||||||
|
.state .err-box{max-width:520px;margin:0 auto;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:24px;text-align:left;line-height:1.7}
|
||||||
|
.state code{background:var(--surface);padding:2px 6px;border-radius:4px;color:var(--yellow);font-size:.85em}
|
||||||
|
.state .retry{margin-top:16px;background:var(--blue);color:#08111d;border:none;padding:8px 18px;border-radius:6px;font-weight:600;cursor:pointer}
|
||||||
|
.degraded-note{margin:14px 32px 0;font-size:.74rem;color:var(--orange);background:rgba(255,138,77,.08);border:1px solid rgba(255,138,77,.2);border-radius:8px;padding:8px 14px}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
|
||||||
|
/* ── 走勢大圖 Modal ── */
|
||||||
|
.card[data-key]{cursor:pointer}
|
||||||
|
.card[data-key]::after{content:'點擊看走勢';position:absolute;bottom:8px;right:12px;font-size:.6rem;color:var(--text2);opacity:0;transition:opacity .15s}
|
||||||
|
.card[data-key]:hover::after{opacity:.6}
|
||||||
|
#scoreClick{cursor:pointer}
|
||||||
|
#modalOverlay{
|
||||||
|
position:fixed;inset:0;z-index:500;background:rgba(4,8,14,.72);backdrop-filter:blur(3px);
|
||||||
|
display:none;align-items:center;justify-content:center;padding:20px;
|
||||||
|
}
|
||||||
|
#modalOverlay.show{display:flex}
|
||||||
|
.modal-panel{
|
||||||
|
background:var(--card);border:1px solid var(--border);border-radius:14px;
|
||||||
|
width:min(820px,100%);max-height:90vh;overflow:auto;box-shadow:0 10px 50px rgba(0,0,0,.5);
|
||||||
|
padding:22px 24px;animation:fadeInUp .25s ease both;
|
||||||
|
}
|
||||||
|
.modal-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:4px}
|
||||||
|
.modal-title{font-size:1.15rem;font-weight:700}
|
||||||
|
.modal-title .en{font-size:.72rem;color:var(--text2);font-weight:400;margin-left:6px}
|
||||||
|
.modal-now{font-size:1.05rem;font-weight:700;margin-top:2px}
|
||||||
|
.modal-close{background:var(--surface);border:1px solid var(--border);color:var(--text2);width:30px;height:30px;border-radius:8px;font-size:1rem;cursor:pointer;flex-shrink:0}
|
||||||
|
.modal-close:hover{border-color:var(--red);color:var(--red)}
|
||||||
|
.range-btns{display:flex;gap:6px;margin:14px 0}
|
||||||
|
.range-btn{background:var(--surface);border:1px solid var(--border);color:var(--text2);padding:5px 14px;border-radius:6px;font-size:.8rem;cursor:pointer;transition:.15s}
|
||||||
|
.range-btn:hover{border-color:var(--blue)}
|
||||||
|
.range-btn.active{background:rgba(77,166,255,.15);color:var(--blue);border-color:rgba(77,166,255,.4)}
|
||||||
|
.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:50px 0;text-align:center;color:var(--text2);font-size:.85rem}
|
||||||
|
.modal-tip{margin-top:14px;font-size:.76rem;color:var(--text2);line-height:1.6;border-top:1px solid var(--border);padding-top:12px}
|
||||||
|
.modal-tip .tip-k{color:var(--blue);font-weight:600;margin-right:4px}
|
||||||
|
.modal-foot{display:flex;justify-content:space-between;font-size:.68rem;color:var(--text2);margin-top:8px}
|
||||||
|
|
||||||
|
/* ── 歷史殷鑑(危機案例)── */
|
||||||
|
.episode-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:16px}
|
||||||
|
.episode{
|
||||||
|
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
|
||||||
|
padding:18px 20px;display:flex;flex-direction:column;
|
||||||
|
border-top:3px solid var(--ec);transition:border-color .2s,box-shadow .2s;
|
||||||
|
}
|
||||||
|
.episode:hover{box-shadow:0 0 22px rgba(0,0,0,.25)}
|
||||||
|
.ep-head{display:flex;align-items:center;gap:12px;margin-bottom:10px}
|
||||||
|
.ep-emoji{font-size:1.5rem;line-height:1}
|
||||||
|
.ep-title{font-size:1rem;font-weight:700;display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
|
||||||
|
.ep-period{font-size:.72rem;color:var(--text2);font-weight:500}
|
||||||
|
.ep-type{font-size:.64rem;font-weight:600;color:var(--ec);background:color-mix(in srgb,var(--ec) 14%,transparent);padding:2px 8px;border-radius:20px;display:inline-block;margin-top:3px}
|
||||||
|
.ep-summary{font-size:.78rem;color:var(--text2);line-height:1.6;margin-bottom:12px}
|
||||||
|
.ep-sig-title{font-size:.7rem;font-weight:600;color:var(--text);letter-spacing:.03em;margin-bottom:7px;display:flex;align-items:center;gap:6px}
|
||||||
|
.ep-sig-title::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--ec)}
|
||||||
|
.ep-sigs{display:flex;flex-direction:column;gap:7px;margin-bottom:12px}
|
||||||
|
.ep-sig{
|
||||||
|
font-size:.74rem;line-height:1.55;color:var(--text2);background:var(--surface);
|
||||||
|
border:1px solid var(--border);border-radius:7px;padding:7px 10px;cursor:pointer;transition:.15s;
|
||||||
|
}
|
||||||
|
.ep-sig:hover{border-color:var(--ec);color:var(--text)}
|
||||||
|
.ep-sig b{color:var(--text);font-weight:600}
|
||||||
|
.ep-sig .sig-go{color:var(--ec);font-size:.66rem;margin-left:4px;opacity:0;transition:.15s}
|
||||||
|
.ep-sig:hover .sig-go{opacity:1}
|
||||||
|
.ep-lesson{font-size:.76rem;line-height:1.6;color:var(--text);background:color-mix(in srgb,var(--ec) 7%,transparent);border-radius:7px;padding:9px 11px;margin-bottom:8px}
|
||||||
|
.ep-lesson b{color:var(--ec)}
|
||||||
|
.ep-watch{font-size:.74rem;line-height:1.6;color:var(--text2);margin-bottom:14px}
|
||||||
|
.ep-watch b{color:var(--text)}
|
||||||
|
.ep-btn{margin-top:auto;background:var(--surface);border:1px solid var(--border);color:var(--ec);padding:8px 14px;border-radius:7px;font-size:.78rem;font-weight:600;cursor:pointer;transition:.15s;width:100%}
|
||||||
|
.ep-btn:hover{border-color:var(--ec);background:color-mix(in srgb,var(--ec) 10%,transparent)}
|
||||||
|
.ep-legend{font-size:.74rem;color:var(--text2);margin:0 0 14px 38px;line-height:1.6;max-width:880px}
|
||||||
|
.ep-legend .ev-chip{display:inline-flex;align-items:center;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:2px 9px;margin:2px 4px 2px 0;font-size:.72rem}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media(max-width:900px){
|
||||||
|
.signal-bar{grid-template-columns:1fr;gap:16px;text-align:center}
|
||||||
|
.signal-details{grid-template-columns:repeat(3,1fr)}
|
||||||
|
.card.wide{grid-column:span 1}
|
||||||
|
.header{padding:12px 16px}
|
||||||
|
.section{margin:20px 16px 0}
|
||||||
|
.signal-bar,.guide,.degraded-note{margin-left:16px;margin-right:16px}
|
||||||
|
.section-intro{margin-left:0}
|
||||||
|
}
|
||||||
|
@media(max-width:600px){
|
||||||
|
.card-grid{grid-template-columns:1fr}
|
||||||
|
.signal-details{grid-template-columns:repeat(2,1fr)}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
|
||||||
|
.section,.signal-bar,.guide{animation:fadeInUp .45s ease both}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ─── Header ─── -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="logo-icon">M</div>
|
||||||
|
MacroScope
|
||||||
|
</div>
|
||||||
|
<nav class="nav-links" id="navLinks"></nav>
|
||||||
|
<div class="header-right">
|
||||||
|
<span class="last-updated" id="lastUpdated"></span>
|
||||||
|
<button class="refresh-btn" id="refreshBtn" title="重新抓取最新資料">↻ 更新</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
<div class="state" id="loadingState">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
正在抓取真實總經資料…
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 浮動說明框 -->
|
||||||
|
<div id="tooltip" role="tooltip"></div>
|
||||||
|
|
||||||
|
<!-- 走勢大圖 -->
|
||||||
|
<div id="modalOverlay" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-panel" id="modalPanel">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<div class="modal-title" id="modalTitle"></div>
|
||||||
|
<div class="modal-now" id="modalNow"></div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" id="modalClose" aria-label="關閉">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="range-btns" id="rangeBtns"></div>
|
||||||
|
<div class="chart-wrap" id="chartWrap"></div>
|
||||||
|
<div class="modal-tip" id="modalTip"></div>
|
||||||
|
<div class="modal-foot" id="modalFoot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 顏色對應(colorKey → CSS 變數 / 十六進位)
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
const HEX = {green:'#00d4aa',red:'#ff4d6a',yellow:'#ffc14d',blue:'#4da6ff',purple:'#b388ff',orange:'#ff8a4d',text:'#e8ecf1',text2:'#8899aa'};
|
||||||
|
const cssVar = (k)=>`var(--${k})`;
|
||||||
|
|
||||||
|
// 給每張卡片的說明資料(key → {label, tip, substitute}),供 tooltip 使用
|
||||||
|
const TIPS = {};
|
||||||
|
// 卡片基本資訊(key → {label, labelEn, colorKey, value}),供走勢大圖使用
|
||||||
|
const CARD_META = {};
|
||||||
|
// 歷史事件標記與危機案例(由 /api/events 載入)
|
||||||
|
let EVENTS = [];
|
||||||
|
let EPISODES = [];
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// Sparkline SVG(吃真實歷史資料)
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function sparkle(data,w=180,h=36,color){
|
||||||
|
if(!data||data.length<2) return '';
|
||||||
|
const mn=Math.min(...data),mx=Math.max(...data),rng=mx-mn||1;
|
||||||
|
const pts=data.map((v,i)=>{
|
||||||
|
const x=(i/(data.length-1))*w;
|
||||||
|
const y=h-4-((v-mn)/rng)*(h-8);
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
});
|
||||||
|
const lastX=w,lastY=h-4-((data[data.length-1]-mn)/rng)*(h-8);
|
||||||
|
const id='g_'+color.replace('#','');
|
||||||
|
const areaPath=`M0,${h} `+pts.map(p=>`L${p}`).join(' ')+` L${w},${h} Z`;
|
||||||
|
const linePath=pts.map(p=>`L${p}`).join(' ').replace(/^L/,'M');
|
||||||
|
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs><linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="${color}" stop-opacity="0.3"/>
|
||||||
|
<stop offset="100%" stop-color="${color}" stop-opacity="0.02"/>
|
||||||
|
</linearGradient></defs>
|
||||||
|
<path d="${areaPath}" fill="url(#${id})"/>
|
||||||
|
<path d="${linePath}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
<circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="2.5" fill="${color}"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 卡片 HTML
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function arrow(dir){return dir==='up'?'▲':dir==='down'?'▼':'●';}
|
||||||
|
|
||||||
|
function cardHTML(c){
|
||||||
|
TIPS[c.key]={label:c.label,tip:c.tip,substitute:c.substitute};
|
||||||
|
CARD_META[c.key]={label:c.label,labelEn:c.labelEn,colorKey:c.valueColorKey,value:c.value};
|
||||||
|
const valColor=cssVar(c.valueColorKey);
|
||||||
|
const chColor=cssVar(c.changeColorKey);
|
||||||
|
const sparkHex=HEX[c.valueColorKey]||HEX.blue;
|
||||||
|
const subTag=c.substitute?`<span class="sub-tag" title="免費替代指標">替代:${c.substitute}</span>`:'';
|
||||||
|
const change=c.change?`<div class="card-change" style="color:${chColor}">${arrow(c.dir)} ${c.change}</div>`:`<div class="card-change"></div>`;
|
||||||
|
return `
|
||||||
|
<div class="card" data-key="${c.key}" role="button" tabindex="0" aria-label="${c.label},點擊看走勢">
|
||||||
|
<div class="card-top">
|
||||||
|
<div class="card-labels">
|
||||||
|
<span class="card-label">${c.label}</span>
|
||||||
|
<span class="card-label-en">${c.labelEn||''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-top-right">
|
||||||
|
<span class="badge ${c.badgeKind}">${c.badge}</span>
|
||||||
|
<button class="info-btn" data-tip-key="${c.key}" aria-label="說明:${c.label}" tabindex="0">?</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${subTag}
|
||||||
|
<div class="card-value" style="color:${valColor}">${c.value}</div>
|
||||||
|
${change}
|
||||||
|
<div class="card-sparkline">${sparkle(c.spark,180,36,sparkHex)}</div>
|
||||||
|
<div class="card-meta"><span>資料日 ${c.asOf||'—'}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 殖利率曲線(真實資料)───
|
||||||
|
function yieldCurveHTML(yc){
|
||||||
|
if(!yc||!yc.maturities||yc.maturities.length<2) return '';
|
||||||
|
const maturities=yc.maturities, yields=yc.yields, prevYields=yc.prevYields;
|
||||||
|
const w=520,h=150,padL=40,padB=25,padT=14,padR=10;
|
||||||
|
const plotW=w-padL-padR, plotH=h-padB-padT;
|
||||||
|
const all=yields.concat(prevYields);
|
||||||
|
let yMin=Math.min(...all), yMax=Math.max(...all);
|
||||||
|
const pad=(yMax-yMin)*0.15||0.3; yMin-=pad; yMax+=pad;
|
||||||
|
const yRange=yMax-yMin||1;
|
||||||
|
const toX=(i)=>padL+(i/(maturities.length-1))*plotW;
|
||||||
|
const toY=(v)=>padT+(1-(v-yMin)/yRange)*plotH;
|
||||||
|
const cur=yields.map((y,i)=>`${i===0?'M':'L'}${toX(i).toFixed(1)},${toY(y).toFixed(1)}`).join(' ');
|
||||||
|
const prev=prevYields.map((y,i)=>`${i===0?'M':'L'}${toX(i).toFixed(1)},${toY(y).toFixed(1)}`).join(' ');
|
||||||
|
let grid='';
|
||||||
|
const step=(yMax-yMin)/4;
|
||||||
|
for(let k=0;k<=4;k++){const v=yMin+step*k;
|
||||||
|
grid+=`<line x1="${padL}" y1="${toY(v)}" x2="${w-padR}" y2="${toY(v)}" stroke="rgba(255,255,255,.06)"/>`;
|
||||||
|
grid+=`<text x="${padL-5}" y="${toY(v)+4}" fill="#8899aa" font-size="9" text-anchor="end">${v.toFixed(1)}%</text>`;}
|
||||||
|
maturities.forEach((m,i)=>{grid+=`<text x="${toX(i)}" y="${h-6}" fill="#8899aa" font-size="9" text-anchor="middle">${m}</text>`;});
|
||||||
|
TIPS['yield_curve']={label:'殖利率曲線',tip:{
|
||||||
|
what:'不同到期天數(3個月到30年)的公債殖利率連成的曲線。',
|
||||||
|
how:'正常是右上斜(長率>短率)。若左高右低(倒掛),代表短率高於長率。',
|
||||||
|
impact:'反向警訊:倒掛在歷史上是相當可靠的衰退前兆。虛線為一個月前比較。',
|
||||||
|
source:'FRED · DGS 系列', freq:'每日'},substitute:null};
|
||||||
|
return `
|
||||||
|
<div class="card wide">
|
||||||
|
<div class="card-top">
|
||||||
|
<div class="card-labels">
|
||||||
|
<span class="card-label">殖利率曲線</span>
|
||||||
|
<span class="card-label-en">Yield Curve</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-top-right">
|
||||||
|
<span class="badge ${yc.inverted?'bad':'good'}">${yc.inverted?'倒掛':'正常'}</span>
|
||||||
|
<button class="info-btn" data-tip-key="yield_curve" aria-label="說明:殖利率曲線" tabindex="0">?</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg width="100%" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
${grid}
|
||||||
|
<path d="${prev}" fill="none" stroke="#8899aa" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
|
||||||
|
<path d="${cur}" fill="none" stroke="#4da6ff" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
${yields.map((y,i)=>`<circle cx="${toX(i).toFixed(1)}" cy="${toY(y).toFixed(1)}" r="3.2" fill="#4da6ff" stroke="#111822" stroke-width="1.5"/>`).join('')}
|
||||||
|
<line x1="${padL+10}" y1="${padT}" x2="${padL+30}" y2="${padT}" stroke="#4da6ff" stroke-width="2"/>
|
||||||
|
<text x="${padL+34}" y="${padT+4}" fill="#4da6ff" font-size="9">目前</text>
|
||||||
|
<line x1="${padL+78}" y1="${padT}" x2="${padL+98}" y2="${padT}" stroke="#8899aa" stroke-width="1.5" stroke-dasharray="4,3" opacity=".6"/>
|
||||||
|
<text x="${padL+102}" y="${padT+4}" fill="#8899aa" font-size="9">一個月前</text>
|
||||||
|
</svg>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 渲染整頁
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
function guideHTML(){
|
||||||
|
return `
|
||||||
|
<div class="guide">
|
||||||
|
<div class="guide-title"><span class="tag">新手必讀</span>如何閱讀這個儀表板</div>
|
||||||
|
<div class="guide-grid">
|
||||||
|
<div><b>顏色</b>:<span class="legend-dot" style="background:var(--green)"></span>綠=偏好訊號、<span class="legend-dot" style="background:var(--red)"></span>紅=警訊、<span class="legend-dot" style="background:var(--yellow)"></span>黃=中性/持平、<span class="legend-dot" style="background:var(--blue)"></span>藍=純數值(無好壞)。</div>
|
||||||
|
<div><b>箭頭 / 徽章</b>:▲上升、▼下降、●持平,描述「和上一期相比的變化方向」。</div>
|
||||||
|
<div><b>反向指標</b>:有些指標「數字越高越糟」,例如<b>失業率、VIX、信用利差、衰退機率</b>——這類下降才是好消息。</div>
|
||||||
|
<div><b>總經健康分數</b>:把下方關鍵指標用透明公式加總成 0–100 分,越高代表環境對風險性資產越友善(滑到分數上看計算明細)。</div>
|
||||||
|
<div><b>每張卡片的「?」</b>:滑過去(手機點一下)看白話解釋:這是什麼、怎麼看、對市場的影響。</div>
|
||||||
|
<div><b>替代指標</b>:少數付費資料(如 ISM、CCI、LEI)以免費的等價指標替代,卡片上會標示。</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data){
|
||||||
|
const main=document.getElementById('main');
|
||||||
|
const scoreColor=cssVar(data.regime?data.regime.colorKey:'yellow');
|
||||||
|
|
||||||
|
// 訊號 pills
|
||||||
|
const signals=(data.signals||[]).map(s=>
|
||||||
|
`<div class="signal-pill"><div class="pill-label">${s.label}</div><div class="pill-value" style="color:${cssVar(s.colorKey)}">${s.value}</div></div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// 健康分數說明(breakdown)放進 TIPS
|
||||||
|
TIPS['__score']={label:'總經健康分數怎麼算',breakdown:data.breakdown||[]};
|
||||||
|
|
||||||
|
let html = guideHTML();
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="signal-bar">
|
||||||
|
<div class="signal-score">
|
||||||
|
<div class="label">總經健康分數 <button class="info-btn" data-tip-key="__score" aria-label="分數計算說明" tabindex="0">?</button></div>
|
||||||
|
<div class="value" id="scoreClick" style="color:${scoreColor}" role="button" tabindex="0" title="點擊看分數走勢">${data.score ?? '--'}</div>
|
||||||
|
<div class="sublabel">/ 100 · 點擊看走勢</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-details">${signals}</div>
|
||||||
|
<div class="signal-regime">
|
||||||
|
<div class="label">目前景氣狀態</div>
|
||||||
|
<div class="regime-badge" style="background:${hexA(data.regime?data.regime.colorKey:'yellow',.15)};color:${scoreColor}">${data.regime?data.regime.label:'--'}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// 降級提示
|
||||||
|
if(data.degraded&&data.degraded.length){
|
||||||
|
html += `<div class="degraded-note">提醒:有 ${data.degraded.length} 個指標暫時抓取失敗(${data.degraded.slice(0,3).map(d=>d.label).join('、')}${data.degraded.length>3?'…':''}),其餘為真實資料。</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各分組
|
||||||
|
const nav=[];
|
||||||
|
(data.groups||[]).forEach(g=>{
|
||||||
|
if(!g.cards||g.cards.length===0) return;
|
||||||
|
nav.push(`<a data-target="group-${g.key}">${g.title}</a>`);
|
||||||
|
let cards;
|
||||||
|
if(g.key==='rates'){
|
||||||
|
// 把殖利率曲線插進利率組(放在利差之後)
|
||||||
|
const arr=g.cards.map(cardHTML);
|
||||||
|
arr.splice(Math.min(4,arr.length),0,yieldCurveHTML(data.yieldCurve));
|
||||||
|
cards=arr.join('');
|
||||||
|
}else{
|
||||||
|
cards=g.cards.map(cardHTML).join('');
|
||||||
|
}
|
||||||
|
html += `
|
||||||
|
<div class="section" id="group-${g.key}">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-icon" style="background:${hexA(g.colorKey,.15)};color:${cssVar(g.colorKey)}">${g.icon}</div>
|
||||||
|
<div class="section-title">${g.title}</div>
|
||||||
|
<div class="section-subtitle">${g.titleEn}</div>
|
||||||
|
</div>
|
||||||
|
<p class="section-intro">${g.intro}</p>
|
||||||
|
<div class="card-grid">${cards}</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 歷史殷鑑(危機案例)
|
||||||
|
if(EPISODES&&EPISODES.length){
|
||||||
|
nav.push(`<a data-target="group-history">歷史殷鑑</a>`);
|
||||||
|
html += episodesSectionHTML();
|
||||||
|
}
|
||||||
|
|
||||||
|
main.innerHTML=html;
|
||||||
|
document.getElementById('navLinks').innerHTML=nav.join('');
|
||||||
|
|
||||||
|
// 更新時間
|
||||||
|
const t=new Date(data.updatedAt||Date.now());
|
||||||
|
document.getElementById('lastUpdated').textContent=
|
||||||
|
`更新:${t.toLocaleDateString('zh-TW')} ${t.toLocaleTimeString('zh-TW',{hour:'2-digit',minute:'2-digit'})}${data.cached?'(快取)':''}`;
|
||||||
|
|
||||||
|
bindNav();
|
||||||
|
bindTooltips();
|
||||||
|
bindCardClicks();
|
||||||
|
bindEpisodes();
|
||||||
|
const sc=document.getElementById('scoreClick');
|
||||||
|
if(sc){sc.addEventListener('click',openScoreModal);sc.addEventListener('keydown',e=>{if(e.key==='Enter')openScoreModal();});}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindCardClicks(){
|
||||||
|
document.querySelectorAll('#main .card[data-key]').forEach(el=>{
|
||||||
|
const key=el.dataset.key;
|
||||||
|
el.addEventListener('click',(e)=>{ if(e.target.closest('.info-btn'))return; openModal(key); });
|
||||||
|
el.addEventListener('keydown',(e)=>{ if(e.key==='Enter'||e.key===' '){e.preventDefault();openModal(key);} });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexA(colorKey,a){
|
||||||
|
const map={green:'0,212,170',red:'255,77,106',yellow:'255,193,77',blue:'77,166,255',purple:'179,136,255',orange:'255,138,77'};
|
||||||
|
return `rgba(${map[colorKey]||map.yellow},${a})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 歷史殷鑑(危機案例)───
|
||||||
|
function episodesSectionHTML(){
|
||||||
|
const evChips=(EVENTS||[]).map(e=>`<span class="ev-chip">${e.emoji} ${e.label}(${e.date.slice(0,4)})</span>`).join('');
|
||||||
|
const cards=EPISODES.map(ep=>{
|
||||||
|
const ec=cssVar(ep.colorKey||'red');
|
||||||
|
const sigs=ep.signals.map(s=>
|
||||||
|
`<div class="ep-sig" data-key="${s.key}"><b>${s.label}</b>|${s.text}<span class="sig-go">看走勢 →</span></div>`
|
||||||
|
).join('');
|
||||||
|
return `
|
||||||
|
<div class="episode" style="--ec:${ec}">
|
||||||
|
<div class="ep-head">
|
||||||
|
<span class="ep-emoji">${ep.emoji}</span>
|
||||||
|
<div>
|
||||||
|
<div class="ep-title">${ep.title}<span class="ep-period">${ep.period}</span></div>
|
||||||
|
<span class="ep-type">${ep.type==='recovery'?'復甦範例':'危機'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="ep-summary">${ep.summary}</p>
|
||||||
|
<div class="ep-sig-title">當時出現的預警訊號</div>
|
||||||
|
<div class="ep-sigs">${sigs}</div>
|
||||||
|
<div class="ep-lesson"><b>啟示|</b>${ep.lesson}</div>
|
||||||
|
<div class="ep-watch"><b>現在可觀察|</b>${ep.watchNow}</div>
|
||||||
|
<button class="ep-btn" data-key="${ep.focusKey}">看當時走勢(${CARD_META[ep.focusKey]?CARD_META[ep.focusKey].label:ep.focusKey})→</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
return `
|
||||||
|
<div class="section" id="group-history">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-icon" style="background:${hexA('red',.15)};color:var(--red)">📜</div>
|
||||||
|
<div class="section-title">歷史殷鑑</div>
|
||||||
|
<div class="section-subtitle">Crisis Playbook</div>
|
||||||
|
</div>
|
||||||
|
<p class="section-intro">回顧幾次重大危機與反彈:當時哪些總經指標「提前」出現異常?學會辨識這些訊號,下一次就能更早觀察。點任一訊號或卡片,會打開該指標的長期走勢,並標出事件發生的時點。</p>
|
||||||
|
<p class="ep-legend">走勢大圖上的標記:${evChips}</p>
|
||||||
|
<div class="episode-grid">${cards}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEpisodes(){
|
||||||
|
document.querySelectorAll('#group-history .ep-sig, #group-history .ep-btn').forEach(el=>{
|
||||||
|
el.addEventListener('click',()=>{ const k=el.dataset.key; if(k) openModal(k,'max'); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 導覽列:平滑捲動 + 捲動高亮 ───
|
||||||
|
function bindNav(){
|
||||||
|
document.querySelectorAll('#navLinks a').forEach(a=>{
|
||||||
|
a.addEventListener('click',()=>{
|
||||||
|
const el=document.getElementById(a.dataset.target);
|
||||||
|
if(el) window.scrollTo({top:el.offsetTop-70,behavior:'smooth'});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll',()=>{
|
||||||
|
const links=[...document.querySelectorAll('#navLinks a')];
|
||||||
|
let cur=null;
|
||||||
|
links.forEach(a=>{
|
||||||
|
const el=document.getElementById(a.dataset.target);
|
||||||
|
if(el&&el.offsetTop-90<=window.scrollY) cur=a;
|
||||||
|
});
|
||||||
|
links.forEach(a=>a.classList.toggle('active',a===cur));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tooltip ───
|
||||||
|
const tooltipEl=document.getElementById('tooltip');
|
||||||
|
function tipContent(key){
|
||||||
|
if(key==='__score'){
|
||||||
|
const b=TIPS['__score'].breakdown||[];
|
||||||
|
const rows=b.length?b.map(x=>`<div class="tip-break"><span>${x.label}:${x.note}</span><span class="${x.delta>=0?'d-pos':'d-neg'}">${x.delta>=0?'+':''}${x.delta}</span></div>`).join(''):'<div class="tip-row">尚無足夠資料計分。</div>';
|
||||||
|
return `<div class="tip-title">總經健康分數怎麼算</div><div class="tip-row" style="margin-bottom:8px">從 50 分(中性)出發,依下列規則加減:</div>${rows}`;
|
||||||
|
}
|
||||||
|
const t=TIPS[key]; if(!t||!t.tip) return '';
|
||||||
|
const tip=t.tip;
|
||||||
|
return `<div class="tip-title">${t.label}</div>
|
||||||
|
<div class="tip-row"><span class="tip-k">這是什麼</span>${tip.what}</div>
|
||||||
|
<div class="tip-row"><span class="tip-k">怎麼看</span>${tip.how}</div>
|
||||||
|
<div class="tip-row"><span class="tip-k">影響</span>${tip.impact}</div>
|
||||||
|
<div class="tip-foot"><span>${tip.source||''}</span><span>${tip.freq||''}</span></div>`;
|
||||||
|
}
|
||||||
|
function showTip(btn){
|
||||||
|
const key=btn.dataset.tipKey;
|
||||||
|
const html=tipContent(key);
|
||||||
|
if(!html) return;
|
||||||
|
tooltipEl.innerHTML=html;
|
||||||
|
tooltipEl.classList.add('show');
|
||||||
|
const r=btn.getBoundingClientRect();
|
||||||
|
const tw=tooltipEl.offsetWidth, th=tooltipEl.offsetHeight;
|
||||||
|
let left=r.left+r.width/2-tw/2;
|
||||||
|
left=Math.max(10,Math.min(left,window.innerWidth-tw-10));
|
||||||
|
let top=r.top-th-10;
|
||||||
|
if(top<10) top=r.bottom+10; // 上方空間不足就放下方
|
||||||
|
tooltipEl.style.left=left+'px';
|
||||||
|
tooltipEl.style.top=top+'px';
|
||||||
|
}
|
||||||
|
function hideTip(){tooltipEl.classList.remove('show');}
|
||||||
|
function bindTooltips(){
|
||||||
|
document.querySelectorAll('.info-btn').forEach(btn=>{
|
||||||
|
btn.addEventListener('mouseenter',()=>showTip(btn));
|
||||||
|
btn.addEventListener('mouseleave',hideTip);
|
||||||
|
btn.addEventListener('focus',()=>showTip(btn));
|
||||||
|
btn.addEventListener('blur',hideTip);
|
||||||
|
btn.addEventListener('click',(e)=>{e.preventDefault();tooltipEl.classList.contains('show')?hideTip():showTip(btn);});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 走勢大圖 Modal
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
const overlay=document.getElementById('modalOverlay');
|
||||||
|
let MODAL={key:null,range:'1y',isScore:false};
|
||||||
|
let CHARTGEO={};
|
||||||
|
const RANGES=[['1m','1個月'],['6m','6個月'],['1y','1年'],['5y','5年'],['10y','10年'],['max','全部']];
|
||||||
|
|
||||||
|
function openModal(key,range){
|
||||||
|
MODAL={key,range:range||'1y',isScore:false};
|
||||||
|
const meta=CARD_META[key]||{label:key};
|
||||||
|
document.getElementById('modalTitle').innerHTML=`${meta.label}<span class="en">${meta.labelEn||''}</span>`;
|
||||||
|
const now=document.getElementById('modalNow');
|
||||||
|
now.textContent=meta.value?('目前:'+meta.value):'';
|
||||||
|
now.style.color=meta.colorKey?cssVar(meta.colorKey):'var(--text)';
|
||||||
|
renderRangeBtns();
|
||||||
|
const tip=TIPS[key]&&TIPS[key].tip;
|
||||||
|
document.getElementById('modalTip').innerHTML=tip?`<div><span class="tip-k">怎麼看</span>${tip.how}</div>`:'';
|
||||||
|
document.getElementById('modalFoot').textContent=tip?`${tip.source||''} ${tip.freq||''}`:'';
|
||||||
|
overlay.classList.add('show');
|
||||||
|
loadSeries();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openScoreModal(){
|
||||||
|
MODAL={key:'__score',range:'max',isScore:true};
|
||||||
|
document.getElementById('modalTitle').innerHTML='總經健康分數走勢<span class="en">Macro Health Score</span>';
|
||||||
|
document.getElementById('modalNow').textContent='';
|
||||||
|
document.getElementById('rangeBtns').innerHTML='';
|
||||||
|
document.getElementById('modalTip').innerHTML='<div><span class="tip-k">說明</span>每天記錄一筆分數,會隨使用天數累積成走勢。</div>';
|
||||||
|
document.getElementById('modalFoot').textContent='本機累積資料';
|
||||||
|
overlay.classList.add('show');
|
||||||
|
loadScoreHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRangeBtns(){
|
||||||
|
document.getElementById('rangeBtns').innerHTML=RANGES.map(([r,l])=>
|
||||||
|
`<button class="range-btn ${r===MODAL.range?'active':''}" data-range="${r}">${l}</button>`).join('');
|
||||||
|
document.querySelectorAll('#rangeBtns .range-btn').forEach(b=>
|
||||||
|
b.addEventListener('click',()=>{MODAL.range=b.dataset.range;renderRangeBtns();loadSeries();}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSeries(){
|
||||||
|
const wrap=document.getElementById('chartWrap');
|
||||||
|
wrap.innerHTML='<div class="chart-empty">載入中…</div>';
|
||||||
|
try{
|
||||||
|
const res=await fetch(`/api/series/${encodeURIComponent(MODAL.key)}?range=${MODAL.range}`);
|
||||||
|
const data=await res.json();
|
||||||
|
if(!data.points||data.points.length<2){wrap.innerHTML='<div class="chart-empty">此區間資料不足。</div>';return;}
|
||||||
|
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});
|
||||||
|
bindChartHover(data.points,{format:data.format,decimals:data.decimals});
|
||||||
|
}catch(e){wrap.innerHTML='<div class="chart-empty">載入失敗。</div>';}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScoreHistory(){
|
||||||
|
const wrap=document.getElementById('chartWrap');
|
||||||
|
wrap.innerHTML='<div class="chart-empty">載入中…</div>';
|
||||||
|
try{
|
||||||
|
const res=await fetch('/api/score-history');
|
||||||
|
const data=await res.json();
|
||||||
|
const pts=(data.points||[]).map(p=>({date:p.date,val:p.score}));
|
||||||
|
if(pts.length<2){wrap.innerHTML='<div class="chart-empty">分數走勢從今天開始累積,明天起就會出現曲線。</div>';return;}
|
||||||
|
wrap.innerHTML=lineChart(pts,{format:'num0',decimals:0,color:HEX.blue,yMin:0,yMax:100});
|
||||||
|
bindChartHover(pts,{format:'num0',decimals:0});
|
||||||
|
}catch(e){wrap.innerHTML='<div class="chart-empty">載入失敗。</div>';}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(){overlay.classList.remove('show');}
|
||||||
|
document.getElementById('modalClose').addEventListener('click',closeModal);
|
||||||
|
overlay.addEventListener('click',e=>{if(e.target===overlay)closeModal();});
|
||||||
|
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal();});
|
||||||
|
|
||||||
|
// 數值格式化(與後端一致,給座標軸用)
|
||||||
|
function fmtVal(v,format,d){
|
||||||
|
d=d??2;
|
||||||
|
const c=(n,dd)=>n.toLocaleString('en-US',{minimumFractionDigits:dd,maximumFractionDigits:dd});
|
||||||
|
switch(format){
|
||||||
|
case 'pct':case 'pct_signed':return v.toFixed(d)+'%';
|
||||||
|
case 'bp':return Math.round(v)+'bp';
|
||||||
|
case 'num0':return c(v,0);
|
||||||
|
case 'num1':return v.toFixed(1);
|
||||||
|
case 'num2':case 'num2_signed':return v.toFixed(2);
|
||||||
|
case 'k':case 'k_signed':return Math.round(v)+'K';
|
||||||
|
case 'trillions':return '$'+v.toFixed(d)+'T';
|
||||||
|
case 'usd':return '$'+v.toFixed(d);
|
||||||
|
case 'usd0':return '$'+c(v,0);
|
||||||
|
default:return v.toFixed(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在已排序(日期遞增)的序列中,找最接近指定日期的索引;超出範圍回 -1
|
||||||
|
function nearestIdxByDate(points,dateStr){
|
||||||
|
const t=new Date(dateStr).getTime();
|
||||||
|
if(!points.length) return -1;
|
||||||
|
if(t<new Date(points[0].date).getTime()||t>new Date(points[points.length-1].date).getTime()) return -1;
|
||||||
|
let best=0,bestDiff=Infinity;
|
||||||
|
for(let i=0;i<points.length;i++){
|
||||||
|
const d=Math.abs(new Date(points[i].date).getTime()-t);
|
||||||
|
if(d<bestDiff){bestDiff=d;best=i;}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineChart(points,opts){
|
||||||
|
const w=720,h=320,padL=58,padR=16,padT=24,padB=34;
|
||||||
|
const plotW=w-padL-padR, plotH=h-padT-padB;
|
||||||
|
const vals=points.map(p=>p.val);
|
||||||
|
let yMin=opts.yMin!=null?opts.yMin:Math.min(...vals);
|
||||||
|
let yMax=opts.yMax!=null?opts.yMax:Math.max(...vals);
|
||||||
|
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 n=points.length;
|
||||||
|
CHARTGEO={padL,plotW,padT,plotH,n,yMin,yRange,w,h};
|
||||||
|
const toX=i=>padL+(i/(n-1))*plotW;
|
||||||
|
const toY=v=>padT+(1-(v-yMin)/yRange)*plotH;
|
||||||
|
let grid='';const ticks=5;
|
||||||
|
for(let k=0;k<=ticks;k++){const v=yMin+(yRange*k/ticks);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)"/>`;
|
||||||
|
grid+=`<text x="${padL-8}" y="${(y+3.5).toFixed(1)}" fill="#8899aa" font-size="11" text-anchor="end">${fmtVal(v,opts.format,opts.decimals)}</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));const dt=points[idx].date;
|
||||||
|
xlab+=`<text x="${toX(idx).toFixed(1)}" y="${h-12}" fill="#8899aa" font-size="10" text-anchor="middle">${dt.slice(2,7).replace('-','/')}</text>`;}
|
||||||
|
let zero='';if(yMin<0&&yMax>0){const zy=toY(0);zero=`<line x1="${padL}" y1="${zy.toFixed(1)}" x2="${w-padR}" y2="${zy.toFixed(1)}" stroke="rgba(255,255,255,.18)" stroke-dasharray="3,3"/>`;}
|
||||||
|
// 歷史事件垂直標記(只畫落在此區間內的)
|
||||||
|
let marks='';
|
||||||
|
(opts.events||[]).forEach(ev=>{
|
||||||
|
const idx=nearestIdxByDate(points,ev.date);
|
||||||
|
if(idx<0) return;
|
||||||
|
const x=toX(idx);
|
||||||
|
marks+=`<g><title>${ev.date} ${ev.label}</title>`+
|
||||||
|
`<line x1="${x.toFixed(1)}" y1="${padT}" x2="${x.toFixed(1)}" y2="${(padT+plotH).toFixed(1)}" stroke="rgba(255,193,77,.45)" stroke-width="1" stroke-dasharray="3,3"/>`+
|
||||||
|
`<text x="${x.toFixed(1)}" y="${(padT-7).toFixed(1)}" fill="#ffc14d" font-size="12" text-anchor="middle">${ev.emoji}</text>`+
|
||||||
|
`</g>`;
|
||||||
|
});
|
||||||
|
const linePts=points.map((p,i)=>`${i===0?'M':'L'}${toX(i).toFixed(1)},${toY(p.val).toFixed(1)}`).join(' ');
|
||||||
|
const area=`M${padL},${(padT+plotH).toFixed(1)} `+points.map((p,i)=>`L${toX(i).toFixed(1)},${toY(p.val).toFixed(1)}`).join(' ')+` L${(padL+plotW).toFixed(1)},${(padT+plotH).toFixed(1)} Z`;
|
||||||
|
const last=points[n-1];
|
||||||
|
return `<svg id="bigChart" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs><linearGradient id="bgGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${opts.color}" stop-opacity=".25"/><stop offset="100%" stop-color="${opts.color}" stop-opacity="0"/></linearGradient></defs>
|
||||||
|
${grid}${xlab}${zero}
|
||||||
|
<path d="${area}" fill="url(#bgGrad)"/>
|
||||||
|
<path d="${linePts}" fill="none" stroke="${opts.color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
${marks}
|
||||||
|
<circle cx="${toX(n-1).toFixed(1)}" cy="${toY(last.val).toFixed(1)}" r="3.5" fill="${opts.color}"/>
|
||||||
|
<g id="hoverG" style="display:none"><line id="hoverLine" y1="${padT}" y2="${padT+plotH}" stroke="#8899aa" stroke-dasharray="3,3"/><circle id="hoverDot" r="4" fill="${opts.color}" stroke="#0a0e17" stroke-width="2"/></g>
|
||||||
|
<rect id="hoverArea" x="${padL}" y="${padT}" width="${plotW}" height="${plotH}" fill="transparent" style="cursor:crosshair"/>
|
||||||
|
<text id="hoverText" fill="#e8ecf1" font-size="11.5" font-weight="600" text-anchor="middle" style="display:none"></text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindChartHover(points,opts){
|
||||||
|
const svg=document.getElementById('bigChart');if(!svg)return;
|
||||||
|
const g=CHARTGEO;
|
||||||
|
const area=document.getElementById('hoverArea');
|
||||||
|
const hg=document.getElementById('hoverG'),line=document.getElementById('hoverLine'),dot=document.getElementById('hoverDot'),txt=document.getElementById('hoverText');
|
||||||
|
const toX=i=>g.padL+(i/(g.n-1))*g.plotW;
|
||||||
|
const toY=v=>g.padT+(1-(v-g.yMin)/g.yRange)*g.plotH;
|
||||||
|
function move(evt){
|
||||||
|
const r=svg.getBoundingClientRect();
|
||||||
|
const sx=(evt.clientX-r.left)*(g.w/r.width);
|
||||||
|
let i=Math.round(((sx-g.padL)/g.plotW)*(g.n-1));
|
||||||
|
i=Math.max(0,Math.min(g.n-1,i));
|
||||||
|
const p=points[i],x=toX(i),y=toY(p.val);
|
||||||
|
hg.style.display='';line.setAttribute('x1',x);line.setAttribute('x2',x);
|
||||||
|
dot.setAttribute('cx',x);dot.setAttribute('cy',y);
|
||||||
|
txt.style.display='';txt.setAttribute('x',Math.max(46,Math.min(g.w-46,x)));txt.setAttribute('y',g.padT+11);
|
||||||
|
txt.textContent=`${p.date} ${fmtVal(p.val,opts.format,opts.decimals)}`;
|
||||||
|
}
|
||||||
|
area.addEventListener('mousemove',move);
|
||||||
|
area.addEventListener('mouseleave',()=>{hg.style.display='none';txt.style.display='none';});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 載入資料
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
async function load(fresh){
|
||||||
|
const main=document.getElementById('main');
|
||||||
|
main.innerHTML=`<div class="state"><div class="spinner"></div>正在抓取真實總經資料…</div>`;
|
||||||
|
try{
|
||||||
|
const [res,evRes]=await Promise.all([
|
||||||
|
fetch('/api/macro'+(fresh?'?fresh=1':'')),
|
||||||
|
fetch('/api/events').catch(()=>null),
|
||||||
|
]);
|
||||||
|
const data=await res.json();
|
||||||
|
if(!res.ok){ renderError(data); return; }
|
||||||
|
if(evRes&&evRes.ok){ try{const ev=await evRes.json();EVENTS=ev.events||[];EPISODES=ev.episodes||[];}catch{} }
|
||||||
|
render(data);
|
||||||
|
}catch(err){
|
||||||
|
renderError({message:'無法連線到伺服器。請確認伺服器已啟動(npm start)。',detail:String(err)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError(data){
|
||||||
|
const main=document.getElementById('main');
|
||||||
|
if(data&&data.error==='missing_api_key'){
|
||||||
|
main.innerHTML=`<div class="state"><div class="err-box">
|
||||||
|
<h2>還差一步:設定免費的 FRED 金鑰</h2>
|
||||||
|
<p>本儀表板的真實資料來自美國聖路易聯儲的 FRED。請依下列步驟設定(約 1 分鐘):</p>
|
||||||
|
<ol style="margin:12px 0 0 18px">
|
||||||
|
<li>到 <a href="${data.hint}" target="_blank" rel="noopener">FRED 申請頁面</a> 註冊並取得免費金鑰</li>
|
||||||
|
<li>把專案內的 <code>.env.example</code> 複製成 <code>.env</code></li>
|
||||||
|
<li>在 <code>.env</code> 填入 <code>FRED_API_KEY=你的金鑰</code></li>
|
||||||
|
<li>重新啟動伺服器:<code>npm start</code></li>
|
||||||
|
</ol>
|
||||||
|
<button class="retry" onclick="load(true)">我設定好了,重新載入</button>
|
||||||
|
</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
main.innerHTML=`<div class="state"><div class="err-box">
|
||||||
|
<h2>載入失敗</h2>
|
||||||
|
<p>${(data&&data.message)||'發生未知錯誤。'}</p>
|
||||||
|
${data&&data.detail?`<p style="color:var(--text2);font-size:.8rem;margin-top:8px">${data.detail}</p>`:''}
|
||||||
|
<button class="retry" onclick="load(true)">重新載入</button>
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('refreshBtn').addEventListener('click',()=>load(true));
|
||||||
|
document.addEventListener('DOMContentLoaded',()=>load(false));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 本機資料庫(SQLite,使用 Node 內建的 node:sqlite,免安裝套件)
|
||||||
|
// 存三種東西:
|
||||||
|
// 1. cache — 整包 /api/macro 結果,重啟伺服器可即時載入
|
||||||
|
// 2. series — 每個指標的完整歷史序列,供「走勢大圖」使用
|
||||||
|
// 3. score_history — 每天記一筆健康分數,累積成「分數走勢」
|
||||||
|
// 資料庫檔:data.db(已在 .gitignore 忽略)
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DB_PATH = path.join(__dirname, '..', 'data.db');
|
||||||
|
|
||||||
|
const db = new DatabaseSync(DB_PATH);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS cache (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS series (
|
||||||
|
series_key TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
val REAL NOT NULL,
|
||||||
|
PRIMARY KEY (series_key, date)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS score_history (
|
||||||
|
date TEXT PRIMARY KEY,
|
||||||
|
score INTEGER NOT NULL,
|
||||||
|
regime TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ─── 整包結果的持久化快取 ───
|
||||||
|
export function savePayload(payload) {
|
||||||
|
db.prepare('INSERT OR REPLACE INTO cache (key, payload, updated_at) VALUES (?, ?, ?)')
|
||||||
|
.run('macro', JSON.stringify(payload), Date.now());
|
||||||
|
}
|
||||||
|
export function loadPayload() {
|
||||||
|
const row = db.prepare('SELECT payload, updated_at FROM cache WHERE key = ?').get('macro');
|
||||||
|
if (!row) return null;
|
||||||
|
try {
|
||||||
|
return { payload: JSON.parse(row.payload), updatedAt: row.updated_at };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 指標歷史序列 ───
|
||||||
|
const insertPoint = db.prepare('INSERT OR REPLACE INTO series (series_key, date, val) VALUES (?, ?, ?)');
|
||||||
|
export function saveSeries(key, points) {
|
||||||
|
if (!points || points.length === 0) return;
|
||||||
|
db.exec('BEGIN');
|
||||||
|
try {
|
||||||
|
for (const p of points) insertPoint.run(key, p.date, p.val);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function getSeries(key, sinceISO) {
|
||||||
|
if (sinceISO) {
|
||||||
|
return db.prepare('SELECT date, val FROM series WHERE series_key = ? AND date >= ? ORDER BY date ASC')
|
||||||
|
.all(key, sinceISO);
|
||||||
|
}
|
||||||
|
return db.prepare('SELECT date, val FROM series WHERE series_key = ? ORDER BY date ASC').all(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 每日健康分數快照(一天一筆,最新覆蓋)───
|
||||||
|
export function saveScoreSnapshot(score, regimeLabel) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
db.prepare('INSERT OR REPLACE INTO score_history (date, score, regime) VALUES (?, ?, ?)')
|
||||||
|
.run(today, score, regimeLabel || null);
|
||||||
|
}
|
||||||
|
export function getScoreHistory() {
|
||||||
|
return db.prepare('SELECT date, score, regime FROM score_history ORDER BY date ASC').all();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 歷史大事件 & 危機案例(給「歷史殷鑑」頁與走勢大圖標註用)
|
||||||
|
//
|
||||||
|
// EVENTS — 圖表上的垂直時間標記(重大轉折點)
|
||||||
|
// EPISODES — 深度案例:當時哪些指標出現異常、提前多久預警、
|
||||||
|
// 得到什麼啟示、現在可以觀察什麼。
|
||||||
|
//
|
||||||
|
// signals[].key 對應 lib/indicators.js 的指標 key,
|
||||||
|
// 點擊即可打開「該指標 + 全部區間」的走勢,並看到事件標記。
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ─── 圖表垂直標記(重大事件時點)───
|
||||||
|
export const EVENTS = [
|
||||||
|
{ date: '2000-03-10', label: '科技泡沫見頂', emoji: '💻' },
|
||||||
|
{ date: '2007-08-09', label: '次貸危機浮現', emoji: '⚠️' },
|
||||||
|
{ date: '2008-09-15', label: '雷曼兄弟倒閉', emoji: '🏦' },
|
||||||
|
{ date: '2020-03-23', label: 'COVID 股災谷底', emoji: '🦠' },
|
||||||
|
{ date: '2022-06-13', label: '通膨見頂・暴力升息', emoji: '🔥' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 危機 / 反彈 深度案例 ───
|
||||||
|
export const EPISODES = [
|
||||||
|
{
|
||||||
|
key: 'dotcom',
|
||||||
|
title: '科技泡沫破裂', titleEn: 'Dot-com Bust',
|
||||||
|
period: '2000–2002', type: 'crisis', emoji: '💻', colorKey: 'red',
|
||||||
|
summary: '1990 年代末網路股狂熱,估值嚴重脫離基本面。聯準會為抑制過熱一路把利率升至 6.5%,刺破泡沫,NASDAQ 自高點重挫約 78%。',
|
||||||
|
signals: [
|
||||||
|
{ key: 'yield_spread', label: '殖利率曲線', text: '2000 年倒掛(短率高於長率),領先衰退約 12 個月。' },
|
||||||
|
{ key: 'recession_prob', label: '衰退機率', text: '殖利率模型推估的衰退機率在 2000–2001 明顯升高。' },
|
||||||
|
{ key: 'unemployment', label: '失業率', text: '2001 年起自約 3.9% 一路升破 6%,就業由盛轉衰。' },
|
||||||
|
],
|
||||||
|
lesson: '估值極端 + 央行收緊 + 殖利率倒掛,是泡沫見頂的經典組合。',
|
||||||
|
watchNow: '留意整體估值是否過熱、殖利率曲線是否再度倒掛。',
|
||||||
|
focusKey: 'yield_spread',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gfc',
|
||||||
|
title: '全球金融海嘯', titleEn: 'Global Financial Crisis',
|
||||||
|
period: '2007–2009', type: 'crisis', emoji: '🏦', colorKey: 'red',
|
||||||
|
summary: '次級房貸違約引爆信用危機,2008 年 9 月雷曼兄弟倒閉,全球金融體系幾近停擺,標普 500 自高點腰斬。',
|
||||||
|
signals: [
|
||||||
|
{ key: 'yield_spread', label: '殖利率曲線', text: '2006 年即倒掛,提前約 18 個月預警。' },
|
||||||
|
{ key: 'fin_cond', label: '金融條件', text: '金融條件指數(涵蓋信用利差)2008 年急速收緊至極端緊縮。' },
|
||||||
|
{ key: 'unemployment', label: '失業率', text: '自 2007 年低點一路飆升至 10%。' },
|
||||||
|
{ key: 'recession_prob', label: '衰退機率', text: '殖利率模型推估的衰退機率在 2007–2008 顯著拉高。' },
|
||||||
|
],
|
||||||
|
lesson: '金融條件與信用利差急速收緊,往往比股市更早示警。',
|
||||||
|
watchNow: '緊盯金融條件指數與高收益債利差是否異常擴大。',
|
||||||
|
focusKey: 'fin_cond',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'covid',
|
||||||
|
title: 'COVID-19 股災', titleEn: 'COVID Crash',
|
||||||
|
period: '2020', type: 'crisis', emoji: '🦠', colorKey: 'red',
|
||||||
|
summary: '2020 年 3 月疫情封城引發史上最快崩跌,標普 500 一個月內跌約 34%;隨後央行天量寬鬆與財政刺激造就 V 型反彈。',
|
||||||
|
signals: [
|
||||||
|
{ key: 'vix', label: 'VIX 恐慌指數', text: '飆破 80,創金融海嘯以來新高,恐慌達到極致。' },
|
||||||
|
{ key: 'claims', label: '初領失業金', text: '一週暴增至數百萬人,為史上僅見。' },
|
||||||
|
{ key: 'fin_cond', label: '金融條件', text: '金融條件瞬間收緊、流動性一度枯竭。' },
|
||||||
|
],
|
||||||
|
lesson: '外生衝擊型崩跌又急又猛,但只要政策全力反制,修復也可能極快。',
|
||||||
|
watchNow: 'VIX 與初領失業金的「跳升速度」,是判斷恐慌強度的關鍵。',
|
||||||
|
focusKey: 'vix',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inflation2022',
|
||||||
|
title: '通膨升息熊市', titleEn: '2022 Inflation Bear',
|
||||||
|
period: '2022', type: 'crisis', emoji: '🔥', colorKey: 'orange',
|
||||||
|
summary: '疫後刺激與供應鏈瓶頸把通膨推上 40 年高點,聯準會 2022 年暴力升息,股債罕見齊跌。',
|
||||||
|
signals: [
|
||||||
|
{ key: 'cpi', label: 'CPI 年增率', text: '衝上 9.1%,為 1981 年以來最高。' },
|
||||||
|
{ key: 'fed_funds', label: '政策利率', text: '一年內從近零升破 5%,速度數十年罕見。' },
|
||||||
|
{ key: 'yield_spread', label: '殖利率曲線', text: '2022 年中再度深度倒掛。' },
|
||||||
|
],
|
||||||
|
lesson: '通膨一旦失控會逼央行不計代價升息,股債同時受壓、傳統避險失效。',
|
||||||
|
watchNow: '觀察 CPI / 核心 PCE 是否回落、殖利率曲線倒掛是否解除。',
|
||||||
|
focusKey: 'cpi',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'recovery2020',
|
||||||
|
title: '危機後大反彈', titleEn: 'Post-COVID Recovery',
|
||||||
|
period: '2020–2021', type: 'recovery', emoji: '🚀', colorKey: 'green',
|
||||||
|
summary: 'COVID 崩跌後,零利率 + QE + 財政紓困帶來流動性洪流,標普 500 在約 18 個月內翻倍,是「壞事件之後」的典型修復範例。',
|
||||||
|
signals: [
|
||||||
|
{ key: 'm2', label: 'M2 貨幣供給', text: '年增率一度飆上逾 25%,流動性極度寬鬆。' },
|
||||||
|
{ key: 'fin_cond', label: '金融條件', text: '由極緊迅速轉為寬鬆,信用恐慌解除、風險偏好回升。' },
|
||||||
|
{ key: 'vix', label: 'VIX 恐慌指數', text: '自 80 一路回落,市場恢復平靜。' },
|
||||||
|
{ key: 'sp500', label: 'S&P 500', text: '股市領先實體經濟強勁反彈、約 18 個月翻倍。' },
|
||||||
|
],
|
||||||
|
lesson: '流動性轉寬鬆、金融條件「由緊轉鬆」,往往是風險資產落底回升的早期訊號。',
|
||||||
|
watchNow: '危機中留意金融條件是否見頂回落、央行是否轉鴿,常是布局轉機的線索。',
|
||||||
|
focusKey: 'sp500',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,347 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 資料引擎 — 負責「抓真實資料」並換算成卡片要顯示的數字
|
||||||
|
//
|
||||||
|
// 流程:FRED / Stooq 取得原始時間序列
|
||||||
|
// → computeMetric() 依設定做 YoY / MoM / 變動量等換算
|
||||||
|
// → 取最新值當顯示值、與前一期比較算「變動」
|
||||||
|
// → 取近期資料做成 sparkline 走勢圖(真實,不再是假的)
|
||||||
|
// → formatValue() 套上 % / bp / $ / K 等單位
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { INDICATORS } from './indicators.js';
|
||||||
|
|
||||||
|
const FRED_BASE = 'https://api.stlouisfed.org/fred/series/observations';
|
||||||
|
const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart/';
|
||||||
|
|
||||||
|
// 自訂錯誤:金鑰未設定時用,讓 API 端點能回傳友善訊息
|
||||||
|
export class MissingKeyError extends Error {}
|
||||||
|
|
||||||
|
function getApiKey() {
|
||||||
|
const key = process.env.FRED_API_KEY;
|
||||||
|
if (!key || key === 'your_fred_api_key_here') {
|
||||||
|
throw new MissingKeyError('尚未設定 FRED_API_KEY');
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDaysAgo(days) {
|
||||||
|
const d = new Date(Date.now() - days * 86400000);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// 併發限制器:FRED 對「短時間內大量同時請求」會回 429,
|
||||||
|
// 因此一次最多只放行 5 個請求,其餘排隊。
|
||||||
|
function createLimiter(max) {
|
||||||
|
let active = 0;
|
||||||
|
const queue = [];
|
||||||
|
const next = () => {
|
||||||
|
if (active >= max || queue.length === 0) return;
|
||||||
|
active++;
|
||||||
|
const { fn, resolve, reject } = queue.shift();
|
||||||
|
fn().then(resolve, reject).finally(() => { active--; next(); });
|
||||||
|
};
|
||||||
|
return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); next(); });
|
||||||
|
}
|
||||||
|
const limit = createLimiter(2);
|
||||||
|
|
||||||
|
// ─── 抓取 FRED 序列 → [{date, value:Number}](已濾掉缺值 '.')───
|
||||||
|
// 透過限制器排隊,並對 429(請求過多)自動退避重試。
|
||||||
|
async function fetchFredSeries(seriesId, startISO) {
|
||||||
|
const key = getApiKey();
|
||||||
|
const url = `${FRED_BASE}?series_id=${encodeURIComponent(seriesId)}` +
|
||||||
|
`&api_key=${key}&file_type=json&sort_order=asc&observation_start=${startISO}`;
|
||||||
|
return limit(async () => {
|
||||||
|
for (let attempt = 0; attempt < 9; attempt++) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.status === 429) { // 過多請求,退避後重試(含抖動避免同步重試)
|
||||||
|
await sleep(700 * (attempt + 1) + Math.random() * 400);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`FRED ${seriesId} 回應 ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
return (json.observations || [])
|
||||||
|
.filter((o) => o.value !== '.' && o.value !== '' && o.value != null)
|
||||||
|
.map((o) => ({ date: o.date, value: Number(o.value) }))
|
||||||
|
.filter((o) => Number.isFinite(o.value));
|
||||||
|
}
|
||||||
|
throw new Error(`FRED ${seriesId} 持續回應 429(請稍後再試)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 抓取 Yahoo Finance 行情(日線)→ [{date, value:收盤}] ───
|
||||||
|
// 伺服器對伺服器呼叫,無 CORS 問題、免金鑰;用於 FRED 無法良好提供的黃金。
|
||||||
|
async function fetchYahooSeries(symbol, range = '1y') {
|
||||||
|
const url = `${YAHOO_BASE}${encodeURIComponent(symbol)}?range=${range}&interval=1d`;
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
|
||||||
|
if (!res.ok) throw new Error(`Yahoo ${symbol} 回應 ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
const result = json?.chart?.result?.[0];
|
||||||
|
const ts = result?.timestamp;
|
||||||
|
const closes = result?.indicators?.quote?.[0]?.close;
|
||||||
|
if (!ts || !closes) throw new Error(`Yahoo ${symbol} 無資料`);
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < ts.length; i++) {
|
||||||
|
if (closes[i] == null || !Number.isFinite(closes[i])) continue;
|
||||||
|
out.push({ date: new Date(ts[i] * 1000).toISOString().slice(0, 10), value: closes[i] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 依 transform 把原始序列換算成「要顯示的指標序列」 ───
|
||||||
|
function computeMetric(points, transform, periodsPerYear) {
|
||||||
|
const v = points.map((p) => p.value);
|
||||||
|
const out = [];
|
||||||
|
switch (transform) {
|
||||||
|
case 'level': // 直接用原值
|
||||||
|
case 'usd':
|
||||||
|
for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] });
|
||||||
|
break;
|
||||||
|
case 'yoy': { // 年增率:與一年前比較
|
||||||
|
const n = periodsPerYear || 12;
|
||||||
|
for (let i = n; i < points.length; i++) {
|
||||||
|
if (v[i - n] === 0) continue;
|
||||||
|
out.push({ date: points[i].date, val: (v[i] / v[i - n] - 1) * 100 });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'mom': { // 月增率:與上一期比較
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
if (v[i - 1] === 0) continue;
|
||||||
|
out.push({ date: points[i].date, val: (v[i] / v[i - 1] - 1) * 100 });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'payems_diff': { // 非農:本期減上期(PAYEMS 單位已是千人)
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
out.push({ date: points[i].date, val: v[i] - v[i - 1] });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'percent_to_bp': // 百分比 → 基點
|
||||||
|
for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] * 100 });
|
||||||
|
break;
|
||||||
|
case 'level_per_thousand': // 人數 → 千人
|
||||||
|
for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] / 1000 });
|
||||||
|
break;
|
||||||
|
case 'millions_to_trillions': // 百萬 → 兆
|
||||||
|
for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] / 1e6 });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
for (let i = 0; i < points.length; i++) out.push({ date: points[i].date, val: v[i] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 取近期資料、平均取樣成 sparkline(最多 24 點)───
|
||||||
|
function buildSparkline(metric) {
|
||||||
|
if (metric.length === 0) return [];
|
||||||
|
// 推估資料頻率(相鄰兩點的中位數天數),決定要回看多久
|
||||||
|
const gaps = [];
|
||||||
|
for (let i = 1; i < metric.length; i++) {
|
||||||
|
gaps.push((new Date(metric[i].date) - new Date(metric[i - 1].date)) / 86400000);
|
||||||
|
}
|
||||||
|
gaps.sort((a, b) => a - b);
|
||||||
|
const medianGap = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 30;
|
||||||
|
let spanDays;
|
||||||
|
if (medianGap <= 4) spanDays = 365; // 每日
|
||||||
|
else if (medianGap <= 10) spanDays = 730; // 每週
|
||||||
|
else if (medianGap <= 45) spanDays = 1460; // 每月
|
||||||
|
else spanDays = 2920; // 每季
|
||||||
|
const cutoff = Date.now() - spanDays * 86400000;
|
||||||
|
let recent = metric.filter((m) => new Date(m.date).getTime() >= cutoff);
|
||||||
|
if (recent.length < 8) recent = metric.slice(-16); // 點太少就直接取最後 16 點
|
||||||
|
// 平均取樣到最多 24 點
|
||||||
|
const target = 24;
|
||||||
|
if (recent.length <= target) return recent.map((m) => m.val);
|
||||||
|
const step = (recent.length - 1) / (target - 1);
|
||||||
|
const sampled = [];
|
||||||
|
for (let i = 0; i < target; i++) sampled.push(recent[Math.round(i * step)].val);
|
||||||
|
return sampled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 數值格式化 ───
|
||||||
|
function fmtNum(n, d) {
|
||||||
|
return n.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||||
|
}
|
||||||
|
function signed(n, d) {
|
||||||
|
return (n >= 0 ? '+' : '') + n.toFixed(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(val, format, decimals) {
|
||||||
|
const d = decimals ?? 2;
|
||||||
|
switch (format) {
|
||||||
|
case 'pct': return `${val.toFixed(d)}%`;
|
||||||
|
case 'pct_signed': return `${signed(val, d)}%`;
|
||||||
|
case 'bp': return `${Math.round(val)}bp`;
|
||||||
|
case 'num0': return fmtNum(val, 0);
|
||||||
|
case 'num1': return val.toFixed(1);
|
||||||
|
case 'num2': return val.toFixed(2);
|
||||||
|
case 'num2_signed': return signed(val, 2);
|
||||||
|
case 'k': return `${Math.round(val).toLocaleString('en-US')}K`;
|
||||||
|
case 'k_signed': return `${val >= 0 ? '+' : ''}${Math.round(val)}K`;
|
||||||
|
case 'trillions': return `$${val.toFixed(d)}T`;
|
||||||
|
case 'usd': return `$${val.toFixed(d)}`;
|
||||||
|
case 'usd0': return `$${fmtNum(val, 0)}`;
|
||||||
|
default: return val.toFixed(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChange(delta, format, decimals) {
|
||||||
|
const d = decimals ?? 2;
|
||||||
|
if (!Number.isFinite(delta)) return '';
|
||||||
|
switch (format) {
|
||||||
|
case 'pct':
|
||||||
|
case 'pct_signed': return signed(delta, d); // 例:+0.03(單位同上方百分比)
|
||||||
|
case 'num0': return `${delta >= 0 ? '+' : ''}${Math.round(delta)}`;
|
||||||
|
case 'num1': return signed(delta, 1);
|
||||||
|
case 'num2':
|
||||||
|
case 'num2_signed': return signed(delta, 2);
|
||||||
|
case 'bp': return `${delta >= 0 ? '+' : ''}${Math.round(delta)}bp`;
|
||||||
|
case 'k':
|
||||||
|
case 'k_signed': return `${delta >= 0 ? '+' : ''}${Math.round(delta)}K`;
|
||||||
|
case 'trillions': return `${delta >= 0 ? '+' : '-'}$${Math.abs(delta * 1000).toFixed(0)}B`; // 兆→十億
|
||||||
|
case 'usd': return `${delta >= 0 ? '+' : '-'}$${Math.abs(delta).toFixed(1)}`;
|
||||||
|
case 'usd0': return `${delta >= 0 ? '+' : '-'}$${fmtNum(Math.abs(delta), 0)}`;
|
||||||
|
default: return signed(delta, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 顏色與徽章判定 ───
|
||||||
|
function classify(ind, dir) {
|
||||||
|
const meaningful = !ind.excludeFromScore;
|
||||||
|
const flat = dir === 'neutral';
|
||||||
|
// 反向指標:下降才是好;一般指標:上升才是好
|
||||||
|
const good = ind.inverted ? dir === 'down' : dir === 'up';
|
||||||
|
let valueColorKey, changeColorKey, badgeKind;
|
||||||
|
if (!meaningful) {
|
||||||
|
valueColorKey = 'blue'; // 中性指標用藍色,不暗示好壞
|
||||||
|
changeColorKey = 'text2';
|
||||||
|
badgeKind = 'neutral';
|
||||||
|
} else if (flat) {
|
||||||
|
valueColorKey = 'yellow';
|
||||||
|
changeColorKey = 'text2';
|
||||||
|
badgeKind = 'neutral';
|
||||||
|
} else {
|
||||||
|
valueColorKey = good ? 'green' : 'red';
|
||||||
|
changeColorKey = good ? 'green' : 'red';
|
||||||
|
badgeKind = good ? 'good' : 'bad';
|
||||||
|
}
|
||||||
|
return { valueColorKey, changeColorKey, badgeKind, good, meaningful };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirOf(delta, scale) {
|
||||||
|
const eps = scale * 0.0005; // 極小變動視為持平
|
||||||
|
if (!Number.isFinite(delta)) return 'neutral';
|
||||||
|
if (delta > eps) return 'up';
|
||||||
|
if (delta < -eps) return 'down';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 抓取單一指標並組成卡片 ───
|
||||||
|
async function buildCard(ind) {
|
||||||
|
let points;
|
||||||
|
if (ind.source === 'yahoo') {
|
||||||
|
points = await fetchYahooSeries(ind.symbol, 'max');
|
||||||
|
} else {
|
||||||
|
// 回看年數拉長到約 26 年,讓走勢大圖能涵蓋 2000 網路泡沫、2008 金融海嘯等
|
||||||
|
// 歷史事件(多數 FRED 序列起點更早,會自動回傳實際擁有的範圍)。
|
||||||
|
// yoy 需多一年前置資料;季資料再多抓幾年確保換算完整。
|
||||||
|
const yearsBack = ind.transform === 'yoy'
|
||||||
|
? (ind.periodsPerYear === 4 ? 30 : 27)
|
||||||
|
: 26;
|
||||||
|
points = await fetchFredSeries(ind.seriesId, isoDaysAgo(yearsBack * 365));
|
||||||
|
}
|
||||||
|
const metric = computeMetric(points, ind.transform, ind.periodsPerYear);
|
||||||
|
if (metric.length === 0) throw new Error(`${ind.key} 換算後無資料`);
|
||||||
|
|
||||||
|
const latest = metric[metric.length - 1];
|
||||||
|
const prev = metric.length > 1 ? metric[metric.length - 2] : null;
|
||||||
|
const value = latest.val;
|
||||||
|
const delta = prev ? value - prev.val : NaN;
|
||||||
|
const scale = Math.max(Math.abs(value), 1);
|
||||||
|
const dir = dirOf(delta, scale);
|
||||||
|
const cls = classify(ind, dir);
|
||||||
|
|
||||||
|
const card = {
|
||||||
|
key: ind.key,
|
||||||
|
group: ind.group,
|
||||||
|
label: ind.label,
|
||||||
|
labelEn: ind.labelEn,
|
||||||
|
value: formatValue(value, ind.format, ind.decimals),
|
||||||
|
rawValue: value,
|
||||||
|
change: formatChange(delta, ind.format, ind.decimals),
|
||||||
|
dir,
|
||||||
|
badge: dir === 'up' ? '上升' : dir === 'down' ? '下降' : '持平',
|
||||||
|
badgeKind: cls.badgeKind,
|
||||||
|
valueColorKey: cls.valueColorKey,
|
||||||
|
changeColorKey: cls.changeColorKey,
|
||||||
|
inverted: !!ind.inverted,
|
||||||
|
good: cls.good,
|
||||||
|
meaningful: cls.meaningful,
|
||||||
|
spark: buildSparkline(metric),
|
||||||
|
substitute: ind.substitute || null,
|
||||||
|
tip: ind.tip,
|
||||||
|
format: ind.format,
|
||||||
|
decimals: ind.decimals ?? 2,
|
||||||
|
asOf: latest.date,
|
||||||
|
};
|
||||||
|
return { card, metric };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把單一指標的格式化函式對外公開(供 /api/series 用)
|
||||||
|
export { formatValue };
|
||||||
|
|
||||||
|
// ─── 抓取全部指標(容錯:個別失敗不影響其他)───
|
||||||
|
export async function getIndicatorCards() {
|
||||||
|
const results = await Promise.allSettled(INDICATORS.map((ind) => buildCard(ind)));
|
||||||
|
const cards = {};
|
||||||
|
const seriesHistory = {}; // key → 完整歷史序列 [{date,val}]
|
||||||
|
const degraded = [];
|
||||||
|
let missingKey = false;
|
||||||
|
results.forEach((r, idx) => {
|
||||||
|
const ind = INDICATORS[idx];
|
||||||
|
if (r.status === 'fulfilled') {
|
||||||
|
cards[ind.key] = r.value.card;
|
||||||
|
seriesHistory[ind.key] = r.value.metric;
|
||||||
|
} else {
|
||||||
|
if (r.reason instanceof MissingKeyError) missingKey = true;
|
||||||
|
degraded.push({ key: ind.key, label: ind.label, reason: String(r.reason?.message || r.reason) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 只要偵測到金鑰未設定,就視為設定問題(畫面顯示設定教學),
|
||||||
|
// 不因為少數免金鑰來源(如黃金)成功就誤判為正常。
|
||||||
|
if (missingKey) {
|
||||||
|
throw new MissingKeyError('尚未設定 FRED_API_KEY');
|
||||||
|
}
|
||||||
|
return { cards, seriesHistory, degraded };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 殖利率曲線(真實,跨天期)───
|
||||||
|
const CURVE_SERIES = [
|
||||||
|
['3M', 'DGS3MO'], ['6M', 'DGS6MO'], ['1Y', 'DGS1'], ['2Y', 'DGS2'],
|
||||||
|
['3Y', 'DGS3'], ['5Y', 'DGS5'], ['7Y', 'DGS7'], ['10Y', 'DGS10'],
|
||||||
|
['20Y', 'DGS20'], ['30Y', 'DGS30'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function getYieldCurve() {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
CURVE_SERIES.map(([, id]) => fetchFredSeries(id, isoDaysAgo(90)))
|
||||||
|
);
|
||||||
|
const maturities = [];
|
||||||
|
const yields = [];
|
||||||
|
const prevYields = [];
|
||||||
|
results.forEach((r, i) => {
|
||||||
|
if (r.status !== 'fulfilled' || r.value.length === 0) return;
|
||||||
|
const pts = r.value;
|
||||||
|
const last = pts[pts.length - 1].value;
|
||||||
|
// 約一個月前(21 個交易日)
|
||||||
|
const ago = pts[Math.max(0, pts.length - 22)].value;
|
||||||
|
maturities.push(CURVE_SERIES[i][0]);
|
||||||
|
yields.push(last);
|
||||||
|
prevYields.push(ago);
|
||||||
|
});
|
||||||
|
const inverted = yields.length >= 2 && yields[0] > yields[yields.length - 1];
|
||||||
|
return { maturities, yields, prevYields, inverted };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,466 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 指標設定 — 全站唯一的「資料字典」
|
||||||
|
// 每個指標都在這裡定義:要抓哪條 FRED 序列、怎麼換算、
|
||||||
|
// 中文名、是否為「反向指標」(數字越高越糟),以及給初學者
|
||||||
|
// 看的三段式解釋(這是什麼 / 怎麼看 / 對市場影響)。
|
||||||
|
//
|
||||||
|
// 欄位說明:
|
||||||
|
// - group 所屬分組(對應下方 GROUPS)
|
||||||
|
// - label 中文主標題
|
||||||
|
// - labelEn 英文副標(保留對照)
|
||||||
|
// - source 'fred' 或 'stooq'
|
||||||
|
// - seriesId FRED 序列代碼(source=fred 時)
|
||||||
|
// - symbol Stooq 代碼(source=stooq 時)
|
||||||
|
// - transform 換算方式(見 lib/fred.js 的 computeMetric)
|
||||||
|
// - periodsPerYear 做 YoY 時,一年有幾期(月=12、季=4、週=52)
|
||||||
|
// - format 顯示格式(見 lib/fred.js 的 formatValue)
|
||||||
|
// - decimals 小數位數
|
||||||
|
// - inverted true = 反向指標(數字越高代表越糟,例如失業率、VIX)
|
||||||
|
// - excludeFromScore true = 不納入總體健康分數(中性或方向不明確者)
|
||||||
|
// - substitute 若為免費替代指標,放原本想要的名稱(會在畫面標示)
|
||||||
|
// - tip 三段式解釋 + 資料來源與更新頻率
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// 分組(依「故事線」排序:利率 → 通膨 → 就業 → 成長 → 貨幣信用 → 市場反應)
|
||||||
|
export const GROUPS = [
|
||||||
|
{
|
||||||
|
key: 'rates',
|
||||||
|
title: '利率 & 殖利率',
|
||||||
|
titleEn: 'Interest Rates & Yield Curve',
|
||||||
|
icon: '$',
|
||||||
|
colorKey: 'blue',
|
||||||
|
intro: '一切的起點。央行(聯準會)的利率決定了整個經濟「借錢的成本」。利率高,企業與家庭就少借錢、少消費,經濟降溫;利率低則相反。先看這裡,後面的通膨、就業、市場都是它的連鎖反應。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inflation',
|
||||||
|
title: '通膨指標',
|
||||||
|
titleEn: 'Inflation Indicators',
|
||||||
|
icon: '\uD83D\uDCC8',
|
||||||
|
colorKey: 'yellow',
|
||||||
|
intro: '聯準會升降息,最主要就是為了「控制物價」。這裡看物價漲多快——數字越接近 2%(聯準會目標)越健康;太高代表生活變貴、可能還要再升息,太低(甚至負的)則代表經濟可能太冷。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'labor',
|
||||||
|
title: '勞動市場',
|
||||||
|
titleEn: 'Labor Market',
|
||||||
|
icon: '\uD83D\uDCBC',
|
||||||
|
colorKey: 'green',
|
||||||
|
intro: '有沒有工作、薪水漲不漲,直接影響大家敢不敢消費。就業強勁通常是好事,但「太強」會推升薪資與物價,讓聯準會更難降息。觀察就業是否從高峰開始降溫,是判斷景氣轉折的關鍵。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'growth',
|
||||||
|
title: '景氣成長',
|
||||||
|
titleEn: 'Economic Growth',
|
||||||
|
icon: '\uD83D\uDCC9',
|
||||||
|
colorKey: 'purple',
|
||||||
|
intro: '把前面的利率、通膨、就業加總起來,整體經濟到底在擴張還是收縮?這組是「成績單」,也包含幾個能提早預警衰退的領先指標。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'money',
|
||||||
|
title: '貨幣 & 信用',
|
||||||
|
titleEn: 'Money Supply & Credit',
|
||||||
|
icon: '\uD83C\uDFE6',
|
||||||
|
colorKey: 'orange',
|
||||||
|
intro: '市場上的錢多不多、好不好借?資金寬鬆時資產容易上漲,資金緊縮(信用利差擴大)往往是市場壓力的早期訊號。這組看的是金融體系的「血液循環」。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sentiment',
|
||||||
|
title: '市場情緒 & 大宗商品',
|
||||||
|
titleEn: 'Markets & Commodities',
|
||||||
|
icon: '\uD83D\uDCCA',
|
||||||
|
colorKey: 'red',
|
||||||
|
intro: '最後是市場的即時反應。股市、波動率、美元與大宗商品價格,反映投資人此刻是貪婪還是恐懼,也是前面所有總經訊號的「綜合投票結果」。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const INDICATORS = [
|
||||||
|
// ───────────── 利率 & 殖利率 ─────────────
|
||||||
|
{
|
||||||
|
key: 'fed_funds', group: 'rates',
|
||||||
|
label: '聯邦基金利率', labelEn: 'Fed Funds Rate (upper)',
|
||||||
|
source: 'fred', seriesId: 'DFEDTARU',
|
||||||
|
transform: 'level', format: 'pct', decimals: 2, inverted: false, excludeFromScore: true,
|
||||||
|
tip: {
|
||||||
|
what: '聯準會(Fed)設定的政策利率目標區間上緣,是所有市場利率的源頭。',
|
||||||
|
how: '升息代表央行想替過熱經濟與通膨降溫;降息代表想刺激經濟。本身無絕對好壞。',
|
||||||
|
impact: '利率走向幾乎左右所有資產價格,是看總經的第一個錨點。',
|
||||||
|
source: 'FRED · DFEDTARU', freq: '隨 FOMC 會議調整',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'treasury_10y', group: 'rates',
|
||||||
|
label: '10 年期公債殖利率', labelEn: '10Y Treasury Yield',
|
||||||
|
source: 'fred', seriesId: 'DGS10',
|
||||||
|
transform: 'level', format: 'pct', decimals: 2, inverted: false, excludeFromScore: true,
|
||||||
|
tip: {
|
||||||
|
what: '美國 10 年期公債的市場殖利率,是全球長期利率的基準。',
|
||||||
|
how: '反映市場對長期成長與通膨的預期;上升代表資金成本變高。',
|
||||||
|
impact: '房貸、企業借貸、股票評價都以它為定價基準。',
|
||||||
|
source: 'FRED · DGS10', freq: '每日',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'treasury_2y', group: 'rates',
|
||||||
|
label: '2 年期公債殖利率', labelEn: '2Y Treasury Yield',
|
||||||
|
source: 'fred', seriesId: 'DGS2',
|
||||||
|
transform: 'level', format: 'pct', decimals: 2, inverted: false, excludeFromScore: true,
|
||||||
|
tip: {
|
||||||
|
what: '美國 2 年期公債殖利率,對「短期升降息預期」最敏感。',
|
||||||
|
how: '通常貼近市場對未來一兩年聯準會利率的看法。',
|
||||||
|
impact: '與 10 年期相比可看出殖利率曲線是否倒掛(見下方)。',
|
||||||
|
source: 'FRED · DGS2', freq: '每日',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'yield_spread', group: 'rates',
|
||||||
|
label: '10年-2年利差', labelEn: '10Y-2Y Spread',
|
||||||
|
source: 'fred', seriesId: 'T10Y2Y',
|
||||||
|
transform: 'percent_to_bp', format: 'bp', decimals: 0, inverted: true,
|
||||||
|
tip: {
|
||||||
|
what: '長天期(10年)減短天期(2年)殖利率的差距,單位是基點(bp)。',
|
||||||
|
how: '正常為正值;一旦變負(倒掛)代表短率高於長率,歷史上常是衰退前兆。',
|
||||||
|
impact: '反向指標:數字越低/負越值得警惕,這是最受關注的衰退訊號之一。',
|
||||||
|
source: 'FRED · T10Y2Y', freq: '每日',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'real_rate', group: 'rates',
|
||||||
|
label: '10年期實質利率', labelEn: 'Real Rate (10Y TIPS)',
|
||||||
|
source: 'fred', seriesId: 'DFII10',
|
||||||
|
transform: 'level', format: 'pct', decimals: 2, inverted: false, excludeFromScore: true,
|
||||||
|
tip: {
|
||||||
|
what: '扣掉通膨預期後的「真實」10年利率(以抗通膨債 TIPS 衡量)。',
|
||||||
|
how: '實質利率越高,持有黃金、成長股等不孳息資產的機會成本越高。',
|
||||||
|
impact: '是黃金與高估值科技股的重要逆風/順風指標。',
|
||||||
|
source: 'FRED · DFII10', freq: '每日',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sofr', group: 'rates',
|
||||||
|
label: '擔保隔夜融資利率', labelEn: 'SOFR',
|
||||||
|
source: 'fred', seriesId: 'SOFR',
|
||||||
|
transform: 'level', format: 'pct', decimals: 2, inverted: false, excludeFromScore: true,
|
||||||
|
tip: {
|
||||||
|
what: '美國銀行間隔夜借錢的基準利率,已取代舊的 Libor。',
|
||||||
|
how: '通常緊貼聯邦基金利率;若突然跳動,代表短期資金市場有壓力。',
|
||||||
|
impact: '大量浮動利率貸款與衍生性商品都以它計價。',
|
||||||
|
source: 'FRED · SOFR', freq: '每日',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ───────────── 通膨 ─────────────
|
||||||
|
{
|
||||||
|
key: 'cpi', group: 'inflation',
|
||||||
|
label: '消費者物價指數 (年增)', labelEn: 'CPI YoY',
|
||||||
|
source: 'fred', seriesId: 'CPIAUCSL',
|
||||||
|
transform: 'yoy', periodsPerYear: 12, format: 'pct', decimals: 2, inverted: true,
|
||||||
|
tip: {
|
||||||
|
what: '一籃子日常商品與服務的價格,與一年前相比漲了多少(年增率)。',
|
||||||
|
how: '聯準會目標約 2%。越高代表生活越貴、可能還要升息。',
|
||||||
|
impact: '反向指標:太高是警訊;數字下降(降溫)通常對股債友善。',
|
||||||
|
source: 'FRED · CPIAUCSL', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'core_cpi', group: 'inflation',
|
||||||
|
label: '核心 CPI (年增)', labelEn: 'Core CPI YoY',
|
||||||
|
source: 'fred', seriesId: 'CPILFESL',
|
||||||
|
transform: 'yoy', periodsPerYear: 12, format: 'pct', decimals: 2, inverted: true,
|
||||||
|
tip: {
|
||||||
|
what: 'CPI 扣掉波動劇烈的食品與能源,更能看出物價的「底層趨勢」。',
|
||||||
|
how: '比整體 CPI 更黏、降得更慢,是聯準會緊盯的數字。',
|
||||||
|
impact: '反向指標:核心通膨遲遲不降,降息就會延後。',
|
||||||
|
source: 'FRED · CPILFESL', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ppi', group: 'inflation',
|
||||||
|
label: '生產者物價指數 (年增)', labelEn: 'PPI YoY',
|
||||||
|
source: 'fred', seriesId: 'PPIFIS',
|
||||||
|
transform: 'yoy', periodsPerYear: 12, format: 'pct', decimals: 2, inverted: true,
|
||||||
|
tip: {
|
||||||
|
what: '生產端(工廠出廠)的物價年增率,常領先消費者端的 CPI。',
|
||||||
|
how: '生產成本上升,之後往往會轉嫁到消費者價格。',
|
||||||
|
impact: '反向指標:可當成 CPI 的「上游預警」。',
|
||||||
|
source: 'FRED · PPIFIS', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pce', group: 'inflation',
|
||||||
|
label: '核心 PCE (年增)', labelEn: 'Core PCE YoY',
|
||||||
|
source: 'fred', seriesId: 'PCEPILFE',
|
||||||
|
transform: 'yoy', periodsPerYear: 12, format: 'pct', decimals: 2, inverted: true,
|
||||||
|
tip: {
|
||||||
|
what: '聯準會「最偏好」的通膨指標——核心個人消費支出物價。',
|
||||||
|
how: '聯準會的 2% 目標其實是看這個數字,重要性高於 CPI。',
|
||||||
|
impact: '反向指標:這個數字往 2% 靠近,是降息的關鍵前提。',
|
||||||
|
source: 'FRED · PCEPILFE', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'breakeven', group: 'inflation',
|
||||||
|
label: '5年通膨預期', labelEn: '5Y Breakeven Inflation',
|
||||||
|
source: 'fred', seriesId: 'T5YIE',
|
||||||
|
transform: 'level', format: 'pct', decimals: 2, inverted: true,
|
||||||
|
tip: {
|
||||||
|
what: '市場(債券交易者)預期未來 5 年的平均通膨率。',
|
||||||
|
how: '反映「市場相不相信通膨會被控制住」;脫錨上升是大麻煩。',
|
||||||
|
impact: '反向指標:穩定在 2% 附近代表預期良好;飆高代表信心動搖。',
|
||||||
|
source: 'FRED · T5YIE', freq: '每日',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ───────────── 勞動市場 ─────────────
|
||||||
|
{
|
||||||
|
key: 'unemployment', group: 'labor',
|
||||||
|
label: '失業率', labelEn: 'Unemployment Rate',
|
||||||
|
source: 'fred', seriesId: 'UNRATE',
|
||||||
|
transform: 'level', format: 'pct', decimals: 2, inverted: true,
|
||||||
|
tip: {
|
||||||
|
what: '想工作但找不到工作的人口比例。',
|
||||||
|
how: '低代表就業市場熱絡;但若從低點開始快速上升,常是衰退啟動的訊號。',
|
||||||
|
impact: '反向指標:上升是警訊(可參考 Sahm 法則:升幅過快即衰退)。',
|
||||||
|
source: 'FRED · UNRATE', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'nfp', group: 'labor',
|
||||||
|
label: '非農就業 (月增)', labelEn: 'Nonfarm Payrolls (MoM)',
|
||||||
|
source: 'fred', seriesId: 'PAYEMS',
|
||||||
|
transform: 'payems_diff', format: 'k_signed', decimals: 0, inverted: false,
|
||||||
|
tip: {
|
||||||
|
what: '排除農業後,當月新增的工作數(千人)。最受市場關注的就業數據。',
|
||||||
|
how: '正數且穩健代表經濟在創造就業;急速放緩代表動能轉弱。',
|
||||||
|
impact: '每月公布時常引發股債大幅波動。',
|
||||||
|
source: 'FRED · PAYEMS(取月變動)', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'claims', group: 'labor',
|
||||||
|
label: '初領失業金人數', labelEn: 'Initial Jobless Claims',
|
||||||
|
source: 'fred', seriesId: 'ICSA',
|
||||||
|
transform: 'level_per_thousand', format: 'k', decimals: 0, inverted: true,
|
||||||
|
tip: {
|
||||||
|
what: '每週第一次申請失業救濟的人數(千人),是最即時的就業溫度計。',
|
||||||
|
how: '低且平穩代表裁員少;持續攀升代表勞動市場開始惡化。',
|
||||||
|
impact: '反向指標:因為每週公布,比月報更早反映轉折。',
|
||||||
|
source: 'FRED · ICSA', freq: '每週',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wages', group: 'labor',
|
||||||
|
label: '平均時薪 (年增)', labelEn: 'Avg Hourly Earnings YoY',
|
||||||
|
source: 'fred', seriesId: 'CES0500000003',
|
||||||
|
transform: 'yoy', periodsPerYear: 12, format: 'pct', decimals: 2, inverted: false, excludeFromScore: true,
|
||||||
|
tip: {
|
||||||
|
what: '民間部門平均時薪與一年前相比的漲幅。',
|
||||||
|
how: '對勞工是好事,但漲太快會推升物價(薪資-通膨螺旋),讓聯準會緊張。',
|
||||||
|
impact: '方向解讀較中性:需搭配通膨一起看,故不計入總分。',
|
||||||
|
source: 'FRED · CES0500000003', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ───────────── 景氣成長 ─────────────
|
||||||
|
{
|
||||||
|
key: 'gdp', group: 'growth',
|
||||||
|
label: '實質 GDP (年增)', labelEn: 'Real GDP YoY',
|
||||||
|
source: 'fred', seriesId: 'GDPC1',
|
||||||
|
transform: 'yoy', periodsPerYear: 4, format: 'pct', decimals: 2, inverted: false,
|
||||||
|
tip: {
|
||||||
|
what: '經通膨調整後的經濟總產出,與一年前相比的成長率。',
|
||||||
|
how: '正成長代表經濟擴張;連兩季萎縮常被視為技術性衰退。',
|
||||||
|
impact: '是經濟整體健康最根本的「成績單」。',
|
||||||
|
source: 'FRED · GDPC1(取年增)', freq: '每季',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mfg', group: 'growth',
|
||||||
|
label: '製造業景氣', labelEn: 'Mfg Activity (Philly Fed)',
|
||||||
|
source: 'fred', seriesId: 'GACDFSA066MSFRBPHI',
|
||||||
|
transform: 'level', format: 'num1', decimals: 1, inverted: false,
|
||||||
|
substitute: 'ISM 製造業 PMI',
|
||||||
|
tip: {
|
||||||
|
what: '費城聯儲製造業景氣調查(擴散指數),用來替代需付費的 ISM 製造業 PMI。',
|
||||||
|
how: '大於 0 代表製造業在擴張,小於 0 代表收縮。',
|
||||||
|
impact: '替代指標:製造業常領先整體景氣,是循環的風向球。',
|
||||||
|
source: 'FRED · GACDFSA066MSFRBPHI', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'indpro', group: 'growth',
|
||||||
|
label: '工業生產 (年增)', labelEn: 'Industrial Production YoY',
|
||||||
|
source: 'fred', seriesId: 'INDPRO',
|
||||||
|
transform: 'yoy', periodsPerYear: 12, format: 'pct', decimals: 2, inverted: false,
|
||||||
|
tip: {
|
||||||
|
what: '工廠、礦業與公用事業的實際產出,與一年前相比。',
|
||||||
|
how: '正成長代表實體生產活絡;轉負代表工業部門走弱。',
|
||||||
|
impact: '與製造業景氣互相印證實體經濟的力道。',
|
||||||
|
source: 'FRED · INDPRO(取年增)', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sentiment_consumer', group: 'growth',
|
||||||
|
label: '消費者信心', labelEn: 'Consumer Sentiment (UMich)',
|
||||||
|
source: 'fred', seriesId: 'UMCSENT',
|
||||||
|
transform: 'level', format: 'num1', decimals: 1, inverted: false,
|
||||||
|
substitute: '世界大型企業聯合會 CCI',
|
||||||
|
tip: {
|
||||||
|
what: '密西根大學消費者信心指數,用來替代需付費的世界大型企業聯合會 CCI。',
|
||||||
|
how: '數字越高代表民眾對經濟與荷包越有信心,越敢消費。',
|
||||||
|
impact: '替代指標:消費占美國經濟約七成,信心是重要前瞻訊號。',
|
||||||
|
source: 'FRED · UMCSENT', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'retail', group: 'growth',
|
||||||
|
label: '零售銷售 (月增)', labelEn: 'Retail Sales (MoM)',
|
||||||
|
source: 'fred', seriesId: 'RSAFS',
|
||||||
|
transform: 'mom', format: 'pct_signed', decimals: 1, inverted: false,
|
||||||
|
tip: {
|
||||||
|
what: '零售與餐飲銷售額與上月相比的變化,直接反映消費力道。',
|
||||||
|
how: '正成長代表民眾持續花錢;轉負代表消費降溫。',
|
||||||
|
impact: '消費是經濟主引擎,這是它的即時脈搏。',
|
||||||
|
source: 'FRED · RSAFS(取月增)', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'recession_prob', group: 'growth',
|
||||||
|
label: '衰退機率 (殖利率模型)', labelEn: 'Recession Probability',
|
||||||
|
source: 'fred', seriesId: 'RECPROUSM156N',
|
||||||
|
transform: 'level', format: 'pct', decimals: 1, inverted: true,
|
||||||
|
substitute: '領先指標 LEI',
|
||||||
|
tip: {
|
||||||
|
what: '紐約聯儲依殖利率曲線推估「未來 12 個月內衰退」的機率,替代需付費的 LEI 領先指標。',
|
||||||
|
how: '數字越高代表模型認為衰退風險越大;超過 30% 通常值得警戒。',
|
||||||
|
impact: '反向指標(替代):是純數據、無情緒的前瞻衰退訊號。',
|
||||||
|
source: 'FRED · RECPROUSM156N', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ───────────── 貨幣 & 信用 ─────────────
|
||||||
|
{
|
||||||
|
key: 'm2', group: 'money',
|
||||||
|
label: 'M2 貨幣供給 (年增)', labelEn: 'M2 Money Supply YoY',
|
||||||
|
source: 'fred', seriesId: 'M2SL',
|
||||||
|
transform: 'yoy', periodsPerYear: 12, format: 'pct', decimals: 2, inverted: false,
|
||||||
|
tip: {
|
||||||
|
what: '市場上流通的廣義貨幣(現金+存款)與一年前相比的變化。',
|
||||||
|
how: '正成長代表流動性增加;負成長(罕見)代表錢在收縮。',
|
||||||
|
impact: '流動性是資產價格的潤滑劑,過去與股市關聯密切。',
|
||||||
|
source: 'FRED · M2SL(取年增)', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'credit_spread', group: 'money',
|
||||||
|
label: '高收益債信用利差', labelEn: 'High-Yield OAS',
|
||||||
|
source: 'fred', seriesId: 'BAMLH0A0HYM2',
|
||||||
|
transform: 'percent_to_bp', format: 'bp', decimals: 0, inverted: true,
|
||||||
|
tip: {
|
||||||
|
what: '高收益(垃圾)債相對公債的額外利率補償,單位基點(bp)。',
|
||||||
|
how: '利差小代表市場願意承擔風險;急速擴大代表恐慌、信用收緊。',
|
||||||
|
impact: '反向指標:擴大是金融壓力的早期且靈敏的訊號。',
|
||||||
|
source: 'FRED · BAMLH0A0HYM2', freq: '每日',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fin_cond', group: 'money',
|
||||||
|
label: '金融條件指數', labelEn: 'Financial Conditions (NFCI)',
|
||||||
|
source: 'fred', seriesId: 'NFCI',
|
||||||
|
transform: 'level', format: 'num2_signed', decimals: 2, inverted: true,
|
||||||
|
tip: {
|
||||||
|
what: '芝加哥聯儲綜合衡量整體金融鬆緊的指數(NFCI)。',
|
||||||
|
how: '注意:正值代表比平均「更緊」,負值代表「更寬鬆」(與直覺相反)。',
|
||||||
|
impact: '反向指標:數字上升代表金融環境收緊,對風險資產不利。',
|
||||||
|
source: 'FRED · NFCI', freq: '每週',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fed_balance', group: 'money',
|
||||||
|
label: '聯準會資產負債表', labelEn: 'Fed Balance Sheet',
|
||||||
|
source: 'fred', seriesId: 'WALCL',
|
||||||
|
transform: 'millions_to_trillions', format: 'trillions', decimals: 2, inverted: false, excludeFromScore: true,
|
||||||
|
tip: {
|
||||||
|
what: '聯準會持有的資產總額(兆美元)。擴張=QE 印鈔,收縮=QT 縮表。',
|
||||||
|
how: '上升代表向市場注入流動性,下降代表抽走流動性。',
|
||||||
|
impact: '是判斷央行「鬆或緊」的直接量化證據,方向中性故不計分。',
|
||||||
|
source: 'FRED · WALCL', freq: '每週',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ───────────── 市場情緒 & 大宗商品 ─────────────
|
||||||
|
{
|
||||||
|
key: 'vix', group: 'sentiment',
|
||||||
|
label: 'VIX 恐慌指數', labelEn: 'VIX',
|
||||||
|
source: 'fred', seriesId: 'VIXCLS',
|
||||||
|
transform: 'level', format: 'num1', decimals: 1, inverted: true,
|
||||||
|
tip: {
|
||||||
|
what: '市場對未來 30 天股市波動的預期,俗稱「恐慌指數」。',
|
||||||
|
how: '一般低於 20 代表平靜;飆高(>30)代表市場恐慌。',
|
||||||
|
impact: '反向指標:低=自滿、高=恐慌,常與股市反向。',
|
||||||
|
source: 'FRED · VIXCLS', freq: '每日',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dxy', group: 'sentiment',
|
||||||
|
label: '美元指數 (廣義)', labelEn: 'Broad USD Index',
|
||||||
|
source: 'fred', seriesId: 'DTWEXBGS',
|
||||||
|
transform: 'level', format: 'num1', decimals: 1, inverted: false, excludeFromScore: true,
|
||||||
|
tip: {
|
||||||
|
what: '美元對一籃子貿易夥伴貨幣的強弱(聯準會廣義美元指數)。',
|
||||||
|
how: '走強通常代表避險或美國相對強勁;走弱有利新興市場與大宗商品。',
|
||||||
|
impact: '方向中性:影響跨資產但無絕對好壞,故不計分。',
|
||||||
|
source: 'FRED · DTWEXBGS', freq: '每日',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'oil', group: 'sentiment',
|
||||||
|
label: '西德州原油 (WTI)', labelEn: 'WTI Crude Oil',
|
||||||
|
source: 'fred', seriesId: 'DCOILWTICO',
|
||||||
|
transform: 'level', format: 'usd', decimals: 1, inverted: false, excludeFromScore: true,
|
||||||
|
tip: {
|
||||||
|
what: '西德州中質原油價格(美元/桶)。',
|
||||||
|
how: '反映能源需求與地緣風險;油價飆漲會推升通膨。',
|
||||||
|
impact: '方向中性:是成長與通膨的雙面刃,故不計分。',
|
||||||
|
source: 'FRED · DCOILWTICO', freq: '每日',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gold', group: 'sentiment',
|
||||||
|
label: '黃金', labelEn: 'Gold Futures (GC=F)',
|
||||||
|
source: 'yahoo', symbol: 'GC=F',
|
||||||
|
transform: 'level', format: 'usd0', decimals: 0, inverted: false, excludeFromScore: true,
|
||||||
|
tip: {
|
||||||
|
what: '黃金期貨價格(美元/盎司),經典的避險與抗通膨資產。',
|
||||||
|
how: '在恐慌、低實質利率或美元走弱時通常上漲。',
|
||||||
|
impact: '方向中性:作為避險溫度計參考,故不計分。',
|
||||||
|
source: 'Yahoo Finance · GC=F', freq: '每日',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'copper', group: 'sentiment',
|
||||||
|
label: '銅價', labelEn: 'Copper (USD/tonne)',
|
||||||
|
source: 'fred', seriesId: 'PCOPPUSDM',
|
||||||
|
transform: 'level', format: 'usd0', decimals: 0, inverted: false, excludeFromScore: true,
|
||||||
|
tip: {
|
||||||
|
what: '全球銅價(美元/公噸)。因廣泛用於工業,被稱為「銅博士」。',
|
||||||
|
how: '上漲常代表全球製造與建設需求強,是景氣的風向球。',
|
||||||
|
impact: '方向中性:作為成長動能參考,故不計分。',
|
||||||
|
source: 'FRED · PCOPPUSDM', freq: '每月',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sp500', group: 'sentiment',
|
||||||
|
label: 'S&P 500 指數', labelEn: 'S&P 500',
|
||||||
|
source: 'fred', seriesId: 'SP500',
|
||||||
|
transform: 'level', format: 'num0', decimals: 0, inverted: false, excludeFromScore: true,
|
||||||
|
tip: {
|
||||||
|
what: '美國 500 大企業股價指數,最具代表性的股市基準。',
|
||||||
|
how: '上漲反映市場樂觀;它是所有總經訊號的「綜合投票結果」。',
|
||||||
|
impact: '方向中性(本身即市場):故不計入總經健康分數。',
|
||||||
|
source: 'FRED · SP500', freq: '每日',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 方便用 key 取得設定
|
||||||
|
export const INDICATOR_MAP = Object.fromEntries(INDICATORS.map((i) => [i.key, i]));
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// 計分引擎 — 把真實數據變成「總經健康分數 + 景氣燈號」
|
||||||
|
//
|
||||||
|
// 原則:透明、可解釋。從 50 分(中性)出發,依幾條白話規則
|
||||||
|
// 加減分,每一條都附在 breakdown 裡,讓初學者看得懂結論怎麼來。
|
||||||
|
// 分數越高代表總經環境對風險性資產越友善。
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function clamp(n, lo, hi) {
|
||||||
|
return Math.max(lo, Math.min(hi, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取得卡片原始值(缺資料回傳 null)
|
||||||
|
function val(cards, key) {
|
||||||
|
const c = cards[key];
|
||||||
|
return c && Number.isFinite(c.rawValue) ? c.rawValue : null;
|
||||||
|
}
|
||||||
|
function dir(cards, key) {
|
||||||
|
return cards[key] ? cards[key].dir : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeScore(cards) {
|
||||||
|
let score = 50;
|
||||||
|
const breakdown = [];
|
||||||
|
const add = (delta, label, note) => {
|
||||||
|
if (delta === 0) return;
|
||||||
|
score += delta;
|
||||||
|
breakdown.push({ label, delta, note });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) 殖利率曲線(倒掛是重要衰退警訊)
|
||||||
|
const spread = val(cards, 'yield_spread'); // bp
|
||||||
|
if (spread != null) {
|
||||||
|
if (spread < 0) add(-15, '殖利率曲線', `倒掛 ${Math.round(spread)}bp,歷史上的衰退前兆`);
|
||||||
|
else if (spread < 50) add(-4, '殖利率曲線', `偏平 ${Math.round(spread)}bp`);
|
||||||
|
else add(6, '殖利率曲線', `正常 ${Math.round(spread)}bp`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 衰退機率模型
|
||||||
|
const rec = val(cards, 'recession_prob'); // %
|
||||||
|
if (rec != null) {
|
||||||
|
const penalty = -Math.round((rec / 100) * 20);
|
||||||
|
add(penalty, '衰退機率', `模型估 ${rec.toFixed(1)}% 未來一年衰退`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 通膨是否接近 2% 目標(用 CPI 年增)
|
||||||
|
const cpi = val(cards, 'cpi');
|
||||||
|
if (cpi != null) {
|
||||||
|
if (cpi <= 2.5) add(10, '通膨', `CPI ${cpi.toFixed(1)}% 接近目標`);
|
||||||
|
else if (cpi <= 3.5) add(3, '通膨', `CPI ${cpi.toFixed(1)}% 略高於目標`);
|
||||||
|
else if (cpi <= 4.5) add(-6, '通膨', `CPI ${cpi.toFixed(1)}% 偏高`);
|
||||||
|
else add(-12, '通膨', `CPI ${cpi.toFixed(1)}% 過高`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 失業率趨勢
|
||||||
|
const unemp = val(cards, 'unemployment');
|
||||||
|
const unempDir = dir(cards, 'unemployment');
|
||||||
|
if (unempDir) {
|
||||||
|
if (unempDir === 'up') add(-8, '就業', '失業率上升,勞動市場降溫');
|
||||||
|
else add(5, '就業', '失業率持平或下降');
|
||||||
|
}
|
||||||
|
if (unemp != null && unemp > 5) add(-3, '就業', `失業率 ${unemp.toFixed(1)}% 偏高`);
|
||||||
|
|
||||||
|
// 5) 信用利差(金融壓力)
|
||||||
|
const hy = val(cards, 'credit_spread'); // bp
|
||||||
|
if (hy != null) {
|
||||||
|
if (hy < 350) add(8, '信用利差', `${Math.round(hy)}bp 偏窄,風險偏好佳`);
|
||||||
|
else if (hy < 500) add(0, '信用利差', `${Math.round(hy)}bp 中性`);
|
||||||
|
else if (hy < 700) add(-8, '信用利差', `${Math.round(hy)}bp 擴大`);
|
||||||
|
else add(-15, '信用利差', `${Math.round(hy)}bp 大幅擴大,信用緊縮`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) 金融條件(NFCI:正值=偏緊)
|
||||||
|
const nfci = val(cards, 'fin_cond');
|
||||||
|
if (nfci != null) {
|
||||||
|
if (nfci < 0) add(8, '金融條件', `NFCI ${nfci.toFixed(2)}(偏寬鬆)`);
|
||||||
|
else if (nfci < 0.2) add(0, '金融條件', `NFCI ${nfci.toFixed(2)}(中性)`);
|
||||||
|
else add(-8, '金融條件', `NFCI ${nfci.toFixed(2)}(偏緊)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7) 製造業景氣(Philly Fed,>0 擴張)
|
||||||
|
const mfg = val(cards, 'mfg');
|
||||||
|
if (mfg != null) {
|
||||||
|
if (mfg > 0) add(6, '製造業', `指數 ${mfg.toFixed(1)}(擴張)`);
|
||||||
|
else add(-6, '製造業', `指數 ${mfg.toFixed(1)}(收縮)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8) 經濟成長(實質 GDP 年增)
|
||||||
|
const gdp = val(cards, 'gdp');
|
||||||
|
if (gdp != null) {
|
||||||
|
if (gdp > 2.5) add(8, '成長', `GDP 年增 ${gdp.toFixed(1)}%(穩健)`);
|
||||||
|
else if (gdp > 1) add(3, '成長', `GDP 年增 ${gdp.toFixed(1)}%(溫和)`);
|
||||||
|
else if (gdp > 0) add(0, '成長', `GDP 年增 ${gdp.toFixed(1)}%(停滯)`);
|
||||||
|
else add(-10, '成長', `GDP 年增 ${gdp.toFixed(1)}%(萎縮)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9) 市場波動(VIX)
|
||||||
|
const vix = val(cards, 'vix');
|
||||||
|
if (vix != null) {
|
||||||
|
if (vix < 16) add(4, '波動率', `VIX ${vix.toFixed(1)}(平靜)`);
|
||||||
|
else if (vix < 22) add(0, '波動率', `VIX ${vix.toFixed(1)}(正常)`);
|
||||||
|
else if (vix < 30) add(-5, '波動率', `VIX ${vix.toFixed(1)}(升高)`);
|
||||||
|
else add(-10, '波動率', `VIX ${vix.toFixed(1)}(恐慌)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
score = Math.round(clamp(score, 0, 100));
|
||||||
|
|
||||||
|
// 景氣 regime
|
||||||
|
let regime;
|
||||||
|
if (score >= 65) regime = { label: '景氣穩健 ✓', colorKey: 'green' };
|
||||||
|
else if (score >= 50) regime = { label: '溫和成長', colorKey: 'yellow' };
|
||||||
|
else if (score >= 35) regime = { label: '景氣放緩 ⚠', colorKey: 'yellow' };
|
||||||
|
else regime = { label: '衰退風險高 ⚠', colorKey: 'red' };
|
||||||
|
|
||||||
|
return { score, regime, breakdown, signals: computeSignals(cards) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 五個訊號燈(陳述客觀狀態,不只看分數)───
|
||||||
|
function computeSignals(cards) {
|
||||||
|
const signals = [];
|
||||||
|
|
||||||
|
const spread = val(cards, 'yield_spread');
|
||||||
|
signals.push(spread == null ? pill('殖利率曲線', '—', 'text2')
|
||||||
|
: spread < 0 ? pill('殖利率曲線', '倒掛', 'red')
|
||||||
|
: spread < 30 ? pill('殖利率曲線', '偏平', 'yellow')
|
||||||
|
: pill('殖利率曲線', '正常', 'green'));
|
||||||
|
|
||||||
|
const cpiDir = dir(cards, 'cpi');
|
||||||
|
signals.push(cpiDir == null ? pill('通膨趨勢', '—', 'text2')
|
||||||
|
: cpiDir === 'down' ? pill('通膨趨勢', '降溫中', 'green')
|
||||||
|
: cpiDir === 'up' ? pill('通膨趨勢', '升溫', 'red')
|
||||||
|
: pill('通膨趨勢', '持平', 'yellow'));
|
||||||
|
|
||||||
|
const unempDir = dir(cards, 'unemployment');
|
||||||
|
signals.push(unempDir == null ? pill('就業市場', '—', 'text2')
|
||||||
|
: unempDir === 'up' ? pill('就業市場', '降溫中', 'yellow')
|
||||||
|
: unempDir === 'down' ? pill('就業市場', '強勁', 'green')
|
||||||
|
: pill('就業市場', '穩定', 'green'));
|
||||||
|
|
||||||
|
const nfci = val(cards, 'fin_cond');
|
||||||
|
signals.push(nfci == null ? pill('金融條件', '—', 'text2')
|
||||||
|
: nfci < 0 ? pill('金融條件', '偏寬', 'green')
|
||||||
|
: nfci < 0.2 ? pill('金融條件', '中性', 'yellow')
|
||||||
|
: pill('金融條件', '偏緊', 'orange'));
|
||||||
|
|
||||||
|
const gdp = val(cards, 'gdp');
|
||||||
|
const mfg = val(cards, 'mfg');
|
||||||
|
const rec = val(cards, 'recession_prob');
|
||||||
|
let momentum;
|
||||||
|
if (gdp == null && mfg == null) momentum = pill('景氣動能', '—', 'text2');
|
||||||
|
else if ((rec != null && rec > 35) || (mfg != null && mfg < 0 && gdp != null && gdp < 1)) momentum = pill('景氣動能', '放緩', 'red');
|
||||||
|
else if (gdp != null && gdp > 2 && (mfg == null || mfg > 0)) momentum = pill('景氣動能', '擴張', 'green');
|
||||||
|
else momentum = pill('景氣動能', '溫和', 'yellow');
|
||||||
|
signals.push(momentum);
|
||||||
|
|
||||||
|
return signals;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pill(label, value, colorKey) {
|
||||||
|
return { label, value, colorKey };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,843 @@
|
||||||
|
{
|
||||||
|
"name": "macroscope",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "macroscope",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/array-flatten": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/body-parser": {
|
||||||
|
"version": "1.20.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||||
|
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"content-type": "~1.0.5",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "~1.2.0",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"qs": "~6.15.1",
|
||||||
|
"raw-body": "~2.5.3",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bytes": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-type": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/destroy": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ee-first": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/etag": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express": {
|
||||||
|
"version": "4.22.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
|
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "~1.3.8",
|
||||||
|
"array-flatten": "1.1.1",
|
||||||
|
"body-parser": "~1.20.5",
|
||||||
|
"content-disposition": "~0.5.4",
|
||||||
|
"content-type": "~1.0.4",
|
||||||
|
"cookie": "~0.7.1",
|
||||||
|
"cookie-signature": "~1.0.6",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"finalhandler": "~1.3.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.0",
|
||||||
|
"merge-descriptors": "1.0.3",
|
||||||
|
"methods": "~1.1.2",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"path-to-regexp": "~0.1.12",
|
||||||
|
"proxy-addr": "~2.0.7",
|
||||||
|
"qs": "~6.15.1",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"send": "~0.19.0",
|
||||||
|
"serve-static": "~1.16.2",
|
||||||
|
"setprototypeof": "1.2.0",
|
||||||
|
"statuses": "~2.0.1",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"utils-merge": "1.0.1",
|
||||||
|
"vary": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/finalhandler": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/forwarded": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fresh": {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
|
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"setprototypeof": "~1.2.0",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"toidentifier": "~1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.4.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/media-typer": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/merge-descriptors": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/methods": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-inspect": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/on-finished": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ee-first": "1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parseurl": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-to-regexp": {
|
||||||
|
"version": "0.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/proxy-addr": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"forwarded": "0.2.0",
|
||||||
|
"ipaddr.js": "1.9.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"side-channel": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/raw-body": {
|
||||||
|
"version": "2.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||||
|
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/send": {
|
||||||
|
"version": "0.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||||
|
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "1.2.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"mime": "1.6.0",
|
||||||
|
"ms": "2.1.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"statuses": "~2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/serve-static": {
|
||||||
|
"version": "1.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||||
|
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"send": "~0.19.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/side-channel": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-list": "^1.0.0",
|
||||||
|
"side-channel-map": "^1.0.1",
|
||||||
|
"side-channel-weakmap": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-list": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-weakmap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-map": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is": {
|
||||||
|
"version": "1.6.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"media-typer": "0.3.0",
|
||||||
|
"mime-types": "~2.1.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unpipe": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/utils-merge": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vary": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "macroscope",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MacroScope — 總經指標儀表板(接 FRED 真實資料)",
|
||||||
|
"type": "module",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node --disable-warning=ExperimentalWarning server.js",
|
||||||
|
"dev": "node --disable-warning=ExperimentalWarning --watch server.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// MacroScope 伺服器
|
||||||
|
// - 對外提供 index.html(前端)
|
||||||
|
// - GET /api/macro 整理好的總經資料(後端持金鑰呼叫 FRED)
|
||||||
|
// - GET /api/series/:key 單一指標的歷史序列(給「走勢大圖」)
|
||||||
|
// - GET /api/score-history 每日健康分數累積歷史
|
||||||
|
// 資料持久化於 SQLite(data.db):重啟即時載入、每天累積分數快照
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { GROUPS, INDICATOR_MAP } from './lib/indicators.js';
|
||||||
|
import { getIndicatorCards, getYieldCurve, MissingKeyError } from './lib/fred.js';
|
||||||
|
import { computeScore } from './lib/score.js';
|
||||||
|
import { EVENTS, EPISODES } from './lib/events.js';
|
||||||
|
import {
|
||||||
|
savePayload, loadPayload, saveSeries, getSeries,
|
||||||
|
saveScoreSnapshot, getScoreHistory,
|
||||||
|
} from './lib/db.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const CACHE_TTL_MS = (Number(process.env.CACHE_TTL_SECONDS) || 3600) * 1000;
|
||||||
|
const hasKey = process.env.FRED_API_KEY && process.env.FRED_API_KEY !== 'your_fred_api_key_here';
|
||||||
|
|
||||||
|
// 記憶體快取(開機時會用 DB 內容預先填入)
|
||||||
|
let cache = { at: 0, payload: null };
|
||||||
|
|
||||||
|
async function buildPayload() {
|
||||||
|
const [{ cards, seriesHistory, degraded }, yieldCurve] = await Promise.all([
|
||||||
|
getIndicatorCards(),
|
||||||
|
getYieldCurve(),
|
||||||
|
]);
|
||||||
|
const { score, regime, breakdown, signals } = computeScore(cards);
|
||||||
|
const groups = GROUPS.map((g) => ({
|
||||||
|
key: g.key, title: g.title, titleEn: g.titleEn, icon: g.icon,
|
||||||
|
colorKey: g.colorKey, intro: g.intro,
|
||||||
|
cards: Object.values(cards).filter((c) => c.group === g.key),
|
||||||
|
}));
|
||||||
|
const payload = {
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
score, regime, breakdown, signals, groups, yieldCurve, degraded,
|
||||||
|
};
|
||||||
|
return { payload, seriesHistory };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抓取 → 更新記憶體快取 → 寫入資料庫(序列 + 分數快照)
|
||||||
|
async function refreshAndCache() {
|
||||||
|
const { payload, seriesHistory } = await buildPayload();
|
||||||
|
cache = { at: Date.now(), payload };
|
||||||
|
try {
|
||||||
|
savePayload(payload);
|
||||||
|
for (const [key, points] of Object.entries(seriesHistory)) saveSeries(key, points);
|
||||||
|
saveScoreSnapshot(payload.score, payload.regime?.label);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('寫入資料庫失敗(不影響顯示):', e.message);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/macro', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const fresh = req.query.fresh === '1';
|
||||||
|
if (!fresh && cache.payload && Date.now() - cache.at < CACHE_TTL_MS) {
|
||||||
|
return res.json({ ...cache.payload, cached: true });
|
||||||
|
}
|
||||||
|
const payload = await refreshAndCache();
|
||||||
|
res.json({ ...payload, cached: false });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof MissingKeyError) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: 'missing_api_key',
|
||||||
|
message: '尚未設定 FRED 金鑰。請複製 .env.example 為 .env 並填入免費的 FRED_API_KEY,再重新啟動伺服器。',
|
||||||
|
hint: 'https://fred.stlouisfed.org/docs/api/api_key.html',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 若有舊快取,至少先給舊資料
|
||||||
|
if (cache.payload) return res.json({ ...cache.payload, cached: true, stale: true });
|
||||||
|
console.error('[api/macro] 失敗:', err);
|
||||||
|
res.status(502).json({ error: 'fetch_failed', message: '取得資料失敗,請稍後再試。', detail: String(err?.message || err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 歷史事件標記 & 危機案例(靜態設定,給走勢標註與「歷史殷鑑」頁用)
|
||||||
|
app.get('/api/events', (req, res) => res.json({ events: EVENTS, episodes: EPISODES }));
|
||||||
|
|
||||||
|
// 單一指標歷史序列(給走勢大圖)
|
||||||
|
const RANGE_DAYS = { '1m': 30, '6m': 182, '1y': 365, '5y': 1825, '10y': 3650, max: null };
|
||||||
|
app.get('/api/series/:key', (req, res) => {
|
||||||
|
const key = req.params.key;
|
||||||
|
const ind = INDICATOR_MAP[key];
|
||||||
|
if (!ind) return res.status(404).json({ error: 'unknown_series', message: `查無指標:${key}` });
|
||||||
|
const range = RANGE_DAYS[req.query.range] !== undefined ? req.query.range : '1y';
|
||||||
|
const days = RANGE_DAYS[range];
|
||||||
|
const since = days ? new Date(Date.now() - days * 86400000).toISOString().slice(0, 10) : null;
|
||||||
|
const points = getSeries(key, since);
|
||||||
|
res.json({
|
||||||
|
key, label: ind.label, labelEn: ind.labelEn,
|
||||||
|
format: ind.format, decimals: ind.decimals ?? 2,
|
||||||
|
inverted: !!ind.inverted, tip: ind.tip, substitute: ind.substitute || null,
|
||||||
|
range, points,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 每日健康分數歷史
|
||||||
|
app.get('/api/score-history', (req, res) => {
|
||||||
|
res.json({ points: getScoreHistory() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => res.json({ ok: true }));
|
||||||
|
app.use(express.static(__dirname));
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`\nMacroScope 已啟動 → http://localhost:${PORT}\n`);
|
||||||
|
if (!hasKey) {
|
||||||
|
console.log('提醒:尚未設定 FRED_API_KEY,畫面會顯示設定教學。');
|
||||||
|
console.log('申請免費金鑰:https://fred.stlouisfed.org/docs/api/api_key.html\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 先用資料庫裡的舊資料填入快取(若有),讓頁面能即時開啟
|
||||||
|
const saved = loadPayload();
|
||||||
|
if (saved) {
|
||||||
|
cache = { at: saved.updatedAt, payload: saved.payload };
|
||||||
|
console.log('已從資料庫載入上次資料,頁面可即時開啟。');
|
||||||
|
}
|
||||||
|
// 背景刷新最新資料(首次或過期時較久,FRED 有流量限制)
|
||||||
|
console.log('正在背景抓取最新資料(首次約需 20–40 秒)…');
|
||||||
|
const t0 = Date.now();
|
||||||
|
refreshAndCache()
|
||||||
|
.then((payload) => {
|
||||||
|
const ok = payload.groups.reduce((a, g) => a + g.cards.length, 0);
|
||||||
|
console.log(`資料就緒:${ok} 個指標,健康分數 ${payload.score}(耗時 ${((Date.now() - t0) / 1000).toFixed(0)} 秒)\n`);
|
||||||
|
})
|
||||||
|
.catch((err) => console.log('背景抓取失敗(開啟頁面時會再試):', String(err?.message || err), '\n'));
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue