1104 lines
49 KiB
JavaScript
1104 lines
49 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 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('正在背景抓取最新資料(首次約需 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'));
|
||
warmCalendarCache()
|
||
.then(() => console.log('日曆快取已就緒(資料庫,每日更新)。'))
|
||
.catch(err => console.warn('[calendar warm]', err?.message || err));
|
||
});
|