feat(ui): split client into Live and Strategy pages and wire new routes

Rebuilds the client into Live (watchlist with charts, fundamentals, news,
quant analysis) and Strategy (backtest runner / precomputed results) pages,
adds the corresponding /api/stocks/:symbol/chart|fundamentals|related|quant
and /api/strategies endpoints, and updates project tooling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 23:21:03 +08:00
parent f54041646f
commit 5716267309
10 changed files with 5187 additions and 296 deletions

View File

@@ -1,16 +1,25 @@
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;
@@ -36,13 +45,23 @@ app.get("/api/watchlist", async (_request, response) => {
app.post("/api/watchlist", async (request, response, next) => {
try {
response.status(201).json(await watchlistStore.add(String(request.body?.symbol ?? ""), request.body?.name));
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);
@@ -53,6 +72,61 @@ app.delete("/api/watchlist/:symbol", async (request, response, next) => {
}
});
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());
@@ -64,7 +138,148 @@ app.get("/api/report/daily", async (_request, response, next) => {
app.post("/api/report/daily/refresh", async (_request, response, next) => {
try {
cachedReport = undefined;
response.json(await getDailyReport());
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;
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;
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,
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);
const scenarioId = requestedScenario && scenarios[requestedScenario]
? 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);
}
@@ -81,18 +296,51 @@ setInterval(() => {
});
}, 24 * 60 * 60 * 1000).unref();
app.listen(port, "127.0.0.1", () => {
console.log(`stock agent api listening at http://127.0.0.1:${port}`);
app.listen(port, host, () => {
console.log(`stock agent api listening at http://${host}:${port}`);
});
async function getDailyReport() {
async function getDailyReport(forceSnapshots = false) {
const now = Date.now();
if (cachedReport && now - cachedReportUpdatedAt < 24 * 60 * 60 * 1000) {
if (!forceSnapshots && cachedReport && now - cachedReportUpdatedAt < 24 * 60 * 60 * 1000) {
return cachedReport;
}
const watchlist = await watchlistStore.list();
cachedReport = await generateDailyReport(watchlist, createProvider(loadIpadsConfig().apiKey), new Date());
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";
}