- 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>
353 lines
11 KiB
TypeScript
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";
|
|
}
|