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}
}
+
+
+
+ setMarket("us")}>美股
+ setMarket("cn")}>A股
+
+
setFilterText(e.target.value)}
+ />
+
+ 排序
+ setSortKey(e.target.value as SortKey)}
+ >
+ 盈亏%
+ 总资产
+ 名称
+ 更新时间
+
+
+
refresh()}
+ disabled={loading}
+ title="重新拉取最新行情和持仓"
+ type="button"
+ >
+
+ {loading ? "加载中" : "刷新"}
+
+
+
+
+
+
+
策略列表
+ {sorted.length} / {strategies.length}
+
+ {loading && sorted.length === 0 ? (
+ 加载中...
+ ) : sorted.length === 0 ? (
+ 暂无在线策略。运行 quant 的 trader.py auto 后再刷新。
+ ) : (
+
+ {sorted.map((s) => (
+
setSelectedName(s.name)}
+ title={`${s.label} (${s.name})`}
+ type="button"
+ >
+
+ {s.label}
+ {s.name}
+
+
+
{money(s.currentValue)}
+
= 0 ? "positive" : "negative"}`}
+ >
+ {s.unrealizedPnl >= 0 ? : }
+ {pct(s.unrealizedPnlPct)}
+
+
+
+ 持仓 {Object.keys(s.holdings).length}
+ {s.lastUpdated ?? "-"}
+
+
+ ))}
+
+ )}
+
+
+
+ {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[] = equityCurve.map((p) => ({
+ time: p.date as Time,
+ value: p.value,
+ }));
+ eqSeries.setData(data);
+
+ if (initialCapital && equityCurve.length > 0) {
+ eqSeries.createPriceLine({
+ price: initialCapital,
+ color: "#5e6a78",
+ lineWidth: 1,
+ lineStyle: 2,
+ axisLabelVisible: true,
+ title: "初始",
+ });
+ }
+
+ chart.timeScale().fitContent();
+
+ const ro = new ResizeObserver((entries) => {
+ const w = entries[0]?.contentRect.width;
+ if (w) chart.applyOptions({ width: w });
+ });
+ ro.observe(containerRef.current);
+
+ return () => {
+ ro.disconnect();
+ chart.remove();
+ chartRef.current = null;
+ };
+ }, [equityCurve, initialCapital]);
+
+ return (
+
+
+
+
Live Equity
+
资产曲线
+
+ {equityCurve.length} 天数据
+ {equityCurve.length > 0
+ ? ` · ${equityCurve[0].date} → ${equityCurve[equityCurve.length - 1].date}`
+ : ""}
+
+
+
+
+
+ );
+}
+
+const DEFAULT_PAGE_SIZE = 30;
+const PAGE_SIZE_OPTIONS = [30, 50, 100];
+
+type PaginationState = { page: number; pageSize: number };
+
+function usePagination(total: number, deps: unknown[] = []): [
+ PaginationState,
+ React.Dispatch>,
+ { totalPages: number; sliceStart: number; sliceEnd: number },
+] {
+ const [state, setState] = useState({
+ page: 1,
+ pageSize: DEFAULT_PAGE_SIZE,
+ });
+
+ // Reset to page 1 whenever the underlying data identity changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(() => setState((s) => ({ ...s, page: 1 })), deps);
+
+ const totalPages = Math.max(1, Math.ceil(total / state.pageSize));
+ const safePage = Math.min(state.page, totalPages);
+ const sliceStart = (safePage - 1) * state.pageSize;
+ const sliceEnd = Math.min(total, sliceStart + state.pageSize);
+ return [
+ { ...state, page: safePage },
+ setState,
+ { totalPages, sliceStart, sliceEnd },
+ ];
+}
+
+function PaginationBar({
+ page,
+ pageSize,
+ totalPages,
+ total,
+ sliceStart,
+ sliceEnd,
+ onPageChange,
+ onPageSizeChange,
+}: {
+ page: number;
+ pageSize: number;
+ totalPages: number;
+ total: number;
+ sliceStart: number;
+ sliceEnd: number;
+ onPageChange: (page: number) => void;
+ onPageSizeChange: (size: number) => void;
+}) {
+ if (total === 0) return null;
+ return (
+
+
+ {sliceStart + 1}–{sliceEnd} / {total}
+
+
+ onPageChange(1)}
+ disabled={page <= 1}
+ >
+ «
+
+ onPageChange(page - 1)}
+ disabled={page <= 1}
+ >
+ ‹
+
+
+ {page} / {totalPages}
+
+ onPageChange(page + 1)}
+ disabled={page >= totalPages}
+ >
+ ›
+
+ onPageChange(totalPages)}
+ disabled={page >= totalPages}
+ >
+ »
+
+ onPageSizeChange(Number(e.target.value))}
+ >
+ {PAGE_SIZE_OPTIONS.map((sz) => (
+
+ {sz} / 页
+
+ ))}
+
+
+
+ );
+}
+
+function HoldingsPanel({
+ holdings,
+ cash,
+ currentValue,
+}: {
+ holdings: LiveStrategyState["holdingsDetail"];
+ cash: number;
+ currentValue: number;
+}) {
+ const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] =
+ usePagination(holdings.length, [holdings]);
+ const visible = holdings.slice(sliceStart, sliceEnd);
+ const cashWeight = currentValue ? cash / currentValue : 0;
+
+ return (
+
+
+
当前持仓
+ {holdings.length} 只
+
+ {holdings.length === 0 ? (
+
当前空仓,全部为现金。
+ ) : (
+ <>
+
+
+
+ 标的
+ 持股
+ 最新价
+ 市值
+ 占组合
+
+
+
+ {visible.map((h) => {
+ const weight = currentValue ? (h.marketValue ?? 0) / currentValue : 0;
+ return (
+
+
+ {h.ticker}
+
+ {h.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}
+ {h.lastPrice !== null ? `$${h.lastPrice.toFixed(2)}` : "-"}
+ {h.marketValue !== null ? money(h.marketValue) : "-"}
+ {pct(weight)}
+
+ );
+ })}
+
+
+
+
+ 现金
+
+ -
+ -
+ {money(cash)}
+ {pct(cashWeight)}
+
+
+
+
setPage((s) => ({ ...s, page: p }))}
+ onPageSizeChange={(sz) => setPage(() => ({ page: 1, pageSize: sz }))}
+ />
+ >
+ )}
+
+ );
+}
+
+function TradeLogPanel({ log }: { log: TradeLogEntry[] }) {
+ // Render newest-first; the trader logs trades chronologically.
+ const reversed = useMemo(() => [...log].reverse(), [log]);
+ const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] =
+ usePagination(reversed.length, [log]);
+ const visible = reversed.slice(sliceStart, sliceEnd);
+
+ return (
+
+
+
交易历史
+ {log.length} 笔
+
+ {log.length === 0 ? (
+
尚无交易记录。
+ ) : (
+ <>
+
+
+
+ 日期
+ 方向
+ 标的
+ 股数
+ 成交价
+ 金额
+ 手续费
+
+
+
+ {visible.map((t, i) => (
+
+ {t.date}
+
+
+ {t.action === "BUY" ? "买入" : "卖出"}
+
+
+
+ {t.ticker}
+
+ {t.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}
+ ${t.price.toFixed(2)}
+ {money(t.value)}
+ {money(t.commission)}
+
+ ))}
+
+
+
setPage((s) => ({ ...s, page: p }))}
+ onPageSizeChange={(sz) => setPage(() => ({ page: 1, pageSize: sz }))}
+ />
+ >
+ )}
+
+ );
+}
+
+function money(v: number): string {
+ if (typeof v !== "number" || !Number.isFinite(v)) return "-";
+ return v.toLocaleString(undefined, {
+ maximumFractionDigits: 0,
+ style: "currency",
+ currency: "USD",
+ });
+}
+
+function pct(v: number): string {
+ if (typeof v !== "number" || !Number.isFinite(v)) return "-";
+ return `${(v * 100).toFixed(1)}%`;
+}
diff --git a/src/client/StrategyPage.tsx b/src/client/StrategyPage.tsx
new file mode 100644
index 0000000..9c68046
--- /dev/null
+++ b/src/client/StrategyPage.tsx
@@ -0,0 +1,850 @@
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import {
+ ColorType,
+ CrosshairMode,
+ LineSeries,
+ createChart,
+ type IChartApi,
+ type ISeriesApi,
+ type LineData,
+ type Time,
+} from "lightweight-charts";
+import { ArrowDown, ArrowUp, BarChart3, Trophy, TrendingDown, TrendingUp } from "lucide-react";
+import type {
+ BacktestResult,
+ PrecomputedIndex,
+ PrecomputedScenarioInfo,
+ StrategyInfo,
+ StrategyMarket,
+ StrategyMetrics,
+ StrategyResult,
+} from "../shared/strategyTypes";
+
+const STRATEGY_COLORS = [
+ "#4db6ff", "#f3c969", "#c084fc", "#26a69a", "#ef5350",
+ "#ff9800", "#8bc34a", "#e91e63", "#00bcd4", "#cddc39",
+];
+
+const MAX_CHART_SELECTION = 10;
+
+type RankMetric =
+ | "annualizedReturn"
+ | "sharpeRatio"
+ | "sortinoRatio"
+ | "calmarRatio"
+ | "maxDrawdown"
+ | "winRate"
+ | "rebalanceWinRate"
+ | "totalReturn";
+
+const RANK_METRICS: Array<{ key: RankMetric; label: string; better: "high" | "low" }> = [
+ { key: "sharpeRatio", label: "Sharpe", better: "high" },
+ { key: "annualizedReturn", label: "年化", better: "high" },
+ { key: "totalReturn", label: "总收益", better: "high" },
+ { key: "calmarRatio", label: "Calmar", better: "high" },
+ { key: "sortinoRatio", label: "Sortino", better: "high" },
+ { key: "maxDrawdown", label: "MaxDD", better: "low" },
+ { key: "winRate", label: "日胜率", better: "high" },
+ { key: "rebalanceWinRate", label: "再平衡胜率", better: "high" },
+];
+
+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;
+}
+
+export function StrategyPage() {
+ const [strategies, setStrategies] = useState([]);
+ const [selected, setSelected] = useState>(new Set());
+ const [market, setMarket] = useState("us");
+ const [scenarioList, setScenarioList] = useState([]);
+ const [scenarioId, setScenarioId] = useState(null);
+ const [precomputed, setPrecomputed] = useState();
+ const [precomputedError, setPrecomputedError] = useState("");
+ const [error, setError] = useState("");
+ const [focusStrategy, setFocusStrategy] = useState(null);
+ const [categoryFilter, setCategoryFilter] = useState("all");
+ const [rankMetric, setRankMetric] = useState("sharpeRatio");
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ api("/api/strategies")
+ .then(setStrategies)
+ .catch((err) => setError(err.message));
+ }, []);
+
+ useEffect(() => {
+ api("/api/strategies/precomputed/index")
+ .then((idx) => {
+ setScenarioList(idx.scenarios);
+ if (idx.scenarios.length > 0 && !scenarioId) {
+ setScenarioId(idx.scenarios[0].id);
+ }
+ })
+ .catch((err) => setPrecomputedError(err instanceof Error ? err.message : "无法加载场景列表"));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const currentScenario = useMemo(
+ () => scenarioList.find((s) => s.id === scenarioId) ?? null,
+ [scenarioList, scenarioId],
+ );
+
+ useEffect(() => {
+ if (!scenarioId) return;
+ setLoading(true);
+ setPrecomputedError("");
+ setPrecomputed(undefined);
+ api(`/api/strategies/precomputed?market=${market}&scenario=${encodeURIComponent(scenarioId)}`)
+ .then((res) => {
+ setPrecomputed(res);
+ const ranked = rankBy(res.strategies, rankMetric);
+ const top = new Set(ranked.slice(0, 5).map((s) => s.name));
+ setSelected(top);
+ if (ranked.length > 0) setFocusStrategy(ranked[0].name);
+ })
+ .catch((err) => {
+ setPrecomputedError(err instanceof Error ? err.message : "无法加载预计算结果");
+ })
+ .finally(() => setLoading(false));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [market, scenarioId]);
+
+ const categoryByName = useMemo(() => {
+ const map = new Map();
+ for (const s of strategies) map.set(s.name, s.category);
+ return map;
+ }, [strategies]);
+
+ const categories = useMemo(() => {
+ if (!precomputed) return ["all"];
+ const seen = new Set();
+ for (const s of precomputed.strategies) {
+ seen.add(categoryByName.get(s.name) ?? "other");
+ }
+ return ["all", ...Array.from(seen).sort()];
+ }, [precomputed, categoryByName]);
+
+ const filteredStrategies = useMemo(() => {
+ if (!precomputed) return [];
+ if (categoryFilter === "all") return precomputed.strategies;
+ return precomputed.strategies.filter(
+ (s) => (categoryByName.get(s.name) ?? "other") === categoryFilter,
+ );
+ }, [precomputed, categoryFilter, categoryByName]);
+
+ const rankedStrategies = useMemo(
+ () => rankBy(filteredStrategies, rankMetric),
+ [filteredStrategies, rankMetric],
+ );
+
+ const rankIndex = useMemo(() => {
+ const map = new Map();
+ rankedStrategies.forEach((s, i) => map.set(s.name, i + 1));
+ return map;
+ }, [rankedStrategies]);
+
+ const chartResult: BacktestResult | undefined = useMemo(() => {
+ if (!precomputed) return undefined;
+ return {
+ ...precomputed,
+ strategies: rankedStrategies.filter((s) => selected.has(s.name)),
+ };
+ }, [precomputed, rankedStrategies, selected]);
+
+ function toggleStrategy(name: string) {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(name)) {
+ next.delete(name);
+ } else {
+ if (next.size >= MAX_CHART_SELECTION) return prev;
+ next.add(name);
+ }
+ return next;
+ });
+ setFocusStrategy(name);
+ }
+
+ function selectTopN(metric: RankMetric, n: number) {
+ if (!precomputed) return;
+ const ranked = rankBy(filteredStrategies, metric);
+ const top = new Set(ranked.slice(0, n).map((s) => s.name));
+ setSelected(top);
+ if (ranked.length > 0) setFocusStrategy(ranked[0].name);
+ setRankMetric(metric);
+ }
+
+ function clearSelection() {
+ setSelected(new Set());
+ }
+
+ const meta = precomputed?.meta;
+ const computedAt = precomputed?.computedAt ? new Date(precomputed.computedAt) : null;
+
+ return (
+
+ {error &&
{error}
}
+ {precomputedError &&
{precomputedError}
}
+
+
+ {/* Left: Strategy selector */}
+
+
+
策略选择
+ {selected.size} / {filteredStrategies.length}
+
+
+
+
+
市场
+
+ setMarket("us")}>美股
+ setMarket("cn")}>A股
+
+
+ {scenarioList.length > 0 && (
+
+
投入方式
+
+ {scenarioList.map((sc) => (
+ setScenarioId(sc.id)}
+ type="button"
+ title={sc.description ?? sc.label}
+ >
+ {sc.label}
+ {summarizeScenario(sc)}
+
+ ))}
+
+
+ )}
+ {meta && (
+
+ {meta.years} 年 · 离线预计算
+ {computedAt ? ` · ${computedAt.toLocaleDateString()}` : ""}
+
+ )}
+
+
+
+
快速选取
+
+ selectTopN("sharpeRatio", 5)}>
+ Top 5 Sharpe
+
+ selectTopN("annualizedReturn", 5)}>
+ Top 5 年化
+
+ 清空
+
+
+
+ {categories.length > 1 && (
+
+ {categories.map((cat) => (
+ setCategoryFilter(cat)}
+ >
+ {categoryLabel(cat)}
+
+ ))}
+
+ )}
+
+
+ {loading &&
加载预计算结果中...
}
+ {!loading && rankedStrategies.length === 0 && (
+
当前筛选下暂无策略
+ )}
+ {rankedStrategies.map((s, i) => {
+ const isSelected = selected.has(s.name);
+ const colorIdx = chartResult?.strategies.findIndex((x) => x.name === s.name) ?? -1;
+ const color = colorIdx >= 0 ? STRATEGY_COLORS[colorIdx % STRATEGY_COLORS.length] : undefined;
+ return (
+
+ #{i + 1}
+ toggleStrategy(s.name)}
+ />
+
+ {displayStrategyName(s)}
+ = 0 ? "positive" : "negative"}`}>
+ {pct(s.metrics.annualizedReturn)}
+
+
+ {s.metrics.sharpeRatio.toFixed(2)}
+
+
+ );
+ })}
+
+
+
+ {/* Right: Results */}
+
+ {chartResult && precomputed ? (
+ <>
+
+
+
+
+
+
+
+ >
+ ) : (
+
+
+
{loading ? "正在加载预计算回测结果..." : "暂无预计算回测结果"}
+
在 quant 项目运行 precompute_backtest.py 后即可查看
+
+ )}
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Ranking Leaderboard
+// ---------------------------------------------------------------------------
+
+function RankingLeaderboard({
+ strategies,
+ selected,
+ onToggle,
+ onFocus,
+ rankMetric,
+ onChangeMetric,
+ categoryByName,
+ rankIndex,
+}: {
+ strategies: StrategyResult[];
+ selected: Set;
+ onToggle: (name: string) => void;
+ onFocus: (name: string) => void;
+ rankMetric: RankMetric;
+ onChangeMetric: (metric: RankMetric) => void;
+ categoryByName: Map;
+ rankIndex: Map;
+}) {
+ return (
+
+
+
+
Leaderboard
+
策略排名
+
点击行加入/移除对比;列头切换排序指标
+
+
{strategies.length} 个策略
+
+
+
+
+
+ #
+ 策略
+ 类别
+ {RANK_METRICS.map((m) => {
+ const active = rankMetric === m.key;
+ return (
+ onChangeMetric(m.key)}
+ title={`按 ${m.label} 排序`}
+ >
+ {m.label}
+ {active && (m.better === "high" ? : )}
+
+ );
+ })}
+
+
+
+ {strategies.map((s) => {
+ const isSelected = selected.has(s.name);
+ const rank = rankIndex.get(s.name) ?? 0;
+ return (
+ {
+ onToggle(s.name);
+ onFocus(s.name);
+ }}
+ >
+
+ {rank}
+
+
+ {displayStrategyName(s)}
+ {s.name}
+
+
+
+ {categoryLabel(categoryByName.get(s.name) ?? "other")}
+
+
+ {s.metrics.sharpeRatio.toFixed(2)}
+ = 0 ? "positive" : "negative"}>
+ {pct(s.metrics.annualizedReturn)}
+
+ = 0 ? "positive" : "negative"}>
+ {pct(s.metrics.totalReturn)}
+
+ {s.metrics.calmarRatio.toFixed(2)}
+ {s.metrics.sortinoRatio.toFixed(2)}
+ {pct(s.metrics.maxDrawdown)}
+ {pct(s.metrics.winRate)}
+ {pctOrDash(s.metrics.rebalanceWinRate)}
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Equity Curve Chart
+// ---------------------------------------------------------------------------
+
+function EquityCurveChart({
+ result,
+ focusStrategy,
+ onFocus,
+ scenario,
+}: {
+ result: BacktestResult;
+ focusStrategy: string | null;
+ onFocus: (name: string) => void;
+ scenario: PrecomputedScenarioInfo | null;
+}) {
+ const containerRef = useRef(null);
+ const chartRef = useRef(null);
+ const seriesMapRef = useRef>>(new Map());
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+ const chart = createChart(containerRef.current, {
+ width: containerRef.current.clientWidth,
+ height: 480,
+ 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 observer = new ResizeObserver((entries) => {
+ const width = entries[0]?.contentRect.width;
+ if (width) chart.applyOptions({ width });
+ });
+ observer.observe(containerRef.current);
+
+ return () => {
+ observer.disconnect();
+ chart.remove();
+ chartRef.current = null;
+ seriesMapRef.current.clear();
+ };
+ }, []);
+
+ useEffect(() => {
+ const chart = chartRef.current;
+ if (!chart) return;
+
+ for (const series of seriesMapRef.current.values()) {
+ chart.removeSeries(series);
+ }
+ seriesMapRef.current.clear();
+
+ result.strategies.forEach((strat, i) => {
+ const color = STRATEGY_COLORS[i % STRATEGY_COLORS.length];
+ const lineWidth = focusStrategy === strat.name ? 3 : 1.6;
+ const series = chart.addSeries(LineSeries, {
+ color,
+ lineWidth: lineWidth as 1 | 2 | 3 | 4,
+ crosshairMarkerVisible: true,
+ priceLineVisible: false,
+ });
+ const data: LineData[] = strat.equityCurve.map((p) => ({
+ time: p.date as Time,
+ value: p.value,
+ }));
+ series.setData(data);
+ seriesMapRef.current.set(strat.name, series);
+ });
+
+ if (result.benchmark.equityCurve.length > 0) {
+ const benchSeries = chart.addSeries(LineSeries, {
+ color: "#7c8694",
+ lineWidth: 1,
+ lineStyle: 2,
+ title: result.benchmark.name,
+ crosshairMarkerVisible: false,
+ priceLineVisible: false,
+ });
+ benchSeries.setData(
+ result.benchmark.equityCurve.map((p) => ({ time: p.date as Time, value: p.value })),
+ );
+ seriesMapRef.current.set("__benchmark__", benchSeries);
+ }
+
+ chart.timeScale().fitContent();
+ }, [result, focusStrategy]);
+
+ return (
+
+
+
+
Equity Curves
+
资产曲线对比
+
+
+ {result.meta.market.toUpperCase()}
+ ·
+ {result.meta.years} 年
+ ·
+ {money(result.meta.initialCapital)} 初始
+ {scenario?.injectionSchedule && (
+ <>
+ ·
+ {summarizeScenario(scenario)}
+ >
+ )}
+
+
+
+ {result.strategies.length === 0 && (
+ 从左侧选取策略加入对比
+ )}
+ {result.strategies.map((strat, i) => (
+ onFocus(strat.name)}
+ type="button"
+ >
+
+ {displayStrategyName(strat)}
+
+ ))}
+ {result.benchmark.equityCurve.length ? (
+
+
+ {result.benchmark.name}
+
+ ) : null}
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Metrics Panel
+// ---------------------------------------------------------------------------
+
+function MetricsPanel({
+ result,
+ focusStrategy,
+ onFocus,
+}: {
+ result: BacktestResult;
+ focusStrategy: string | null;
+ onFocus: (name: string) => void;
+}) {
+ const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0];
+ return (
+
+
+
核心指标
+ {focused && {displayStrategyName(focused)} }
+
+ {focused ? (
+ <>
+
+ 0} />
+ 0} />
+
+ 1} />
+ 1} />
+
+ 0.5} />
+ 0.5} />
+ 0.5
+ }
+ />
+
+
+ 终值 {money(focused.equityCurve.at(-1)?.value)}
+ 最大回撤 {pct(focused.metrics.maxDrawdown)}
+ 年化 {pct(focused.metrics.annualizedReturn)}
+ 交易 {focused.trades.length.toLocaleString()} 天
+
+ >
+ ) : (
+
从排名表选取至少一个策略查看核心指标
+ )}
+
+ {result.strategies.length > 1 && (
+
+
+
+
+ 策略
+ 年化
+ Sharpe
+ MaxDD
+ 日胜率
+ 再平衡胜率
+
+
+
+ {result.strategies.map((s) => (
+ onFocus(s.name)}>
+
+ {displayStrategyName(s)}
+ {s.name}
+
+ 0 ? "positive" : "negative"}>{pct(s.metrics.annualizedReturn)}
+ {s.metrics.sharpeRatio.toFixed(2)}
+ {pct(s.metrics.maxDrawdown)}
+ {pct(s.metrics.winRate)}
+ {pctOrDash(s.metrics.rebalanceWinRate)}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+function MetricCell({ label, value, positive, negative }: { label: string; value: string; positive?: boolean; negative?: boolean }) {
+ const cls = positive ? "positive" : negative ? "negative" : "";
+ return (
+
+ {value}
+ {label}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Drawdown Info
+// ---------------------------------------------------------------------------
+
+function DrawdownInfo({ result, focusStrategy }: { result: BacktestResult; focusStrategy: string | null }) {
+ const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0];
+ if (!focused) {
+ return (
+
+ );
+ }
+ const topDrawdowns = [...focused.drawdowns].sort((a, b) => a.depth - b.depth).slice(0, 5);
+ return (
+
+
+
重大回撤
+ {displayStrategyName(focused)}
+
+ {topDrawdowns.length === 0 ? (
+
无显著回撤期间 (阈值 -5%)
+ ) : (
+
+
+
+ 开始
+ 结束
+ 深度
+ 恢复
+
+
+
+ {topDrawdowns.map((dd, i) => (
+
+ {dd.start}
+ {dd.end}
+ {pct(dd.depth)}
+ {dd.recoveryDate ?? "未恢复"}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Return Heatmap
+// ---------------------------------------------------------------------------
+
+function ReturnHeatmap({ result, focusStrategy }: { result: BacktestResult; focusStrategy: string | null }) {
+ const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0];
+ if (!focused) return null;
+ const { monthlyReturns, yearlyReturns } = focused;
+ const years = Object.keys(yearlyReturns).sort();
+ const months = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"];
+ const monthLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+
+ return (
+
+
+
收益热力图
+ {displayStrategyName(focused)}
+
+
+
+
+
+ Year
+ {monthLabels.map((m) => {m} )}
+ YTD
+
+
+
+ {years.map((year) => (
+
+ {year}
+ {months.map((month) => {
+ const key = `${year}-${month}`;
+ const val = monthlyReturns[key];
+ return (
+
+ {val !== undefined ? `${(val * 100).toFixed(1)}` : ""}
+
+ );
+ })}
+ = 0 ? "positive" : "negative"}`}>
+ {yearlyReturns[year] !== undefined ? pct(yearlyReturns[year]) : "-"}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function rankBy(strategies: StrategyResult[], metric: RankMetric): StrategyResult[] {
+ const better = RANK_METRICS.find((m) => m.key === metric)?.better ?? "high";
+ const dir = better === "high" ? -1 : 1;
+ const get = (s: StrategyResult) => {
+ const v = s.metrics[metric as keyof StrategyMetrics];
+ return typeof v === "number" && Number.isFinite(v) ? v : (better === "high" ? -Infinity : Infinity);
+ };
+ return [...strategies].sort((a, b) => dir * (get(a) - get(b)));
+}
+
+function summarizeScenario(sc: PrecomputedScenarioInfo): string {
+ if (!sc.injectionSchedule) return `${money(sc.capital)} 一次性`;
+ const inj = sc.injectionSchedule;
+ if (inj.frequency === "yearly_months" && inj.months?.length) {
+ const monthLabel = inj.months.join("/");
+ return `${money(sc.capital)} 起 · 每年 ${monthLabel} 月注入 ${money(inj.amount)}`;
+ }
+ const freqLabel = inj.frequency === "monthly" ? "每月" : inj.frequency === "quarterly" ? "每季" : "每年";
+ return `${money(sc.capital)} 起 · ${freqLabel}注入 ${money(inj.amount)}`;
+}
+
+function categoryLabel(cat: string): string {
+ if (cat === "all") return "全部";
+ if (cat === "stock_picker") return "选股";
+ if (cat === "factor_combo") return "因子";
+ if (cat === "tactical_allocation") return "配置";
+ if (cat === "other") return "其他";
+ return cat;
+}
+
+function pct(val: number): string {
+ return `${(val * 100).toFixed(1)}%`;
+}
+
+function pctOrDash(val: number | null | undefined): string {
+ return typeof val === "number" && Number.isFinite(val) ? pct(val) : "-";
+}
+
+function money(value: number | undefined): string {
+ return typeof value === "number" && Number.isFinite(value)
+ ? value.toLocaleString(undefined, { maximumFractionDigits: 0, style: "currency", currency: "USD" })
+ : "-";
+}
+
+function displayStrategyName(strategy: Pick): string {
+ const label = typeof strategy.label === "string" ? strategy.label.trim() : "";
+ return label || strategy.name;
+}
+
+function heatColor(val: number | undefined): string {
+ if (val === undefined) return "transparent";
+ if (val > 0) {
+ const intensity = Math.min(val / 0.15, 1);
+ return `rgba(38, 166, 154, ${0.15 + intensity * 0.6})`;
+ }
+ const intensity = Math.min(Math.abs(val) / 0.15, 1);
+ return `rgba(239, 83, 80, ${0.15 + intensity * 0.6})`;
+}
diff --git a/src/client/main.tsx b/src/client/main.tsx
index 7a3d129..2c5b3cf 100644
--- a/src/client/main.tsx
+++ b/src/client/main.tsx
@@ -1,34 +1,113 @@
-import React, { useEffect, useMemo, useState } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
-import { Activity, Bell, CircleDollarSign, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
-import type { DailyReport, WatchStock } from "../shared/types";
+import {
+ CandlestickSeries,
+ ColorType,
+ CrosshairMode,
+ HistogramSeries,
+ LineSeries,
+ createChart,
+ type CandlestickData,
+ type HistogramData,
+ type IChartApi,
+ type LineData,
+ type LogicalRange,
+ type MouseEventParams,
+ type Time
+} from "lightweight-charts";
+import {
+ Activity,
+ BarChart3,
+ Bell,
+ Database,
+ Filter,
+ Pencil,
+ Plus,
+ RefreshCw,
+ ShieldAlert,
+ Tag,
+ Trash2,
+ TrendingUp,
+ X,
+ ZoomIn,
+ ZoomOut
+} from "lucide-react";
+import type {
+ ChartPeriod,
+ DailyReport,
+ StockChartData,
+ StockFundamentalsData,
+ StockQuantAnalysis,
+ StockRelatedData,
+ WatchStock
+} from "../shared/types";
import "./styles.css";
+import { StrategyPage } from "./StrategyPage";
+import { LivePage } from "./LivePage";
-interface HealthStatus {
- providerConfigured: boolean;
- baseUrl: string;
- wireApi: string;
- scheduler: string;
-}
+type TagFilter = "all" | "untagged" | string;
+type DetailKey = "chart" | "fundamentals" | "related" | "analysis";
+type LoadStatus = "idle" | "loading" | "done" | "error";
+type MaKey = "ma5" | "ma20" | "ma60" | "ma120" | "ma200";
+const PERIODS: ChartPeriod[] = ["D", "W", "M", "Y"];
+const MA_OPTIONS: Array<{ key: MaKey; label: string; color: string }> = [
+ { key: "ma5", label: "MA5", color: "#f3c969" },
+ { key: "ma20", label: "MA20", color: "#4db6ff" },
+ { key: "ma60", label: "MA60", color: "#c084fc" },
+ { key: "ma120", label: "MA120", color: "#f59e0b" },
+ { key: "ma200", label: "MA200", color: "#94a3b8" }
+];
+const EMPTY_DETAIL_STATUS: Record = {
+ chart: "idle",
+ fundamentals: "idle",
+ related: "idle",
+ analysis: "idle"
+};
function App() {
+ const [pageTab, setPageTab] = useState<"watchlist" | "strategies" | "live">("watchlist");
const [watchlist, setWatchlist] = useState([]);
const [report, setReport] = useState();
- const [health, setHealth] = useState({
- providerConfigured: false,
- baseUrl: "",
- wireApi: "responses",
- scheduler: "24h"
- });
+ const [chartData, setChartData] = useState();
+ const [fundamentals, setFundamentals] = useState();
+ const [relatedData, setRelatedData] = useState();
+ const [quantAnalysis, setQuantAnalysis] = useState();
const [symbol, setSymbol] = useState("");
const [name, setName] = useState("");
+ const [selectedId, setSelectedId] = useState("");
+ const [selectedTag, setSelectedTag] = useState("all");
+ const [chartPeriod, setChartPeriod] = useState("D");
+ const [zoomCount, setZoomCount] = useState(110);
+ const [tagDraft, setTagDraft] = useState("");
+ const [editingTagId, setEditingTagId] = useState(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [detailStatus, setDetailStatus] = useState>(EMPTY_DETAIL_STATUS);
+ const detailRequestRef = useRef(0);
useEffect(() => {
void loadAll();
}, []);
+ const selectedStock = useMemo(() => watchlist.find((stock) => stock.id === selectedId) ?? watchlist[0], [selectedId, watchlist]);
+
+ useEffect(() => {
+ if (!selectedStock) {
+ setChartData(undefined);
+ setFundamentals(undefined);
+ setRelatedData(undefined);
+ setQuantAnalysis(undefined);
+ return;
+ }
+ setSelectedId(selectedStock.id);
+ setChartData(undefined);
+ setFundamentals(undefined);
+ setRelatedData(undefined);
+ setQuantAnalysis(undefined);
+ void loadStockDetails(selectedStock.display);
+ }, [selectedStock?.id]);
+
const marketCounts = useMemo(() => {
return {
CN: watchlist.filter((stock) => stock.market === "CN").length,
@@ -37,18 +116,32 @@ function App() {
};
}, [watchlist]);
+ const allTags = useMemo(() => Array.from(new Set(watchlist.flatMap((stock) => stock.tags))).sort(), [watchlist]);
+ const filteredWatchlist = useMemo(() => {
+ if (selectedTag === "all") {
+ return watchlist;
+ }
+ if (selectedTag === "untagged") {
+ return watchlist.filter((stock) => stock.tags.length === 0);
+ }
+ return watchlist.filter((stock) => stock.tags.includes(selectedTag));
+ }, [selectedTag, watchlist]);
+
+ const selectedReportItem = useMemo(() => {
+ return report?.items.find((item) => item.stock.id === selectedStock?.id);
+ }, [report, selectedStock?.id]);
+
async function loadAll() {
setLoading(true);
setError("");
try {
- const [nextWatchlist, nextReport, nextHealth] = await Promise.all([
+ const [nextWatchlist, nextReport] = await Promise.all([
api("/api/watchlist"),
- api("/api/report/daily"),
- api("/api/health")
+ api("/api/report/daily")
]);
setWatchlist(nextWatchlist);
setReport(nextReport);
- setHealth(nextHealth);
+ setSelectedId((current) => current || nextWatchlist[0]?.id || "");
} catch (nextError) {
setError(errorMessage(nextError));
} finally {
@@ -56,16 +149,118 @@ function App() {
}
}
+ async function loadStockDetails(display: string, refresh = false) {
+ const requestId = ++detailRequestRef.current;
+ setDetailLoading(true);
+ setDetailStatus({ chart: "loading", fundamentals: "loading", related: "loading", analysis: "loading" });
+ setError("");
+ try {
+ const suffix = refresh ? "?refresh=1" : "";
+ const separator = refresh ? "&" : "?";
+ const failures: string[] = [];
+ const tasks = [
+ trackDetail("chart", api(`/api/stocks/${encodeURIComponent(display)}/chart${suffix}${separator}period=${chartPeriod}`), requestId)
+ .then((value) => {
+ if (requestId === detailRequestRef.current) {
+ setChartData(value);
+ }
+ })
+ .catch(() => {
+ failures.push("K线");
+ if (!refresh && requestId === detailRequestRef.current) {
+ setChartData(undefined);
+ }
+ }),
+ trackDetail("fundamentals", api(`/api/stocks/${encodeURIComponent(display)}/fundamentals${suffix}`), requestId)
+ .then((value) => {
+ if (requestId === detailRequestRef.current) {
+ setFundamentals(value);
+ }
+ })
+ .catch(() => failures.push("财报")),
+ trackDetail("related", api(`/api/stocks/${encodeURIComponent(display)}/related${suffix}`), requestId)
+ .then((value) => {
+ if (requestId === detailRequestRef.current) {
+ setRelatedData(value);
+ }
+ })
+ .catch(() => failures.push("研究数据")),
+ trackDetail("analysis", api(`/api/stocks/${encodeURIComponent(display)}/analysis${suffix}`), requestId)
+ .then((value) => {
+ if (requestId === detailRequestRef.current) {
+ setQuantAnalysis(value);
+ }
+ })
+ .catch(() => failures.push("Agent分析"))
+ ];
+ await Promise.allSettled(tasks);
+ if (requestId !== detailRequestRef.current) {
+ return;
+ }
+ if (failures.length) {
+ setError(`部分实时数据源未返回有效数据:${failures.join("、")}。未使用模拟数据替代。`);
+ }
+ } catch (nextError) {
+ if (requestId === detailRequestRef.current) {
+ setError(errorMessage(nextError));
+ }
+ } finally {
+ if (requestId === detailRequestRef.current) {
+ setDetailLoading(false);
+ }
+ }
+ }
+
+ async function loadChart(display: string, period: ChartPeriod) {
+ const requestId = ++detailRequestRef.current;
+ setDetailLoading(true);
+ setDetailStatus((current) => ({ ...current, chart: "loading" }));
+ setError("");
+ try {
+ const nextChart = await api(`/api/stocks/${encodeURIComponent(display)}/chart?period=${period}`);
+ if (requestId !== detailRequestRef.current) {
+ return;
+ }
+ setChartData(nextChart);
+ setDetailStatus((current) => ({ ...current, chart: "done" }));
+ } catch (nextError) {
+ if (requestId === detailRequestRef.current) {
+ setDetailStatus((current) => ({ ...current, chart: "error" }));
+ setError(`K线数据源未返回有效 ${period} 周期数据,已保留现有缓存且未使用模拟数据替代。`);
+ }
+ } finally {
+ if (requestId === detailRequestRef.current) {
+ setDetailLoading(false);
+ }
+ }
+ }
+
+ async function trackDetail(key: DetailKey, promise: Promise, requestId: number): Promise {
+ try {
+ const result = await promise;
+ if (requestId === detailRequestRef.current) {
+ setDetailStatus((current) => ({ ...current, [key]: "done" }));
+ }
+ return result;
+ } catch (nextError) {
+ if (requestId === detailRequestRef.current) {
+ setDetailStatus((current) => ({ ...current, [key]: "error" }));
+ }
+ throw nextError;
+ }
+ }
+
async function addStock(event: React.FormEvent) {
event.preventDefault();
setError("");
try {
- await api("/api/watchlist", {
+ const added = await api("/api/watchlist", {
method: "POST",
- body: JSON.stringify({ symbol, name })
+ body: JSON.stringify({ symbol, name, tags: [] })
});
setSymbol("");
setName("");
+ setSelectedId(added.id);
await loadAll();
} catch (nextError) {
setError(errorMessage(nextError));
@@ -77,10 +272,38 @@ function App() {
await loadAll();
}
+ async function saveTags(stock: WatchStock, tagText: string) {
+ const updated = await api(`/api/watchlist/${encodeURIComponent(stock.display)}/tags`, {
+ method: "PUT",
+ body: JSON.stringify({ tags: parseTagText(tagText) })
+ });
+ setWatchlist((stocks) => stocks.map((item) => (item.id === updated.id ? updated : item)));
+ }
+
+ function beginEditTags(stock: WatchStock) {
+ setEditingTagId(stock.id);
+ setTagDraft(stock.tags.join(", "));
+ }
+
+ function cancelEditTags() {
+ setEditingTagId(null);
+ setTagDraft("");
+ }
+
+ async function commitEditTags(stock: WatchStock) {
+ await saveTags(stock, tagDraft);
+ setEditingTagId(null);
+ setTagDraft("");
+ }
+
async function refreshReport() {
setLoading(true);
+ setError("");
try {
setReport(await api("/api/report/daily/refresh", { method: "POST" }));
+ if (selectedStock) {
+ await loadStockDetails(selectedStock.display, true);
+ }
} catch (nextError) {
setError(errorMessage(nextError));
} finally {
@@ -88,110 +311,824 @@ function App() {
}
}
+ function chooseTag(tag: TagFilter) {
+ setSelectedTag(tag);
+ const next = tag === "all" ? watchlist[0] : tag === "untagged" ? watchlist.find((stock) => stock.tags.length === 0) : watchlist.find((stock) => stock.tags.includes(tag));
+ if (next) {
+ setSelectedId(next.id);
+ }
+ }
+
+ function changePeriod(period: ChartPeriod) {
+ setChartPeriod(period);
+ setZoomCount(defaultZoomCount(period));
+ if (selectedStock) {
+ void loadChart(selectedStock.display, period);
+ }
+ }
+
+ function zoomIn() {
+ setZoomCount((count) => Math.max(24, Math.round(count * 0.72)));
+ }
+
+ function zoomOut() {
+ setZoomCount((count) => Math.min(260, Math.round(count * 1.38)));
+ }
+
return (
-
Personal Stock Agent
-
统一自选股监控
+
Personal Stock Monitor
+
策略实盘与量化监控
-
-
- 刷新
+
+ setPageTab("watchlist")}>自选股
+ setPageTab("strategies")}>策略回测
+ setPageTab("live")}>在线监测
+
+
+
+ {loading ? "加载中" : "刷新"}
{error ? {error}
: null}
+ {pageTab === "live" ? (
+
+ ) : pageTab === "strategies" ? (
+
+ ) : (
+ <>
+
+
} label="自选股" value={watchlist.length} />
} label="A股 / 港股 / 美股" value={`${marketCounts.CN} / ${marketCounts.HK} / ${marketCounts.US}`} />
- } label="IPADS_API_KEY" value={health.providerConfigured ? "已配置" : "未配置"} />
- } label="wire API" value={health.wireApi} />
+ } label="标签" value={allTags.length} />
+ } label="本地缓存" value="K线 / 财报 / 分析" />
-
-
+
+
自选股
+ {filteredWatchlist.length} / {watchlist.length}
-
- {watchlist.map((stock) => (
-
-
- {stock.display}
- {stock.name || marketName(stock.market)}
-
-
removeStock(stock.display)} title="移除" aria-label={`移除 ${stock.display}`}>
-
+
+
+
按标签筛选
+
+ chooseTag("all")}>
+
+ 全部
+
+ chooseTag("untagged")}>
+ 未标记
+
+ {allTags.map((tagName) => (
+ chooseTag(tagName)}>
+ {tagName}
+ ))}
+
+
+
+
+ {filteredWatchlist.map((stock) => {
+ const isSelected = selectedStock?.id === stock.id;
+ const isEditing = editingTagId === stock.id;
+ return (
+
+
setSelectedId(stock.id)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ setSelectedId(stock.id);
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ >
+
+ {stock.display}
+ {stock.name || marketName(stock.market)}
+
+
+
{
+ event.stopPropagation();
+ if (isEditing) {
+ cancelEditTags();
+ } else {
+ beginEditTags(stock);
+ }
+ }}
+ >
+ {isEditing ? : }
+
+
{
+ event.stopPropagation();
+ if (editingTagId === stock.id) cancelEditTags();
+ void removeStock(stock.display);
+ }}
+ >
+
+
+
+
+
+
+
+ {isEditing ? (
+
+ setTagDraft(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ void commitEditTags(stock);
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ cancelEditTags();
+ }
+ }}
+ placeholder="例如: core, AI, dividend"
+ />
+ void commitEditTags(stock)}
+ >
+ 保存
+
+
+ ) : null}
+
+ );
+ })}
+ {filteredWatchlist.length === 0 ?
当前标签下暂无自选股
: null}
+
+
+
+
+
+
+
+
K Line
+
{selectedStock ? `${selectedStock.display} ${selectedStock.name ?? ""}` : "暂无自选股"}
+
+
+
+ {detailLoading ? "加载中" : chartData ? `缓存 ${new Date(chartData.cachedAt).toLocaleString()}` : "无缓存"}
+
+
+
+
+ {PERIODS.map((period) => (
+ changePeriod(period)}>
+ {period}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
财报与估值
+
{selectedReportItem?.recommendation.reasons.join(" · ") ?? report?.summary ?? "暂无日报"}
+
+
{fundamentals ? `缓存 ${new Date(fundamentals.cachedAt).toLocaleString()}` : ""}
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+function KLineChart({ data, snapshot, zoomCount }: { data?: StockChartData; snapshot?: DailyReport["items"][number]["snapshot"]; zoomCount: number }) {
+ const visibleCount = Math.min(data?.candles.length ?? zoomCount, zoomCount);
+ const candles = data?.candles ?? [];
+ const indicators = data?.indicators ?? [];
+ const chartRootRef = useRef(null);
+ const [selectedDate, setSelectedDate] = useState();
+ const [visibleMas, setVisibleMas] = useState>({
+ ma5: true,
+ ma20: true,
+ ma60: true,
+ ma120: false,
+ ma200: false
+ });
+
+ useEffect(() => {
+ setSelectedDate(undefined);
+ }, [data?.stock.id, data?.period]);
+
+ useEffect(() => {
+ const container = chartRootRef.current;
+ if (!container || !candles.length) {
+ return;
+ }
+
+ const chart = createTerminalChart(container);
+ const candleSeries = chart.addSeries(CandlestickSeries, {
+ upColor: "#ef5350",
+ downColor: "#26a69a",
+ borderUpColor: "#ef5350",
+ borderDownColor: "#26a69a",
+ wickUpColor: "#ef5350",
+ wickDownColor: "#26a69a",
+ priceLineColor: "#d5dee9"
+ });
+ const maSeries = MA_OPTIONS.flatMap((option) => {
+ if (!visibleMas[option.key]) {
+ return [];
+ }
+ return [
+ {
+ option,
+ series: chart.addSeries(LineSeries, lineOptions(option.color))
+ }
+ ];
+ });
+ const volumeSeries = chart.addSeries(
+ HistogramSeries,
+ {
+ priceFormat: { type: "volume" },
+ priceScaleId: "volume",
+ lastValueVisible: false,
+ priceLineVisible: false
+ },
+ 1
+ );
+ const macdSeries = chart.addSeries(
+ HistogramSeries,
+ {
+ priceScaleId: "macd",
+ lastValueVisible: false,
+ priceLineVisible: false
+ },
+ 2
+ );
+ const macdLine = chart.addSeries(LineSeries, lineOptions("#f3c969", "DIF"), 2);
+ const signalLine = chart.addSeries(LineSeries, lineOptions("#4db6ff", "DEA"), 2);
+
+ const candleData = candles.map(toCandleData);
+ candleSeries.setData(candleData);
+ for (const item of maSeries) {
+ item.series.setData(indicators.map((point) => toLineData(point.date, point[item.option.key])).filter(isLineData));
+ }
+ volumeSeries.setData(candles.map((candle) => toVolumeData(candle)));
+ macdSeries.setData(indicators.map(toMacdHistogram).filter(isHistogramData));
+ macdLine.setData(indicators.map((point) => toLineData(point.date, point.macd)).filter(isLineData));
+ signalLine.setData(indicators.map((point) => toLineData(point.date, point.macdSignal)).filter(isLineData));
+
+ chart.priceScale("right").applyOptions({ borderColor: "#1e2938", scaleMargins: { top: 0.08, bottom: 0.05 } });
+ chart.priceScale("volume", 1).applyOptions({ borderVisible: false });
+ chart.priceScale("macd", 2).applyOptions({ borderVisible: false });
+ const setLatestRange = () => {
+ chart.timeScale().setVisibleLogicalRange({ from: Math.max(0, candles.length - visibleCount), to: candles.length - 1 });
+ };
+ setLatestRange();
+
+ let correctingRange = false;
+ const clampRightEdge = (range: LogicalRange | null) => {
+ if (!range || correctingRange) {
+ return;
+ }
+ const lastIndex = candles.length - 1;
+ const rangeTo = Number(range.to);
+ const rangeFrom = Number(range.from);
+ if (rangeTo > lastIndex) {
+ const width = rangeTo - rangeFrom;
+ correctingRange = true;
+ chart.timeScale().setVisibleLogicalRange({ from: Math.max(0, lastIndex - width), to: lastIndex });
+ correctingRange = false;
+ }
+ };
+ chart.timeScale().subscribeVisibleLogicalRangeChange(clampRightEdge);
+
+ const handleClick = (param: MouseEventParams) => {
+ const date = timeToDate(param.time);
+ if (date) {
+ setSelectedDate(date);
+ }
+ };
+ chart.subscribeClick(handleClick);
+
+ const resize = new ResizeObserver(([entry]) => {
+ chart.applyOptions({ width: Math.round(entry.contentRect.width) });
+ });
+ resize.observe(container);
+
+ return () => {
+ chart.unsubscribeClick(handleClick);
+ chart.timeScale().unsubscribeVisibleLogicalRangeChange(clampRightEdge);
+ resize.disconnect();
+ chart.remove();
+ };
+ }, [candles, indicators, visibleCount, visibleMas]);
+
+ if (!candles.length) {
+ return 数据源暂无历史 K 线;已忽略技术图表,不使用模拟数据
;
+ }
+
+ const last = candles[candles.length - 1];
+ const previous = candles[candles.length - 2];
+ const lastIndicator = indicators[indicators.length - 1];
+ const macdHistogram = lastIndicator?.macdHistogram;
+ const selectedIndex = selectedDate ? candles.findIndex((candle) => candle.date === selectedDate) : -1;
+ const selected = selectedIndex >= 0 ? candles[selectedIndex] : undefined;
+ const selectedPrevious = selectedIndex > 0 ? candles[selectedIndex - 1] : undefined;
+ const selectedIndicator = selectedDate ? indicators.find((point) => point.date === selectedDate) : undefined;
+ const dayChange = previous?.close ? (last.close - previous.close) / previous.close : undefined;
+ const sessionChange = last.open ? (last.close - last.open) / last.open : undefined;
+
+ return (
+
+
+ {last.close.toFixed(2)}
+ {formatPercent(dayChange)}
+ {previous ? `${formatSigned(last.close - previous.close)} vs 前收` : "前收 -"}
+ 开收 {formatPercent(sessionChange)}
+ O {last.open.toFixed(2)}
+ H {last.high.toFixed(2)}
+ L {last.low.toFixed(2)}
+ V {compactNumber(last.volume)}
+ RSI {formatOptional(lastIndicator?.rsi14)}
+ MACD {formatOptional(macdHistogram)}
+ PE {snapshot?.pe ?? "-"}
+ {data?.period ?? "D"} · {candles.length}
+
+
+ {MA_OPTIONS.map((option) => (
+ setVisibleMas((current) => ({ ...current, [option.key]: !current[option.key] }))}
+ type="button"
+ >
+
+ {option.label} {formatOptional(lastIndicator?.[option.key])}
+
+ ))}
+
+
+
+
+ );
+}
+
+function DayDetailPanel({
+ candle,
+ previousCandle,
+ indicator,
+ period
+}: {
+ candle?: StockChartData["candles"][number];
+ previousCandle?: StockChartData["candles"][number];
+ indicator?: StockChartData["indicators"][number];
+ period: ChartPeriod;
+}) {
+ if (!candle) {
+ return {period === "D" ? "点击任意日 K 查看当日完整数据" : "点击任意 K 线查看该周期完整数据"}
;
+ }
+
+ const change = previousCandle?.close ? (candle.close - previousCandle.close) / previousCandle.close : undefined;
+ const sessionChange = candle.open ? (candle.close - candle.open) / candle.open : undefined;
+ const amplitude = candle.low ? (candle.high - candle.low) / candle.low : undefined;
+ return (
+
+
+ {candle.date}
+ {formatPercent(change)} vs 前收
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function StatusBar({ status }: { status: Record }) {
+ const items: Array<[DetailKey, string]> = [
+ ["chart", "K线"],
+ ["fundamentals", "财报"],
+ ["related", "研究数据"],
+ ["analysis", "Agent分析"]
+ ];
+ if (items.every(([key]) => status[key] === "idle")) {
+ return null;
+ }
+ return (
+
+ {items.map(([key, label]) => (
+
+ {label}: {statusLabel(status[key])}
+
+ ))}
+
+ );
+}
+
+function createTerminalChart(container: HTMLDivElement): IChartApi {
+ return createChart(container, {
+ width: container.clientWidth,
+ height: 420,
+ autoSize: true,
+ layout: {
+ background: { type: ColorType.Solid, color: "#070b11" },
+ textColor: "#9aa4b2",
+ attributionLogo: false,
+ panes: {
+ separatorColor: "#1b2431",
+ separatorHoverColor: "#2e79c8",
+ enableResize: true
+ }
+ },
+ grid: {
+ vertLines: { color: "#111a26" },
+ horzLines: { color: "#111a26" }
+ },
+ crosshair: {
+ mode: CrosshairMode.Normal,
+ vertLine: { color: "#46627f", labelBackgroundColor: "#1c2a3a" },
+ horzLine: { color: "#46627f", labelBackgroundColor: "#1c2a3a" }
+ },
+ rightPriceScale: {
+ borderColor: "#1e2938"
+ },
+ timeScale: {
+ borderColor: "#1e2938",
+ timeVisible: true,
+ secondsVisible: false,
+ rightOffset: 0,
+ barSpacing: 9,
+ fixRightEdge: true,
+ lockVisibleTimeRangeOnResize: true
+ },
+ localization: {
+ priceFormatter: (value: number) => value.toFixed(value >= 100 ? 2 : 3)
+ },
+ handleScale: {
+ axisPressedMouseMove: true,
+ mouseWheel: true,
+ pinch: true
+ },
+ handleScroll: {
+ mouseWheel: true,
+ pressedMouseMove: true,
+ horzTouchDrag: true,
+ vertTouchDrag: false
+ }
+ });
+}
+
+function timeToDate(time?: Time) {
+ if (!time) {
+ return undefined;
+ }
+ if (typeof time === "string") {
+ return time;
+ }
+ if (typeof time === "number") {
+ return new Date(time * 1000).toISOString().slice(0, 10);
+ }
+ return `${time.year}-${String(time.month).padStart(2, "0")}-${String(time.day).padStart(2, "0")}`;
+}
+
+function lineOptions(color: string, title = "") {
+ return {
+ color,
+ ...(title ? { title } : {}),
+ lineWidth: 2 as const,
+ lastValueVisible: false,
+ priceLineVisible: false
+ };
+}
+
+function toCandleData(candle: StockChartData["candles"][number]): CandlestickData {
+ return {
+ time: candle.date,
+ open: candle.open,
+ high: candle.high,
+ low: candle.low,
+ close: candle.close
+ };
+}
+
+function toVolumeData(candle: StockChartData["candles"][number]): HistogramData {
+ return {
+ time: candle.date,
+ value: candle.volume,
+ color: candle.close >= candle.open ? "rgba(239, 83, 80, 0.46)" : "rgba(38, 166, 154, 0.46)"
+ };
+}
+
+function toMacdHistogram(point: StockChartData["indicators"][number]): HistogramData | undefined {
+ if (!isNumber(point.macdHistogram)) {
+ return undefined;
+ }
+ return {
+ time: point.date,
+ value: point.macdHistogram,
+ color: point.macdHistogram >= 0 ? "rgba(239, 83, 80, 0.58)" : "rgba(38, 166, 154, 0.58)"
+ };
+}
+
+function toLineData(date: string, value: number | undefined): LineData | undefined {
+ return isNumber(value) ? { time: date, value } : undefined;
+}
+
+function isLineData(value: LineData | undefined): value is LineData {
+ return value !== undefined;
+}
+
+function isHistogramData(value: HistogramData | undefined): value is HistogramData {
+ return value !== undefined;
+}
+
+function FundamentalsTable({ data, item }: { data?: StockFundamentalsData; item?: DailyReport["items"][number] }) {
+ const statements = data?.statements ?? [];
+ if (!statements.length) {
+ return 暂无财报缓存
;
+ }
+ return (
+ <>
+
+ {item?.snapshot.price ?? "-"} {item?.snapshot.currency ?? ""}
+ PE {item?.snapshot.pe ?? "-"}
+ {valuationLabel(item?.valuation.label)}
+
+
+
期间
+
营收
+
净利润
+
EPS
+
毛利率
+ {statements.map((statement) => (
+
+ {statement.period}
+ {compactNumber(statement.revenue)}
+ {compactNumber(statement.netIncome)}
+ {formatOptional(statement.eps)}
+ {formatPercent(statement.grossMargin)}
+
+ ))}
+
+ >
+ );
+}
+
+function RelatedDataPanel({ data }: { data?: StockRelatedData }) {
+ const context = data?.data;
+ return (
+
+
+
+
相关市场数据
+
{data ? `缓存 ${new Date(data.cachedAt).toLocaleString()}` : "暂无缓存"}
+
+
+
+ {context ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {context.recentNews?.length ?
: null}
+ {context.socialMentions?.length ?
: null}
+ {context.riskFlags.length ?
{context.riskFlags.join(" · ")}
: null}
+
+ ) : (
+
暂无相关市场数据
+ )}
+
+ );
+}
+
+function DataCoveragePanel({
+ chartData,
+ fundamentals,
+ relatedData,
+ analysis
+}: {
+ chartData?: StockChartData;
+ fundamentals?: StockFundamentalsData;
+ relatedData?: StockRelatedData;
+ analysis?: StockQuantAnalysis;
+}) {
+ const firstCandle = chartData?.candles[0];
+ const lastCandle = chartData?.candles[chartData.candles.length - 1];
+ return (
+
+
+
+
数据覆盖
+
打开自选股时,若当天没有分析,会用这些缓存数据重新调用 agent。
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function QuantAnalysisPanel({ data }: { data?: StockQuantAnalysis }) {
+ const analysis = data?.analysis;
+ return (
+
+
+
+
Agent 量化分析
+
{data ? `生成 ${new Date(data.generatedAt).toLocaleString()}` : "等待分析"}
+
+
+
+ {analysis ? (
+ <>
+
+ {formatOptional(analysis.score)}
+ {actionLabel(analysis.action)}
+ 置信度 {isNumber(analysis.confidence) ? (analysis.confidence * 100).toFixed(0) : "-"}% · {analysis.horizon}
+
+
{analysis.summary}
+ {analysis.tradePlan ?
: null}
+ {analysis.marketContext?.length ? (
+
+ {analysis.marketContext.map((item) => (
+ {item}
+ ))}
+
+ ) : null}
+
+ {analysis.factors.map((factor) => (
+
+
+ {factor.name}
+ {factor.evidence}
+
+
{isNumber(factor.score) ? factor.score.toFixed(0) : "-"}
))}
- {watchlist.length === 0 ?
暂无自选股
: null}
-
+
+ {analysis.riskNotes.map((note) => (
+ {note}
+ ))}
+
+ >
+ ) : (
+ 暂无量化分析
+ )}
+
+ );
+}
-
-
-
IPADS 接口
-
-
- base_url
- {health.baseUrl || "-"}
- env_key
- IPADS_API_KEY
- 自动监控
- {health.scheduler}
-
-
-
+function TradePlanPanel({ plan }: { plan: NonNullable
}) {
+ return (
+
+
+
+
+
+
+
{plan.rationale}
+ {plan.invalidation ?
{plan.invalidation} : null}
+
+ );
+}
-
-
-
-
今日监控日报
-
{report?.summary ?? "暂无日报"}
-
-
{report ? new Date(report.generatedAt).toLocaleString() : ""}
-
-
- {report?.items.map((item) => (
-
-
-
- {item.stock.display}
- {item.stock.name || marketName(item.stock.market)}
-
-
{actionLabel(item.recommendation.action)}
-
-
- {item.snapshot.price} {item.snapshot.currency}
- PE {item.snapshot.pe}
- {valuationLabel(item.valuation.label)}
-
- {item.recommendation.reasons.join(" · ")}
-
- 财报: {item.earnings.nextDate ?? "未披露"}
- 可比均值: {item.valuation.peerAveragePe ?? "-"}
- 相关股票: {item.snapshot.peers.map((peer) => peer.display).join(", ") || "-"}
-
- {item.news[0] ? (
-
- {item.news[0].title}
-
- ) : null}
-
- ))}
-
-
-
+function MentionList({ title, items }: { title: string; items: NonNullable }) {
+ return (
+
+ );
+}
+
+function Signal({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+ {label}
+ {value}
+
);
}
@@ -205,8 +1142,17 @@ function Metric({ icon, label, value }: { icon: React.ReactNode; label: string;
);
}
-function Badge({ tone, children }: { tone: "buy_watch" | "hold" | "avoid"; children: React.ReactNode }) {
- return {children} ;
+function TagList({ tags }: { tags: string[] }) {
+ if (!tags.length) {
+ return 无标签 ;
+ }
+ return (
+
+ {tags.map((tagName) => (
+ {tagName}
+ ))}
+
+ );
}
async function api(path: string, init?: RequestInit): Promise {
@@ -227,16 +1173,68 @@ async function api(path: string, init?: RequestInit): Promise {
return (await response.json()) as T;
}
+function parseTagText(value: string): string[] {
+ return value.split(/[,,]/).map((tagName) => tagName.trim()).filter(Boolean);
+}
+
function marketName(market: string) {
return market === "CN" ? "A股" : market === "HK" ? "港股" : "美股";
}
+function valuationLabel(label?: string) {
+ return label === "discount" ? "估值折价" : label === "premium" ? "估值溢价" : label === "fair" ? "估值合理" : "估值未知";
+}
+
function actionLabel(action: string) {
return action === "buy_watch" ? "买入观察" : action === "avoid" ? "回避" : "持有";
}
-function valuationLabel(label: string) {
- return label === "discount" ? "估值折价" : label === "premium" ? "估值溢价" : label === "fair" ? "估值合理" : "估值未知";
+function tradeDirectionLabel(direction: string) {
+ return direction === "long" ? "做多观察" : direction === "reduce" ? "减仓" : direction === "avoid" ? "回避" : "等待";
+}
+
+function statusLabel(status: LoadStatus) {
+ return status === "loading" ? "进行中" : status === "done" ? "完成" : status === "error" ? "失败" : "等待";
+}
+
+function defaultZoomCount(period: ChartPeriod) {
+ return period === "D" ? 110 : period === "W" ? 96 : period === "M" ? 72 : 36;
+}
+
+function compactNumber(value?: number) {
+ if (!isNumber(value)) {
+ return "-";
+ }
+ if (Math.abs(value) >= 1_000_000_000) {
+ return `${(value / 1_000_000_000).toFixed(2)}B`;
+ }
+ if (Math.abs(value) >= 1_000_000) {
+ return `${(value / 1_000_000).toFixed(1)}M`;
+ }
+ return value.toLocaleString();
+}
+
+function formatPercent(value?: number) {
+ return isNumber(value) ? `${(value * 100).toFixed(2)}%` : "-";
+}
+
+function formatSigned(value?: number) {
+ if (!isNumber(value)) {
+ return "-";
+ }
+ return `${value >= 0 ? "+" : ""}${value.toFixed(2)}`;
+}
+
+function changeClass(value?: number) {
+ return isNumber(value) && value >= 0 ? "up" : "down";
+}
+
+function formatOptional(value?: number) {
+ return isNumber(value) ? value.toFixed(2) : "-";
+}
+
+function isNumber(value: unknown): value is number {
+ return typeof value === "number" && Number.isFinite(value);
}
function errorMessage(error: unknown) {
diff --git a/src/client/styles.css b/src/client/styles.css
index 6d10014..67d2d31 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -1,6 +1,6 @@
:root {
- color: #18202c;
- background: #eef1f4;
+ color: #e7edf5;
+ background: #080a0e;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -13,10 +13,14 @@
body {
margin: 0;
min-width: 320px;
+ background:
+ linear-gradient(180deg, rgba(23, 28, 36, 0.95), rgba(8, 10, 14, 1) 360px),
+ #080a0e;
}
button,
-input {
+input,
+select {
font: inherit;
}
@@ -25,34 +29,40 @@ button {
}
.shell {
- width: min(1180px, calc(100% - 32px));
+ width: min(1680px, calc(100% - 28px));
margin: 0 auto;
- padding: 28px 0 40px;
+ padding: 18px 0 28px;
}
.topbar,
-.layout,
.metrics,
-.reportGrid,
-.itemTitle,
+.workspace,
.panelHeader,
-.watchItem,
-.quoteLine {
+.stockForm,
+.watchlist,
+.infoGrid,
+.insightGrid,
+.quoteLine,
+.configRows,
+.fundTable {
display: grid;
- gap: 16px;
+ gap: 12px;
}
.topbar {
- grid-template-columns: 1fr auto;
+ grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
+ min-height: 58px;
+ padding: 6px 0 16px;
margin-bottom: 22px;
+ border-bottom: 1px solid #1d2530;
}
.eyebrow {
- margin: 0 0 4px;
- color: #596579;
- font-size: 13px;
- font-weight: 700;
+ margin: 0 0 3px;
+ color: #7f8da0;
+ font-size: 11px;
+ font-weight: 850;
letter-spacing: 0;
text-transform: uppercase;
}
@@ -64,261 +74,1033 @@ p {
}
h1 {
- font-size: clamp(30px, 4vw, 48px);
- line-height: 1.05;
+ color: #f4f7fb;
+ font-size: clamp(24px, 2.8vw, 38px);
+ line-height: 1.06;
letter-spacing: 0;
}
h2 {
- font-size: 18px;
+ color: #f0f4fa;
+ font-size: 15px;
letter-spacing: 0;
}
-.iconText {
- border: 0;
- border-radius: 8px;
- background: #184c8f;
+.iconText,
+.iconButton,
+.ghostIcon,
+.smallButton,
+.tagChip,
+.toolButton,
+.segmented button {
+ border-radius: 6px;
+ min-height: 36px;
+}
+
+.iconText,
+.smallButton {
+ border: 1px solid #2c74b8;
+ background: #155f9f;
color: #fff;
- min-height: 42px;
- padding: 0 16px;
- font-weight: 700;
+ font-weight: 850;
}
.iconText {
display: inline-flex;
align-items: center;
gap: 8px;
+ min-height: 38px;
+ padding: 0 14px;
+}
+
+.iconText:disabled {
+ opacity: 0.65;
+}
+
+.smallButton {
+ padding: 0 11px;
}
.iconButton,
.ghostIcon {
- width: 42px;
- height: 42px;
+ width: 40px;
+ height: 40px;
display: inline-grid;
place-items: center;
- border-radius: 8px;
}
.iconButton {
- border: 0;
- background: #1f7a5c;
+ border: 1px solid #208c6c;
+ background: #14785e;
color: #fff;
}
.ghostIcon {
- border: 1px solid #d6dce5;
- background: #fff;
- color: #596579;
+ border: 1px solid #283241;
+ background: #111821;
+ color: #91a0b3;
}
.alert {
- border-left: 4px solid #bd3b3b;
- background: #fff;
- color: #8f2020;
- padding: 12px 14px;
- margin-bottom: 18px;
- border-radius: 8px;
+ border: 1px solid #7a2b34;
+ background: #251015;
+ color: #ff9aa5;
+ padding: 11px 13px;
+ margin-bottom: 14px;
+ border-radius: 6px;
+}
+
+.statusBar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: -4px 0 14px;
+}
+
+.loadState {
+ border: 1px solid #202936;
+ border-radius: 999px;
+ background: #0d1219;
+ color: #91a0b3;
+ padding: 6px 10px;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.loadState.loading {
+ border-color: #2e79c8;
+ color: #9dd3ff;
+ background: #102235;
+}
+
+.loadState.done {
+ border-color: #17654e;
+ color: #74e4bd;
+ background: #10281f;
+}
+
+.loadState.error {
+ border-color: #7a2b34;
+ color: #ff99a1;
+ background: #2a1117;
}
.metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
- margin-bottom: 18px;
-}
-
-.metric,
-.panel,
-.report,
-.reportItem {
- background: #fff;
- border: 1px solid #dfe4ea;
- border-radius: 8px;
-}
-
-.metric {
- min-height: 104px;
- padding: 18px;
- display: grid;
- align-content: space-between;
-}
-
-.metric svg {
- color: #b45f24;
-}
-
-.metric span {
- color: #667085;
- font-size: 13px;
-}
-
-.metric strong {
- font-size: 24px;
-}
-
-.layout {
- grid-template-columns: 1.15fr 0.85fr;
- margin-bottom: 18px;
-}
-
-.panel,
-.report {
- padding: 18px;
-}
-
-.panelHeader {
- grid-template-columns: 1fr auto;
- align-items: start;
- margin-bottom: 16px;
-}
-
-.panelHeader p,
-.panelHeader span,
-.reason,
-.empty,
-.detailRows,
-.watchItem span {
- color: #667085;
- font-size: 13px;
-}
-
-.stockForm {
- display: grid;
- grid-template-columns: 1fr 1fr auto;
- gap: 10px;
margin-bottom: 14px;
}
-.configRows {
- display: grid;
- grid-template-columns: 92px minmax(0, 1fr);
- gap: 10px 12px;
- align-items: baseline;
+.metric,
+.panel {
+ background: #10151d;
+ border: 1px solid #202936;
+ border-radius: 8px;
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
}
-.configRows span {
- color: #465366;
+.metric {
+ min-height: 78px;
+ padding: 13px 14px;
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-template-rows: auto auto;
+ gap: 5px 10px;
+ align-content: center;
+}
+
+.metric svg {
+ grid-row: 1 / 3;
+ color: #d5a44b;
+}
+
+.metric span,
+.panelHeader p,
+.panelHeader span,
+.empty,
+.hint,
+.watchItem span,
+.configRows span,
+.cacheMeta {
+ color: #8390a2;
+ font-size: 12px;
+}
+
+.metric strong {
+ color: #f4f7fb;
+ font-size: 18px;
+ line-height: 1.1;
+}
+
+.workspace {
+ grid-template-columns: 330px minmax(0, 1fr);
+ align-items: start;
+}
+
+.sidebar,
+.detailStack {
+ min-width: 0;
+}
+
+.panel {
+ padding: 14px;
+}
+
+.sidebar {
+ position: sticky;
+ top: 12px;
+}
+
+.panelHeader {
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ margin-bottom: 12px;
+}
+
+.chartHeader {
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.cacheMeta {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.sidebarSectionLabel {
+ font-size: 11px;
+ color: #7f8da0;
+ font-weight: 800;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+
+.sidebarHint {
+ margin: 0;
+ font-size: 11px;
+ color: #6f7c8f;
+ line-height: 1.4;
+}
+
+.stockForm {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: 14px;
+ padding: 10px;
+ background: #0c1118;
+ border: 1px solid #1d2530;
+ border-radius: 7px;
+}
+
+.stockFormRow {
+ display: flex;
+ gap: 6px;
+ min-width: 0;
+}
+
+.stockFormRow > .ghostInput {
+ flex: 1 1 0;
+ min-width: 0;
+ width: 0;
+}
+
+.stockFormSubmitRow .ghostButton {
+ flex-shrink: 0;
+}
+
+.stockFormSubmit {
+ justify-content: center;
+}
+
+.sidebarSection {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 10px;
+ background: #0c1118;
+ border: 1px solid #1d2530;
+ border-radius: 7px;
+ margin-bottom: 14px;
+ min-width: 0;
+}
+
+.tagFilterSection .tagFilter {
+ margin-bottom: 0;
+}
+
+input {
+ width: 100%;
+ min-height: 40px;
+ border: 1px solid #283241;
+ border-radius: 6px;
+ padding: 0 11px;
+ color: #e7edf5;
+ background: #0b1017;
+}
+
+input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ min-height: 16px;
+ padding: 0;
+ margin: 0;
+ flex: 0 0 auto;
+}
+
+input::placeholder {
+ color: #536173;
+}
+
+input:focus {
+ outline: 2px solid rgba(46, 121, 200, 0.45);
+ border-color: #2e79c8;
+}
+
+.tagFilter {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ margin-bottom: 12px;
+}
+
+.tagChip {
+ border: 1px solid #283241;
+ background: #0d131b;
+ color: #a8b3c2;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 0 9px;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.tagChip.active {
+ border-color: #2e79c8;
+ background: #112943;
+ color: #d5ebff;
+}
+
+.watchlist {
+ gap: 6px;
+}
+
+.watchItem {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ text-align: left;
+ border: 1px solid #1d2530;
+ background: #0c1118;
+ border-radius: 7px;
+ padding: 9px 10px;
+ transition: border-color 0.12s, background 0.12s;
+ min-width: 0;
+}
+
+.watchItem:hover {
+ border-color: #2c3a4f;
+ background: #0d141d;
+}
+
+.watchItem.selected {
+ border-color: #2c74b8;
+ background: rgba(77, 182, 255, 0.07);
+}
+
+.watchItem.editing {
+ border-color: #2c74b8;
+}
+
+.watchItemHead {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 8px;
+ cursor: pointer;
+ min-width: 0;
+}
+
+.watchItemHead:focus-visible {
+ outline: 2px solid rgba(46, 121, 200, 0.45);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+.watchItemTitle {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.watchItemTitle strong {
+ color: #f3f6fb;
font-size: 13px;
+ font-weight: 800;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.watchItemTitle span {
+ color: #8390a2;
+ font-size: 11px;
+ font-weight: 700;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.watchItemActions {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+.watchItemTags {
+ min-width: 0;
+}
+
+.watchItemTagEdit {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+.watchItemTagEdit .ghostInput {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+.ghostIconButton {
+ width: 26px;
+ height: 26px;
+ display: inline-grid;
+ place-items: center;
+ border: 1px solid #1d2530;
+ background: transparent;
+ color: #7f8da0;
+ border-radius: 5px;
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s, color 0.12s;
+}
+
+.ghostIconButton:hover {
+ border-color: #2c74b8;
+ background: #11202f;
+ color: #cfe6ff;
+}
+
+.ghostIconButton.danger:hover {
+ border-color: #7a2b34;
+ background: #2a1117;
+ color: #ff9aa5;
+}
+
+.ghostButtonPrimary {
+ border-color: #2c74b8;
+ background: #112943;
+ color: #cfe6ff;
+}
+
+.ghostButtonPrimary:hover:not(:disabled) {
+ border-color: #4d97e0;
+ background: #163556;
+ color: #ffffff;
+}
+
+.tagList {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+}
+
+.tagList em {
+ border-radius: 999px;
+ background: #10281f;
+ color: #7bdcbf;
+ font-size: 11px;
+ font-style: normal;
+ font-weight: 850;
+ padding: 2px 7px;
+}
+
+.tagList.muted {
+ color: #617083;
+}
+
+.detailStack {
+ display: grid;
+ gap: 14px;
+}
+
+.chartPanel {
+ background: #070b11;
+ border-color: #1a2230;
+ padding: 14px;
+}
+
+.chartPanel .eyebrow,
+.chartPanel .cacheMeta,
+.chartPanel .panelHeader span {
+ color: #7f8da0;
+}
+
+.chartControls {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 5px;
+ margin: -4px 0 8px;
+}
+
+.segmented {
+ display: inline-grid;
+ grid-template-columns: repeat(4, 28px);
+ gap: 2px;
+ border: 1px solid #222c3a;
+ border-radius: 6px;
+ padding: 2px;
+ background: #080d13;
+}
+
+.segmented button,
+.toolButton {
+ border: 0;
+ background: #111923;
+ color: #9caabc;
+ font-size: 11px;
+ font-weight: 850;
+ min-height: 26px;
+}
+
+.segmented button.active {
+ background: #d5a44b;
+ color: #0b0f15;
+}
+
+.toolButton {
+ width: 28px;
+ display: inline-grid;
+ place-items: center;
+ border-radius: 6px;
+}
+
+.chartWrap {
+ display: grid;
+ gap: 10px;
+}
+
+.tickerTape {
+ min-height: 36px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 7px;
+ color: #a8b3c2;
+ font-size: 12px;
+}
+
+.tickerTape strong {
+ color: #f7fafc;
+ font-size: 24px;
+ line-height: 1;
+ margin-right: 2px;
+}
+
+.tickerTape span {
+ border: 1px solid #1d2632;
+ border-radius: 6px;
+ background: #0d131b;
+ padding: 5px 8px;
+ font-weight: 850;
+}
+
+.tickerTape .up {
+ color: #ff6b72;
+ border-color: rgba(239, 83, 80, 0.3);
+ background: rgba(239, 83, 80, 0.1);
+}
+
+.tickerTape .down {
+ color: #3bd6aa;
+ border-color: rgba(38, 166, 154, 0.3);
+ background: rgba(38, 166, 154, 0.1);
+}
+
+.maToggleBar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ margin-top: -2px;
+}
+
+.maToggleBar button {
+ min-height: 27px;
+ border: 1px solid #1d2632;
+ border-radius: 6px;
+ background: #0d131b;
+ color: #8f9caf;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 8px;
+ font-size: 11px;
+ font-weight: 850;
+}
+
+.maToggleBar button.active {
+ border-color: #334256;
+ color: #f0f5fb;
+ background: #121b26;
+}
+
+.maToggleBar i {
+ width: 14px;
+ height: 2px;
+ border-radius: 999px;
+ opacity: 0.45;
+}
+
+.maToggleBar button.active i {
+ opacity: 1;
+}
+
+.libraryChart {
+ width: 100%;
+ min-height: 390px;
+ overflow: hidden;
+ border: 1px solid #1a2230;
+ border-radius: 7px;
+ background: #070b11;
+}
+
+.chartEmpty {
+ min-height: 340px;
+ display: grid;
+ place-items: center;
+ color: #7f8da0;
+ border: 1px solid #1a2230;
+ border-radius: 7px;
+ background: #070b11;
+}
+
+.dayDetail {
+ border: 1px solid #1a2230;
+ border-radius: 7px;
+ background: #0a0f16;
+ padding: 9px;
+ display: grid;
+ gap: 8px;
+}
+
+.dayDetail.empty {
+ min-height: 42px;
+ place-items: center;
+ color: #7f8da0;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.dayDetailTitle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.dayDetailTitle strong {
+ color: #f0f5fb;
+ font-size: 13px;
+}
+
+.dayDetailTitle span {
+ border-radius: 999px;
+ padding: 3px 8px;
+ font-size: 11px;
+ font-weight: 850;
+}
+
+.dayDetailTitle .up {
+ color: #ff737a;
+ background: rgba(239, 83, 80, 0.12);
+}
+
+.dayDetailTitle .down {
+ color: #4be2b8;
+ background: rgba(38, 166, 154, 0.12);
+}
+
+.dayDetailGrid {
+ display: grid;
+ grid-template-columns: repeat(12, minmax(86px, 1fr));
+ gap: 6px;
+ overflow-x: auto;
+}
+
+.dayDetailGrid .signal {
+ padding: 7px 8px;
+ gap: 2px;
+}
+
+.dayDetailGrid .signal span {
+ font-size: 10px;
+}
+
+.dayDetailGrid .signal strong {
+ font-size: 12px;
+}
+
+.infoGrid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.insightGrid {
+ grid-template-columns: minmax(360px, 0.95fr) minmax(0, 1.25fr);
+}
+
+.hint {
+ margin-top: 8px;
+}
+
+.configRows {
+ grid-template-columns: 92px minmax(0, 1fr);
+ gap: 8px 12px;
+ align-items: baseline;
}
.configRows strong {
min-width: 0;
overflow-wrap: anywhere;
- font-size: 14px;
+ color: #e7edf5;
+ font-size: 13px;
}
-input {
- width: 100%;
- min-height: 42px;
- border: 1px solid #cad2dd;
- border-radius: 8px;
- padding: 0 12px;
- color: #18202c;
- background: #f9fafb;
+.report {
+ min-width: 0;
}
-input:focus {
- outline: 2px solid #8bb8e8;
- border-color: #184c8f;
+.quoteLine {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ margin-bottom: 12px;
}
-.watchlist {
+.quoteLine span {
+ border: 1px solid #202936;
+ border-radius: 7px;
+ background: #0d1219;
+ min-height: 34px;
display: grid;
- gap: 10px;
+ place-items: center;
+ color: #dfe7f1;
+ font-size: 12px;
+ font-weight: 850;
+ text-align: center;
+ padding: 0 8px;
}
-.watchItem {
- grid-template-columns: 1fr auto;
+.fundTable {
+ grid-template-columns: 0.7fr repeat(4, minmax(0, 1fr));
+ gap: 0;
+ overflow: hidden;
+ border: 1px solid #202936;
+ border-radius: 7px;
+}
+
+.fundTable > * {
+ min-height: 36px;
+ display: grid;
align-items: center;
- padding: 10px 0;
- border-top: 1px solid #edf0f3;
+ padding: 0 10px;
+ border-bottom: 1px solid #202936;
+ color: #cfd8e5;
+ font-size: 12px;
}
-.watchItem div,
-.itemTitle div {
+.fundTable > *:nth-last-child(-n + 5) {
+ border-bottom: 0;
+}
+
+.fundHead {
+ background: #121923;
+ color: #8d9aad;
+ font-weight: 850;
+}
+
+.fundTable strong {
+ color: #f2f6fb;
+ font-size: 12px;
+}
+
+.relatedGrid {
display: grid;
- gap: 3px;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 9px;
}
-.reportGrid {
+.coverageRows {
+ display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 9px;
}
-.reportItem {
- padding: 16px;
+.signal {
+ min-width: 0;
+ border: 1px solid #202936;
+ border-radius: 7px;
+ background: #0d1219;
+ padding: 10px;
+ display: grid;
+ gap: 4px;
}
-.itemTitle {
- grid-template-columns: 1fr auto;
+.signal span {
+ color: #8390a2;
+ font-size: 11px;
+}
+
+.signal strong {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ color: #f0f5fb;
+ font-size: 13px;
+}
+
+.riskFlags {
+ grid-column: 1 / -1;
+ border: 1px solid #61451d;
+ border-radius: 7px;
+ background: #21180c;
+ color: #e3ba72;
+ padding: 10px;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.mentionList {
+ grid-column: 1 / -1;
+ border: 1px solid #202936;
+ border-radius: 7px;
+ background: #0d1219;
+ padding: 10px;
+ display: grid;
+ gap: 7px;
+}
+
+.mentionList > strong {
+ color: #f0f5fb;
+ font-size: 12px;
+}
+
+.mentionList a {
+ color: #aebbd0;
+ font-size: 12px;
+ line-height: 1.4;
+ text-decoration: none;
+}
+
+.mentionList a:hover {
+ color: #d5a44b;
+}
+
+.mentionList a span {
+ color: #6f7f94;
+ font-size: 10px;
+ font-weight: 850;
+ margin-right: 6px;
+ text-transform: uppercase;
+}
+
+.analysisPanel {
+ min-width: 0;
+}
+
+.analysisScore {
+ display: flex;
+ flex-wrap: wrap;
align-items: center;
- margin-bottom: 14px;
+ gap: 10px;
+ margin-bottom: 10px;
+}
+
+.analysisScore strong {
+ color: #f5f8fc;
+ font-size: 36px;
+ line-height: 1;
+}
+
+.analysisScore em {
+ color: #8390a2;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 800;
+}
+
+.analysisSummary {
+ color: #c8d2df;
+ font-size: 13px;
+ line-height: 1.55;
+ margin-bottom: 12px;
+}
+
+.tradePlan {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 8px;
+ border: 1px solid #273246;
+ border-radius: 7px;
+ background: #0a1018;
+ padding: 10px;
+ margin-bottom: 12px;
+}
+
+.tradePlan p,
+.tradePlan em {
+ grid-column: 1 / -1;
+ color: #c8d2df;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.tradePlan em {
+ color: #e6c071;
+ font-style: normal;
+}
+
+.marketContext {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ margin-bottom: 12px;
+}
+
+.marketContext span {
+ border: 1px solid #273246;
+ border-radius: 999px;
+ background: #0d1219;
+ color: #aebbd0;
+ padding: 5px 9px;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.factorList {
+ display: grid;
+ gap: 8px;
+}
+
+.factorRow {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 42px;
+ gap: 10px;
+ align-items: center;
+ border-top: 1px solid #202936;
+ padding-top: 8px;
+}
+
+.factorRow div {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+}
+
+.factorRow strong {
+ color: #eef4fb;
+ font-size: 12px;
+}
+
+.factorRow span {
+ color: #8390a2;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.factorRow b {
+ border-radius: 7px;
+ background: #112943;
+ color: #9dd3ff;
+ min-height: 30px;
+ display: grid;
+ place-items: center;
+}
+
+.riskNotes {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ margin-top: 12px;
+}
+
+.riskNotes span {
+ border: 1px solid #202936;
+ border-radius: 999px;
+ background: #0d1219;
+ color: #9aa8ba;
+ padding: 5px 9px;
+ font-size: 11px;
+ font-weight: 800;
}
.badge {
border-radius: 999px;
padding: 5px 10px;
font-size: 12px;
- font-weight: 800;
+ font-weight: 850;
}
.buy_watch {
- color: #0f5c43;
- background: #dff4eb;
+ color: #74e4bd;
+ background: #10281f;
}
.hold {
- color: #684513;
- background: #f8ead2;
+ color: #e6c071;
+ background: #21180c;
}
.avoid {
- color: #8f2020;
- background: #f7dddd;
+ color: #ff99a1;
+ background: #2a1117;
}
-.quoteLine {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- margin-bottom: 10px;
+@media (max-width: 1180px) {
+ .workspace,
+ .infoGrid,
+ .insightGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .sidebar {
+ position: static;
+ }
+
+ .relatedGrid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .coverageRows {
+ grid-template-columns: 1fr;
+ }
+
+ .tradePlan {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
}
-.quoteLine span {
- border-radius: 8px;
- background: #f2f5f8;
- min-height: 34px;
- display: grid;
- place-items: center;
- font-size: 13px;
- font-weight: 700;
-}
-
-.reason {
- min-height: 38px;
- line-height: 1.45;
-}
-
-.detailRows {
- display: grid;
- gap: 6px;
- margin: 12px 0;
-}
-
-.news {
- display: block;
- color: #184c8f;
- font-size: 13px;
- font-weight: 700;
- text-decoration: none;
- overflow-wrap: anywhere;
-}
-
-@media (max-width: 860px) {
+@media (max-width: 760px) {
.shell {
- width: min(100% - 20px, 720px);
- padding-top: 18px;
+ width: min(100% - 16px, 720px);
+ padding-top: 14px;
}
.topbar,
- .layout,
- .reportGrid,
- .metrics {
+ .metrics,
+ .quoteLine {
grid-template-columns: 1fr;
}
@@ -326,7 +1108,1366 @@ input:focus {
grid-template-columns: 1fr auto;
}
- .stockForm input:first-child {
+ .stockForm input,
+ .stockForm input:first-child,
+ .stockForm input:nth-child(3) {
grid-column: 1 / -1;
}
+
+ .stockForm .iconButton {
+ width: 100%;
+ grid-column: 1 / -1;
+ }
+
+ .cacheMeta {
+ white-space: normal;
+ justify-content: start;
+ }
+
+ .chartControls {
+ justify-content: start;
+ }
+
+ .libraryChart {
+ min-height: 360px;
+ }
+
+ .dayDetailGrid {
+ grid-template-columns: repeat(12, minmax(82px, 1fr));
+ }
+
+ .relatedGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .tradePlan {
+ grid-template-columns: 1fr;
+ }
+
+ .fundTable {
+ grid-template-columns: repeat(5, minmax(86px, 1fr));
+ overflow-x: auto;
+ }
+}
+
+/* ---------------------------------------------------------------------------
+ Tab navigation
+--------------------------------------------------------------------------- */
+
+.topbarTabs {
+ display: flex;
+ gap: 4px;
+ background: #131820;
+ border-radius: 8px;
+ padding: 3px;
+}
+
+.tabButton {
+ background: transparent;
+ border: none;
+ color: #7f8da0;
+ font-size: 13px;
+ font-weight: 600;
+ padding: 6px 16px;
+ border-radius: 6px;
+ transition: all 0.15s;
+}
+
+.tabButton.active {
+ background: #1d2530;
+ color: #f0f4fa;
+}
+
+.tabButton:hover:not(.active) {
+ color: #b0b8c4;
+}
+
+/* ---------------------------------------------------------------------------
+ Strategy Page
+--------------------------------------------------------------------------- */
+
+.strategyPage {
+ padding-top: 8px;
+}
+
+.strategyLayout {
+ display: grid;
+ grid-template-columns: minmax(340px, 360px) minmax(0, 1fr);
+ gap: 16px;
+ align-items: start;
+}
+
+.strategySelector {
+ position: sticky;
+ top: 16px;
+ max-height: calc(100vh - 100px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 14px !important;
+}
+
+.strategyConfig {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.configRow {
+ display: grid;
+ grid-template-columns: 82px minmax(0, 1fr);
+ align-items: center;
+ gap: 8px;
+}
+
+.configRow label {
+ font-size: 12px;
+ color: #7f8da0;
+ white-space: nowrap;
+}
+
+.configRow input[type="number"],
+.configRow select {
+ width: 100%;
+ min-width: 0;
+ min-height: 34px;
+ background: #131820;
+ border: 1px solid #1d2530;
+ color: #e7edf5;
+ border-radius: 5px;
+ padding: 5px 8px;
+ font-size: 13px;
+}
+
+.configRow .segmented.small {
+ width: 100%;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.configRow .segmented.small button {
+ min-height: 28px;
+ padding: 4px 8px;
+ font-size: 12px;
+}
+
+.precomputedHeader {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid #1d2530;
+}
+
+.precomputedHeaderRow {
+ display: grid;
+ grid-template-columns: 82px minmax(0, 1fr);
+ align-items: center;
+ gap: 8px;
+}
+
+.precomputedHeaderRow label {
+ font-size: 12px;
+ color: #7f8da0;
+}
+
+.precomputedHint {
+ margin: 0;
+ font-size: 11px;
+ color: #6f7c8f;
+ font-weight: 700;
+ letter-spacing: 0;
+}
+
+.scenarioPicker {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.scenarioPickerLabel {
+ font-size: 11px;
+ color: #7f8da0;
+ font-weight: 800;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+
+.scenarioOptions {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.scenarioOption {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ background: #0c1118;
+ border: 1px solid #1d2530;
+ border-radius: 6px;
+ padding: 8px 10px;
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s;
+}
+
+.scenarioOption strong {
+ color: #e7edf5;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.scenarioOption span {
+ color: #7f8da0;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.scenarioOption:hover {
+ border-color: #2c3a4f;
+}
+
+.scenarioOption.active {
+ border-color: #2c74b8;
+ background: rgba(77, 182, 255, 0.07);
+}
+
+.scenarioOption.active strong {
+ color: #cfe6ff;
+}
+
+.quickPicks {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.quickPicksLabel {
+ font-size: 11px;
+ color: #7f8da0;
+ font-weight: 800;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+
+.quickPicksRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.quickPickButton {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: #14304a;
+ border: 1px solid #1d4571;
+ color: #cfe6ff;
+ border-radius: 5px;
+ padding: 5px 9px;
+ font-size: 11px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background 0.12s;
+}
+
+.quickPickButton:hover {
+ background: #1c3f5e;
+}
+
+.quickPickButton.ghost {
+ background: transparent;
+ border-color: #283241;
+ color: #91a0b3;
+}
+
+.quickPickButton.ghost:hover {
+ background: #131820;
+}
+
+.strategyList {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ max-height: 460px;
+ overflow-y: auto;
+ border-top: 1px solid #1d2530;
+ padding-top: 6px;
+}
+
+.strategyItem {
+ display: grid;
+ grid-template-columns: 26px 16px 10px minmax(0, 1fr) auto auto;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 6px;
+ border-radius: 5px;
+ cursor: pointer;
+ font-size: 12px;
+ color: #b0b8c4;
+ transition: background 0.12s;
+}
+
+.strategyItem:hover {
+ background: #1a2030;
+}
+
+.strategyItem.selected {
+ background: #1a2a3d;
+ color: #f0f4fa;
+}
+
+.strategyItem input[type="checkbox"] {
+ accent-color: #4db6ff;
+ margin: 0;
+ width: 14px;
+ height: 14px;
+}
+
+.strategyRank {
+ font-size: 10px;
+ font-weight: 800;
+ color: #6f7c8f;
+ text-align: center;
+ letter-spacing: 0;
+}
+
+.strategyRank[data-top="1"] {
+ color: #f3c969;
+}
+
+.strategyDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ border: 1px solid #2a3344;
+}
+
+.strategyLabel {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.strategyMetric {
+ font-size: 11px;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+}
+
+.strategyMetric.positive {
+ color: #26a69a;
+}
+
+.strategyMetric.negative {
+ color: #ef5350;
+}
+
+.strategyMetricMuted {
+ font-size: 10px;
+ font-weight: 700;
+ color: #7f8da0;
+ font-variant-numeric: tabular-nums;
+ min-width: 28px;
+ text-align: right;
+}
+
+.strategyEmpty {
+ color: #7f8da0;
+ font-size: 12px;
+ line-height: 1.45;
+ padding: 10px 6px;
+}
+
+.strategyResults {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.legendEmpty {
+ color: #6f7c8f;
+ font-size: 12px;
+ font-style: italic;
+}
+
+/* Ranking leaderboard */
+.rankingPanel {
+ padding: 14px !important;
+}
+
+.rankingWrap {
+ overflow-x: auto;
+ border: 1px solid #1d2530;
+ border-radius: 7px;
+ background: #0c1118;
+ margin-top: 6px;
+}
+
+.rankingTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+
+.rankingTable th {
+ text-align: left;
+ color: #7f8da0;
+ font-weight: 600;
+ padding: 8px 10px;
+ border-bottom: 1px solid #1d2530;
+ background: #0c1118;
+ white-space: nowrap;
+ position: sticky;
+ top: 0;
+}
+
+.rankingTable th.sortable {
+ cursor: pointer;
+ user-select: none;
+}
+
+.rankingTable th.sortable:hover {
+ color: #cfe6ff;
+}
+
+.rankingTable th.sortable.active {
+ color: #4db6ff;
+ display: inline;
+}
+
+.rankingTable th.sortable svg {
+ margin-left: 4px;
+ vertical-align: middle;
+}
+
+.rankingTable td {
+ padding: 7px 10px;
+ border-bottom: 1px solid #131820;
+ color: #b0b8c4;
+ white-space: nowrap;
+}
+
+.rankingTable tbody tr {
+ cursor: pointer;
+}
+
+.rankingTable tbody tr:hover td {
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.rankingTable tbody tr.selected td {
+ background: rgba(77, 182, 255, 0.06);
+ color: #f0f4fa;
+}
+
+.rankingTable td.positive {
+ color: #26a69a;
+}
+
+.rankingTable td.negative {
+ color: #ef5350;
+}
+
+.rankingTable td:nth-child(2) strong {
+ display: block;
+ color: #eef4fb;
+}
+
+.rankSub {
+ display: block;
+ margin-top: 2px;
+ color: #6f7c8f;
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.rankCol {
+ width: 50px;
+ text-align: center;
+}
+
+.rankBadge {
+ display: inline-block;
+ min-width: 26px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ background: #131820;
+ color: #b0b8c4;
+ font-size: 11px;
+ font-weight: 800;
+ text-align: center;
+}
+
+.rankBadge.top {
+ background: rgba(243, 201, 105, 0.12);
+ color: #f3c969;
+ border: 1px solid rgba(243, 201, 105, 0.3);
+}
+
+.categoryChip {
+ display: inline-block;
+ padding: 2px 8px;
+ background: #131820;
+ border: 1px solid #1d2530;
+ border-radius: 999px;
+ font-size: 10px;
+ color: #91a0b3;
+ font-weight: 700;
+}
+
+.emptyState {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ min-height: 400px;
+ color: #7f8da0;
+ text-align: center;
+}
+
+.emptyState svg {
+ opacity: 0.4;
+}
+
+/* Equity chart */
+.equityChartContainer {
+ width: 100%;
+ min-height: 480px;
+}
+
+.equityHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 12px;
+ margin-bottom: 6px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid #161e2a;
+}
+
+.equityTitle {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.equityTitle h2 {
+ font-size: 16px;
+ line-height: 1.2;
+}
+
+.equityMeta {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 700;
+ color: #8390a2;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.equityMetaItem {
+ padding: 3px 8px;
+ border: 1px solid #1d2530;
+ border-radius: 4px;
+ background: #0c1118;
+ color: #cfd8e3;
+}
+
+.equityMetaSep {
+ color: #4a5566;
+ font-weight: 800;
+}
+
+.legendRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 10px;
+}
+
+.legendItem {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ background: transparent;
+ border: 1px solid #2a2a4a;
+ border-radius: 4px;
+ padding: 3px 8px;
+ font-size: 11px;
+ color: #b0b8c4;
+ cursor: pointer;
+ transition: all 0.12s;
+}
+
+.legendItem.focused {
+ border-color: #4db6ff;
+ color: #f0f4fa;
+ background: rgba(77, 182, 255, 0.08);
+}
+
+.legendItem.hidden {
+ color: #5e6a78;
+ opacity: 0.62;
+}
+
+.legendItem.hidden .legendDot {
+ background: transparent !important;
+ border: 1px solid #5e6a78;
+}
+
+.benchmarkLegend {
+ color: #8d98a8;
+ cursor: default;
+}
+
+.benchmarkLegend .legendDot {
+ border-radius: 2px;
+ background: #777;
+}
+
+.legendDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+/* Metrics panel */
+.metricsPanel {
+ padding: 14px !important;
+}
+
+.metricsGrid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.focusSummary {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: -4px 0 12px;
+}
+
+.focusSummary span {
+ min-height: 30px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border: 1px solid #222c3a;
+ border-radius: 6px;
+ background: #0d131b;
+ color: #b8c3d1;
+ padding: 0 10px;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.metricCell {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ padding: 8px 4px;
+ background: #131820;
+ border-radius: 6px;
+}
+
+.metricValue {
+ font-size: 16px;
+ font-weight: 700;
+ color: #f0f4fa;
+}
+
+.metricCell.positive .metricValue {
+ color: #26a69a;
+}
+
+.metricCell.negative .metricValue {
+ color: #ef5350;
+}
+
+.metricLabel {
+ font-size: 11px;
+ color: #7f8da0;
+}
+
+/* Comparison table */
+.comparisonWrap {
+ overflow-x: auto;
+ border: 1px solid #1d2530;
+ border-radius: 7px;
+ background: #0c1118;
+}
+
+.comparisonTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.comparisonTable th {
+ text-align: left;
+ color: #7f8da0;
+ font-weight: 600;
+ padding: 8px 10px;
+ border-bottom: 1px solid #1d2530;
+ white-space: nowrap;
+}
+
+.comparisonTable td {
+ padding: 9px 10px;
+ border-bottom: 1px solid #131820;
+ color: #b0b8c4;
+ vertical-align: middle;
+ white-space: nowrap;
+}
+
+.comparisonTable tbody tr {
+ cursor: pointer;
+}
+
+.comparisonTable tbody tr:hover td {
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.comparisonTable td:first-child {
+ min-width: 220px;
+ white-space: normal;
+}
+
+.comparisonTable td:first-child strong,
+.comparisonTable td:first-child span {
+ display: block;
+}
+
+.comparisonTable td:first-child strong {
+ color: #eef4fb;
+ font-size: 12px;
+}
+
+.comparisonTable td:first-child span {
+ margin-top: 2px;
+ color: #6f7c8f;
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.comparisonTable tr.focused td {
+ color: #f0f4fa;
+ background: rgba(77, 182, 255, 0.08);
+}
+
+.comparisonTable .positive {
+ color: #26a69a;
+}
+
+.comparisonTable .negative {
+ color: #ef5350;
+}
+
+/* Drawdown panel */
+.drawdownPanel {
+ padding: 14px !important;
+}
+
+.drawdownTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.drawdownTable th {
+ text-align: left;
+ color: #7f8da0;
+ font-weight: 600;
+ padding: 5px 8px;
+ border-bottom: 1px solid #1d2530;
+}
+
+.drawdownTable td {
+ padding: 5px 8px;
+ border-bottom: 1px solid #131820;
+ color: #b0b8c4;
+}
+
+.drawdownTable .negative {
+ color: #ef5350;
+}
+
+/* Return heatmap */
+.heatmapPanel {
+ padding: 14px !important;
+}
+
+.heatmapContainer {
+ overflow-x: auto;
+}
+
+.heatmapTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 11px;
+ min-width: 700px;
+}
+
+.heatmapTable th {
+ text-align: center;
+ color: #7f8da0;
+ font-weight: 600;
+ padding: 4px 2px;
+ border-bottom: 1px solid #1d2530;
+}
+
+.heatmapTable td {
+ text-align: center;
+ padding: 4px 2px;
+ color: #e7edf5;
+}
+
+.heatmapTable .yearLabel {
+ font-weight: 600;
+ color: #b0b8c4;
+ text-align: left;
+ padding-left: 6px;
+}
+
+.heatCell {
+ border-radius: 3px;
+ font-size: 10px;
+ min-width: 42px;
+}
+
+.yearReturn {
+ font-weight: 700;
+ font-size: 12px;
+}
+
+.yearReturn.positive {
+ color: #26a69a;
+}
+
+.yearReturn.negative {
+ color: #ef5350;
+}
+
+/* ---------------------------------------------------------------------------
+ Live Page
+--------------------------------------------------------------------------- */
+
+.livePage {
+ padding-top: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.liveTopbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+
+.ghostInput {
+ flex: 1 1 200px;
+ min-width: 160px;
+ min-height: 32px;
+ background: #0c1118;
+ border: 1px solid #1d2530;
+ color: #e7edf5;
+ border-radius: 6px;
+ padding: 4px 10px;
+ font-size: 12px;
+ transition: border-color 0.12s, background 0.12s;
+}
+
+.ghostInput::placeholder {
+ color: #5e6a78;
+}
+
+.ghostInput:hover {
+ border-color: #2c3a4f;
+}
+
+.ghostInput:focus {
+ border-color: #2c74b8;
+ outline: none;
+ background: #0d141d;
+}
+
+.ghostSelectLabel {
+ display: inline-flex;
+ align-items: stretch;
+ gap: 0;
+ border: 1px solid #1d2530;
+ border-radius: 6px;
+ background: #0c1118;
+ overflow: hidden;
+ min-height: 32px;
+}
+
+.ghostSelectLabel > span {
+ display: inline-flex;
+ align-items: center;
+ padding: 0 8px;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0;
+ color: #7f8da0;
+ background: #0a0f15;
+ border-right: 1px solid #1d2530;
+ text-transform: uppercase;
+}
+
+.ghostSelect {
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ background: transparent;
+ background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12' fill='none' stroke='%237f8da0' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 5l3 3 3-3'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ background-size: 10px;
+ border: none;
+ color: #cfd8e3;
+ padding: 0 26px 0 10px;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ outline: none;
+}
+
+.ghostSelectLabel:hover {
+ border-color: #2c3a4f;
+}
+
+.ghostSelectLabel:focus-within {
+ border-color: #2c74b8;
+}
+
+.ghostSelect option {
+ background: #0c1118;
+ color: #e7edf5;
+}
+
+/* Standalone select without the label chip (e.g. pagination size). */
+.ghostSelectCompact {
+ min-height: 28px;
+ background: #0c1118;
+ background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12' fill='none' stroke='%237f8da0' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 5l3 3 3-3'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 8px center;
+ background-size: 10px;
+ border: 1px solid #1d2530;
+ border-radius: 5px;
+ padding: 0 22px 0 8px;
+}
+
+.ghostSelectCompact:hover {
+ border-color: #2c3a4f;
+}
+
+.ghostButton {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 32px;
+ padding: 0 14px;
+ border: 1px solid #1d2530;
+ background: #0c1118;
+ color: #cfd8e3;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s, color 0.12s;
+}
+
+.ghostButton:hover:not(:disabled) {
+ border-color: #2c74b8;
+ background: #11202f;
+ color: #e7edf5;
+}
+
+.ghostButton:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.ghostButton.loading svg {
+ animation: ghostSpin 1s linear infinite;
+}
+
+@keyframes ghostSpin {
+ to { transform: rotate(360deg); }
+}
+
+.liveLayout {
+ display: grid;
+ grid-template-columns: minmax(320px, 360px) minmax(0, 1fr);
+ gap: 16px;
+ align-items: start;
+}
+
+.liveSidebar {
+ position: sticky;
+ top: 16px;
+ max-height: calc(100vh - 120px);
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px !important;
+}
+
+.liveList {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.liveListItem {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 8px 10px;
+ background: #0c1118;
+ border: 1px solid #1d2530;
+ border-radius: 6px;
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.liveListItem:hover {
+ border-color: #2c3a4f;
+}
+
+.liveListItem.selected {
+ border-color: #2c74b8;
+ background: rgba(77, 182, 255, 0.07);
+}
+
+.liveListTitle {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+ max-width: 100%;
+}
+
+.liveListTitle strong {
+ color: #eef4fb;
+ font-size: 13px;
+ font-weight: 800;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: block;
+ max-width: 100%;
+}
+
+.liveListName {
+ color: #6f7c8f;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: block;
+ max-width: 100%;
+}
+
+.liveListBody {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-variant-numeric: tabular-nums;
+}
+
+.liveListValue {
+ font-size: 14px;
+ font-weight: 800;
+ color: #e7edf5;
+}
+
+.liveListPnl {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.liveListPnl.positive {
+ color: #26a69a;
+}
+
+.liveListPnl.negative {
+ color: #ef5350;
+}
+
+.liveListMeta {
+ display: flex;
+ justify-content: space-between;
+ font-size: 10px;
+ color: #6f7c8f;
+ font-weight: 700;
+}
+
+.liveDetail {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.liveSummary {
+ padding: 14px !important;
+}
+
+.liveSummaryHead {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.liveTotalPnl {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+ font-variant-numeric: tabular-nums;
+}
+
+.liveTotalPnl strong {
+ font-size: 22px;
+ font-weight: 800;
+ color: #e7edf5;
+}
+
+.liveTotalPnl span {
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.liveTotalPnl.positive strong,
+.liveTotalPnl.positive span {
+ color: #26a69a;
+}
+
+.liveTotalPnl.negative strong,
+.liveTotalPnl.negative span {
+ color: #ef5350;
+}
+
+.liveSummaryGrid {
+ margin-top: 14px;
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 10px;
+}
+
+.summaryCell {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ padding: 8px 4px;
+ background: #131820;
+ border-radius: 6px;
+}
+
+.summaryValue {
+ font-size: 15px;
+ font-weight: 700;
+ color: #f0f4fa;
+ font-variant-numeric: tabular-nums;
+}
+
+.summaryCell.positive .summaryValue {
+ color: #26a69a;
+}
+
+.summaryCell.negative .summaryValue {
+ color: #ef5350;
+}
+
+.summaryLabel {
+ font-size: 11px;
+ color: #7f8da0;
+}
+
+.liveEquityChart {
+ width: 100%;
+ min-height: 280px;
+}
+
+.holdingsPanel {
+ padding: 14px !important;
+}
+
+.holdingsTable,
+.tradeLogTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+
+.holdingsTable th,
+.tradeLogTable th {
+ text-align: left;
+ color: #7f8da0;
+ font-weight: 600;
+ padding: 7px 10px;
+ border-bottom: 1px solid #1d2530;
+ white-space: nowrap;
+}
+
+.holdingsTable td,
+.tradeLogTable td {
+ padding: 7px 10px;
+ border-bottom: 1px solid #131820;
+ color: #b0b8c4;
+ white-space: nowrap;
+}
+
+.holdingsTable td:first-child strong,
+.tradeLogTable td:first-child strong {
+ color: #eef4fb;
+}
+
+.holdingsCashRow td {
+ background: rgba(255, 255, 255, 0.02);
+ color: #7f8da0;
+}
+
+.tradeAction {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.tradeAction.buy {
+ background: rgba(38, 166, 154, 0.15);
+ color: #26a69a;
+ border: 1px solid rgba(38, 166, 154, 0.3);
+}
+
+.tradeAction.sell {
+ background: rgba(239, 83, 80, 0.15);
+ color: #ef5350;
+ border: 1px solid rgba(239, 83, 80, 0.3);
+}
+
+.tradeLogPanel {
+ padding: 14px !important;
+}
+
+.paginationBar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 10px;
+ padding-top: 8px;
+ border-top: 1px solid #1d2530;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+
+.paginationRange {
+ color: #7f8da0;
+ font-weight: 700;
+}
+
+.paginationControls {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.paginationButton {
+ min-width: 28px;
+ min-height: 28px;
+ padding: 0 8px;
+ background: #0c1118;
+ border: 1px solid #1d2530;
+ color: #cfd8e3;
+ border-radius: 5px;
+ font-size: 13px;
+ font-weight: 700;
+ line-height: 1;
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s, color 0.12s;
+}
+
+.paginationButton:hover:not(:disabled) {
+ border-color: #2c74b8;
+ background: #11202f;
+ color: #e7edf5;
+}
+
+.paginationButton:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.paginationPage {
+ padding: 0 10px;
+ color: #b0b8c4;
+ font-weight: 800;
+ letter-spacing: 0;
+}
+
+/* Live page responsive */
+@media (max-width: 960px) {
+ .liveLayout {
+ grid-template-columns: 1fr;
+ }
+ .liveSidebar {
+ position: static;
+ max-height: none;
+ }
+ .liveSummaryGrid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+/* Strategy page responsive */
+@media (max-width: 960px) {
+ .strategyLayout {
+ grid-template-columns: 1fr;
+ }
+
+ .strategySelector {
+ position: static;
+ max-height: none;
+ }
+
+ .metricsGrid {
+ grid-template-columns: repeat(2, 1fr);
+ }
}
diff --git a/src/server/index.ts b/src/server/index.ts
index b19e554..6cc60ee 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -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; markets?: Record }) {
+ if (raw.scenarios && typeof raw.scenarios === "object") {
+ return raw.scenarios as Record;
+ }>;
+ }
+ // 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;
+ }>;
+ }
+ 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 {
+ 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";
+}
diff --git a/vite.config.ts b/vite.config.ts
index 4c77a46..94502a6 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,11 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
+const host = process.env.HOST ?? "127.0.0.1";
+const clientPort = Number(process.env.CLIENT_PORT ?? 5173);
+const apiPort = Number(process.env.PORT ?? 8787);
+
export default defineConfig({
plugins: [react()],
server: {
+ host,
+ port: clientPort,
+ allowedHosts: ["gahow-pc.ipads-lab.se.sjtu.edu.cn"],
proxy: {
- "/api": "http://127.0.0.1:8787"
+ "/api": `http://127.0.0.1:${apiPort}`
}
}
});