import "dotenv/config"; import cors from "cors"; import express from "express"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { generateDailyReport } from "../agent/report"; import { createProvider } from "../agent/provider"; import { normalizeSymbol } from "../agent/symbols"; import { StockDataCache } from "../agent/stockDataCache"; import { WatchlistStore } from "../agent/watchlistStore"; import { loadIpadsConfig } from "./ipadsConfig"; import type { ChartPeriod, WatchStock } from "../shared/types"; import type { BacktestConfig } from "../shared/strategyTypes"; import { listStrategies, runBacktest, getCachedResult, getLiveStrategies } from "./strategyService"; const port = Number(process.env.PORT ?? 8787); const host = process.env.HOST ?? "127.0.0.1"; const dataDir = process.env.STOCK_AGENT_DATA_DIR ?? join(process.cwd(), "data"); const watchlistStore = new WatchlistStore(join(dataDir, "watchlist.json")); const dataProvider = createProvider(loadIpadsConfig().apiKey); const stockDataCache = new StockDataCache(join(dataDir, "cache"), dataProvider); const app = express(); let cachedReportUpdatedAt = 0; let cachedReport: Awaited> | undefined; app.use(cors()); app.use(express.json()); app.get("/api/health", async (_request, response) => { const config = loadIpadsConfig(); response.json({ ok: true, providerConfigured: config.apiKeyConfigured, baseUrl: config.baseUrl, wireApi: config.wireApi, scheduler: "24h" }); }); app.get("/api/watchlist", async (_request, response) => { response.json(await watchlistStore.list()); }); app.post("/api/watchlist", async (request, response, next) => { try { response.status(201).json(await watchlistStore.add(String(request.body?.symbol ?? ""), request.body?.name, parseTags(request.body?.tags))); cachedReport = undefined; } catch (error) { next(error); } }); app.put("/api/watchlist/:symbol/tags", async (request, response, next) => { try { const updated = await watchlistStore.updateTags(request.params.symbol, parseTags(request.body?.tags)); cachedReport = undefined; response.json(updated); } catch (error) { next(error); } }); app.delete("/api/watchlist/:symbol", async (request, response, next) => { try { await watchlistStore.remove(request.params.symbol); cachedReport = undefined; response.status(204).end(); } catch (error) { next(error); } }); app.get("/api/stocks/:symbol/chart", async (request, response, next) => { try { const stock = await findWatchStock(request.params.symbol); response.json( request.query.refresh === "1" ? await stockDataCache.refreshChart(stock, parsePeriod(request.query.period)) : await stockDataCache.chart(stock, parsePeriod(request.query.period)) ); } catch (error) { next(error); } }); app.get("/api/stocks/:symbol/fundamentals", async (request, response, next) => { try { response.json(await stockDataCache.fundamentals(await findWatchStock(request.params.symbol), request.query.refresh === "1")); } catch (error) { next(error); } }); app.get("/api/stocks/:symbol/related", async (request, response, next) => { try { response.json(await stockDataCache.relatedData(await findWatchStock(request.params.symbol), request.query.refresh === "1")); } catch (error) { next(error); } }); app.get("/api/stocks/:symbol/analysis", async (request, response, next) => { try { const stock = await findWatchStock(request.params.symbol); if (request.query.refresh !== "1" && (await stockDataCache.hasAnalysis(stock))) { const analysis = await stockDataCache.quantAnalysis(stock); if (await stockDataCache.analysisNeedsRefresh(stock)) { void stockDataCache.quantAnalysis(stock, true).catch((error) => { console.error(`background analysis refresh failed for ${stock.display}`, error); }); } response.json(analysis); return; } if (request.query.refresh !== "1") { void stockDataCache.quantAnalysis(stock, true).catch((error) => { console.error(`background analysis generation failed for ${stock.display}`, error); }); response.json(stockDataCache.placeholderAnalysis(stock)); return; } response.json(await stockDataCache.quantAnalysis(stock, request.query.refresh === "1")); } catch (error) { next(error); } }); app.get("/api/report/daily", async (_request, response, next) => { try { response.json(await getDailyReport()); } catch (error) { next(error); } }); app.post("/api/report/daily/refresh", async (_request, response, next) => { try { cachedReport = undefined; response.json(await getDailyReport(true)); } catch (error) { next(error); } }); // --------------------------------------------------------------------------- // Strategy / Quant endpoints // --------------------------------------------------------------------------- app.get("/api/strategies", async (_request, response, next) => { try { response.json(await listStrategies()); } catch (error) { next(error); } }); function loadPrecomputedFile() { const precomputedPath = join(dataDir, "precomputed", "backtest_results.json"); if (!existsSync(precomputedPath)) { throw new Error("Pre-computed results not available. Run precompute_backtest.py first."); } return JSON.parse(readFileSync(precomputedPath, "utf-8")); } // Newer files expose `scenarios`; legacy files expose `markets` at the top level. function resolveScenarios(raw: { scenarios?: Record; markets?: Record }) { if (raw.scenarios && typeof raw.scenarios === "object") { return raw.scenarios as Record; injectionSchedule: unknown; markets: Record; }>; } // Backward compat — wrap legacy single-scenario file as one anonymous scenario. if (raw.markets) { return { legacy: { id: "legacy", label: "默认", capital: 100000, injectionSchedule: null, markets: raw.markets, }, } as Record; injectionSchedule: unknown; markets: Record; }>; } throw new Error("Pre-computed file is missing both `scenarios` and `markets`."); } app.get("/api/strategies/precomputed/index", (_request, response, next) => { try { const raw = loadPrecomputedFile(); const scenarios = resolveScenarios(raw); const list = Object.values(scenarios).map((sc) => ({ id: sc.id, label: sc.label, description: sc.description, capital: sc.capital, capitalByMarket: sc.capitalByMarket, injectionSchedule: sc.injectionSchedule ?? null, availableMarkets: Object.keys(sc.markets ?? {}), })); response.json({ computedAt: raw.computedAt, scenarios: list }); } catch (error) { next(error); } }); app.get("/api/strategies/precomputed", (_request, response, next) => { try { const market = String(_request.query.market ?? "us"); const requestedScenario = _request.query.scenario ? String(_request.query.scenario) : undefined; const raw = loadPrecomputedFile(); const scenarios = resolveScenarios(raw); const scenarioIds = Object.keys(scenarios); // An explicit but unknown scenario is a client error; silently serving // another scenario would mislabel the results. if (requestedScenario && !Object.hasOwn(scenarios, requestedScenario)) { throw new Error(`Unknown pre-computed scenario: ${requestedScenario}`); } const scenarioId = requestedScenario ?? scenarioIds[0]; const scenario = scenarios[scenarioId]; if (!scenario) { throw new Error("No pre-computed scenarios available."); } const marketResult = scenario.markets?.[market]; if (!marketResult) { throw new Error(`No pre-computed results for market: ${market} in scenario ${scenarioId}`); } response.json({ id: `precomputed_${scenarioId}_${market}`, scenarioId, computedAt: raw.computedAt, ...(marketResult as object), }); } catch (error) { next(error); } }); app.post("/api/strategies/backtest", async (request, response, next) => { try { const config = request.body as BacktestConfig; if (!config.strategies?.length) throw new Error("strategies is required"); if (!config.market) throw new Error("market is required"); const result = await runBacktest({ strategies: config.strategies, market: config.market, years: config.years ?? 10, initialCapital: config.initialCapital ?? 100000, fixedFee: config.fixedFee ?? 2.0, txCost: config.txCost ?? 0.001, injectionSchedule: config.injectionSchedule, }); response.json(result); } catch (error) { next(error); } }); app.get("/api/strategies/results/:id", async (request, response, next) => { try { const result = await getCachedResult(request.params.id); if (!result) throw new Error("Result not found"); response.json(result); } catch (error) { next(error); } }); app.get("/api/strategies/live", async (request, response, next) => { try { const market = String(request.query.market ?? "us"); const strategy = request.query.strategy ? String(request.query.strategy) : undefined; response.json(await getLiveStrategies(market, strategy)); } catch (error) { next(error); } }); app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => { const message = error instanceof Error ? error.message : "Unknown server error"; response.status(400).json({ error: message }); }); setInterval(() => { void getDailyReport().catch((error) => { console.error("daily report refresh failed", error); }); }, 24 * 60 * 60 * 1000).unref(); app.listen(port, host, () => { console.log(`stock agent api listening at http://${host}:${port}`); }); async function getDailyReport(forceSnapshots = false) { const now = Date.now(); if (!forceSnapshots && cachedReport && now - cachedReportUpdatedAt < 24 * 60 * 60 * 1000) { return cachedReport; } const watchlist = await watchlistStore.list(); cachedReport = await generateDailyReport( watchlist, { snapshot: (stock) => stockDataCache.snapshot(stock, forceSnapshots), candles: (stock) => dataProvider.candles(stock), fundamentals: (stock) => dataProvider.fundamentals(stock), relatedData: (stock) => dataProvider.relatedData(stock), quantAnalysis: (input) => dataProvider.quantAnalysis(input) }, new Date() ); cachedReportUpdatedAt = now; return cachedReport; } async function findWatchStock(symbolInput: string): Promise { const normalized = normalizeSymbol(symbolInput); const stock = (await watchlistStore.list()).find((item) => item.display === normalized.display); if (!stock) { throw new Error("Stock is not in watchlist"); } return stock; } function parseTags(value: unknown): string[] { if (Array.isArray(value)) { return value.map(String); } if (typeof value === "string") { return value.split(","); } return []; } function parsePeriod(value: unknown): ChartPeriod { return value === "W" || value === "M" || value === "Y" ? value : "D"; }