finance-dashboard/server.js

1104 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ═══════════════════════════════════════════════════════════
// MacroScope 伺服器
// - 對外提供 index.html前端
// - GET /api/macro 整理好的總經資料(後端持金鑰呼叫 FRED
// - GET /api/series/:key 單一指標的歷史序列(給「走勢大圖」)
// - GET /api/score-history 每日健康分數累積歷史
// 資料持久化於 SQLitedata.db重啟即時載入、每天累積分數快照
// ═══════════════════════════════════════════════════════════
import 'dotenv/config';
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
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,
listTrades, getTrade, insertTrade, updateTrade, deleteTrade, tradeStats,
getCachedJSON, putCachedJSON, getCachedEntry,
getCompanyIntelCustom, saveCompanyIntelCustom,
} from './lib/db.js';
import { mergeCustomIntel, localizeIntel } from './lib/companyintel-i18n.js';
import { ensurePriceHistory, buildVolumeSeries } from './lib/price-store.js';
import { getKnowledge, getNote, knowledgeReady } from './lib/knowledge.js';
import { getFundamentals, getLatestFilingInfo, getQuote, getCompanyProfile } from './lib/fundamentals.js';
import { buildReport } from './lib/fincheck.js';
import { RANGES, INTERVALS } from './lib/marketdata.js';
import { runBacktest, STRATEGIES } from './lib/backtest.js';
import { getInvestMap } from './lib/investmap.js';
import { buildGraph } from './lib/graph.js';
import {
getCalendarPayload, getCalendarWatchlist, saveCalendarWatchlist, warmCalendarCache,
} from './lib/calendar-cache.js';
import { getCompanyIntel, runCompanyIntelSync } from './lib/companyintel.js';
import { syncSecArchive, getSecArchivePayload, resolveArchiveFile } from './lib/sec-archive.js';
import { buildSectorFlowPayload } from './lib/sector-flow.js';
import {
getStockWatchlist, saveStockWatchlist, normalizeWatchlistPayload, allWatchlistSymbols,
} from './lib/watchlist.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ENV_PATH = path.join(__dirname, '.env');
const app = express();
app.use(express.json({ limit: '1mb' }));
const PORT = process.env.PORT || 3000;
const CACHE_TTL_MS = (Number(process.env.CACHE_TTL_SECONDS) || 3600) * 1000;
// 財報變動頻率低(季報),因此長期存資料庫、盡量沿用:
// FUND_SOFT_MS 內直接用快取、完全不連網;超過才用輕量探針查 SEC 是否有新財報,
// 沒有新財報就續用快取(只更新檢查時間),有新財報或探針失敗且超過 FUND_HARD_MS 才重抓。
const FUND_SOFT_MS = (Number(process.env.FUND_SOFT_HOURS) || 12) * 3600 * 1000;
const FUND_HARD_MS = (Number(process.env.FUND_HARD_DAYS) || 3) * 24 * 3600 * 1000;
// 歷史股價快取:日線 6 小時內沿用、週/月線 1 天內沿用(節省 API
const HIST_TTL_MS = (Number(process.env.HIST_SOFT_HOURS) || 6) * 3600 * 1000;
// 近即時報價:免費來源可能延遲,短快取避免切換畫面時密集連打。
const QUOTE_TTL_MS = (Number(process.env.QUOTE_TTL_SECONDS) || 60) * 1000;
const PROFILE_TTL_MS = (Number(process.env.PROFILE_TTL_HOURS) || 24) * 3600 * 1000;
const INTEL_TTL_MS = (Number(process.env.INTEL_TTL_HOURS) || 6) * 3600 * 1000;
const SECTOR_TTL_MS = (Number(process.env.SECTOR_TTL_HOURS) || 6) * 3600 * 1000;
const SYMBOL_RE = /^[A-Z0-9.\-]{1,12}$/;
const hasKey = process.env.FRED_API_KEY && process.env.FRED_API_KEY !== 'your_fred_api_key_here';
const SETTINGS_FIELDS = [
{ key: 'FRED_API_KEY', label: 'FRED API Key', type: 'secret', group: 'market', hint: '總經資料與部分日曆資料使用。' },
{ key: 'OPENCODE_GO_API_KEY', label: 'OpenCode Go API Key', type: 'secret', group: 'ai', hint: 'OpenCode Go provider。' },
{ key: 'OPENCODE_GO_MODEL', label: 'OpenCode Go Model', type: 'text', group: 'ai', hint: '從 OpenCode Go 的 /models 端點抓取後選擇。' },
{ key: 'GROK_API_KEY', label: 'Grok API Key', type: 'secret', group: 'ai', hint: 'xAI / Grok provider。' },
{ key: 'GROK_MODEL', label: 'Grok Model', type: 'text', group: 'ai', hint: '從 xAI /models 端點抓取後選擇。' },
{ key: 'AI_ACTIVE_PROVIDER', label: '預設 AI Provider', type: 'text', group: 'ai', hint: 'opencode-go 或 grok。' },
];
function parseEnvText(text) {
const out = {};
for (const line of String(text || '').split(/\r?\n/)) {
const m = line.match(/^\s*([\w.-]+)\s*=\s*(.*)\s*$/);
if (!m) continue;
let v = m[2] || '';
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[m[1]] = v;
}
return out;
}
function readEnvFile() {
try { return parseEnvText(fs.readFileSync(ENV_PATH, 'utf8')); }
catch (_) { return {}; }
}
function quoteEnvValue(v) {
v = String(v == null ? '' : v);
if (!v || /[\s#"'\\]/.test(v)) return JSON.stringify(v);
return v;
}
function writeEnvUpdates(updates) {
const src = fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, 'utf8') : '';
const lines = src.split(/\r?\n/);
const used = new Set();
const next = lines.map(line => {
const m = line.match(/^(\s*)([\w.-]+)(\s*=\s*)(.*)$/);
if (!m || !(m[2] in updates)) return line;
used.add(m[2]);
return `${m[1]}${m[2]}${m[3]}${quoteEnvValue(updates[m[2]])}`;
});
for (const [k, v] of Object.entries(updates)) {
if (!used.has(k)) next.push(`${k}=${quoteEnvValue(v)}`);
}
fs.writeFileSync(ENV_PATH, next.join('\n').replace(/\n{3,}/g, '\n\n'));
Object.assign(process.env, updates);
}
function maskSecret(v) {
if (!v) return '';
v = String(v);
return v.length <= 8 ? '已設定' : `${v.slice(0, 4)}${v.slice(-4)}`;
}
// 記憶體快取(開機時會用 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 金鑰。請到 AI 設定頁填入免費的 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 }));
app.get('/api/sectors', async (req, res) => {
const key = 'sectors:flow:v1';
const entry = getCachedEntry(key);
const fresh = req.query.fresh === '1';
try {
if (!fresh && entry && Date.now() - entry.updatedAt < SECTOR_TTL_MS) {
return res.json({ ...entry.value, cached: true, cachedAt: new Date(entry.updatedAt).toISOString() });
}
const payload = await buildSectorFlowPayload();
putCachedJSON(key, payload);
res.json({ ...payload, cached: false });
} catch (err) {
console.error('[api/sectors]', err?.message || err);
if (entry?.value) {
return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
}
res.status(502).json({ error: 'sectors_failed', message: String(err?.message || err) });
}
});
// 單一指標歷史序列(給走勢大圖)
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/knowledge', (req, res) => {
const k = getKnowledge();
if (!k) return res.status(503).json({ error: 'knowledge_not_built', message: '知識庫尚未建立,請先執行 npm run build:knowledge。' });
res.json(k);
});
app.get('/api/note/:kind/:id', (req, res) => {
const note = getNote(req.params.kind, req.params.id);
if (!note) return res.status(404).json({ error: 'note_not_found', message: '找不到這篇筆記。' });
res.json(note);
});
// ─── 財報健檢 ───
app.get('/api/fundamentals/:symbol', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!/^[A-Z0-9.\-]{1,12}$/.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const cacheKey = 'fund:' + symbol;
const fresh = req.query.fresh === '1';
const entry = getCachedEntry(cacheKey); // { value, updatedAt } | null
try {
if (!fresh && entry) {
const hasMetricPayload = entry.value?._metricsVersion >= 2 && (Array.isArray(entry.value?.quarters) || Array.isArray(entry.value?.annual));
const age = Date.now() - entry.updatedAt;
// 1) 還很新 → 直接用快取,完全不連網
if (hasMetricPayload && age < FUND_SOFT_MS) return res.json({ ...entry.value, cached: true });
// 2) 稍舊 → 用輕量探針確認 SEC 是否有新財報
const probe = await getLatestFilingInfo(symbol).catch(() => null);
const known = entry.value._latestFiling;
const noUpdate = hasMetricPayload && (probe ? (known && probe.accn === known) : (age <= FUND_HARD_MS));
if (noUpdate) {
// 沒有新財報(或暫時無法判斷但還沒到硬上限)→ 續用快取,只更新「檢查時間」
const v = { ...entry.value, _checkedAt: Date.now() };
putCachedJSON(cacheKey, v); // 更新 updated_at避免短時間內重複探針
return res.json({ ...v, cached: true });
}
}
// 3) 首次、或偵測到新財報、或使用者手動更新 → 真正抓取
const fundamentals = await getFundamentals(symbol);
const report = buildReport(fundamentals);
const probe = await getLatestFilingInfo(symbol).catch(() => null);
const now = Date.now();
const payload = {
symbol: fundamentals.symbol, name: fundamentals.name, source: fundamentals.source,
currency: fundamentals.currency, asOf: fundamentals.asOf, price: fundamentals.price, report,
peTrailing: fundamentals.peTrailing, marketCap: fundamentals.marketCap,
sharesOutstanding: fundamentals.sharesOutstanding,
targetPrice: fundamentals.targetPrice, dividendYield: fundamentals.dividendYield,
quarters: fundamentals.quarters, annual: fundamentals.annual, balance: fundamentals.balance,
_metricsVersion: 2,
_fetchedAt: now, _checkedAt: now,
_latestFiling: probe ? probe.accn : null, _latestForm: probe ? probe.form : null,
};
putCachedJSON(cacheKey, payload);
res.json({ ...payload, cached: false });
} catch (err) {
console.error('[api/fundamentals]', symbol, err?.message || err);
// 抓取失敗但有舊資料 → 回舊資料(標記 stale不讓使用者卡住、也避免一直重試燒 API
if (entry) return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
res.status(502).json({ error: 'fundamentals_failed', message: String(err?.message || err) });
}
});
// ─── 歷史股價(價格走勢 + 回測共用,持久 DB 快取)───
const PRICE_RANGE_DAYS = { '3mo': 92, '6mo': 184, '1y': 370, '2y': 740, '5y': 1855, '10y': 3710, max: null };
function trimHistoryRange(payload, range) {
if (!payload?.points || !PRICE_RANGE_DAYS[range]) return payload;
const since = new Date(Date.now() - PRICE_RANGE_DAYS[range] * 86400000).toISOString().slice(0, 10);
return { ...payload, points: payload.points.filter(p => p.date >= since) };
}
async function enrichTodayVolume(payload, symbol, refreshQuote) {
if (payload.interval !== '1d') return payload;
let quote = getCachedEntry(`quote:${symbol}`)?.value || {};
const needQuote = refreshQuote || quote.volume == null;
if (needQuote) {
try {
const q = await getQuote(symbol);
quote = { symbol, ...q };
putCachedJSON(`quote:${symbol}`, { ...quote, _fetchedAt: Date.now() });
} catch (_) { /* 沿用快取 */ }
}
const volumePoints = buildVolumeSeries(
payload.points,
payload.allBarsPoints || payload.points,
quote,
'1d',
);
const today = new Date().toISOString().slice(0, 10);
const todayBar = volumePoints.find(p => p.date === today);
const todayVolume = todayBar?.volume ?? quote.volume ?? null;
const avgVolume = quote.avgVolume ?? null;
return {
...payload,
volumePoints,
todayVolume,
avgVolume,
volumeRatio: todayVolume != null && avgVolume ? todayVolume / avgVolume : null,
volumeNote: todayBar?.partialSession
? '當日成交量來自即時報價(收盤 K 仍截至昨日完整棒)'
: (todayVolume != null ? '含當日成交量' : null),
};
}
async function getHistoryCached(symbol, range, interval, fresh) {
const ttl = interval === '1mo' ? 7 * 24 * 3600 * 1000
: interval === '1wk' ? 24 * 3600 * 1000
: HIST_TTL_MS;
try {
const { payload, cached, fetchMode } = await ensurePriceHistory(symbol, interval, {
fresh: fresh === true,
ttlMs: ttl,
});
let enriched = await enrichTodayVolume(payload, symbol, fresh === true);
const trimmed = trimHistoryRange({ ...enriched, range }, range);
if (trimmed.volumePoints) {
const since = PRICE_RANGE_DAYS[range]
? new Date(Date.now() - PRICE_RANGE_DAYS[range] * 86400000).toISOString().slice(0, 10)
: null;
trimmed.volumePoints = since
? trimmed.volumePoints.filter(p => p.date >= since)
: trimmed.volumePoints;
}
return {
...trimmed,
cached,
stale: !!payload.fetchError,
fetchError: payload.fetchError || null,
fetchMode,
dbBars: payload.dbBars,
researchBars: payload.researchBars,
researchThrough: payload.researchThrough,
researchNote: payload.researchNote,
firstDate: payload.firstDate,
lastDate: payload.lastDate,
};
} catch (err) {
const legacyKey = `hist:${symbol}:max:${interval}`;
const entry = getCachedEntry(legacyKey);
if (entry) {
return {
...trimHistoryRange({ ...entry.value, range }, range),
cached: true,
stale: true,
fetchError: String(err?.message || err),
};
}
throw err;
}
}
app.get('/api/price/:symbol', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const range = RANGES.includes(req.query.range) ? req.query.range : '5y';
const interval = INTERVALS.includes(req.query.interval) ? req.query.interval : '1d';
try {
const h = await getHistoryCached(symbol, range, interval, req.query.fresh === '1');
res.json(h);
} catch (err) {
console.error('[api/price]', symbol, err?.message || err);
res.status(502).json({ error: 'price_failed', message: String(err?.message || err) });
}
});
app.get('/api/quote/:symbol', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const key = `quote:${symbol}`;
const entry = getCachedEntry(key);
const fresh = req.query.fresh === '1';
try {
if (!fresh && entry && Date.now() - entry.updatedAt < QUOTE_TTL_MS) {
return res.json({ ...entry.value, cached: true });
}
const quote = await getQuote(symbol);
const payload = { symbol, ...quote, _fetchedAt: Date.now() };
putCachedJSON(key, payload);
res.json({ ...payload, cached: false });
} catch (err) {
console.error('[api/quote]', symbol, err?.message || err);
if (entry) return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
res.status(502).json({ error: 'quote_failed', message: String(err?.message || err) });
}
});
app.get('/api/profile/:symbol', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const key = `profile:${symbol}`;
const entry = getCachedEntry(key);
const fresh = req.query.fresh === '1';
try {
if (!fresh && entry && Date.now() - entry.updatedAt < PROFILE_TTL_MS) return res.json({ ...entry.value, cached: true });
const profile = await getCompanyProfile(symbol);
const payload = { ...profile, _fetchedAt: Date.now() };
putCachedJSON(key, payload);
res.json({ ...payload, cached: false });
} catch (err) {
console.error('[api/profile]', symbol, err?.message || err);
if (entry) return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
res.status(502).json({ error: 'profile_failed', message: String(err?.message || err) });
}
});
function intelPayloadStale(payload, symbol) {
if (!payload) return true;
const goog = (payload.management?.searches || []).some(s => /google\.com\/search/i.test(s.url || ''))
|| (payload.industryChain?.searches || []).some(s => /google\.com\/search/i.test(s.url || ''));
if (goog) return true;
const us = /^[A-Z][A-Z0-9.\-]{0,7}$/.test(symbol) && !symbol.includes('.');
if (us && !(payload.resources || []).length) return true;
const groups = [...(payload.industryChain?.upstreamDetail || []), ...(payload.industryChain?.downstreamDetail || [])];
const hasObjEntities = groups.some(g => (g.entities || []).some(e => e && typeof e === 'object' && e.symbol));
if (groups.length && !hasObjEntities) return true;
if ((payload.industryChain?.peers || []).length > 0) return true;
if (payload.chainLayout !== 'upstream_downstream_v2') return true;
return false;
}
app.get('/api/company-intel/:symbol', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const key = `intel:${symbol}`;
const entry = getCachedEntry(key);
const fresh = req.query.fresh === '1';
try {
const cacheOk = !fresh && entry && Date.now() - entry.updatedAt < INTEL_TTL_MS && !intelPayloadStale(entry.value, symbol);
if (cacheOk) {
const custom = getCompanyIntelCustom(symbol);
const { sanitizeIntelNewsPayload } = await import('./lib/companyintel.js');
let payload = custom?.data
? mergeCustomIntel(localizeIntel(entry.value), custom.data)
: entry.value;
payload = sanitizeIntelNewsPayload(payload);
const { attachIntelSyncStatus } = await import('./lib/companyintel-ai.js');
payload = attachIntelSyncStatus(payload, symbol);
return res.json({ ...payload, cached: true });
}
const profile = getCachedEntry(`profile:${symbol}`)?.value || {};
const doSync = req.query.sync === '1';
const payload = await getCompanyIntel(symbol, profile, {
sync: doSync,
force: fresh,
useAI: req.query.ai !== '0',
});
putCachedJSON(key, payload);
res.json({ ...payload, cached: false, synced: doSync });
} catch (err) {
console.error('[api/company-intel]', symbol, err?.message || err);
if (entry) return res.json({ ...entry.value, cached: true, stale: true, fetchError: String(err?.message || err) });
res.status(502).json({ error: 'intel_failed', message: String(err?.message || err) });
}
});
app.put('/api/company-intel/:symbol/custom', (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const body = req.body;
if (!body || typeof body !== 'object') {
return res.status(400).json({ error: 'bad_body', message: '請提供 JSON 物件。' });
}
try {
const saved = saveCompanyIntelCustom(symbol, body);
const intelKey = `intel:${symbol}`;
const entry = getCachedEntry(intelKey);
if (entry?.value) {
putCachedJSON(intelKey, mergeCustomIntel(localizeIntel(entry.value), body));
}
res.json({ ok: true, symbol: saved.symbol, updatedAt: saved.updatedAt });
} catch (err) {
res.status(400).json({ error: 'save_failed', message: String(err?.message || err) });
}
});
app.get('/api/company-intel/:symbol/custom', (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const row = getCompanyIntelCustom(symbol);
res.json(row ? { symbol, data: row.data, updatedAt: row.updatedAt } : { symbol, data: null });
});
app.post('/api/company-intel/:symbol/sync', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const force = req.query.fresh === '1' || req.body?.force === true;
const useAI = req.body?.useAI !== false && req.query.ai !== '0';
try {
const profile = getCachedEntry(`profile:${symbol}`)?.value || {};
const result = await runCompanyIntelSync(symbol, profile, { force, useAI });
const intelKey = `intel:${symbol}`;
const payload = result.skipped
? await getCompanyIntel(symbol, profile, { sync: false })
: await getCompanyIntel(symbol, profile, { sync: false, force: true });
putCachedJSON(intelKey, payload);
res.json({
ok: true,
symbol,
skipped: result.skipped,
skipReason: result.skipReason || null,
nextRefreshAfter: result.nextRefreshAfter || payload.nextRefreshAfter,
nextPublicLabel: result.nextPublicLabel || payload.nextPublicLabel,
aiError: result.aiError || null,
sources: result.sources,
enrichedAt: payload.enrichedAt,
intel: payload,
});
} catch (err) {
console.error('[api/company-intel/sync]', symbol, err?.message || err);
res.status(502).json({ error: 'intel_sync_failed', message: String(err?.message || err) });
}
});
app.get('/api/sec-archive/:symbol', (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
res.json(getSecArchivePayload(symbol));
});
app.post('/api/sec-archive/:symbol/sync', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const force = req.query.fresh === '1' || req.body?.force === true;
try {
const payload = await syncSecArchive(symbol, { force });
res.json(payload);
} catch (err) {
console.error('[api/sec-archive/sync]', symbol, err?.message || err);
res.status(502).json({ error: 'sec_archive_failed', message: String(err?.message || err) });
}
});
app.get('/api/sec-archive/:symbol/file', (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
const accession = String(req.query.accession || '').trim();
const file = String(req.query.file || '').trim();
if (!SYMBOL_RE.test(symbol) || !accession) {
return res.status(400).json({ error: 'bad_request', message: '需要 accession。' });
}
const full = resolveArchiveFile(symbol, accession, file || undefined);
if (!full) return res.status(404).json({ error: 'not_found', message: '本機尚無此檔案,請先同步封存。' });
res.sendFile(full);
});
function addDaysISO(base, days) {
const d = new Date(base + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
function calendarSymbols(req) {
const fromQuery = String(req.query.symbols || '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
return [...new Set(fromQuery)].filter(s => SYMBOL_RE.test(s)).slice(0, 30);
}
app.get('/api/calendar/watchlist', (req, res) => {
res.json({ symbols: getCalendarWatchlist() });
});
app.put('/api/calendar/watchlist', (req, res) => {
const raw = Array.isArray(req.body?.symbols) ? req.body.symbols : String(req.body?.symbols || '').split(',');
const symbols = saveCalendarWatchlist(raw.filter(s => SYMBOL_RE.test(String(s).trim().toUpperCase())));
res.json({ ok: true, symbols });
});
app.get('/api/watchlist', (req, res) => {
const data = getStockWatchlist();
res.json({ ...data, symbolCount: allWatchlistSymbols(data).length });
});
app.put('/api/watchlist', (req, res) => {
const data = saveStockWatchlist(req.body);
res.json({ ok: true, ...data, symbolCount: allWatchlistSymbols(data).length });
});
app.get('/api/watchlist/quotes', async (req, res) => {
const symbols = [...new Set(String(req.query.symbols || '').split(',').map(s => s.trim().toUpperCase()).filter(s => SYMBOL_RE.test(s)))].slice(0, 48);
if (!symbols.length) return res.json({ quotes: [] });
const quotes = await Promise.all(symbols.map(async (symbol) => {
try {
const key = `quote:${symbol}`;
let q = getCachedEntry(key)?.value;
if (q?.price == null) {
q = await getQuote(symbol);
putCachedJSON(key, { symbol, ...q, _fetchedAt: Date.now() });
}
let chg = q?.changePercent;
if (chg == null) {
try {
const h = await getHistoryCached(symbol, '3mo', '1d', false);
const p = h?.points || [];
if (p.length >= 2 && p[0].close > 0) {
chg = ((p[p.length - 1].close / p[0].close) - 1) * 100;
}
} catch { /* skip */ }
}
return {
symbol,
name: q?.name || q?.shortName || symbol,
price: q?.price ?? null,
change: q?.change ?? null,
changePercent: chg ?? null,
currency: q?.currency || 'USD',
};
} catch (e) {
return { symbol, error: String(e?.message || e) };
}
}));
res.json({ quotes });
});
app.get('/api/calendar', async (req, res) => {
const today = new Date().toISOString().slice(0, 10);
const start = /^\d{4}-\d{2}-\d{2}$/.test(req.query.start) ? req.query.start : today;
const end = /^\d{4}-\d{2}-\d{2}$/.test(req.query.end) ? req.query.end : addDaysISO(today, 60);
const fromQuery = calendarSymbols(req);
const stored = getCalendarWatchlist();
const symbols = fromQuery.length ? fromQuery : stored;
const forceFresh = req.query.fresh === '1';
try {
const payload = await getCalendarPayload({ start, end, symbols, forceFresh });
res.json(payload);
} catch (err) {
console.error('[api/calendar]', err?.message || err);
res.status(502).json({ error: 'calendar_failed', message: String(err?.message || err) });
}
});
app.get('/api/backtest/:symbol', async (req, res) => {
const symbol = String(req.params.symbol || '').trim().toUpperCase();
if (!SYMBOL_RE.test(symbol)) return res.status(400).json({ error: 'bad_symbol', message: '代號格式不正確。' });
const strategy = STRATEGIES[req.query.strategy] ? req.query.strategy : 'buyhold';
const range = RANGES.includes(req.query.range) ? req.query.range : '5y';
const numQ = (k) => (req.query[k] != null && req.query[k] !== '') ? Number(req.query[k]) : undefined;
try {
const h = await getHistoryCached(symbol, range, '1d', false);
const result = runBacktest(h.points, {
strategy, monthly: numQ('monthly'), short: numQ('short'), long: numQ('long'), drop: numQ('drop'),
});
res.json({ symbol, name: h.name, currency: h.currency, range, cached: h.cached, ...result });
} catch (err) {
console.error('[api/backtest]', symbol, err?.message || err);
res.status(502).json({ error: 'backtest_failed', message: String(err?.message || err) });
}
});
app.get('/api/graph', (req, res) => {
try {
const k = getKnowledge();
if (!k) return res.status(503).json({ error: 'knowledge_not_built', message: '知識庫尚未建立。' });
res.json(buildGraph(k, req.query));
} catch (err) {
res.status(500).json({ error: 'graph_failed', message: String(err?.message || err) });
}
});
app.get('/api/investmap', (req, res) => {
try {
const k = getKnowledge();
const byNum = {};
for (const p of (k?.principles || [])) byNum[p.num] = { title: p.title, id: p.id };
res.json(getInvestMap(byNum));
} catch (err) {
res.status(500).json({ error: 'investmap_failed', message: String(err?.message || err) });
}
});
// ─── 交易復盤 ───
app.get('/api/trades', (req, res) => res.json({ trades: listTrades() }));
app.get('/api/trades/stats', (req, res) => res.json(tradeStats()));
app.post('/api/trades', (req, res) => {
try { res.json(insertTrade(req.body || {})); }
catch (e) { res.status(400).json({ error: 'bad_trade', message: String(e?.message || e) }); }
});
app.put('/api/trades/:id', (req, res) => {
const row = updateTrade(Number(req.params.id), req.body || {});
if (!row) return res.status(404).json({ error: 'not_found', message: '查無此交易。' });
res.json(row);
});
app.delete('/api/trades/:id', (req, res) => {
deleteTrade(Number(req.params.id));
res.json({ ok: true });
});
// ─── AI Provider 代理OpenCode Go / Grok ───
const AI_PROVIDERS = {
'opencode-go': {
label: 'OpenCode Go',
endpoint: 'https://opencode.ai/zen/go/v1/chat/completions',
modelsEndpoint: 'https://opencode.ai/zen/go/v1/models',
keyEnv: 'OPENCODE_GO_API_KEY',
modelEnv: 'OPENCODE_GO_MODEL',
mode: 'chat',
},
grok: {
label: 'Grok',
endpoint: 'https://api.x.ai/v1/responses',
modelsEndpoint: 'https://api.x.ai/v1/models',
keyEnv: 'GROK_API_KEY',
modelEnv: 'GROK_MODEL',
mode: 'responses',
},
};
function normalizeAIText(data, mode) {
if (mode === 'responses') {
if (data?.output_text) return data.output_text;
const chunks = [];
for (const item of data?.output || []) {
for (const c of item?.content || []) {
if (typeof c?.text === 'string') chunks.push(c.text);
else if (typeof c?.content === 'string') chunks.push(c.content);
}
}
return chunks.join('\n').trim();
}
return data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || '';
}
function compactForPrompt(v, max = 16000) {
const s = typeof v === 'string' ? v : JSON.stringify(v, null, 2);
return s.length > max ? s.slice(0, max) + '\n...(上下文已截斷)' : s;
}
/** 依實際附帶的資料決定 page / chat避免「在資料頁但沒資料」仍強制套用分析格式 */
function finalizeAIContext(ctx = {}) {
const view = String(ctx.view || '').trim();
let hasPageData = false;
if (view === 'macro') {
const m = ctx.macro;
hasPageData = !!(m && (m.score != null || m.focusedCard || (m.signals && m.signals.length)));
} else if (view === 'stock') {
const s = ctx.stock;
hasPageData = !!(s && !s.error && (s.fundamentals || s.quote || (s.history?.points?.length > 0) || s.technical?.close != null));
} else if (view === 'calendar') {
hasPageData = !!(ctx.calendar?.events?.length);
} else if (view === 'journal') {
hasPageData = !!(ctx.journal?.trades?.length || ctx.journal?.stats);
} else if (view === 'learn') {
const n = ctx.learning?.focusedNote;
hasPageData = !!(n?.body || n?.title || (ctx.learning?.visibleText || '').trim().length > 80);
}
return { ...ctx, view, hasPageData, mode: hasPageData ? 'page' : 'chat' };
}
function cachedValue(entry) {
if (!entry) return null;
return { ...entry.value, cached: true, cachedAt: new Date(entry.updatedAt).toISOString() };
}
function summarizeMacro(payload, focus) {
if (!payload) return null;
const cards = (payload.groups || []).flatMap(g => (g.cards || []).map(c => ({ ...c, groupTitle: g.title })));
const focusedCard = focus?.key ? cards.find(c => c.key === focus.key) : null;
const series = focusedCard ? getSeries(focusedCard.key, null).slice(-160) : [];
return {
updatedAt: payload.updatedAt,
cached: true,
score: payload.score,
regime: payload.regime,
signals: payload.signals,
focusedCard: focusedCard ? {
key: focusedCard.key,
group: focusedCard.groupTitle,
label: focusedCard.label,
labelEn: focusedCard.labelEn,
value: focusedCard.value,
change: focusedCard.change,
status: focusedCard.status,
human: focusedCard.human,
context: focusedCard.context,
tip: focusedCard.tip,
series,
} : null,
};
}
async function stockAIContext(symbol, focus, allowFetch) {
if (!SYMBOL_RE.test(symbol || '')) return { symbol, error: 'bad_symbol' };
const out = { symbol, subPage: focus?.subPage || null, sources: [] };
let fundEntry = getCachedEntry(`fund:${symbol}`);
if (!fundEntry && allowFetch) {
const fundamentals = await getFundamentals(symbol);
const report = buildReport(fundamentals);
const payload = {
_metricsVersion: 2,
_fetchedAt: Date.now(),
symbol: fundamentals.symbol, name: fundamentals.name, source: fundamentals.source,
currency: fundamentals.currency, asOf: fundamentals.asOf, price: fundamentals.price, report,
peTrailing: fundamentals.peTrailing, marketCap: fundamentals.marketCap,
sharesOutstanding: fundamentals.sharesOutstanding,
targetPrice: fundamentals.targetPrice, dividendYield: fundamentals.dividendYield,
quarters: fundamentals.quarters, annual: fundamentals.annual, balance: fundamentals.balance,
_latestFiling: await getLatestFilingInfo(symbol).catch(() => null),
};
putCachedJSON(`fund:${symbol}`, payload);
fundEntry = getCachedEntry(`fund:${symbol}`);
out.sources.push('fundamentals:fetched');
}
let quoteEntry = getCachedEntry(`quote:${symbol}`);
if (!quoteEntry && allowFetch) {
const quote = await getQuote(symbol);
putCachedJSON(`quote:${symbol}`, { symbol, ...quote, _fetchedAt: Date.now() });
quoteEntry = getCachedEntry(`quote:${symbol}`);
out.sources.push('quote:fetched');
}
let histPayload = null;
if (allowFetch) {
try {
const h = await ensurePriceHistory(symbol, '1d', { fresh: false, ttlMs: HIST_TTL_MS });
histPayload = h.payload;
out.sources.push(h.fetchMode ? `history:${h.fetchMode}` : 'history:db');
} catch (_) { /* 允許缺歷史 */ }
}
const fundamentals = cachedValue(fundEntry);
const quote = cachedValue(quoteEntry);
const history = histPayload ? {
...histPayload,
cached: true,
cachedAt: histPayload._fetchedAt ? new Date(histPayload._fetchedAt).toISOString() : null,
} : null;
out.fundamentals = fundamentals ? {
symbol: fundamentals.symbol,
name: fundamentals.name,
cachedAt: fundamentals.cachedAt,
asOf: fundamentals.asOf,
price: fundamentals.price,
report: fundamentals.report,
peTrailing: fundamentals.peTrailing,
marketCap: fundamentals.marketCap,
dividendYield: fundamentals.dividendYield,
quarters: (fundamentals.quarters || []).slice(0, 8),
annual: (fundamentals.annual || []).slice(0, 5),
balance: fundamentals.balance,
} : null;
out.quote = quote;
out.history = history ? { ...history, points: (history.points || []).slice(-260) } : null;
out.cacheStatus = {
fundamentals: !!fundEntry,
quote: !!quoteEntry,
history: !!(histPayload?.points?.length),
};
return out;
}
async function buildAIPageContext({ view, focus = {}, client = {}, allowFetch = true }) {
const base = {
view,
focus,
client,
dataPolicy: 'cache-first: data.db first; fetch only when DB cache is missing; never force fresh for AI context.',
collectedAt: new Date().toISOString(),
};
if (view === 'macro') {
let saved = loadPayload();
if (!saved && allowFetch) {
const payload = await refreshAndCache();
saved = { payload, updatedAt: Date.now() };
}
base.macro = summarizeMacro(saved?.payload || cache.payload, focus);
} else if (view === 'stock') {
const symbol = String(focus.symbol || client.symbol || '').trim().toUpperCase();
if (symbol) {
base.stock = await stockAIContext(symbol, focus, allowFetch);
if (client.technical) base.stock.technical = client.technical;
}
} else if (view === 'calendar') {
const symbols = getCalendarWatchlist();
const today = new Date().toISOString().slice(0, 10);
const end = new Date(Date.now() + 60 * 86400000).toISOString().slice(0, 10);
const baseEntry = getCachedEntry(`calendar:base:v5:${today}`);
const earnEntry = symbols.length ? getCachedEntry(`calendar:earn:v5:${today}:${[...symbols].sort().join(',')}`) : null;
if (baseEntry?.value) {
const baseEvents = (baseEntry.value.events || []).filter(e => e.category !== 'earnings');
const earnEvents = symbols.length ? (earnEntry?.value?.events || []).filter(e => e.category === 'earnings') : [];
const events = [...baseEvents, ...earnEvents]
.filter(e => e.date >= today && e.date <= end)
.sort((a, b) => (a.date + (a.time || '')).localeCompare(b.date + (b.time || '')))
.slice(0, 80);
base.calendar = {
cached: true,
cachedAt: new Date(baseEntry.updatedAt).toISOString(),
watchlist: symbols,
events,
sources: baseEntry.value.sources || [],
};
} else {
base.calendar = allowFetch
? await getCalendarPayload({ start: today, end, symbols, forceFresh: false })
.then(d => ({ ...d, events: (d.events || []).slice(0, 80) }))
.catch(e => ({ error: String(e?.message || e), watchlist: symbols }))
: { cached: false, watchlist: symbols, events: [] };
}
} else if (view === 'journal') {
base.journal = {
stats: tradeStats(),
trades: listTrades().slice(0, 80),
};
} else if (view === 'learn') {
const note = focus.kind && focus.id ? getNote(focus.kind, focus.id) : null;
base.learning = {
focusedNote: note ? {
kind: focus.kind,
id: focus.id,
title: note.title,
summary: note.summary,
body: String(note.body || '').slice(0, 10000),
} : client.currentNote || null,
visibleText: client.visibleText || '',
personalNotes: client.personalNotes || [],
};
}
return finalizeAIContext(base);
}
function normalizeModelList(data) {
const items = Array.isArray(data?.data) ? data.data : Array.isArray(data?.models) ? data.models : Array.isArray(data) ? data : [];
return items
.map(m => (typeof m === 'string' ? { id: m } : { id: m?.id || m?.name || m?.model, created: m?.created, ownedBy: m?.owned_by || m?.ownedBy }))
.filter(m => m.id);
}
async function listProviderModels(provider, apiKey) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 30000);
try {
const r = await fetch(provider.modelsEndpoint, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: ctrl.signal,
});
const data = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = data?.error?.message || data?.message || `${provider.label} 回傳 ${r.status}`;
const err = new Error(msg);
err.status = r.status;
throw err;
}
return normalizeModelList(data);
} finally {
clearTimeout(timer);
}
}
app.post('/api/ai/models', async (req, res) => {
const providerId = String(req.body?.provider || '').trim();
const provider = AI_PROVIDERS[providerId];
if (!provider) return res.status(400).json({ error: 'bad_provider', message: '不支援的 AI provider。' });
const apiKey = String(req.body?.apiKey || process.env[provider.keyEnv] || '').trim();
if (!apiKey) return res.status(400).json({ error: 'missing_key', message: '請先在 AI 設定填入 API key。' });
try {
const models = await listProviderModels(provider, apiKey);
res.json({ provider: providerId, models });
} catch (err) {
res.status(502).json({ error: 'models_failed', message: String(err?.message || err) });
}
});
app.post('/api/ai/context', async (req, res) => {
try {
const view = String(req.body?.view || '').trim();
const focus = req.body?.focus || {};
const client = req.body?.client || {};
const allowFetch = req.body?.allowFetch !== false;
const context = await buildAIPageContext({ view, focus, client, allowFetch });
res.json(context);
} catch (err) {
res.status(502).json({ error: 'context_failed', message: String(err?.message || err) });
}
});
app.post('/api/ai/chat', async (req, res) => {
const providerId = String(req.body?.provider || '').trim();
const provider = AI_PROVIDERS[providerId];
if (!provider) return res.status(400).json({ error: 'bad_provider', message: '不支援的 AI provider。' });
const apiKey = String(req.body?.apiKey || process.env[provider.keyEnv] || '').trim();
let model = String(req.body?.model || process.env[provider.modelEnv] || '').trim();
const question = String(req.body?.question || '').trim();
const context = finalizeAIContext(req.body?.context || {});
if (!apiKey) return res.status(400).json({ error: 'missing_key', message: '請先在 AI 設定填入 API key。' });
if (!model) {
const models = await listProviderModels(provider, apiKey).catch(() => []);
model = models[0]?.id || '';
}
if (!model) return res.status(400).json({ error: 'missing_model', message: '請先設定模型。' });
if (!question) return res.status(400).json({ error: 'missing_question', message: '請輸入問題。' });
const hasPageData = context.hasPageData === true;
const system = hasPageData ? [
'你是 MacroScope 的投資學習助理。',
'使用者正在 App 某個頁面提問,並附上該頁可取得的結構化資料(可能不完整)。',
'請優先根據附帶資料回答;資料未提及的不要捏造。語氣自然,像教學對話即可,不必強制固定章節格式,除非使用者要求條列或摘要。',
'若資料不足以回答,請直接說明缺什麼、建議在畫面上查看哪裡。',
'不要聲稱已即時查網路。',
'內容僅供學習,不構成投資建議。',
].join('\n') : [
'你是 MacroScope 的 AI 助手。',
'這是一般對話,沒有附帶頁面結構化資料。請用繁體中文自然回答,像一般聊天即可。',
'若使用者問投資或財務判斷,可給教學性說明,並提醒僅供學習、不構成投資建議。',
'需要本 App 內的總經、個股、日曆或筆記資料時,請建議使用者切到對應頁面後再問。',
'不要聲稱已即時查網路。',
].join('\n');
const user = hasPageData
? [`使用者問題:${question}`, '', '目前頁面上下文JSON', compactForPrompt(context)].join('\n')
: [`使用者問題:${question}`, '', `目前所在視圖:${context.view || '(未知)'}(無可用頁面資料,請當一般對話)`].join('\n');
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 120000);
const body = provider.mode === 'responses'
? { model, store: false, input: [{ role: 'system', content: system }, { role: 'user', content: user }] }
: { model, messages: [{ role: 'system', content: system }, { role: 'user', content: user }], temperature: 0.2 };
const r = await fetch(provider.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(body),
signal: ctrl.signal,
});
clearTimeout(timer);
const data = await r.json().catch(() => ({}));
if (!r.ok) {
return res.status(502).json({
error: 'provider_failed',
message: data?.error?.message || data?.message || `${provider.label} 回傳 ${r.status}`,
detail: data,
});
}
res.json({ provider: providerId, model, text: normalizeAIText(data, provider.mode), raw: { id: data.id, usage: data.usage } });
} catch (err) {
res.status(502).json({ error: 'ai_failed', message: String(err?.message || err) });
}
});
app.get('/api/settings/env', (req, res) => {
const env = { ...readEnvFile(), ...process.env };
res.json({
envPath: ENV_PATH,
fields: SETTINGS_FIELDS.map(f => ({
...f,
value: f.type === 'secret' ? '' : (env[f.key] || ''),
hasValue: !!env[f.key],
masked: f.type === 'secret' ? maskSecret(env[f.key]) : '',
})),
});
});
app.post('/api/settings/env', (req, res) => {
const allowed = new Set(SETTINGS_FIELDS.map(f => f.key));
const secret = new Set(SETTINGS_FIELDS.filter(f => f.type === 'secret').map(f => f.key));
const body = req.body?.values || {};
const updates = {};
for (const [k, raw] of Object.entries(body)) {
if (!allowed.has(k)) continue;
const v = String(raw == null ? '' : raw).trim();
if (secret.has(k) && !v) continue; // 留空代表保留既有 secret
updates[k] = v;
}
try {
if (Object.keys(updates).length) writeEnvUpdates(updates);
const env = { ...readEnvFile(), ...process.env };
res.json({
ok: true,
envPath: ENV_PATH,
updated: Object.keys(updates),
fields: SETTINGS_FIELDS.map(f => ({
...f,
value: f.type === 'secret' ? '' : (env[f.key] || ''),
hasValue: !!env[f.key],
masked: f.type === 'secret' ? maskSecret(env[f.key]) : '',
})),
});
} catch (err) {
res.status(500).json({ error: 'env_write_failed', message: String(err?.message || err) });
}
});
app.get('/api/health', (req, res) => res.json({ ok: true, knowledge: knowledgeReady() }));
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('正在背景抓取最新資料(首次約需 2040 秒)…');
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'));
warmCalendarCache()
.then(() => console.log('日曆快取已就緒(資料庫,每日更新)。'))
.catch(err => console.warn('[calendar warm]', err?.message || err));
});