commit 2effe74b223b1f0585d95274527044ba77ce823f Author: 王性驊 Date: Tue Jun 2 17:40:21 2026 +0800 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ea764b3 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f854a44 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env +*.log +.DS_Store +data.db +data.db-* +.gstack/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f3dcf3 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# MacroScope — 總經指標儀表板 + +一個給初學者看的美國總體經濟儀表板。資料來自美國聖路易聯儲的 **FRED**(免費、公開), +全中文介面、每張卡片都有白話解釋,並用透明公式算出「總經健康分數」。 + +> 為什麼需要一個後端?FRED 官方 API 不允許瀏覽器直接呼叫(沒有 CORS),而且金鑰不能放在前端外洩。 +> 所以這裡用一支很小的 Node 伺服器當「代理」:金鑰只留在伺服器,瀏覽器只跟自己的 `/api/macro` 溝通。 + +--- + +## 三步驟啟動 + +### 1. 申請免費的 FRED 金鑰(約 1 分鐘) +到 註冊帳號,取得一組 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 多數指標本身就是每日/每週/每月更新。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..6f0dd0f --- /dev/null +++ b/index.html @@ -0,0 +1,838 @@ + + + + + +MacroScope — 總經指標儀表板 + + + + + +
+ + +
+ + +
+
+ +
+
+
+ 正在抓取真實總經資料… +
+
+ + + + + + + + + + diff --git a/lib/db.js b/lib/db.js new file mode 100644 index 0000000..1684149 --- /dev/null +++ b/lib/db.js @@ -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(); +} diff --git a/lib/events.js b/lib/events.js new file mode 100644 index 0000000..bab3c9e --- /dev/null +++ b/lib/events.js @@ -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', + }, +]; diff --git a/lib/fred.js b/lib/fred.js new file mode 100644 index 0000000..0eed96e --- /dev/null +++ b/lib/fred.js @@ -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 }; +} diff --git a/lib/indicators.js b/lib/indicators.js new file mode 100644 index 0000000..c57bc84 --- /dev/null +++ b/lib/indicators.js @@ -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])); diff --git a/lib/score.js b/lib/score.js new file mode 100644 index 0000000..fb31320 --- /dev/null +++ b/lib/score.js @@ -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 }; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d13a89d --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a313ce1 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..24d4e49 --- /dev/null +++ b/server.js @@ -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')); +});