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:
@@ -170,7 +170,7 @@ function ChatPnl({
|
||||
}, [result.alpha_id, result.fetched_at, result.cached]);
|
||||
return result.cached ? (
|
||||
<div>
|
||||
{pnl && <PnlChart points={pnl.points} />}
|
||||
{pnl && <PnlChart points={pnl.points} series={pnl.series} />}
|
||||
<small>
|
||||
{String(result.count)} 条记录 · 缓存于{" "}
|
||||
{formatTime(result.fetched_at as string, timezone)}
|
||||
|
||||
@@ -313,7 +313,7 @@ export function AlphaDetail({
|
||||
</div>
|
||||
{pnl?.cached ? (
|
||||
<>
|
||||
<PnlChart points={pnl.points} />
|
||||
<PnlChart points={pnl.points} series={pnl.series} />
|
||||
<p className="muted">
|
||||
{pnl.points.length} 条记录 · 缺失数据保留为断点
|
||||
</p>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 <div className="chart-empty">暂无可绘制的 PnL 数据</div>;
|
||||
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 (
|
||||
<svg
|
||||
className="pnl-chart"
|
||||
viewBox="0 0 700 240"
|
||||
role="img"
|
||||
aria-label={`PnL 曲线,共 ${points.length} 条记录`}
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1="62"
|
||||
x2="672"
|
||||
y1={24 + i * 60}
|
||||
y2={24 + i * 60}
|
||||
stroke="var(--line)"
|
||||
/>
|
||||
<text x="52" y={28 + i * 60} textAnchor="end">
|
||||
{(high - (i * range) / 3).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
<path d={segments} fill="none" stroke="var(--accent)" strokeWidth="2" />
|
||||
{points.length === 1 && points[0].value !== null && (
|
||||
<circle cx={x(0)} cy={y(points[0].value)} r="3" fill="var(--accent)" />
|
||||
<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>
|
||||
)}
|
||||
<text x="62" y="230">
|
||||
{points[0]?.date.slice(0, 10)}
|
||||
</text>
|
||||
<text x="672" y="230" textAnchor="end">
|
||||
{points.at(-1)?.date.slice(0, 10)}
|
||||
</text>
|
||||
</svg>
|
||||
<svg
|
||||
className="pnl-chart"
|
||||
viewBox="0 0 700 240"
|
||||
role="img"
|
||||
aria-label={`PnL 曲线,共 ${curves.length} 条曲线,${dates.length} 条记录`}
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1="62"
|
||||
x2="672"
|
||||
y1={24 + i * 60}
|
||||
y2={24 + i * 60}
|
||||
stroke="var(--line)"
|
||||
/>
|
||||
<text x="52" y={28 + i * 60} textAnchor="end">
|
||||
{(high - (i * range) / 3).toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{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 (
|
||||
<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">
|
||||
{dates[0]?.slice(0, 10)}
|
||||
</text>
|
||||
<text x="672" y="230" textAnchor="end">
|
||||
{dates.at(-1)?.slice(0, 10)}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string[] | number | string | null>;
|
||||
|
||||
Reference in New Issue
Block a user