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:
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user