Add cumulative P&L banner to fund overview for at-a-glance profit/loss.

Expose period delta from equity curve start and surface it in the toolbar and summary cards with green/red cues.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-16 00:19:43 +08:00
parent 83a7465996
commit 717a3d5694
6 changed files with 223 additions and 4 deletions
+32
View File
@@ -74,6 +74,30 @@ def compute_drawdown(values: list[float]) -> dict[str, Any]:
}
def compute_period_delta(series: list[dict]) -> dict[str, Any]:
"""相对曲线起点的资金变动(U 与 %);含出入金影响,口径同权益曲线."""
pts = [
p
for p in (series or [])
if isinstance(p, dict) and isinstance(p.get("total_usdt"), (int, float))
]
if not pts:
return {
"start_usdt": None,
"period_delta_usdt": None,
"period_delta_pct": None,
}
start = round(float(pts[0]["total_usdt"]), 4)
end = round(float(pts[-1]["total_usdt"]), 4)
delta = round(end - start, 4)
pct = round((delta / start) * 100, 2) if start > 0 else None
return {
"start_usdt": start,
"period_delta_usdt": delta,
"period_delta_pct": pct,
}
def _atomic_write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
@@ -343,6 +367,7 @@ def build_fund_overview(
day_delta = round(series[-1]["total_usdt"] - series[-2]["total_usdt"], 4)
elif data_ok and total is not None:
day_delta = round(total - series[-1]["total_usdt"], 4)
period = compute_period_delta(series)
accounts_out.append(
{
@@ -361,6 +386,9 @@ def build_fund_overview(
"series": series,
"drawdown": dd,
"day_delta_usdt": day_delta,
"start_usdt": period["start_usdt"],
"period_delta_usdt": period["period_delta_usdt"],
"period_delta_pct": period["period_delta_pct"],
}
)
if key:
@@ -387,6 +415,7 @@ def build_fund_overview(
total_day_delta = round(
total_series[-1]["total_usdt"] - total_series[-2]["total_usdt"], 4
)
total_period = compute_period_delta(total_series)
return {
"ok": True,
@@ -400,6 +429,9 @@ def build_fund_overview(
"live_known_count": live_known,
"total_usdt": round(live_total, 4) if live_known > 0 else None,
"day_delta_usdt": total_day_delta,
"start_usdt": total_period["start_usdt"],
"period_delta_usdt": total_period["period_delta_usdt"],
"period_delta_pct": total_period["period_delta_pct"],
"series": total_series,
"drawdown": total_dd,
},
+105 -1
View File
@@ -7266,6 +7266,10 @@ html[data-theme="light"] .funds-stage {
50% { opacity: 0.55; transform: scale(0.88); }
}
.funds-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 12px;
margin-bottom: 14px;
}
.funds-btn-refresh {
@@ -7273,15 +7277,115 @@ html[data-theme="light"] .funds-stage {
letter-spacing: 0.06em;
font-size: 0.72rem;
}
.funds-pnl-banner {
flex: 1 1 240px;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 16px;
padding: 10px 14px;
border-radius: var(--radius);
border: 1px solid var(--border-soft);
background: rgba(0, 0, 0, 0.28);
}
html[data-theme="light"] .funds-pnl-banner {
background: rgba(255, 255, 255, 0.82);
}
.funds-pnl-banner.is-pos {
border-color: color-mix(in srgb, var(--green) 45%, var(--border-soft));
background: color-mix(in srgb, var(--green) 10%, rgba(0, 0, 0, 0.28));
box-shadow: inset 0 0 28px color-mix(in srgb, var(--green) 8%, transparent);
}
.funds-pnl-banner.is-neg {
border-color: color-mix(in srgb, var(--red) 45%, var(--border-soft));
background: color-mix(in srgb, var(--red) 10%, rgba(0, 0, 0, 0.28));
box-shadow: inset 0 0 28px color-mix(in srgb, var(--red) 8%, transparent);
}
html[data-theme="light"] .funds-pnl-banner.is-pos {
background: color-mix(in srgb, var(--green) 8%, rgba(255, 255, 255, 0.9));
}
html[data-theme="light"] .funds-pnl-banner.is-neg {
background: color-mix(in srgb, var(--red) 8%, rgba(255, 255, 255, 0.9));
}
.funds-pnl-main {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 12px;
min-width: 0;
}
.funds-pnl-label {
font-family: var(--display);
font-size: 0.62rem;
letter-spacing: 0.12em;
color: var(--muted);
text-transform: uppercase;
}
.funds-pnl-value {
font-family: var(--font);
font-variant-numeric: tabular-nums;
font-size: 1.35rem;
font-weight: 700;
letter-spacing: 0.01em;
line-height: 1.1;
}
.funds-pnl-pct {
font-family: var(--mono);
font-size: 0.85rem;
font-weight: 600;
opacity: 0.92;
}
.funds-pnl-value.pos,
.funds-pnl-pct.pos,
.funds-pnl-side-val.pos {
color: var(--green);
}
.funds-pnl-value.neg,
.funds-pnl-pct.neg,
.funds-pnl-side-val.neg {
color: var(--red);
}
.funds-pnl-side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
padding-left: 12px;
border-left: 1px solid var(--border-soft);
}
.funds-pnl-side-label {
font-family: var(--display);
font-size: 0.58rem;
letter-spacing: 0.1em;
color: var(--muted);
text-transform: uppercase;
}
.funds-pnl-side-val {
font-family: var(--font);
font-variant-numeric: tabular-nums;
font-size: 0.95rem;
font-weight: 600;
}
.funds-status.err {
color: var(--red);
}
.funds-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.funds-stat-card-pnl .funds-stat-val {
font-size: 1.35rem;
}
.funds-stat-sub {
margin-top: 4px;
font-size: 0.72rem;
font-family: var(--mono);
color: var(--muted);
}
.funds-stat-card {
position: relative;
background: rgba(0, 0, 0, 0.28);
+49
View File
@@ -10,6 +10,12 @@
const elDdU = document.getElementById("funds-total-dd-u");
const elDdPct = document.getElementById("funds-total-dd-pct");
const elDelta = document.getElementById("funds-total-delta");
const elPeriod = document.getElementById("funds-total-period");
const elPeriodSub = document.getElementById("funds-total-period-sub");
const elPeriodBanner = document.getElementById("funds-period-delta");
const elPeriodPct = document.getElementById("funds-period-pct");
const elDayChip = document.getElementById("funds-day-chip");
const elPnlBanner = document.getElementById("funds-pnl-banner");
const elMeta = document.getElementById("funds-meta");
const elDescBody = document.getElementById("funds-desc-body");
const elChartSub = document.getElementById("funds-chart-sub");
@@ -50,6 +56,13 @@
return sign + v.toFixed(2) + " U";
}
function fmtPct(n) {
if (n == null || !Number.isFinite(Number(n))) return "—";
const v = Number(n);
const sign = v > 0 ? "+" : "";
return sign + v.toFixed(2) + "%";
}
function deltaClass(n) {
if (!Number.isFinite(Number(n))) return "";
if (Number(n) > 0) return "pos";
@@ -193,6 +206,8 @@
const ddPct = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—";
const deltaCls = deltaClass(ac.day_delta_usdt);
const deltaText = monitored ? fmtDelta(ac.day_delta_usdt) : "—";
const periodCls = deltaClass(ac.period_delta_usdt);
const periodText = monitored ? fmtDelta(ac.period_delta_usdt) : "—";
return (
'<button type="button" class="funds-ac-card' +
offCls +
@@ -227,6 +242,11 @@
trading +
"</span></div>" +
optLine +
'<div><span class="k">累计盈亏</span><span class="v ' +
periodCls +
'">' +
periodText +
"</span></div>" +
'<div><span class="k">较昨日</span><span class="v ' +
deltaCls +
'">' +
@@ -370,6 +390,35 @@
elDelta.textContent = fmtDelta(totals.day_delta_usdt);
elDelta.className = "funds-stat-val " + deltaClass(totals.day_delta_usdt);
}
const periodCls = deltaClass(totals.period_delta_usdt);
if (elPeriod) {
elPeriod.textContent = fmtDelta(totals.period_delta_usdt);
elPeriod.className = "funds-stat-val " + periodCls;
}
if (elPeriodSub) {
const startDay = data.history_start_day || "—";
const pct = fmtPct(totals.period_delta_pct);
elPeriodSub.textContent =
pct !== "—"
? "自 " + startDay + " · " + pct
: "自 " + startDay + " 起相对起点";
}
if (elPeriodBanner) {
elPeriodBanner.textContent = fmtDelta(totals.period_delta_usdt);
elPeriodBanner.className = "funds-pnl-value " + periodCls;
}
if (elPeriodPct) {
elPeriodPct.textContent = fmtPct(totals.period_delta_pct);
elPeriodPct.className = "funds-pnl-pct " + periodCls;
}
if (elDayChip) {
elDayChip.textContent = fmtDelta(totals.day_delta_usdt);
elDayChip.className = "funds-pnl-side-val " + deltaClass(totals.day_delta_usdt);
}
if (elPnlBanner) {
elPnlBanner.className =
"funds-pnl-banner" + (periodCls ? " is-" + periodCls : "");
}
if (elMeta) {
const parts = [
"交易日 " + (data.trading_day || "—"),
+18 -2
View File
@@ -15,7 +15,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260715-hub-phone-folds" />
<link rel="stylesheet" href="/assets/app.css?v=20260716-funds-pnl-banner" />
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
<script src="/assets/account_risk_badge.js?v=4"></script>
@@ -625,6 +625,17 @@
</div>
<div class="funds-toolbar toolbar">
<button type="button" id="funds-btn-refresh" class="primary funds-btn-refresh">同步快照</button>
<div id="funds-pnl-banner" class="funds-pnl-banner" aria-live="polite">
<div class="funds-pnl-main">
<span class="funds-pnl-label">累计盈亏 · P&amp;L</span>
<span id="funds-period-delta" class="funds-pnl-value"></span>
<span id="funds-period-pct" class="funds-pnl-pct"></span>
</div>
<div class="funds-pnl-side">
<span class="funds-pnl-side-label">较昨日</span>
<span id="funds-day-chip" class="funds-pnl-side-val"></span>
</div>
</div>
<span id="funds-status" class="toolbar-meta funds-status"></span>
</div>
<section class="funds-summary">
@@ -632,6 +643,11 @@
<div class="funds-stat-label">总资金 · TOTAL</div>
<div id="funds-total-usdt" class="funds-stat-value"></div>
</div>
<div class="funds-stat-card funds-stat-card-pnl">
<div class="funds-stat-label">累计盈亏 · SINCE START</div>
<div id="funds-total-period" class="funds-stat-val"></div>
<div id="funds-total-period-sub" class="funds-stat-sub">相对统计起点</div>
</div>
<div class="funds-stat-card">
<div class="funds-stat-label">较昨日 · Δ24H</div>
<div id="funds-total-delta" class="funds-stat-val"></div>
@@ -1301,7 +1317,7 @@
<script src="/assets/calculator.js?v=20260715-calc-tabs"></script>
<script src="/assets/trade_stats_calendar.js?v=3"></script>
<script src="/assets/archive.js?v=20260710-archive-cum2"></script>
<script src="/assets/funds.js?v=20260707-hub-options-funds"></script>
<script src="/assets/funds.js?v=20260716-funds-pnl-banner"></script>
<script src="/assets/dashboard.js?v=20260708-options-expiry-cd"></script>
<script src="/assets/strategy.js?v=3"></script>
<script src="/assets/help.js?v=1"></script>
+4 -1
View File
@@ -27,6 +27,7 @@
### 总览
- **总资金**:当前监控板最新一轮聚合的实时合计(资金户+交易户齐全才计入)
- **累计盈亏**:相对统计起点(`HUB_FUND_HISTORY_START_DAY`)首个快照的总资金变动(U / %);顶栏大字绿涨红跌,一眼可看盈亏。含出入金影响,口径同权益曲线,不含浮盈
- **较昨日**:相对上一交易日快照点的变动(U)
- **最大回撤**:总资金历史曲线的峰值回撤(U / %)
- **总资金曲线**:近 180 交易日
@@ -78,9 +79,11 @@
返回字段概要:
- `totals.total_usdt` — 当前总资金
- `totals.day_delta_usdt` — 较昨日变动
- `totals.period_delta_usdt` / `period_delta_pct` / `start_usdt` — 相对曲线起点累计盈亏
- `totals.series[]``{ day, total_usdt }` 总曲线
- `totals.drawdown``{ peak_usdt, max_drawdown_u, max_drawdown_pct }`
- `accounts[]` — 分户实时余额,曲线,回撤,`monitored` 标记
- `accounts[]` — 分户实时余额,曲线,回撤,日/累计变动,`monitored` 标记
---
+15
View File
@@ -5,6 +5,7 @@ from lib.hub.hub_fund_history_lib import (
account_total_usdt,
build_fund_overview,
compute_drawdown,
compute_period_delta,
get_fund_history,
record_fund_snapshot,
)
@@ -23,6 +24,20 @@ def test_compute_drawdown():
assert dd["max_drawdown_pct"] == 25.0
def test_compute_period_delta():
out = compute_period_delta(
[
{"day": "2026-06-09", "total_usdt": 100},
{"day": "2026-06-10", "total_usdt": 112.5},
]
)
assert out["start_usdt"] == 100.0
assert out["period_delta_usdt"] == 12.5
assert out["period_delta_pct"] == 12.5
empty = compute_period_delta([])
assert empty["period_delta_usdt"] is None
def test_build_fund_overview_skips_unmonitored(tmp_path, monkeypatch):
hist_path = tmp_path / "hub_fund_history.json"
monkeypatch.setattr("hub_fund_history_lib.FUND_HISTORY_PATH", hist_path)