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.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