// ═══════════════════════════════════════════════════════════ // MacroScope 伺服器 // - 對外提供 dist/(React 前端) // - 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, 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 分,重大衝擊可加速至 5–8 分。原始即時分保留供比較。', 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('正在背景抓取最新資料(首次約需 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)); 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 交易員排程已啟動:交易日跑盤前/盤中/盤後;每日產業研究包含週末與休市日。'); });