From 7350d6286c6926fc046c7693927d849897571896 Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Tue, 19 May 2026 23:21:03 +0800 Subject: [PATCH] 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 --- README.md | 24 + index.html | 2 +- package-lock.json | 16 + package.json | 3 +- src/client/LivePage.tsx | 606 +++++++++ src/client/StrategyPage.tsx | 850 ++++++++++++ src/client/main.tsx | 1204 +++++++++++++++-- src/client/styles.css | 2507 ++++++++++++++++++++++++++++++++--- src/server/index.ts | 262 +++- vite.config.ts | 9 +- 10 files changed, 5187 insertions(+), 296 deletions(-) create mode 100644 src/client/LivePage.tsx create mode 100644 src/client/StrategyPage.tsx diff --git a/README.md b/README.md index 3a69b83..5ebc818 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,14 @@ Frontend: http://127.0.0.1:5173 Backend: http://127.0.0.1:8787 +LAN access: + +```bash +HOST=0.0.0.0 CLIENT_PORT=5174 PORT=8787 npm run dev +``` + +Open `http://gahow-pc.ipads-lab.se.sjtu.edu.cn:5174` from another device on the same network. + ## IPADS API 配置文件: @@ -44,3 +52,19 @@ The response output text must be JSON matching the stock snapshot schema used by - Normalize symbols into A股、港股、美股 markets. - Generate a daily report with earnings timing, peer stocks, valuation discount/premium, related news, and buy-watch/hold/avoid recommendations. - Refreshes the cached daily report every 24 hours while the backend process is running. +- View each watchlist stock with locally cached D/W/M/Y K-line chart data, zoom controls, MA5/MA20/MA60, RSI14, MACD, volume, and cached financial statements. US K-line and quote/research fields use Nasdaq as the fixed data source and target at least 5 years of verified daily OHLCV history when available. +- Cache related market context such as sector, liquidity, volatility, drawdown, relative strength, capital flow, sentiment, and risk flags. +- Run an agent-backed quantitative analysis across technical, valuation, growth, quality, liquidity, sentiment, and risk factors to classify each stock as buy-watch/hold/avoid. +- Add tags to every watchlist stock and filter the watchlist by tag. +- In configured provider mode, prompts require verified data and do not permit approximate or fabricated values; missing data is surfaced instead of being filled with synthetic values. +- K-line cache updates incrementally: once 5 years of daily bars exist locally, refreshes request only the latest missing window and merge by date. + +## Local cache + +Persistent app data defaults to `./data` and can be moved with: + +```env +STOCK_AGENT_DATA_DIR=/path/to/stock-agent-data +``` + +The backend stores watchlist data in `watchlist.json` and per-stock cached snapshot/K-line/financial data under `cache/`. diff --git a/index.html b/index.html index f9a89ba..7c00985 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Personal Stock Agent + Personal Stock Monitor
diff --git a/package-lock.json b/package-lock.json index 9ed180f..65b6e16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "dotenv": "^17.4.2", "express": "^5.1.0", + "lightweight-charts": "^5.2.0", "lucide-react": "^1.14.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -2041,6 +2042,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fancy-canvas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", + "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2332,6 +2339,15 @@ "node": ">=6" } }, + "node_modules/lightweight-charts": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.2.0.tgz", + "integrity": "sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==", + "license": "Apache-2.0", + "dependencies": { + "fancy-canvas": "2.1.0" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", diff --git a/package.json b/package.json index 4261199..d055849 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"", "dev:server": "tsx watch src/server/index.ts", - "dev:client": "vite --host 127.0.0.1", + "dev:client": "vite", "test": "vitest run", "build": "tsc --noEmit && vite build" }, @@ -15,6 +15,7 @@ "cors": "^2.8.5", "dotenv": "^17.4.2", "express": "^5.1.0", + "lightweight-charts": "^5.2.0", "lucide-react": "^1.14.0", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/src/client/LivePage.tsx b/src/client/LivePage.tsx new file mode 100644 index 0000000..c44d7af --- /dev/null +++ b/src/client/LivePage.tsx @@ -0,0 +1,606 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + ColorType, + CrosshairMode, + LineSeries, + createChart, + type IChartApi, + type LineData, + type Time, +} from "lightweight-charts"; +import { ArrowDownRight, ArrowUpRight, RefreshCw, Wallet } from "lucide-react"; +import type { + LiveStrategyState, + StrategyMarket, + TradeLogEntry, +} from "../shared/strategyTypes"; + +async function api(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, + }); + if (!response.ok) { + const body = (await response.json().catch(() => undefined)) as { error?: string } | undefined; + throw new Error(body?.error ?? `Request failed: ${response.status}`); + } + return (await response.json()) as T; +} + +type SortKey = "currentValue" | "unrealizedPnlPct" | "name" | "lastUpdated"; + +export function LivePage() { + const [market, setMarket] = useState("us"); + const [strategies, setStrategies] = useState([]); + const [selectedName, setSelectedName] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [sortKey, setSortKey] = useState("unrealizedPnlPct"); + const [filterText, setFilterText] = useState(""); + + const refresh = (mk: StrategyMarket = market) => { + setLoading(true); + setError(""); + api(`/api/strategies/live?market=${mk}`) + .then((res) => { + setStrategies(res); + if (res.length > 0) { + setSelectedName((prev) => + prev && res.some((s) => s.name === prev) ? prev : res[0].name, + ); + } else { + setSelectedName(null); + } + }) + .catch((err) => setError(err instanceof Error ? err.message : "加载失败")) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + refresh(market); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [market]); + + const sorted = useMemo(() => { + const list = [...strategies]; + const ft = filterText.trim().toLowerCase(); + const filtered = ft + ? list.filter( + (s) => + s.name.toLowerCase().includes(ft) || + s.label.toLowerCase().includes(ft), + ) + : list; + filtered.sort((a, b) => { + switch (sortKey) { + case "currentValue": + return (b.currentValue ?? 0) - (a.currentValue ?? 0); + case "unrealizedPnlPct": + return (b.unrealizedPnlPct ?? 0) - (a.unrealizedPnlPct ?? 0); + case "name": + return a.label.localeCompare(b.label); + case "lastUpdated": + return (b.lastUpdated ?? "").localeCompare(a.lastUpdated ?? ""); + } + }); + return filtered; + }, [strategies, sortKey, filterText]); + + const focused = useMemo( + () => sorted.find((s) => s.name === selectedName) ?? sorted[0], + [sorted, selectedName], + ); + + return ( +
+ {error &&
{error}
} + +
+
+ + +
+ setFilterText(e.target.value)} + /> + + +
+ +
+ + +
+ {focused ? ( + + ) : ( +
+ +

未选择策略

+
+ )} +
+
+
+ ); +} + +function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) { + const m = strategy; + return ( + <> +
+
+
+

{m.name}

+

{m.label}

+

+ {m.market.toUpperCase()} · 初始 {money(m.initialCapital)} · 更新于 {m.lastUpdated ?? "-"} + {m.priceAsOf && m.priceAsOf !== m.lastUpdated ? ` · 价格 ${m.priceAsOf}` : ""} +

+
+
= 0 ? "positive" : "negative"}`}> + {money(m.currentValue)} + + {m.unrealizedPnl >= 0 ? "+" : ""} + {money(m.unrealizedPnl)} ({pct(m.unrealizedPnlPct)}) + +
+
+
+ + + + + + {m.metrics && } + {m.metrics && } + {m.metrics && } +
+
+ + + + + + + + ); +} + +function SummaryCell({ + label, + value, + positive, + negative, +}: { + label: string; + value: string; + positive?: boolean; + negative?: boolean; +}) { + const cls = positive ? "positive" : negative ? "negative" : ""; + return ( +
+ {value} + {label} +
+ ); +} + +function LiveEquityChart({ + equityCurve, + initialCapital, +}: { + equityCurve: { date: string; value: number }[]; + initialCapital: number; +}) { + const containerRef = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + const chart = createChart(containerRef.current, { + width: containerRef.current.clientWidth, + height: 280, + layout: { + background: { type: ColorType.Solid, color: "#0d1219" }, + textColor: "#b0b0b0", + attributionLogo: false, + }, + grid: { + vertLines: { color: "#1a2030" }, + horzLines: { color: "#1a2030" }, + }, + crosshair: { mode: CrosshairMode.Normal }, + timeScale: { borderColor: "#1d2530", timeVisible: false }, + rightPriceScale: { borderColor: "#1d2530" }, + }); + chartRef.current = chart; + + const eqSeries = chart.addSeries(LineSeries, { + color: "#4db6ff", + lineWidth: 2, + priceLineVisible: false, + }); + const data: LineData