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
+12
View File
@@ -0,0 +1,12 @@
# GLB 五组 PnL 展示
Type: task
Status: resolved
## Scope
从已有 raw 缓存解析总体、可投资性约束、AMER、APAC、EMEA 五组曲线,详情与聊天图表使用固定不同颜色和图例,保留细线、缺失断点、总体相关性基线和旧缓存兼容。
## Validation
接口覆盖真实响应形状、乱序列、缺失数值、旧缓存和非 GLB。前端构建及浏览器验证。
## Answer
已完成缓存读取、GLB series 接口和详情/聊天五色细线图例。59 项后端测试、Ruff、前端构建、Alpha 管理浏览器回归通过;合成五组数据在桌面及 390px 窄屏验证,断点和线宽符合预期。未部署。
+3
View File
@@ -26,10 +26,13 @@ async def search(ctx, args):
async def pnl(ctx, args): async def pnl(ctx, args):
data = await ctx.business.get_alpha_pnl(args.alpha_id) data = await ctx.business.get_alpha_pnl(args.alpha_id)
points = data.pop("points") points = data.pop("points")
# Chart data is fetched by the UI; keep large series out of model context.
series = data.pop("series", [])
return { return {
**data, **data,
"alpha_id": args.alpha_id, "alpha_id": args.alpha_id,
"count": len(points), "count": len(points),
"series": [{"id": item["id"], "label": item["label"]} for item in series],
"first": points[0] if points else None, "first": points[0] if points else None,
"last": points[-1] if points else None, "last": points[-1] if points else None,
"null_count": sum(p["value"] is None for p in points), "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 return result
def pnl_points(raw): def pnl_points(raw, column=None):
"""Use schema column names, preserving missing values rather than creating zero PnL.""" """Use schema column names, preserving missing values rather than creating zero PnL."""
records = raw.get("records") records = raw.get("records")
schema = raw.get("schema") or {} schema = raw.get("schema") or {}
@@ -261,6 +261,7 @@ def pnl_points(raw):
else: else:
names = [p.get("name", "") if isinstance(p, dict) else str(p) for p in properties] names = [p.get("name", "") if isinstance(p, dict) else str(p) for p in properties]
normalized = [name.lower() for name in names] normalized = [name.lower() for name in names]
value_names = (column,) if column else ("pnl", "value")
if not isinstance(records, list): if not isinstance(records, list):
raise ValueError("PnL 缺少 records") raise ValueError("PnL 缺少 records")
points = [] points = []
@@ -268,15 +269,17 @@ def pnl_points(raw):
if isinstance(row, dict): if isinstance(row, dict):
row = {str(k).lower(): v for k, v in row.items()} 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) 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: else:
date_i = next( date_i = next(
(i for i, n in enumerate(normalized) if n in ("date", "datetime", "timestamp")), None (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) 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) <= max(date_i, pnl_i): if date_i is None or pnl_i is None or not isinstance(row, list) or len(row) <= date_i:
raise ValueError("PnL schema 无法识别日期或数值列") 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 timestamp is not None:
if isinstance(timestamp, (int, float)): if isinstance(timestamp, (int, float)):
from datetime import timezone from datetime import timezone
@@ -286,3 +289,30 @@ def pnl_points(raw):
).isoformat() ).isoformat()
points.append({"date": str(timestamp), "value": number(value)}) points.append({"date": str(timestamp), "value": number(value)})
return sorted(points, key=lambda p: p["date"]) 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 fastapi import HTTPException
from sqlalchemy import delete, func, select, update 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 .jobs import ACTIVE
from .models import Account, Alpha, Job, JobItem, Pnl, Research, ResearchTag, SelfCorrelation, now from .models import Account, Alpha, Job, JobItem, Pnl, Research, ResearchTag, SelfCorrelation, now
from .research.provenance import alpha_sources, source_kinds from .research.provenance import alpha_sources, source_kinds
@@ -140,12 +140,14 @@ class Business:
).model_dump(mode="json") ).model_dump(mode="json")
async def get_alpha_pnl(self, alpha_id): 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 尚未同步") raise HTTPException(404, "Alpha 尚未同步")
row = await self.db.get(Pnl, alpha_id) row = await self.db.get(Pnl, alpha_id)
return { return {
"cached": row is not None, "cached": row is not None,
"points": row.points if row else [], "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, "fetched_at": row.fetched_at.isoformat() if row else None,
} }
+7
View File
@@ -358,9 +358,16 @@ class PnlPoint(BaseModel):
value: float | None value: float | None
class PnlSeries(BaseModel):
id: str
label: str
points: list[PnlPoint]
class PnlOutput(BaseModel): class PnlOutput(BaseModel):
cached: bool cached: bool
points: list[PnlPoint] points: list[PnlPoint]
series: list[PnlSeries] = Field(default_factory=list)
fetched_at: datetime | None 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}
+1 -1
View File
@@ -170,7 +170,7 @@ function ChatPnl({
}, [result.alpha_id, result.fetched_at, result.cached]); }, [result.alpha_id, result.fetched_at, result.cached]);
return result.cached ? ( return result.cached ? (
<div> <div>
{pnl && <PnlChart points={pnl.points} />} {pnl && <PnlChart points={pnl.points} series={pnl.series} />}
<small> <small>
{String(result.count)} 条记录 · 缓存于{" "} {String(result.count)} 条记录 · 缓存于{" "}
{formatTime(result.fetched_at as string, timezone)} {formatTime(result.fetched_at as string, timezone)}
+1 -1
View File
@@ -313,7 +313,7 @@ export function AlphaDetail({
</div> </div>
{pnl?.cached ? ( {pnl?.cached ? (
<> <>
<PnlChart points={pnl.points} /> <PnlChart points={pnl.points} series={pnl.series} />
<p className="muted"> <p className="muted">
{pnl.points.length} 条记录 · 缺失数据保留为断点 {pnl.points.length} 条记录 · 缺失数据保留为断点
</p> </p>
+96 -21
View File
@@ -1,33 +1,74 @@
import type { Pnl } from "../types"; import type { Pnl } from "../types";
export function PnlChart({ points }: { points: Pnl["points"] }) { const colors: Record<string, string> = {
const values = points 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) .map((p) => p.value)
.filter((v): v is number => v !== null && Number.isFinite(v)); .filter((v): v is number => v !== null && Number.isFinite(v)),
);
if (!values.length) if (!values.length)
return <div className="chart-empty">暂无可绘制的 PnL 数据</div>; return <div className="chart-empty">暂无可绘制的 PnL 数据</div>;
const low = Math.min(...values), const low = Math.min(...values),
high = Math.max(...values), high = Math.max(...values),
range = high - low || Math.max(1, Math.abs(high) * 0.1); 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) => 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; 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 ( return (
<div>
{curves.length > 1 && (
<div
aria-label="PnL 图例"
style={{
display: "flex",
flexWrap: "wrap",
gap: "8px 20px",
fontSize: 12,
}}
>
{curves.map((curve) => (
<span
key={curve.id}
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
>
<span
aria-hidden="true"
style={{
width: 20,
borderTop: `1px solid ${colors[curve.id] ?? "var(--accent)"}`,
}}
/>
{curve.label}
</span>
))}
</div>
)}
<svg <svg
className="pnl-chart" className="pnl-chart"
viewBox="0 0 700 240" viewBox="0 0 700 240"
role="img" role="img"
aria-label={`PnL 曲线,共 ${points.length} 条记录`} aria-label={`PnL 曲线,共 ${curves.length} 条曲线,${dates.length} 条记录`}
> >
{[0, 1, 2, 3].map((i) => ( {[0, 1, 2, 3].map((i) => (
<g key={i}> <g key={i}>
@@ -45,16 +86,50 @@ export function PnlChart({ points }: { points: Pnl["points"] }) {
</text> </text>
</g> </g>
))} ))}
<path d={segments} fill="none" stroke="var(--accent)" strokeWidth="2" /> {curves.map((curve) => {
{points.length === 1 && points[0].value !== null && ( const byDate = new Map(
<circle cx={x(0)} cy={y(points[0].value)} r="3" fill="var(--accent)" /> 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 (
<g key={curve.id} data-pnl-series={curve.id}>
<title>{curve.label}</title>
<path d={segments} fill="none" stroke={color} strokeWidth="1" />
{isolated.map((point) => (
<circle
key={point.index}
cx={x(point.index)}
cy={y(point.value)}
r="2"
fill={color}
/>
))}
</g>
);
})}
<text x="62" y="230"> <text x="62" y="230">
{points[0]?.date.slice(0, 10)} {dates[0]?.slice(0, 10)}
</text> </text>
<text x="672" y="230" textAnchor="end"> <text x="672" y="230" textAnchor="end">
{points.at(-1)?.date.slice(0, 10)} {dates.at(-1)?.slice(0, 10)}
</text> </text>
</svg> </svg>
</div>
); );
} }
+1
View File
@@ -156,6 +156,7 @@ export type AlphaPage = {
export type Pnl = { export type Pnl = {
cached: boolean; cached: boolean;
points: { date: string; value: number | null }[]; points: { date: string; value: number | null }[];
series?: { id: string; label: string; points: Pnl["points"] }[];
fetched_at: string | null; fetched_at: string | null;
}; };
export type Facets = Record<string, string[] | number | string | null>; export type Facets = Record<string, string[] | number | string | null>;