140 lines
6.1 KiB
JavaScript
140 lines
6.1 KiB
JavaScript
|
|
// ═══════════════════════════════════════════════════════════
|
|||
|
|
// 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'));
|
|||
|
|
});
|