diff --git a/.scratch/glb-pnl/issues/01-display.md b/.scratch/glb-pnl/issues/01-display.md new file mode 100644 index 0000000..4f477b9 --- /dev/null +++ b/.scratch/glb-pnl/issues/01-display.md @@ -0,0 +1,12 @@ +# GLB 五组 PnL 展示 +Type: task +Status: resolved + +## Scope +从已有 raw 缓存解析总体、可投资性约束、AMER、APAC、EMEA 五组曲线,详情与聊天图表使用固定不同颜色和图例,保留细线、缺失断点、总体相关性基线和旧缓存兼容。 + +## Validation +接口覆盖真实响应形状、乱序列、缺失数值、旧缓存和非 GLB。前端构建及浏览器验证。 + +## Answer +已完成缓存读取、GLB series 接口和详情/聊天五色细线图例。59 项后端测试、Ruff、前端构建、Alpha 管理浏览器回归通过;合成五组数据在桌面及 390px 窄屏验证,断点和线宽符合预期。未部署。 diff --git a/backend/app/ai/alpha_tools.py b/backend/app/ai/alpha_tools.py index 423b0b4..954cdee 100644 --- a/backend/app/ai/alpha_tools.py +++ b/backend/app/ai/alpha_tools.py @@ -26,10 +26,13 @@ async def search(ctx, args): async def pnl(ctx, args): data = await ctx.business.get_alpha_pnl(args.alpha_id) points = data.pop("points") + # Chart data is fetched by the UI; keep large series out of model context. + series = data.pop("series", []) return { **data, "alpha_id": args.alpha_id, "count": len(points), + "series": [{"id": item["id"], "label": item["label"]} for item in series], "first": points[0] if points else None, "last": points[-1] if points else None, "null_count": sum(p["value"] is None for p in points), diff --git a/backend/app/alphas.py b/backend/app/alphas.py index cf52a8d..9315605 100644 --- a/backend/app/alphas.py +++ b/backend/app/alphas.py @@ -251,7 +251,7 @@ def summary(item: Alpha, research: Research): return result -def pnl_points(raw): +def pnl_points(raw, column=None): """Use schema column names, preserving missing values rather than creating zero PnL.""" records = raw.get("records") schema = raw.get("schema") or {} @@ -261,6 +261,7 @@ def pnl_points(raw): else: names = [p.get("name", "") if isinstance(p, dict) else str(p) for p in properties] normalized = [name.lower() for name in names] + value_names = (column,) if column else ("pnl", "value") if not isinstance(records, list): raise ValueError("PnL 缺少 records") points = [] @@ -268,15 +269,17 @@ def pnl_points(raw): if isinstance(row, dict): row = {str(k).lower(): v for k, v in row.items()} timestamp = next((row[k] for k in ("date", "datetime", "timestamp") if k in row), None) - value = next((row[k] for k in ("pnl", "value") if k in row), None) + value = next((row[k] for k in value_names if k in row), None) else: date_i = next( (i for i, n in enumerate(normalized) if n in ("date", "datetime", "timestamp")), None ) - pnl_i = next((i for i, n in enumerate(normalized) if n in ("pnl", "value")), None) - if date_i is None or pnl_i is None or not isinstance(row, list) or len(row) <= max(date_i, pnl_i): + pnl_i = next((i for i, n in enumerate(normalized) if n in value_names), None) + if date_i is None or pnl_i is None or not isinstance(row, list) or len(row) <= date_i: raise ValueError("PnL schema 无法识别日期或数值列") - timestamp, value = row[date_i], row[pnl_i] + if len(row) <= pnl_i and column is None: + raise ValueError("PnL schema 无法识别日期或数值列") + timestamp, value = row[date_i], row[pnl_i] if len(row) > pnl_i else None if timestamp is not None: if isinstance(timestamp, (int, float)): from datetime import timezone @@ -286,3 +289,30 @@ def pnl_points(raw): ).isoformat() points.append({"date": str(timestamp), "value": number(value)}) return sorted(points, key=lambda p: p["date"]) + + +def glb_pnl_series(raw, points): + """Read GLB display series from cached raw data; keep the correlation baseline intact. + + Missing columns are omitted, while missing values remain gaps. Legacy caches + containing only normalized points still return their overall PnL. + """ + series = [{"id": "pnl", "label": "总体 PnL", "points": points}] + schema = raw.get("schema") or {} + properties = schema.get("properties", []) if isinstance(schema, dict) else schema + names = properties if isinstance(properties, dict) else [ + p.get("name", "") if isinstance(p, dict) else str(p) for p in properties + ] + available = {name.lower() for name in names} + for row in raw.get("records", []): + if isinstance(row, dict): + available.update(str(key).lower() for key in row) + for column, label in ( + ("investability-constrained-pnl", "可投资性约束 PnL"), + ("amer-pnl", "AMER PnL"), + ("apac-pnl", "APAC PnL"), + ("emea-pnl", "EMEA PnL"), + ): + if column in available: + series.append({"id": column, "label": label, "points": pnl_points(raw, column)}) + return series diff --git a/backend/app/business.py b/backend/app/business.py index 220e4a7..fbbd0e2 100644 --- a/backend/app/business.py +++ b/backend/app/business.py @@ -10,7 +10,7 @@ from uuid import uuid4 from fastapi import HTTPException from sqlalchemy import delete, func, select, update -from .alphas import list_statement, sorted_statement, submission_condition, summary +from .alphas import glb_pnl_series, list_statement, sorted_statement, submission_condition, summary from .jobs import ACTIVE from .models import Account, Alpha, Job, JobItem, Pnl, Research, ResearchTag, SelfCorrelation, now from .research.provenance import alpha_sources, source_kinds @@ -140,12 +140,14 @@ class Business: ).model_dump(mode="json") async def get_alpha_pnl(self, alpha_id): - if not await self.db.get(Alpha, alpha_id): + alpha = await self.db.get(Alpha, alpha_id) + if not alpha: raise HTTPException(404, "Alpha 尚未同步") row = await self.db.get(Pnl, alpha_id) return { "cached": row is not None, "points": row.points if row else [], + "series": glb_pnl_series(row.raw, row.points) if row and alpha.region == "GLB" else [], "fetched_at": row.fetched_at.isoformat() if row else None, } diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e53cabe..f8c3a6f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -358,9 +358,16 @@ class PnlPoint(BaseModel): value: float | None +class PnlSeries(BaseModel): + id: str + label: str + points: list[PnlPoint] + + class PnlOutput(BaseModel): cached: bool points: list[PnlPoint] + series: list[PnlSeries] = Field(default_factory=list) fetched_at: datetime | None diff --git a/backend/tests/test_glb_pnl.py b/backend/tests/test_glb_pnl.py new file mode 100644 index 0000000..93e66ea --- /dev/null +++ b/backend/tests/test_glb_pnl.py @@ -0,0 +1,55 @@ +"""GLB display data uses raw caches without changing the correlation baseline.""" + +import pytest + +from app.alphas import glb_pnl_series, pnl_points, upsert_alpha +from app.models import Pnl +from tests.conftest import alpha + +COLUMNS = ["date", "pnl", "investability-constrained-pnl", "amer-pnl", "apac-pnl", "emea-pnl"] +RAW = { + "schema": {"properties": [{"name": name} for name in COLUMNS]}, + "records": [["2025-01-02", 100, 80, 50, None, -20], ["2025-01-01", 0, 0, 0, 0, 0]], +} + + +@pytest.mark.parametrize("region", ["GLB", "USA"]) +async def test_existing_raw_cache_exposes_glb_series_without_refresh(app, logged_in, region): + points = pnl_points(RAW) + async with app.state.runner.sessions.begin() as db: + await upsert_alpha(db, alpha("glb", settings={"region": region})) + db.add(Pnl(alpha_id="glb", raw=RAW, points=points)) + response = await logged_in.get("/api/v1/alphas/glb/pnl") + assert response.status_code == 200 + result = response.json() + assert result["cached"] and result["points"] == points + if region == "GLB": + assert [s["id"] for s in result["series"]] == COLUMNS[1:] + assert [s["points"][-1]["value"] for s in result["series"]] == [100, 80, 50, None, -20] + else: + assert result["series"] == [] + + +def test_reordered_columns_and_incomplete_rows_preserve_gaps(): + raw = { + "schema": {"properties": {"AMER-PNL": {}, "date": {}, "pnl": {}, "emea-pnl": {}}}, + "records": [[12, "2025-01-01", 30, "NaN"], [20, "2025-01-02", 50]], + } + series = glb_pnl_series(raw, pnl_points(raw)) + assert [s["id"] for s in series] == ["pnl", "amer-pnl", "emea-pnl"] + assert [p["value"] for p in series[1]["points"]] == [12, 20] + assert [p["value"] for p in series[2]["points"]] == [None, None] + + +def test_dictionary_records_and_legacy_cache(): + raw = {"records": [{"timestamp": 1735689600000, "value": 5, "apac-pnl": -2}]} + series = glb_pnl_series(raw, pnl_points(raw)) + assert series[1]["points"] == [{"date": "2025-01-01T00:00:00+00:00", "value": -2}] + assert glb_pnl_series({}, pnl_points(raw)) == series[:1] + + +async def test_uncached_glb_is_empty(app, logged_in): + async with app.state.runner.sessions.begin() as db: + await upsert_alpha(db, alpha("empty", settings={"region": "GLB"})) + result = (await logged_in.get("/api/v1/alphas/empty/pnl")).json() + assert result == {"cached": False, "points": [], "series": [], "fetched_at": None} diff --git a/frontend/src/ai/AlphaToolCard.tsx b/frontend/src/ai/AlphaToolCard.tsx index ddf475d..c136a37 100644 --- a/frontend/src/ai/AlphaToolCard.tsx +++ b/frontend/src/ai/AlphaToolCard.tsx @@ -170,7 +170,7 @@ function ChatPnl({ }, [result.alpha_id, result.fetched_at, result.cached]); return result.cached ? (
- {pnl && } + {pnl && } {String(result.count)} 条记录 · 缓存于{" "} {formatTime(result.fetched_at as string, timezone)} diff --git a/frontend/src/components/AlphaDetail.tsx b/frontend/src/components/AlphaDetail.tsx index 303d2eb..7455661 100644 --- a/frontend/src/components/AlphaDetail.tsx +++ b/frontend/src/components/AlphaDetail.tsx @@ -313,7 +313,7 @@ export function AlphaDetail({
{pnl?.cached ? ( <> - +

{pnl.points.length} 条记录 · 缺失数据保留为断点

diff --git a/frontend/src/components/PnlChart.tsx b/frontend/src/components/PnlChart.tsx index b0d9afe..fc5d56d 100644 --- a/frontend/src/components/PnlChart.tsx +++ b/frontend/src/components/PnlChart.tsx @@ -1,60 +1,135 @@ import type { Pnl } from "../types"; -export function PnlChart({ points }: { points: Pnl["points"] }) { - const values = points - .map((p) => p.value) - .filter((v): v is number => v !== null && Number.isFinite(v)); +const colors: Record = { + pnl: "#2563eb", + "investability-constrained-pnl": "#d97706", + "amer-pnl": "#059669", + "apac-pnl": "#9333ea", + "emea-pnl": "#e11d48", +}; + +export function PnlChart({ + points, + series, +}: { + points: Pnl["points"]; + series?: Pnl["series"]; +}) { + const curves = series?.length + ? series + : [{ id: "pnl", label: "总体 PnL", points }]; + const values = curves.flatMap((curve) => + curve.points + .map((p) => p.value) + .filter((v): v is number => v !== null && Number.isFinite(v)), + ); if (!values.length) return
暂无可绘制的 PnL 数据
; const low = Math.min(...values), high = Math.max(...values), range = high - low || Math.max(1, Math.abs(high) * 0.1); + // Align dates across series; absent dates and null values both remain gaps. + const dates = [ + ...new Set(curves.flatMap((curve) => curve.points.map((p) => p.date))), + ].sort(); const x = (index: number) => - 62 + (index / Math.max(1, points.length - 1)) * 610; + 62 + (index / Math.max(1, dates.length - 1)) * 610; const y = (v: number) => 24 + ((high - v) / range) * 180; - let segments = ""; - let connected = false; - points.forEach((point, i) => { - if (point.value === null) { - connected = false; - return; - } - segments += `${connected ? " L" : " M"}${x(i)} ${y(point.value)}`; - connected = true; - }); return ( - - {[0, 1, 2, 3].map((i) => ( - - - - {(high - (i * range) / 3).toLocaleString(undefined, { - maximumFractionDigits: 0, - })} - - - ))} - - {points.length === 1 && points[0].value !== null && ( - +
+ {curves.length > 1 && ( +
+ {curves.map((curve) => ( + + + ))} +
)} - - {points[0]?.date.slice(0, 10)} - - - {points.at(-1)?.date.slice(0, 10)} - - + + {[0, 1, 2, 3].map((i) => ( + + + + {(high - (i * range) / 3).toLocaleString(undefined, { + maximumFractionDigits: 0, + })} + + + ))} + {curves.map((curve) => { + const byDate = new Map( + curve.points.map((point) => [point.date, point.value]), + ); + let segments = "", + connected = false; + const isolated: { index: number; value: number }[] = []; + const valid = (value: number | null | undefined): value is number => + value != null && Number.isFinite(value); + dates.forEach((date, i) => { + const value = byDate.get(date); + if (!valid(value)) { + connected = false; + return; + } + segments += `${connected ? " L" : " M"}${x(i)} ${y(value)}`; + if (!connected && !valid(byDate.get(dates[i + 1]))) + isolated.push({ index: i, value }); + connected = true; + }); + const color = colors[curve.id] ?? "var(--accent)"; + return ( + + {curve.label} + + {isolated.map((point) => ( + + ))} + + ); + })} + + {dates[0]?.slice(0, 10)} + + + {dates.at(-1)?.slice(0, 10)} + + +
); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 68fe82a..119f718 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -156,6 +156,7 @@ export type AlphaPage = { export type Pnl = { cached: boolean; points: { date: string; value: number | null }[]; + series?: { id: string; label: string; points: Pnl["points"] }[]; fetched_at: string | null; }; export type Facets = Record;