finance-tools/server.js

2439 lines
101 KiB
JavaScript
Raw Permalink 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 伺服器
// - 對外提供 dist/React 前端)
// - 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, stabilizeScore, regimeForScore } from './lib/score.js';
import { EVENTS, EPISODES } from './lib/events.js';
import {
savePayload, loadPayload, saveSeries, getSeries,
saveScoreSnapshot, getScoreHistory, getPreviousScoreSnapshot,
listTrades, getTrade, insertTrade, updateTrade, deleteTrade, tradeStats,
listTradeAccounts, getTradeAccount, createTradeAccount, updateTradeAccount, deleteTradeAccount,
getTradeAccountPurgeStats, setAccountDirective,
getCachedJSON, putCachedJSON, getCachedEntry,
getCompanyIntelCustom, saveCompanyIntelCustom,
listAiAnalysisHistory,
} from './lib/db.js';
import { getOrCreateAiSummary } from './lib/ai-summary.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 { getSkillDrills, skillDrillsReady } from './lib/skill-drills.js';
import {
getSkillProgressMap,
markSkillRead,
getOpenMistakes,
getDrillHistory,
resolveMistake,
recordDrillAttempt,
migrateSkillLocalData,
getPersonalNote,
savePersonalNote,
getCoachChatMessages,
saveCoachChatMessages,
getSkillNotesIndex,
} from './lib/skill-store.js';
import { getFundamentals, getLatestFilingInfo, getQuote, getCompanyProfile } from './lib/fundamentals.js';
import { buildTradeDashboard, buildJournalAiContext, buildPortfolioTotal } from './lib/trade-accounts.js';
import {
runAiSimulation,
runPreMarketResearch,
runPostMarketReview,
runIntradayReview,
runDailyIndustryResearch,
listAccountSimulationRuns,
tickAiSimulations,
getTraderBrief,
buildTraderOperationalStatus,
buildTraderParameterSummary,
refreshDailyTakeaway,
} from './lib/ai-trader.js';
import { listTraderPersonalities } from './lib/trader-personalities.js';
import { getTraderChat, saveTraderChat, clearTraderChat, addTraderMemory } from './lib/ai-trader-memory.js';
import { buildTodayFollowSignals } from './lib/trader-follow-signals.js';
import { marketStatusLabel, getTraderPhase, traderPhaseLabel, isMarketOpenForAccount } from './lib/market-hours.js';
import { listAIProviders } from './lib/ai-client.js';
import {
getAiDebugStatus,
setAiDebugEnabled,
recordAiDebug,
listAiDebugLogs,
listAiTraderEvents,
clearAiDebugLogs,
getAiDebugUsageSummary,
} from './lib/ai-debug.js';
import { buildReport } from './lib/fincheck.js';
import { buildFundAnalysis } from './lib/fund-analysis.js';
import { RANGES, INTERVALS } from './lib/marketdata.js';
import { runBacktest, STRATEGIES } from './lib/backtest.js';
import {
listStrategies, getStrategy, createStrategy, updateStrategy, deleteStrategy,
resolveStrategyRun, seedStrategiesFromBook, listEngines,
} from './lib/strategy-store.js';
import { getInvestMap } from './lib/investmap.js';
import { buildGraph } from './lib/graph.js';
import {
getCalendarPayload, getCalendarWatchlist, saveCalendarWatchlist, resolveEarningsSymbols, 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';
import {
getPatternProgressMap,
markPatternRead,
recordPatternScanHits,
} from './lib/pattern-store.js';
import { getPlayerState, resetPlayerCharacter, setPlayerDisplayName, setPlayerEpithet } from './lib/player-store.js';
import { loadCatalog, listPatterns, categories, chapters, getPattern } from './lib/patterns/catalog.js';
import { detectPatterns } from './lib/patterns/detect.js';
import { enrichScanResult } from './lib/patterns/action-plan.js';
import { registerPatternBookRoutes } from './lib/pattern-book.js';
import { resolveSymbol } from './lib/tw-marketdata.js';
import { getTwInstitutionalFlow } from './lib/tw-institutional.js';
import { getStockHolders } from './lib/stock-holders.js';
import { getAnalystConsensus } from './lib/analyst-consensus.js';
import { getEtfExposure } from './lib/etf-exposure.js';
import { getWeathervane } from './lib/weathervane.js';
import { getSocialWatch } from './lib/social-watch.js';
import { getNotableHolders } from './lib/notable-holders.js';
import {
loadAIConfigBundle,
saveAIConfigBundle,
loadAgentMd,
buildMcpPromptSection,
skillsForContext,
ensureAIConfigDefaults,
loadMcpConfig,
DEFAULT_AGENT_MD,
DEFAULT_MCP,
} from './lib/ai-config.js';
import {
listSkillLibrary,
installFromPaste,
previewSkillPaste,
installCustomSkill,
setSkillEnabled,
deleteCustomSkill,
resetSkillsLibrary,
} from './lib/skills-library.js';
import { buildMcpCatalog } from './lib/mcp-registry.js';
import { listMcpTools, invokeMcpTool } from './lib/mcp-client.js';
import { gatherMcpContext, compactMcpForPrompt } from './lib/mcp-gather.js';
import { OPTIONAL_API_KEYS, getDataSourcesPayload, GROUP_LABELS } from './lib/data-sources.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 HOST = process.env.HOST || '0.0.0.0';
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,16}$/;
function normalizeApiSymbol(raw) {
const resolved = resolveSymbol(raw);
return { symbol: resolved.yahoo, market: resolved.market, display: resolved.display };
}
const hasKey = process.env.FRED_API_KEY && process.env.FRED_API_KEY !== 'your_fred_api_key_here';
const AI_SETTINGS_FIELDS = [
{ 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。' },
];
const SETTINGS_FIELDS = [...OPTIONAL_API_KEYS, ...AI_SETTINGS_FIELDS];
async function getMacroPayloadForMcp() {
if (cache.payload && Date.now() - cache.at < CACHE_TTL_MS) return cache.payload;
if (cache.payload) return cache.payload;
try {
return await refreshAndCache();
} catch {
return cache.payload || null;
}
}
const mcpDeps = () => ({ getMacroPayload: getMacroPayloadForMcp });
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 raw = computeScore(cards);
const prior = getPreviousScoreSnapshot();
const score = stabilizeScore(raw.score, prior?.score);
const regime = regimeForScore(score);
const breakdown = raw.breakdown;
const signals = raw.signals;
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,
rawScore: raw.score,
scoreChange: prior ? score - prior.score : null,
priorScore: prior?.score ?? null,
regime,
breakdown,
signals,
coverage: raw.coverage,
scoreMethod: {
label: '趨勢總經分',
description: '主燈號以前一日趨勢分為錨,吸收 35% 最新資訊;一般日最多變動 3 分,重大衝擊可加速至 58 分。原始即時分保留供比較。',
smoothing: '35% new information',
maxDailyMove: 8,
},
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 }));
// 歷史風向標:指標百分位定位 + 最相似歷史期(從 SQLite 序列計算)
app.get('/api/weathervane', (req, res) => {
const window = ['5y', '10y', '20y', 'max'].includes(req.query.window) ? req.query.window : '10y';
try {
res.json(getWeathervane(window));
} catch (err) {
console.error('[api/weathervane]', err?.message || err);
res.status(502).json({ error: 'weathervane_failed', message: String(err?.message || err), indicators: [], similar: null });
}
});
// 風向標 · 大人物社群動態X / Truth Social
app.get('/api/social-watch', async (req, res) => {
const lang = req.query.lang === 'zh' ? 'zh' : 'en';
const fresh = req.query.fresh === '1';
try {
const payload = await getSocialWatch({ lang, force: fresh });
res.json(payload);
} catch (err) {
console.error('[api/social-watch]', err?.message || err);
res.status(502).json({
error: 'social_watch_failed',
message: String(err?.message || err),
posts: [],
accounts: [],
});
}
});
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);
});
// 心法試煉題庫覆寫EP 標的、日期視窗、同群干擾項)
app.get('/api/skill-drills', (req, res) => {
const d = getSkillDrills();
if (!d) return res.status(503).json({ error: 'skill_drills_not_built', message: '試煉題庫尚未建立,請執行 npm run build:skill-drills。' });
res.json(d);
});
// 心法修煉進度、錯題本、答題紀錄SQLite
app.get('/api/skill-state', (req, res) => {
try {
res.json({
progressMap: getSkillProgressMap(),
openMistakes: getOpenMistakes(),
notesIndex: getSkillNotesIndex(),
});
} catch (err) {
res.status(500).json({ error: 'skill_state_failed', message: String(err?.message || err) });
}
});
// 角色養成:由後端依 SQLite 心法/圖鑑/復盤資料計算,寫入 player_snapshot前端不可自行加分
app.get('/api/player-state', (req, res) => {
try {
const state = getPlayerState();
if (state.error) return res.status(503).json(state);
res.json(state);
} catch (err) {
res.status(500).json({ error: 'player_state_failed', message: String(err?.message || err) });
}
});
app.post('/api/player-state/reset', (req, res) => {
try {
const state = resetPlayerCharacter();
if (state.error) return res.status(503).json(state);
res.json({ ok: true, message: '角色養成已重置為 Lv.1。', ...state });
} catch (err) {
res.status(500).json({ error: 'player_reset_failed', message: String(err?.message || err) });
}
});
app.post('/api/player-state/name', (req, res) => {
try {
const name = req.body?.name ?? req.body?.displayName;
const state = setPlayerDisplayName(name);
if (state.error) return res.status(400).json(state);
res.json(state);
} catch (err) {
res.status(500).json({ error: 'player_name_failed', message: String(err?.message || err) });
}
});
app.post('/api/player-state/epithet', (req, res) => {
try {
const epithetId = req.body?.epithetId ?? req.body?.id ?? 'auto';
const state = setPlayerEpithet(epithetId);
if (state.error) return res.status(400).json(state);
res.json(state);
} catch (err) {
res.status(500).json({ error: 'player_epithet_failed', message: String(err?.message || err) });
}
});
app.post('/api/skill-progress/read', (req, res) => {
const principleId = String(req.body?.principleId || '').trim();
if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
try {
const progress = markSkillRead(principleId);
res.json({ ok: true, progress, progressMap: getSkillProgressMap() });
} catch (err) {
res.status(500).json({ error: 'skill_read_failed', message: String(err?.message || err) });
}
});
app.get('/api/skill-drill/history/:principleId', (req, res) => {
const principleId = String(req.params.principleId || '').trim();
if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 30));
try {
res.json({ principleId, attempts: getDrillHistory(principleId, limit) });
} catch (err) {
res.status(500).json({ error: 'skill_history_failed', message: String(err?.message || err) });
}
});
app.post('/api/skill-drill/attempt', (req, res) => {
const principleId = String(req.body?.principleId || '').trim();
if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
try {
const result = recordDrillAttempt({
principleId,
principleTitle: String(req.body?.principleTitle || principleId),
score: Number(req.body?.score) || 0,
breakdown: req.body?.breakdown || {},
weakPoints: Array.isArray(req.body?.weakPoints) ? req.body.weakPoints : [],
fatalErrors: Array.isArray(req.body?.fatalErrors) ? req.body.fatalErrors : [],
feedback: String(req.body?.feedback || ''),
step1Wrong: !!req.body?.step1Wrong,
answersSummary: String(req.body?.answersSummary || ''),
answers: req.body?.answers || null,
});
res.json({ ok: true, ...result });
} catch (err) {
res.status(500).json({ error: 'skill_attempt_failed', message: String(err?.message || err) });
}
});
app.post('/api/skill-mistakes/:attemptId/resolve', (req, res) => {
try {
const openMistakes = resolveMistake(req.params.attemptId);
res.json({ ok: true, openMistakes });
} catch (err) {
res.status(500).json({ error: 'skill_resolve_failed', message: String(err?.message || err) });
}
});
app.post('/api/skill-progress/migrate', (req, res) => {
try {
const result = migrateSkillLocalData({
progress: req.body?.progress || {},
mistakes: req.body?.mistakes || [],
personalNotes: req.body?.personalNotes || {},
coachChats: req.body?.coachChats || {},
});
res.json({ ok: true, ...result });
} catch (err) {
res.status(500).json({ error: 'skill_migrate_failed', message: String(err?.message || err) });
}
});
app.get('/api/skill-notes/:principleId', (req, res) => {
const principleId = String(req.params.principleId || '').trim();
if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
try {
res.json({ principleId, note: getPersonalNote(principleId) });
} catch (err) {
res.status(500).json({ error: 'skill_note_get_failed', message: String(err?.message || err) });
}
});
app.put('/api/skill-notes/:principleId', (req, res) => {
const principleId = String(req.params.principleId || '').trim();
if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
try {
const note = savePersonalNote(principleId, req.body?.text ?? '');
res.json({ ok: true, principleId, note, notesIndex: getSkillNotesIndex() });
} catch (err) {
res.status(500).json({ error: 'skill_note_save_failed', message: String(err?.message || err) });
}
});
app.get('/api/skill-coach-chat/:principleId', (req, res) => {
const principleId = String(req.params.principleId || '').trim();
if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
try {
res.json({ principleId, messages: getCoachChatMessages(principleId) });
} catch (err) {
res.status(500).json({ error: 'skill_chat_get_failed', message: String(err?.message || err) });
}
});
app.put('/api/skill-coach-chat/:principleId', (req, res) => {
const principleId = String(req.params.principleId || '').trim();
if (!principleId) return res.status(400).json({ error: 'missing_id', message: '缺少 principleId。' });
const messages = Array.isArray(req.body?.messages) ? req.body.messages : [];
try {
const saved = saveCoachChatMessages(principleId, messages);
res.json({ ok: true, principleId, ...saved, notesIndex: getSkillNotesIndex() });
} catch (err) {
res.status(500).json({ error: 'skill_chat_save_failed', message: String(err?.message || err) });
}
});
function attachFundAnalysis(payload, symbol) {
const intelEntry = getCachedEntry(`intel:${symbol}`);
const intel = intelEntry?.value;
const sectorEntry = getCachedEntry('sectors:flow:v1');
const intelHints = intel?.industryChain
? {
sector: intel.management?.sector || payload.sector,
industry: intel.management?.industry || payload.industry,
upstreamDetail: intel.industryChain.upstreamDetail,
downstreamDetail: intel.industryChain.downstreamDetail,
newsTw: intel.newsTw || [],
newsGlobal: intel.newsGlobal || [],
sectorFlow: sectorEntry?.value || null,
}
: {
sector: payload.sector,
industry: payload.industry,
newsTw: intel?.newsTw || [],
newsGlobal: intel?.newsGlobal || [],
sectorFlow: sectorEntry?.value || null,
};
return { ...payload, analysis: buildFundAnalysis(payload, intelHints) };
}
// ─── 財報健檢 ───
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({ ...attachFundAnalysis(entry.value, symbol), 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({ ...attachFundAnalysis(v, symbol), 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,
balanceHistory: fundamentals.balanceHistory,
sector: fundamentals.sector, industry: fundamentals.industry,
_metricsVersion: 3,
_fetchedAt: now, _checkedAt: now,
_latestFiling: probe ? probe.accn : null, _latestForm: probe ? probe.form : null,
};
putCachedJSON(cacheKey, payload);
res.json({ ...attachFundAnalysis(payload, symbol), cached: false });
} catch (err) {
console.error('[api/fundamentals]', symbol, err?.message || err);
// 抓取失敗但有舊資料 → 回舊資料(標記 stale不讓使用者卡住、也避免一直重試燒 API
if (entry) return res.json({ ...attachFundAnalysis(entry.value, symbol), 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 = {
'1d': 1, '5d': 5, '1mo': 31, '3mo': 92, '6mo': 184, '1y': 370, '2y': 740, '5y': 1855, '10y': 3710, max: null,
};
function pointMs(p) {
const d = String(p?.date || '');
return Date.parse(d.includes('T') ? d : `${d}T00:00:00Z`);
}
function trimHistoryRange(payload, range) {
if (!payload?.points || !PRICE_RANGE_DAYS[range]) return payload;
const sinceMs = Date.now() - PRICE_RANGE_DAYS[range] * 86400000;
return { ...payload, points: payload.points.filter(p => pointMs(p) >= sinceMs) };
}
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 volByDate = new Map(
(volumePoints || []).map((p) => [p.date, p.volume]).filter(([, v]) => v != null),
);
const points = (payload.points || []).map((p) => ({
...p,
volume: p.volume ?? volByDate.get(p.date) ?? null,
}));
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,
points,
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 === '5m' ? 5 * 60 * 1000
: interval === '15m' ? 10 * 60 * 1000
: interval === '1h' ? 15 * 60 * 1000
: 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 sinceMs = PRICE_RANGE_DAYS[range] ? Date.now() - PRICE_RANGE_DAYS[range] * 86400000 : null;
trimmed.volumePoints = sinceMs != null
? trimmed.volumePoints.filter(p => pointMs(p) >= sinceMs)
: 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/pattern-state', (req, res) => {
try {
res.json({ progressMap: getPatternProgressMap() });
} catch (err) {
res.status(500).json({ error: 'pattern_state_failed', message: String(err?.message || err) });
}
});
app.post('/api/pattern-progress/read', (req, res) => {
const patternId = String(req.body?.patternId || '').trim();
if (!patternId) return res.status(400).json({ error: 'missing_id', message: '缺少 patternId。' });
try {
const progress = markPatternRead(patternId);
res.json({ ok: true, progress, progressMap: getPatternProgressMap() });
} catch (err) {
res.status(500).json({ error: 'pattern_read_failed', message: String(err?.message || err) });
}
});
app.post('/api/pattern-progress/scan-hits', (req, res) => {
const patternIds = Array.isArray(req.body?.patternIds) ? req.body.patternIds : [];
try {
res.json({ ok: true, progressMap: recordPatternScanHits(patternIds) });
} catch (err) {
res.status(500).json({ error: 'pattern_scan_hits_failed', message: String(err?.message || err) });
}
});
// ─── 線型學堂 ───
app.get('/api/patterns/catalog', (req, res) => {
try {
const cat = loadCatalog();
const market = req.query.market === 'tw' || req.query.market === 'us' ? req.query.market : null;
res.json({
...cat,
patterns: listPatterns({ market }),
categories: categories(),
chapters: chapters(),
});
} catch (err) {
res.status(500).json({ error: 'catalog_failed', message: String(err?.message || err) });
}
});
app.get('/api/patterns/scan/:symbol', async (req, res) => {
let resolved;
try { resolved = normalizeApiSymbol(req.params.symbol); }
catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
const range = RANGES.includes(req.query.range) ? req.query.range : '2y';
const lookback = Math.min(500, Math.max(30, Number(req.query.lookback) || 120));
try {
const h = await getHistoryCached(resolved.symbol, range, '1d', req.query.fresh === '1');
const pts = h.points || [];
const { signals, rows, scanned } = detectPatterns(pts, { market: resolved.market, lookbackBars: lookback });
const { signals: enriched, setups, actionable } = enrichScanResult(
signals,
rows,
id => getPattern(id),
);
res.json({
symbol: resolved.symbol,
display: resolved.display,
market: resolved.market,
range,
from: pts[0]?.date,
to: pts[pts.length - 1]?.date,
scanned,
signalCount: enriched.length,
signals: enriched.slice(0, 80),
recentSignals: enriched.slice(0, 15),
setups,
actionable,
});
} catch (err) {
console.error('[api/patterns/scan]', resolved.symbol, err?.message || err);
res.status(502).json({ error: 'scan_failed', message: String(err?.message || err) });
}
});
app.get('/api/price/:symbol', async (req, res) => {
let resolved;
try { resolved = normalizeApiSymbol(req.params.symbol); }
catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
const symbol = resolved.symbol;
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() };
if (quote.price != null) {
putCachedJSON(key, payload);
res.json({ ...payload, cached: false });
} else if (entry?.value?.price != null) {
res.json({ ...entry.value, cached: true, stale: true, fetchError: 'quote_empty' });
} else {
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) });
}
});
// ─── AI 總結MCP + 本機資料 → 每日 SQLite 快取,省 token───
app.get('/api/ai-summary/: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 scope = String(req.query.scope || 'stock').trim().toLowerCase();
const force = req.query.fresh === '1';
const auto = req.query.auto === '1';
try {
if (!force && !auto) {
const { getTodayAiAnalysis } = await import('./lib/db.js');
const hit = getTodayAiAnalysis(symbol, scope);
if (hit) {
return res.json({
ok: true,
cached: true,
skipped: true,
skipReason: '今日已分析,讀取資料庫快取',
symbol,
scope,
summary: hit,
history: listAiAnalysisHistory(symbol, 5),
});
}
}
const result = await getOrCreateAiSummary(symbol, scope, { force, deps: mcpDeps() });
res.json({ ...result, history: listAiAnalysisHistory(symbol, 5) });
} catch (err) {
console.error('[api/ai-summary]', symbol, err?.message || err);
res.status(502).json({ error: 'ai_summary_failed', message: String(err?.message || err) });
}
});
app.post('/api/ai-summary/: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 scope = String(req.query.scope || req.body?.scope || 'stock').trim().toLowerCase();
try {
const result = await getOrCreateAiSummary(symbol, scope, { force: true, deps: mcpDeps() });
res.json({ ...result, history: listAiAnalysisHistory(symbol, 5) });
} catch (err) {
console.error('[api/ai-summary/sync]', symbol, err?.message || err);
res.status(502).json({ error: 'ai_summary_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 fresh = req.query.fresh === '1';
const quotes = await Promise.all(symbols.map(async (symbol) => {
try {
const key = `quote:${symbol}`;
let q = fresh ? null : getCachedEntry(key)?.value;
if (fresh || q?.price == null) {
const fetched = await getQuote(symbol);
if (fetched.price != null) {
q = fetched;
putCachedJSON(key, { symbol, ...q, _fetchedAt: Date.now() });
} else if (!q?.price) {
q = fetched;
}
}
let chg = q?.changePercent;
if (chg == null && q?.price != null && q?.previousClose > 0) {
chg = ((q.price / q.previousClose) - 1) * 100;
}
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/stock/:symbol/holders', async (req, res) => {
let resolved;
try { resolved = normalizeApiSymbol(req.params.symbol); }
catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
try {
const payload = await getStockHolders(resolved.symbol, { force: req.query.fresh === '1' });
res.json(payload);
} catch (err) {
console.error('[api/stock/holders]', err?.message || err);
res.status(502).json({ error: 'holders_failed', message: String(err?.message || err) });
}
});
app.get('/api/stock/:symbol/analyst-consensus', async (req, res) => {
let resolved;
try { resolved = normalizeApiSymbol(req.params.symbol); }
catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
try {
const payload = await getAnalystConsensus(resolved.symbol);
res.json(payload);
} catch (err) {
console.error('[api/stock/analyst-consensus]', err?.message || err);
res.status(502).json({ error: 'analyst_failed', message: String(err?.message || err) });
}
});
app.get('/api/stock/:symbol/etf-holders', async (req, res) => {
let resolved;
try { resolved = normalizeApiSymbol(req.params.symbol); }
catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
try {
let holders = null;
try { holders = await getStockHolders(resolved.symbol); } catch { /* optional */ }
const payload = await getEtfExposure(resolved.symbol, {
funds: holders?.funds,
institutions: holders?.institutions,
force: req.query.fresh === '1',
});
res.json(payload);
} catch (err) {
console.error('[api/stock/etf-holders]', err?.message || err);
res.status(502).json({ error: 'etf_holders_failed', message: String(err?.message || err) });
}
});
app.get('/api/stock/:symbol/notable-holders', async (req, res) => {
let resolved;
try { resolved = normalizeApiSymbol(req.params.symbol); }
catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
try {
let holders = null;
try { holders = await getStockHolders(resolved.symbol); } catch { /* optional */ }
const payload = await getNotableHolders(resolved.symbol, {
institutions: holders?.institutions,
force: req.query.fresh === '1',
});
res.json(payload);
} catch (err) {
console.error('[api/stock/notable-holders]', err?.message || err);
res.status(502).json({ error: 'notable_holders_failed', message: String(err?.message || err) });
}
});
app.get('/api/stock/:symbol/institutional-flow', async (req, res) => {
let resolved;
try { resolved = normalizeApiSymbol(req.params.symbol); }
catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
const days = Math.min(30, Math.max(5, parseInt(req.query.days, 10) || 12));
try {
if (resolved.market === 'tw') {
const flow = await getTwInstitutionalFlow(resolved.symbol, days);
if (!flow) return res.status(404).json({ error: 'no_tw_flow', message: '無台股法人資料' });
return res.json(flow);
}
const force = req.query.fresh === '1';
const [holders, analyst] = await Promise.all([
getStockHolders(resolved.symbol, { force }),
getAnalystConsensus(resolved.symbol).catch(() => null),
]);
res.json({
market: 'us',
symbol: resolved.symbol,
display: resolved.display,
breakdown: holders.breakdown,
institutions: holders.institutions?.slice(0, 12) || [],
funds: holders.funds?.slice(0, 8) || [],
etfHolders: holders.etfHolders?.slice(0, 20) || [],
etfCount: holders.etfCount,
days: [],
summary: holders.breakdown ? {
institutionsPct: holders.breakdown.institutionsPct,
insidersPct: holders.breakdown.insidersPct,
institutionsCount: holders.breakdown.institutionsCount,
} : null,
disclaimer: holders.disclaimer || '美股以機構/基金持股占比與大戶名單呈現;每日法人買賣超為台股專屬(請用 2330 等台股代號)。',
etfDisclaimer: holders.etfDisclaimer,
etfScanNote: holders.etfScanNote || null,
meta: holders.meta || null,
analyst,
cached: holders.cached,
fallback: holders.fallback,
partial: holders.partial,
source: holders.source,
});
} catch (err) {
console.error('[api/stock/institutional-flow]', err?.message || err);
res.status(502).json({ error: 'flow_failed', message: String(err?.message || err) });
}
});
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 symMeta = resolveEarningsSymbols(fromQuery);
const forceFresh = req.query.fresh === '1';
try {
const payload = await getCalendarPayload({
start, end, symbols: symMeta.symbols, symMeta, 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) });
}
});
function backtestNumQ(req, k) {
return (req.query[k] != null && req.query[k] !== '') ? Number(req.query[k]) : undefined;
}
function buildBacktestOpts(req, strategyId, runParams = {}) {
const strategy = strategyId || 'buyhold';
return {
strategy,
monthly: runParams.monthly ?? backtestNumQ(req, 'monthly'),
short: runParams.short ?? backtestNumQ(req, 'short'),
long: runParams.long ?? backtestNumQ(req, 'long'),
drop: runParams.drop ?? backtestNumQ(req, 'drop'),
fast: runParams.fast ?? backtestNumQ(req, 'fast'),
slow: runParams.slow ?? backtestNumQ(req, 'slow'),
period: runParams.period ?? backtestNumQ(req, 'period'),
buyBelow: runParams.buyBelow ?? backtestNumQ(req, 'buyBelow'),
sellAbove: runParams.sellAbove ?? backtestNumQ(req, 'sellAbove'),
minPct: runParams.minPct ?? backtestNumQ(req, 'minPct'),
holdDays: runParams.holdDays ?? backtestNumQ(req, 'holdDays'),
};
}
app.get('/api/strategies/engines', (_req, res) => {
res.json({ engines: listEngines() });
});
app.get('/api/strategies', (req, res) => {
try {
const includeDisabled = req.query.all === '1';
res.json({ strategies: listStrategies({ includeDisabled }) });
} catch (err) {
res.status(500).json({ error: 'strategies_failed', message: String(err?.message || err) });
}
});
app.post('/api/strategies/seed', (req, res) => {
try {
const out = seedStrategiesFromBook({ force: req.body?.force === true });
res.json({ ok: true, ...out, strategies: listStrategies({ includeDisabled: true }) });
} catch (err) {
res.status(500).json({ error: 'seed_failed', message: String(err?.message || err) });
}
});
app.post('/api/strategies', (req, res) => {
try {
const s = createStrategy(req.body || {});
res.status(201).json({ ok: true, strategy: s });
} catch (err) {
res.status(400).json({ error: 'create_failed', message: String(err?.message || err) });
}
});
app.put('/api/strategies/:id', (req, res) => {
try {
const s = updateStrategy(req.params.id, req.body || {});
if (!s) return res.status(404).json({ error: 'not_found', message: '找不到策略' });
res.json({ ok: true, strategy: s });
} catch (err) {
res.status(400).json({ error: 'update_failed', message: String(err?.message || err) });
}
});
app.delete('/api/strategies/:id', (req, res) => {
try {
const ok = deleteStrategy(req.params.id);
if (!ok) return res.status(404).json({ error: 'not_found', message: '找不到策略' });
res.json({ ok: true });
} catch (err) {
res.status(400).json({ error: 'delete_failed', message: String(err?.message || err) });
}
});
app.get('/api/backtest/:symbol', async (req, res) => {
let resolved;
try { resolved = normalizeApiSymbol(req.params.symbol); }
catch (e) { return res.status(400).json({ error: 'bad_symbol', message: String(e?.message || e) }); }
const symbol = resolved.symbol;
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';
let strategyMeta = null;
let strategy = STRATEGIES[req.query.strategy] ? req.query.strategy : 'buyhold';
let runParams = {};
if (req.query.strategyId) {
const resolvedStrat = resolveStrategyRun(req.query.strategyId, req.query);
if (!resolvedStrat) return res.status(404).json({ error: 'strategy_not_found', message: '找不到策略或已停用' });
if (resolvedStrat.error) return res.status(400).json({ error: resolvedStrat.error, message: '策略引擎不支援' });
strategy = resolvedStrat.engine;
runParams = resolvedStrat.runParams || {};
strategyMeta = {
id: resolvedStrat.id,
slug: resolvedStrat.slug,
name: resolvedStrat.name,
source: resolvedStrat.source,
formula: resolvedStrat.formula,
patternId: resolvedStrat.patternId,
bookRef: resolvedStrat.bookRef,
bookPage: resolvedStrat.bookPage,
};
}
try {
const h = await getHistoryCached(symbol, range, '1d', false);
const result = runBacktest(h.points, buildBacktestOpts(req, strategy, runParams));
res.json({
symbol, display: resolved.display, market: resolved.market, name: h.name, currency: h.currency,
range, cached: h.cached, strategyMeta, dataSource: 'yahoo_nasdaq_db',
...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) });
}
});
// ─── 交易復盤 ───
function parseAccountId(req) {
const raw = req.query?.accountId ?? req.body?.accountId ?? req.body?.account_id;
if (raw == null || raw === '') return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
app.get('/api/trades/accounts', (_req, res) => {
try { res.json({ accounts: listTradeAccounts() }); }
catch (e) { res.status(500).json({ error: String(e.message) }); }
});
app.get('/api/trades/portfolio-total', async (_req, res) => {
try {
res.json(await buildPortfolioTotal());
} catch (e) {
res.status(500).json({ error: 'portfolio_total_failed', message: String(e.message) });
}
});
app.post('/api/trades/accounts', (req, res) => {
try { res.status(201).json(createTradeAccount(req.body || {})); }
catch (e) { res.status(400).json({ error: String(e.message) }); }
});
app.put('/api/trades/accounts/:id/directive', (req, res) => {
try {
const id = Number(req.params.id);
const directive = String(req.body?.directive ?? '').trim();
const account = setAccountDirective(id, directive);
if (!account) return res.status(404).json({ error: 'not_found' });
if (directive) {
addTraderMemory(id, {
kind: 'directive',
title: '老闆指令',
body: directive,
meta: { source: 'user' },
});
}
res.json(account);
} catch (e) { res.status(400).json({ error: String(e.message) }); }
});
app.put('/api/trades/accounts/:id', (req, res) => {
try {
const row = updateTradeAccount(Number(req.params.id), req.body || {});
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(row);
} catch (e) { res.status(400).json({ error: String(e.message) }); }
});
app.get('/api/trades/accounts/:id/purge-stats', (req, res) => {
try {
const stats = getTradeAccountPurgeStats(Number(req.params.id));
if (!stats) return res.status(404).json({ error: 'not_found', message: '查無帳號' });
res.json(stats);
} catch (e) { res.status(500).json({ error: String(e.message), message: String(e.message) }); }
});
app.delete('/api/trades/accounts/:id', (req, res) => {
try {
const force = req.query?.force === '1' || req.query?.force === 'true' || !!req.body?.force;
if (!deleteTradeAccount(Number(req.params.id), { force })) {
return res.status(404).json({ error: 'not_found', message: '查無帳號' });
}
res.json({ ok: true });
} catch (e) {
const msg = String(e.message);
res.status(400).json({ error: msg, message: msg });
}
});
app.get('/api/trades/dashboard', async (req, res) => {
try {
const dash = await buildTradeDashboard(parseAccountId(req));
if (!dash) return res.status(404).json({ error: 'not_found' });
res.json(dash);
} catch (e) { res.status(500).json({ error: String(e.message) }); }
});
app.get('/api/trades/ai-providers', (_req, res) => {
res.json({ providers: listAIProviders() });
});
app.get('/api/trades/personality-templates', (_req, res) => {
try {
res.json({ templates: listTraderPersonalities() });
} catch (e) { res.status(500).json({ error: String(e.message) }); }
});
app.get('/api/trades/accounts/:id/trader-brief', async (req, res) => {
try {
const id = Number(req.params.id);
const account = getTradeAccount(id);
if (!account) return res.status(404).json({ error: 'not_found' });
const dash = account.kind === 'ai' ? await buildTradeDashboard(id) : null;
const followSignals = account.kind === 'ai' ? buildTodayFollowSignals(id, dash) : null;
const phase = getTraderPhase(account);
res.json({
account,
phase,
phaseLabel: traderPhaseLabel(phase),
marketStatus: marketStatusLabel(account.simulationMarket),
canTrade: phase === 'intraday' && isMarketOpenForAccount(account.simulationMarket),
brief: getTraderBrief(id),
followSignals,
operationalStatus: buildTraderOperationalStatus(id),
effectiveParams: account.kind === 'ai' ? buildTraderParameterSummary(id) : null,
});
} catch (e) { res.status(500).json({ error: String(e.message) }); }
});
app.post('/api/trades/accounts/:id/daily-takeaway', async (req, res) => {
try {
const takeaway = await refreshDailyTakeaway(Number(req.params.id));
res.json({ ok: true, takeaway });
} catch (e) {
const msg = String(e?.message || e);
res.status(400).json({ error: 'takeaway_failed', message: msg });
}
});
app.get('/api/trades/accounts/:id/trader-chat', (req, res) => {
const account = getTradeAccount(Number(req.params.id));
if (!account) return res.status(404).json({ error: 'not_found' });
res.json({ accountId: account.id, ...getTraderChat(account.id) });
});
app.put('/api/trades/accounts/:id/trader-chat', (req, res) => {
const account = getTradeAccount(Number(req.params.id));
if (!account) return res.status(404).json({ error: 'not_found' });
const saved = saveTraderChat(account.id, req.body?.messages || []);
res.json({ ok: true, ...saved });
});
app.delete('/api/trades/accounts/:id/trader-chat', (req, res) => {
const account = getTradeAccount(Number(req.params.id));
if (!account) return res.status(404).json({ error: 'not_found' });
res.json({ ok: true, ...clearTraderChat(account.id) });
});
app.post('/api/trades/accounts/:id/simulate', async (req, res) => {
try {
const id = Number(req.params.id);
const force = !!req.body?.force;
const phase = String(req.body?.phase || req.query?.phase || 'auto').trim();
const deps = mcpDeps();
let result;
if (phase === 'pre_market' || phase === 'analyze') result = await runPreMarketResearch(id, { force, deps });
else if (phase === 'industry_report') result = await runDailyIndustryResearch(id, { force });
else if (phase === 'post_market') result = await runPostMarketReview(id, { force });
else if (phase === 'intraday_review') result = await runIntradayReview(id, { force });
else if (phase === 'intraday') result = await runAiSimulation(id, { force, phase: 'intraday' });
else result = await runAiSimulation(id, { force, phase: 'auto', deps });
res.json(result);
} catch (e) {
const msg = String(e?.message || e);
res.status(400).json({ error: 'simulate_failed', message: msg });
}
});
app.get('/api/trades/accounts/:id/simulation-runs', (req, res) => {
try {
const limit = Math.min(50, Math.max(1, Number(req.query?.limit) || 20));
const runs = listAccountSimulationRuns(Number(req.params.id), limit);
const account = getTradeAccount(Number(req.params.id));
res.json({
runs,
marketStatus: account ? marketStatusLabel(account.simulationMarket) : null,
});
} catch (e) { res.status(500).json({ error: String(e.message) }); }
});
app.get('/api/trades', (req, res) => res.json({ trades: listTrades(parseAccountId(req)) }));
app.get('/api/trades/stats', (req, res) => res.json(tradeStats(parseAccountId(req))));
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' || view === 'hub' || view === 'market') {
const m = ctx.macro;
hasPageData = !!(m && (m.score != null || m.focusedCard || (m.signals && m.signals.length)))
|| !!(ctx.weathervane?.similar)
|| !!(ctx.sectors?.leaders?.length)
|| !!(ctx.calendar?.events?.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 || ctx.journal?.dashboard);
} else if (view === 'learn' || view === 'skills') {
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),
};
if (focus?.subPage === 'backtest') {
try {
out.strategies = listStrategies().slice(0, 24);
out.strategyEngines = listEngines().map(e => ({ id: e.id, label: e.label }));
} catch { /* ignore */ }
}
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' || view === 'hub' || view === 'market') {
let saved = loadPayload();
if (!saved && allowFetch) {
const payload = await refreshAndCache();
saved = { payload, updatedAt: Date.now() };
}
base.macro = summarizeMacro(saved?.payload || cache.payload, focus);
try {
const wv = getWeathervane(String(focus.window || '10y'));
base.weathervane = {
window: wv.window,
asOf: wv.asOf,
similar: wv.similar ? {
date: wv.similar.date,
distance: wv.similar.distance,
regimeLabel: wv.similar.regimeLabel,
note: wv.similar.note,
whySimilar: wv.similar.detail?.whySimilar,
} : null,
indicators: (wv.indicators || []).slice(0, 10).map(i => ({
key: i.key, label: i.label, valueText: i.valueText, pct: i.pct,
})),
};
} catch (_) { /* 風向標可缺 */ }
if (view === 'market' && focus.tab === 'flow') {
const secEntry = getCachedEntry('sectors:flow:v1');
if (secEntry?.value) {
base.sectors = {
rotation: secEntry.value.rotation,
leaders: (secEntry.value.sectors || []).filter(s => !s.error).slice(0, 12).map(s => ({
etf: s.etf, nameZh: s.nameZh, ret5d: s.ret5d, quadrant: s.quadrant?.labelZh,
})),
};
}
}
if (view === 'market' && focus.tab === 'calendar') {
const today = new Date().toISOString().slice(0, 10);
const end = new Date(Date.now() + 45 * 86400000).toISOString().slice(0, 10);
const baseEntry = getCachedEntry(`calendar:base:v5:${today}`);
if (baseEntry?.value?.events) {
base.calendar = {
events: baseEntry.value.events.filter(e => e.date >= today && e.date <= end).slice(0, 40),
};
}
}
base.pageMeta = { app: 'investor-rpg', route: view, tab: focus.tab || null, card: focus.cardTitle || null };
} 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 symMeta = resolveEarningsSymbols([]);
const symbols = symMeta.symbols;
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,
portfolioSymbols: symMeta.portfolioSymbols,
events,
sources: baseEntry.value.sources || [],
};
} else {
base.calendar = allowFetch
? await getCalendarPayload({ start: today, end, symbols, symMeta, forceFresh: false })
.then(d => ({ ...d, events: (d.events || []).slice(0, 80) }))
.catch(e => ({ error: String(e?.message || e), watchlist: symbols, portfolioSymbols: symMeta.portfolioSymbols }))
: { cached: false, watchlist: symbols, portfolioSymbols: symMeta.portfolioSymbols, events: [] };
}
} else if (view === 'journal') {
const accountId = Number(client.accountId) || null;
const journalCtx = await buildJournalAiContext(accountId).catch(() => null);
const traderBrief = accountId && client.traderMode ? getTraderBrief(accountId) : null;
base.journal = {
...(journalCtx || {
stats: tradeStats(accountId),
trades: listTrades(accountId).slice(0, 80),
}),
traderBrief,
traderMode: !!client.traderMode,
};
} 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 || [],
};
} else if (view === 'skills') {
const k = getKnowledge();
const pid = String(focus.principleId || focus.id || '').trim();
let principle = pid ? (k?.principles || []).find(p => p.id === pid) : null;
if (!principle && pid) {
principle = (k?.principles || []).find(p => p.title === pid || p.id.includes(pid) || pid.includes(p.id));
}
let noteBody = principle ? String(principle.body || '').slice(0, 10000) : '';
if (!noteBody && pid) {
const fromNote = getNote('principle', pid) || getNote('principle', principle?.id || '');
if (fromNote?.body) noteBody = String(fromNote.body).slice(0, 10000);
}
if (!noteBody && focus.cardTitle) {
const fromTitle = getNote('principle', focus.cardTitle);
if (fromTitle?.body) noteBody = String(fromTitle.body).slice(0, 10000);
}
const noteTitle = principle?.title || focus.cardTitle || pid || '心法卡';
const note = (principle || noteBody || pid)
? { kind: 'principle', id: principle?.id || pid, title: noteTitle, body: noteBody || String(client.visibleText || '').slice(0, 10000) }
: null;
base.learning = {
focusedNote: note,
visibleText: String(client.visibleText || '').slice(0, 12000),
principleCount: (k?.principles || []).length,
principleMapTitle: k?.principleMap?.title || '心法地圖',
};
base.pageMeta = { app: 'investor-rpg', route: 'skills', card: focus.cardTitle || null, principleId: pid || null };
}
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.get('/api/ai/status', (req, res) => {
const active = String(process.env.AI_ACTIVE_PROVIDER || 'grok').trim();
const providers = Object.entries(AI_PROVIDERS).map(([id, p]) => {
const hasKey = !!String(process.env[p.keyEnv] || '').trim();
const model = String(process.env[p.modelEnv] || '').trim();
return { id, label: p.label, hasKey, model, active: id === active };
});
res.json({ active, providers, ready: providers.some(p => p.hasKey) });
});
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) });
}
});
function buildChatSystemPrompt(context) {
const hasPageData = context.hasPageData === true;
const agentMd = loadAgentMd();
const mcpSection = buildMcpPromptSection();
const pageRules = hasPageData
? [
'## 本輪對話模式:頁面問答',
'使用者正在 App 某個頁面提問,並附上該頁可取得的結構化資料(可能不完整)。',
'請優先根據附帶 JSON 回答;資料未提及的不要捏造。',
'若資料不足,說明缺什麼、建議在畫面上查看哪裡。',
].join('\n')
: [
'## 本輪對話模式:一般聊天',
'沒有附帶頁面結構化資料。需要總經、市場、日曆等資料時,請建議使用者切到對應頁面後再問。',
].join('\n');
return [agentMd, pageRules, mcpSection].filter(Boolean).join('\n\n');
}
app.get('/api/ai/skills', (req, res) => {
const pathname = String(req.query?.pathname || '/');
const focus = {
...(req.query?.cardTitle ? { cardTitle: String(req.query.cardTitle) } : {}),
...(req.query?.label ? { label: String(req.query.label) } : {}),
...(req.query?.principleId ? { principleId: String(req.query.principleId) } : {}),
};
res.json({ pathname, focus, skills: skillsForContext({ pathname, focus }) });
});
app.get('/api/settings/ai-config', (req, res) => {
res.json(loadAIConfigBundle());
});
app.post('/api/settings/ai-config', (req, res) => {
try {
const body = req.body || {};
const bundle = saveAIConfigBundle({
agentMd: body.agentMd,
mcp: body.mcp,
});
res.json({ ok: true, ...bundle });
} catch (err) {
res.status(500).json({ error: 'save_failed', message: String(err?.message || err) });
}
});
app.post('/api/settings/ai-config/reset', (req, res) => {
try {
saveAIConfigBundle({
agentMd: DEFAULT_AGENT_MD,
mcp: DEFAULT_MCP,
});
resetSkillsLibrary();
res.json({ ok: true, ...loadAIConfigBundle() });
} catch (err) {
res.status(500).json({ error: 'reset_failed', message: String(err?.message || err) });
}
});
app.get('/api/settings/skills', (req, res) => {
try {
res.json(listSkillLibrary());
} catch (err) {
res.status(500).json({ error: 'skills_failed', message: String(err?.message || err) });
}
});
app.post('/api/settings/skills/preview', (req, res) => {
try {
const paste = String(req.body?.paste || req.body?.text || '');
const skills = previewSkillPaste(paste);
res.json({ ok: true, skills });
} catch (err) {
res.status(400).json({ error: 'parse_failed', message: String(err?.message || err) });
}
});
app.post('/api/settings/skills/install', (req, res) => {
try {
const body = req.body || {};
if (body.paste) {
const result = installFromPaste(body.paste);
return res.json({ ok: true, ...result });
}
const result = installCustomSkill(body);
res.json({ ok: true, ...result });
} catch (err) {
res.status(400).json({ error: 'install_failed', message: String(err?.message || err) });
}
});
app.post('/api/settings/skills/toggle', (req, res) => {
try {
const { id, enabled } = req.body || {};
if (!id) return res.status(400).json({ error: 'missing_id', message: '缺少技能 id' });
const library = setSkillEnabled(String(id), enabled !== false);
res.json({ ok: true, library });
} catch (err) {
res.status(500).json({ error: 'toggle_failed', message: String(err?.message || err) });
}
});
app.delete('/api/settings/skills/:id', (req, res) => {
try {
const library = deleteCustomSkill(req.params.id);
res.json({ ok: true, library });
} catch (err) {
res.status(400).json({ error: 'delete_failed', message: String(err?.message || err) });
}
});
app.get('/api/mcp/status', async (req, res) => {
try {
const catalog = buildMcpCatalog();
const { enabled } = loadMcpConfig();
const probe = String(req.query?.probe || '') === '1';
const servers = [];
for (const item of catalog) {
const entry = { ...item, enabled: enabled.includes(item.id) };
if (probe && entry.enabled && !entry.blocked && entry.needsKey && !entry.hasKey) {
entry.status = 'needs_key';
} else if (probe && entry.enabled && item.id !== 'macroscope') {
const listed = await listMcpTools(item.id).catch((e) => ({ ok: false, message: String(e?.message || e) }));
entry.status = listed.ok ? 'ok' : 'error';
entry.toolCount = listed.tools?.length ?? entry.toolCount;
if (!listed.ok) entry.error = listed.message || listed.error;
} else if (probe && entry.enabled && item.id === 'macroscope') {
entry.status = 'ok';
} else {
entry.status = entry.enabled ? 'ready' : 'off';
}
servers.push(entry);
}
res.json({ servers, enabled, projectMcp: path.join(__dirname, '..', '.mcp.json') });
} catch (err) {
res.status(500).json({ error: 'mcp_status_failed', message: String(err?.message || err) });
}
});
app.post('/api/mcp/invoke', async (req, res) => {
const serverId = String(req.body?.serverId || '').trim();
const tool = String(req.body?.tool || '').trim();
const args = req.body?.args || {};
if (!serverId || !tool) {
return res.status(400).json({ error: 'bad_request', message: '需要 serverId 與 tool。' });
}
try {
const result = await invokeMcpTool(serverId, tool, args, mcpDeps());
res.json(result);
} catch (err) {
res.status(502).json({ error: 'mcp_invoke_failed', message: String(err?.message || err) });
}
});
app.get('/api/ai/debug', (req, res) => {
try {
const limit = Math.min(200, Math.max(1, Number(req.query?.limit) || 50));
res.json({
...getAiDebugStatus(),
usageSummary: getAiDebugUsageSummary(),
logs: listAiDebugLogs({ limit, source: req.query?.source, accountId: req.query?.accountId }),
events: listAiTraderEvents({ limit: Math.max(200, limit), accountId: req.query?.accountId }),
});
} catch (e) { res.status(500).json({ error: 'debug_read_failed', message: String(e?.message || e) }); }
});
app.put('/api/ai/debug', (req, res) => {
try {
res.json({ ok: true, ...setAiDebugEnabled(!!req.body?.enabled) });
} catch (e) { res.status(500).json({ error: 'debug_update_failed', message: String(e?.message || e) }); }
});
app.delete('/api/ai/debug', (_req, res) => {
try {
res.json({ ok: true, ...clearAiDebugLogs() });
} catch (e) { res.status(500).json({ error: 'debug_clear_failed', message: String(e?.message || e) }); }
});
function chatDebugMeta(context = {}) {
const client = context.client || {};
let source = 'owl_guide';
if (client.traderMode) source = 'ai_trader_chat';
else if (context.view === 'skills') source = 'owl_coach';
return { source, accountId: client.accountId ?? null };
}
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;
let mcpBundle = { calls: [], hasLiveData: false };
try {
mcpBundle = await gatherMcpContext(question, context, mcpDeps());
} catch (e) {
mcpBundle = { calls: [], hasLiveData: false, error: String(e?.message || e) };
}
const system = buildChatSystemPrompt(context);
const skillHint = req.body?.skillId
? `\n(使用者透過技能快捷「${req.body.skillId}」發問)`
: '';
const mcpBlock = compactMcpForPrompt(mcpBundle);
const userParts = [`使用者問題:${question}${skillHint}`];
if (hasPageData) {
userParts.push('', '目前頁面上下文JSON', compactForPrompt(context));
} else {
userParts.push('', `目前所在視圖:${context.view || '(未知)'}(無可用頁面資料,請當一般對話)`);
}
if (mcpBlock) {
userParts.push('', 'MCP 即時資料(後端真實呼叫,請優先引用):', mcpBlock);
} else if (mcpBundle.error) {
userParts.push('', `MCP 擷取失敗:${mcpBundle.error}`);
}
const user = userParts.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) {
const error = data?.error?.message || data?.message || `${provider.label} 回傳 ${r.status}`;
recordAiDebug({
...chatDebugMeta(context), provider: providerId, model, endpoint: provider.endpoint,
status: 'error', request: body, response: data, error,
});
return res.status(502).json({
error: 'provider_failed',
message: error,
detail: data,
});
}
const text = normalizeAIText(data, provider.mode);
recordAiDebug({
...chatDebugMeta(context), provider: providerId, model, endpoint: provider.endpoint,
status: 'ok', request: body, response: { id: data.id, usage: data.usage, text },
});
res.json({
provider: providerId,
model,
text,
mcp: { hasLiveData: mcpBundle.hasLiveData, summary: mcpBundle.summary, calls: mcpBundle.calls?.map((c) => ({ server: c.serverId, tool: c.tool, ok: c.ok })) },
raw: { id: data.id, usage: data.usage },
});
} catch (err) {
recordAiDebug({
...chatDebugMeta(context), provider: providerId, model, endpoint: provider.endpoint,
status: 'error', request: { system, user }, error: String(err?.message || err),
});
res.status(502).json({ error: 'ai_failed', message: String(err?.message || err) });
}
});
const SKILL_ASSESS_RUBRIC = `你是投資教學教練「金幣貓頭鷹」。使用者完成三關心法試煉,請依 rubric 評分(教學用,非投資建議)。
評分維度(總分 100
- mechanism 0-25是否理解通用機制而非只背案例
- trigger 0-25能否辨識觸發訊號、未混淆其他心法
- scenario 0-30歷史情境與 K 線走勢下的動作與理由是否合理
- discipline 0-20風險/倉位/停損/觀望意識
致命錯誤 fatalErrors若有則總分不得超過 60逆勢滿倉梭哈、完全無風險意識、把原則用反、鼓勵違法/內線行為。
精通門檻score >= 95 且 fatalErrors 為空陣列。
請「只」回傳一個 JSON 程式碼區塊,格式如下(不要其他廢話):
\`\`\`skill-assess
{
"score": 87,
"fatalErrors": [],
"breakdown": { "mechanism": 22, "trigger": 20, "scenario": 25, "discipline": 20 },
"feedback": "2-4 句總評",
"weakPoints": ["扣分項1", "扣分項2"],
"nextDrill": "建議補練方向"
}
\`\`\``;
function parseSkillAssessBlock(text) {
const raw = String(text || '');
const fence = raw.match(/```skill-assess\s*([\s\S]*?)```/i);
const jsonStr = fence ? fence[1].trim() : raw.trim();
try {
const j = JSON.parse(jsonStr);
let score = Math.round(Number(j.score));
if (!Number.isFinite(score)) return null;
score = Math.max(0, Math.min(100, score));
const fatalErrors = Array.isArray(j.fatalErrors) ? j.fatalErrors.map(String) : [];
if (fatalErrors.length) score = Math.min(score, 60);
const breakdown = j.breakdown || {};
return {
score,
mastered: score >= 95 && fatalErrors.length === 0,
fatalErrors,
breakdown: {
mechanism: Number(breakdown.mechanism) || 0,
trigger: Number(breakdown.trigger) || 0,
scenario: Number(breakdown.scenario) || 0,
discipline: Number(breakdown.discipline) || 0,
},
feedback: String(j.feedback || '').trim(),
weakPoints: Array.isArray(j.weakPoints) ? j.weakPoints.map(String) : [],
nextDrill: j.nextDrill ? String(j.nextDrill) : '',
};
} catch {
return null;
}
}
app.post('/api/ai/skill-assess', 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 drillAnswers = String(req.body?.drillAnswers || '').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 (!drillAnswers) return res.status(400).json({ error: 'missing_answers', message: '缺少試煉作答。' });
const system = [buildChatSystemPrompt(context), SKILL_ASSESS_RUBRIC].join('\n\n');
const userParts = [
'請評分以下心法試煉作答:',
'',
drillAnswers,
'',
'目前頁面上下文JSON',
compactForPrompt(context),
];
const user = userParts.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.15 };
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) {
const error = data?.error?.message || data?.message || `${provider.label} 回傳 ${r.status}`;
recordAiDebug({
source: 'owl_skill_assess', provider: providerId, model, endpoint: provider.endpoint,
status: 'error', request: body, response: data, error,
});
return res.status(502).json({
error: 'provider_failed',
message: error,
detail: data,
});
}
const text = normalizeAIText(data, provider.mode);
recordAiDebug({
source: 'owl_skill_assess', provider: providerId, model, endpoint: provider.endpoint,
status: 'ok', request: body, response: { id: data.id, usage: data.usage, text },
});
const assessment = parseSkillAssessBlock(text);
if (!assessment) {
return res.status(502).json({
error: 'parse_failed',
message: '教練評分格式解析失敗,請重試。',
text,
});
}
res.json({
provider: providerId,
model,
text: assessment.feedback || text,
assessment,
});
} catch (err) {
recordAiDebug({
source: 'owl_skill_assess', provider: providerId, model, endpoint: provider.endpoint,
status: 'error', request: { system, user }, error: String(err?.message || err),
});
res.status(502).json({ error: 'ai_failed', message: String(err?.message || err) });
}
});
app.get('/api/settings/data-sources', (req, res) => {
res.json(getDataSourcesPayload());
});
app.get('/api/settings/env', (req, res) => {
const env = { ...readEnvFile(), ...process.env };
res.json({
envPath: ENV_PATH,
groups: GROUP_LABELS,
fields: SETTINGS_FIELDS.map(f => ({
...f,
value: f.type === 'secret' ? '' : (env[f.key] || ''),
hasValue: !!String(env[f.key] || '').trim(),
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() }));
// 《短線交易日線圖大全》教材卷軸MD → HTML含 content/raw/patterns 後備)
registerPatternBookRoutes(app);
const DIST_DIR = path.join(__dirname, 'dist');
app.use(express.static(DIST_DIR));
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/book-patterns/')) return next();
res.sendFile(path.join(DIST_DIR, 'index.html'));
});
app.listen(PORT, HOST, () => {
ensureAIConfigDefaults();
console.log(`\nMacroScope 已啟動 → http://${HOST}:${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));
try {
const seed = seedStrategiesFromBook();
if (seed.seeded) console.log(`回測策略庫已 seed ${seed.seeded} 筆(共 ${seed.total})。`);
} catch (err) {
console.warn('[strategy seed]', err?.message || err);
}
// AI 紙上交易模擬排程(每分鐘檢查,符合間隔且盤中才執行)
const simDeps = mcpDeps();
setInterval(() => tickAiSimulations(simDeps), 60_000);
setTimeout(() => tickAiSimulations(simDeps), 15_000);
console.log('AI 交易員排程已啟動:交易日跑盤前/盤中/盤後;每日產業研究包含週末與休市日。');
});