2026-06-02 09:40:21 +00:00
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// 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,
|
2026-06-03 09:21:58 +00:00
|
|
|
|
listTrades, getTrade, insertTrade, updateTrade, deleteTrade, tradeStats,
|
|
|
|
|
|
getCachedJSON, putCachedJSON, getCachedEntry,
|
2026-06-02 09:40:21 +00:00
|
|
|
|
} from './lib/db.js';
|
2026-06-03 09:21:58 +00:00
|
|
|
|
import { getKnowledge, getNote, knowledgeReady } from './lib/knowledge.js';
|
2026-06-03 16:42:07 +00:00
|
|
|
|
import { getFundamentals, getLatestFilingInfo, getQuote, getCompanyProfile } from './lib/fundamentals.js';
|
2026-06-03 09:21:58 +00:00
|
|
|
|
import { buildReport } from './lib/fincheck.js';
|
2026-06-03 16:42:07 +00:00
|
|
|
|
import { getHistory, getHistorySince, RANGES, INTERVALS } from './lib/marketdata.js';
|
2026-06-03 09:21:58 +00:00
|
|
|
|
import { runBacktest, STRATEGIES } from './lib/backtest.js';
|
|
|
|
|
|
import { getInvestMap } from './lib/investmap.js';
|
2026-06-03 09:33:23 +00:00
|
|
|
|
import { buildGraph } from './lib/graph.js';
|
2026-06-03 16:42:07 +00:00
|
|
|
|
import {
|
|
|
|
|
|
getCalendarPayload, getCalendarWatchlist, saveCalendarWatchlist, warmCalendarCache,
|
|
|
|
|
|
} from './lib/calendar-cache.js';
|
|
|
|
|
|
import { getCompanyIntel } from './lib/companyintel.js';
|
2026-06-02 09:40:21 +00:00
|
|
|
|
|
|
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
|
|
const app = express();
|
2026-06-03 09:21:58 +00:00
|
|
|
|
app.use(express.json());
|
2026-06-02 09:40:21 +00:00
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
|
|
const CACHE_TTL_MS = (Number(process.env.CACHE_TTL_SECONDS) || 3600) * 1000;
|
2026-06-03 09:21:58 +00:00
|
|
|
|
// 財報變動頻率低(季報),因此長期存資料庫、盡量沿用:
|
|
|
|
|
|
// 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;
|
2026-06-03 16:42:07 +00:00
|
|
|
|
// 近即時報價:免費來源可能延遲,短快取避免切換畫面時密集連打。
|
|
|
|
|
|
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;
|
2026-06-03 09:21:58 +00:00
|
|
|
|
const SYMBOL_RE = /^[A-Z0-9.\-]{1,12}$/;
|
2026-06-02 09:40:21 +00:00
|
|
|
|
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() });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-03 09:21:58 +00:00
|
|
|
|
// ─── 學習教材:知識庫 ───
|
|
|
|
|
|
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) {
|
2026-06-03 16:42:07 +00:00
|
|
|
|
const hasMetricPayload = entry.value?._metricsVersion >= 2 && (Array.isArray(entry.value?.quarters) || Array.isArray(entry.value?.annual));
|
2026-06-03 09:21:58 +00:00
|
|
|
|
const age = Date.now() - entry.updatedAt;
|
|
|
|
|
|
// 1) 還很新 → 直接用快取,完全不連網
|
2026-06-03 16:42:07 +00:00
|
|
|
|
if (hasMetricPayload && age < FUND_SOFT_MS) return res.json({ ...entry.value, cached: true });
|
2026-06-03 09:21:58 +00:00
|
|
|
|
// 2) 稍舊 → 用輕量探針確認 SEC 是否有新財報
|
|
|
|
|
|
const probe = await getLatestFilingInfo(symbol).catch(() => null);
|
|
|
|
|
|
const known = entry.value._latestFiling;
|
2026-06-03 16:42:07 +00:00
|
|
|
|
const noUpdate = hasMetricPayload && (probe ? (known && probe.accn === known) : (age <= FUND_HARD_MS));
|
2026-06-03 09:21:58 +00:00
|
|
|
|
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,
|
2026-06-03 16:42:07 +00:00
|
|
|
|
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,
|
2026-06-03 09:21:58 +00:00
|
|
|
|
_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 快取)───
|
2026-06-03 16:42:07 +00:00
|
|
|
|
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) };
|
|
|
|
|
|
}
|
|
|
|
|
|
function mergeHistory(oldPayload, patchPayload) {
|
|
|
|
|
|
const map = new Map();
|
|
|
|
|
|
for (const p of (oldPayload.points || [])) map.set(p.date, p);
|
|
|
|
|
|
for (const p of (patchPayload.points || [])) map.set(p.date, p);
|
|
|
|
|
|
const points = [...map.values()].sort((a, b) => a.date < b.date ? -1 : 1);
|
|
|
|
|
|
return {
|
|
|
|
|
|
...oldPayload,
|
|
|
|
|
|
...patchPayload,
|
|
|
|
|
|
points,
|
|
|
|
|
|
_lastIncrementalAt: Date.now(),
|
|
|
|
|
|
_incremental: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-06-03 09:21:58 +00:00
|
|
|
|
async function getHistoryCached(symbol, range, interval, fresh) {
|
|
|
|
|
|
const key = `hist:${symbol}:${range}:${interval}`;
|
|
|
|
|
|
const ttl = interval === '1d' ? HIST_TTL_MS : 24 * 3600 * 1000;
|
|
|
|
|
|
const entry = getCachedEntry(key);
|
2026-06-03 16:42:07 +00:00
|
|
|
|
if (!fresh && entry && Date.now() - entry.updatedAt < ttl) return { ...trimHistoryRange(entry.value, range), cached: true };
|
2026-06-03 09:21:58 +00:00
|
|
|
|
try {
|
2026-06-03 16:42:07 +00:00
|
|
|
|
let hist;
|
|
|
|
|
|
const oldPoints = entry?.value?.points || [];
|
|
|
|
|
|
const lastDate = oldPoints.length ? oldPoints[oldPoints.length - 1].date : null;
|
|
|
|
|
|
if (lastDate) hist = mergeHistory(entry.value, await getHistorySince(symbol, lastDate, range, interval));
|
|
|
|
|
|
else hist = await getHistory(symbol, range, interval);
|
2026-06-03 09:21:58 +00:00
|
|
|
|
const payload = { ...hist, _fetchedAt: Date.now() };
|
|
|
|
|
|
putCachedJSON(key, payload);
|
2026-06-03 16:42:07 +00:00
|
|
|
|
return { ...trimHistoryRange(payload, range), cached: false };
|
2026-06-03 09:21:58 +00:00
|
|
|
|
} catch (err) {
|
2026-06-03 16:42:07 +00:00
|
|
|
|
if (entry) return { ...trimHistoryRange(entry.value, range), cached: true, stale: true, fetchError: String(err?.message || err) };
|
2026-06-03 09:21:58 +00:00
|
|
|
|
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) });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-03 16:42:07 +00:00
|
|
|
|
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) });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
|
if (!fresh && entry && Date.now() - entry.updatedAt < INTEL_TTL_MS) return res.json({ ...entry.value, cached: true });
|
|
|
|
|
|
const profile = getCachedEntry(`profile:${symbol}`)?.value || {};
|
|
|
|
|
|
const payload = await getCompanyIntel(symbol, profile);
|
|
|
|
|
|
putCachedJSON(key, payload);
|
|
|
|
|
|
res.json({ ...payload, cached: false });
|
|
|
|
|
|
} 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) });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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/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) });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-03 09:21:58 +00:00
|
|
|
|
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) });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-03 09:33:23 +00:00
|
|
|
|
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) });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-03 09:21:58 +00:00
|
|
|
|
app.get('/api/investmap', (req, res) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const k = getKnowledge();
|
|
|
|
|
|
const byNum = {};
|
|
|
|
|
|
for (const p of (k?.principles || [])) byNum[p.num] = { title: p.title, id: p.id };
|
|
|
|
|
|
res.json(getInvestMap(byNum));
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
res.status(500).json({ error: 'investmap_failed', message: String(err?.message || err) });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 交易復盤 ───
|
|
|
|
|
|
app.get('/api/trades', (req, res) => res.json({ trades: listTrades() }));
|
|
|
|
|
|
app.get('/api/trades/stats', (req, res) => res.json(tradeStats()));
|
|
|
|
|
|
app.post('/api/trades', (req, res) => {
|
|
|
|
|
|
try { res.json(insertTrade(req.body || {})); }
|
|
|
|
|
|
catch (e) { res.status(400).json({ error: 'bad_trade', message: String(e?.message || e) }); }
|
|
|
|
|
|
});
|
|
|
|
|
|
app.put('/api/trades/:id', (req, res) => {
|
|
|
|
|
|
const row = updateTrade(Number(req.params.id), req.body || {});
|
|
|
|
|
|
if (!row) return res.status(404).json({ error: 'not_found', message: '查無此交易。' });
|
|
|
|
|
|
res.json(row);
|
|
|
|
|
|
});
|
|
|
|
|
|
app.delete('/api/trades/:id', (req, res) => {
|
|
|
|
|
|
deleteTrade(Number(req.params.id));
|
|
|
|
|
|
res.json({ ok: true });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.get('/api/health', (req, res) => res.json({ ok: true, knowledge: knowledgeReady() }));
|
2026-06-02 09:40:21 +00:00
|
|
|
|
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'));
|
2026-06-03 16:42:07 +00:00
|
|
|
|
warmCalendarCache()
|
|
|
|
|
|
.then(() => console.log('日曆快取已就緒(資料庫,每日更新)。'))
|
|
|
|
|
|
.catch(err => console.warn('[calendar warm]', err?.message || err));
|
2026-06-02 09:40:21 +00:00
|
|
|
|
});
|