feat: display five GLB PnL series with distinct colors
Deploy production / deploy (push) Successful in 56s
Deploy production / deploy (push) Successful in 56s
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
# GLB 五组 PnL 展示
|
||||||
|
Type: task
|
||||||
|
Status: resolved
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
从已有 raw 缓存解析总体、可投资性约束、AMER、APAC、EMEA 五组曲线,详情与聊天图表使用固定不同颜色和图例,保留细线、缺失断点、总体相关性基线和旧缓存兼容。
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
接口覆盖真实响应形状、乱序列、缺失数值、旧缓存和非 GLB。前端构建及浏览器验证。
|
||||||
|
|
||||||
|
## Answer
|
||||||
|
已完成缓存读取、GLB series 接口和详情/聊天五色细线图例。59 项后端测试、Ruff、前端构建、Alpha 管理浏览器回归通过;合成五组数据在桌面及 390px 窄屏验证,断点和线宽符合预期。未部署。
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user