feat: display five GLB PnL series with distinct colors
Deploy production / deploy (push) Successful in 56s

This commit is contained in:
yuxuanhui
2026-09-10 14:09:41 +08:00
parent f8e23dccb9
commit 6ce33eb572
10 changed files with 241 additions and 56 deletions
+3
View File
@@ -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),
+35 -5
View File
@@ -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
+4 -2
View File
@@ -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,
}
+7
View File
@@ -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
+55
View File
@@ -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}