Files
quant-trading/web/src/server/index.ts
Gahow Wang eefd879a93 fix(strategy-ui): correct rankings, currency, injection view and staleness
- MaxDD ranks shallower drawdowns first (values are negative); ruined
  results are badged and never ranked as best.
- Metrics render '—' for null/non-finite values instead of crashing.
- Amounts use the market's currency (CNY for CN) instead of hard-coded USD.
- Injection scenarios show invested / final value / profit / return on
  invested from capitalFlows and label return metrics as time-weighted.
- Precomputed results show their data end date and warn when older than 7
  days; live strategies warn when not updated for 4 days, show start date,
  price as-of, stale holdings, and same-window benchmark with excess return.
- Unknown precomputed scenario returns 400 instead of silently serving the
  first scenario; the client uses the scenarioId it receives.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 01:22:07 +08:00

353 lines
11 KiB
TypeScript

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<ReturnType<typeof generateDailyReport>> | 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<string, unknown>; markets?: Record<string, unknown> }) {
if (raw.scenarios && typeof raw.scenarios === "object") {
return raw.scenarios as Record<string, {
id: string;
label: string;
description?: string;
capital: number;
capitalByMarket?: Record<string, number>;
injectionSchedule: unknown;
markets: Record<string, unknown>;
}>;
}
// 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<string, {
id: string;
label: string;
description?: string;
capital: number;
capitalByMarket?: Record<string, number>;
injectionSchedule: unknown;
markets: Record<string, unknown>;
}>;
}
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<WatchStock> {
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";
}