feat(b3b): surface backtest verdict (PASS/FAIL + Sharpe/DSR) in cockpit fleet view
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
th:first-child, td:first-child { text-align:left; } a { color:#58a6ff; text-decoration:none; }
|
||||
.badge { padding:2px 8px; border-radius:10px; font-size:12px; }
|
||||
.WAIT { background:#21262d; color:#8b949e; } .GO { background:#193b1e; color:#3fb950; } .NO_GO { background:#3b1919; color:#f85149; }
|
||||
.PASS { background:#193b1e; color:#3fb950; } .FAIL { background:#3b1919; color:#f85149; }
|
||||
.pos { color:#3fb950; } .neg { color:#f85149; } .muted { color:#8b949e; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block body %}
|
||||
<table hx-get="/" hx-trigger="every 60s" hx-select="table" hx-swap="outerHTML">
|
||||
<thead><tr><th>strategy</th><th>sleeve</th><th>days</th><th>ret%</th><th>Sharpe</th>
|
||||
<th>maxDD</th><th>nav</th><th>gate</th></tr></thead>
|
||||
<th>maxDD</th><th>nav</th><th>gate</th><th>backtest</th></tr></thead>
|
||||
<tbody>
|
||||
{% for f in fleet %}
|
||||
<tr>
|
||||
@@ -15,6 +15,14 @@
|
||||
<td class="neg">{{ '%.1f'|format(100*f.maxdd) }}%</td>
|
||||
<td>{{ spark(f.strategy_id)|safe }}</td>
|
||||
<td><span class="badge {{ f.gate_status }}">{{ f.gate_status }}</span></td>
|
||||
<td>
|
||||
{% if f.bt_status %}
|
||||
<span class="badge {{ f.bt_status }}">{{ f.bt_status }}</span>
|
||||
<span class="muted" style="font-size:11px">Sh {{ '%.2f'|format(f.bt_sharpe) }} / DSR {{ '%.2f'|format(f.bt_dsr) }}</span>
|
||||
{% else %}
|
||||
<span class="muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -9,6 +9,15 @@
|
||||
· Sharpe {{ '%.2f'|format(d.sharpe) }} · maxDD {{ '%.1f'|format(100*d.maxdd) }}%</p>
|
||||
<div>{{ curve|safe }}</div>
|
||||
|
||||
{% if d.bt_status %}
|
||||
<h3>backtest verdict <span class="badge {{ d.bt_status }}">{{ d.bt_status }}</span>
|
||||
<span class="muted">as of {{ d.bt_as_of or '—' }}</span></h3>
|
||||
<p><span class="{{ 'pos' if d.bt_cagr >= 0 else 'neg' }}">CAGR {{ '%+.1f'|format(100*d.bt_cagr) }}%</span>
|
||||
· vol {{ '%.1f'|format(100*d.bt_ann_vol) }}% · Sharpe {{ '%.2f'|format(d.bt_sharpe) }}
|
||||
· maxDD {{ '%.1f'|format(100*d.bt_maxdd) }}% · DSR {{ '%.2f'|format(d.bt_dsr) }}
|
||||
· OOS Sharpe {{ '%.2f'|format(d.bt_oos_sharpe) }}</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>per-period returns & rolling mean <span class="muted">(raw — no normalization)</span></h3>
|
||||
{% for period in ["daily", "weekly", "monthly"] %}
|
||||
<details {% if period == "weekly" %}open{% endif %}>
|
||||
|
||||
@@ -14,9 +14,11 @@ class DashboardService:
|
||||
|
||||
def fleet(self) -> list[FleetRow]:
|
||||
summaries = {r.strategy_id: r for r in self._repo.all_summaries()}
|
||||
backtests = {r.strategy_id: r for r in self._repo.all_backtest_summaries()}
|
||||
out: list[FleetRow] = []
|
||||
for reg in self._repo.registry():
|
||||
s = summaries.get(reg.strategy_id)
|
||||
b = backtests.get(reg.strategy_id) # most strategies have no backtest verdict -> None
|
||||
out.append(FleetRow(
|
||||
strategy_id=reg.strategy_id, display_name=reg.display_name, sleeve=reg.sleeve,
|
||||
days=s.days if s else 0,
|
||||
@@ -25,6 +27,12 @@ class DashboardService:
|
||||
gate_status=s.gate_status if s else "WAIT",
|
||||
gate_reason=s.gate_reason if s else "no data yet",
|
||||
as_of=s.as_of if s else "",
|
||||
bt_status=("PASS" if b.passed else "FAIL") if b else "",
|
||||
bt_sharpe=b.sharpe if b else None,
|
||||
bt_dsr=b.dsr if b else None,
|
||||
bt_cagr=b.cagr if b else None,
|
||||
bt_maxdd=b.max_drawdown if b else None,
|
||||
bt_as_of=b.as_of if b else "",
|
||||
))
|
||||
return out
|
||||
|
||||
@@ -33,6 +41,7 @@ class DashboardService:
|
||||
if reg is None:
|
||||
return None
|
||||
s = {r.strategy_id: r for r in self._repo.all_summaries()}.get(strategy_id)
|
||||
b = {r.strategy_id: r for r in self._repo.all_backtest_summaries()}.get(strategy_id)
|
||||
history = self._repo.nav_history(strategy_id)
|
||||
dates = [p.date for p in history]
|
||||
rets = [p.ret for p in history]
|
||||
@@ -44,4 +53,9 @@ class DashboardService:
|
||||
gate_status=s.gate_status if s else "WAIT",
|
||||
gate_reason=s.gate_reason if s else "no data yet", as_of=s.as_of if s else "",
|
||||
history=history, periods=periods,
|
||||
bt_status=("PASS" if b.passed else "FAIL") if b else "",
|
||||
bt_cagr=b.cagr if b else None, bt_ann_vol=b.ann_vol if b else None,
|
||||
bt_sharpe=b.sharpe if b else None, bt_maxdd=b.max_drawdown if b else None,
|
||||
bt_dsr=b.dsr if b else None, bt_oos_sharpe=b.oos_sharpe if b else None,
|
||||
bt_as_of=b.as_of if b else "",
|
||||
)
|
||||
|
||||
@@ -21,6 +21,14 @@ class FleetRow:
|
||||
gate_status: str
|
||||
gate_reason: str
|
||||
as_of: str
|
||||
# historical-backtest verdict; only the equity-factor constructions have one. "no backtest" renders
|
||||
# as bt_status="" with bt_* numerics None (template shows an em-dash).
|
||||
bt_status: str = "" # "PASS" | "FAIL" | "" (none)
|
||||
bt_sharpe: float | None = None
|
||||
bt_dsr: float | None = None
|
||||
bt_cagr: float | None = None
|
||||
bt_maxdd: float | None = None
|
||||
bt_as_of: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -38,6 +46,15 @@ class StrategyDetail:
|
||||
as_of: str
|
||||
history: list[ForwardNavRow]
|
||||
periods: dict[str, list[PeriodPoint]] # raw per-period rolling means: keys "daily","weekly","monthly"
|
||||
# historical-backtest verdict; None when the strategy has no backtest row (most do not).
|
||||
bt_status: str = "" # "PASS" | "FAIL" | "" (none)
|
||||
bt_cagr: float | None = None
|
||||
bt_ann_vol: float | None = None
|
||||
bt_sharpe: float | None = None
|
||||
bt_maxdd: float | None = None
|
||||
bt_dsr: float | None = None
|
||||
bt_oos_sharpe: float | None = None
|
||||
bt_as_of: str = ""
|
||||
|
||||
|
||||
class DashboardReadModel(Protocol):
|
||||
|
||||
@@ -6,6 +6,7 @@ import datetime as dt
|
||||
|
||||
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
|
||||
from fxhnt.application.dashboard_service import DashboardService
|
||||
from fxhnt.application.forward_models import BacktestSummary
|
||||
from fxhnt.application.forward_models import ForwardNavRow as Row
|
||||
from fxhnt.application.forward_models import ForwardSummary
|
||||
|
||||
@@ -18,6 +19,13 @@ def _seeded() -> ForwardNavRepo:
|
||||
Row("combined", "2026-06-06", 0.0099, 1.0201)], at)
|
||||
repo.upsert_summary(ForwardSummary("combined", "2026-06-06", 2, 1.0201, 0.0201, 1.2, -0.01),
|
||||
"WAIT", "2/20 forward days", at)
|
||||
# backtest verdicts exist only for the equity-factor constructions
|
||||
repo.upsert_backtest_summary(BacktestSummary(
|
||||
"eqfactor_long", "2026-05-30", cagr=0.12, ann_vol=0.18, sharpe=0.85, max_drawdown=-0.22,
|
||||
passed=True, dsr=0.61, is_sharpe=0.9, oos_sharpe=0.7, pvalue=0.03), at)
|
||||
repo.upsert_backtest_summary(BacktestSummary(
|
||||
"eqfactor_ls", "2026-05-30", cagr=-0.01, ann_vol=0.11, sharpe=-0.05, max_drawdown=-0.31,
|
||||
passed=False, dsr=-0.2, is_sharpe=0.1, oos_sharpe=-0.3, pvalue=0.6), at)
|
||||
return repo
|
||||
|
||||
|
||||
@@ -57,3 +65,27 @@ def test_detail_includes_raw_period_aggregations() -> None:
|
||||
def test_detail_unknown_strategy_is_none() -> None:
|
||||
svc = DashboardService(_seeded())
|
||||
assert svc.detail("does-not-exist") is None
|
||||
|
||||
|
||||
def test_fleet_surfaces_backtest_verdict() -> None:
|
||||
fleet = DashboardService(_seeded()).fleet()
|
||||
el = next(f for f in fleet if f.strategy_id == "eqfactor_long")
|
||||
assert el.bt_status == "PASS"
|
||||
assert abs(el.bt_sharpe - 0.85) < 1e-9
|
||||
assert abs(el.bt_dsr - 0.61) < 1e-9
|
||||
assert abs(el.bt_cagr - 0.12) < 1e-9
|
||||
assert abs(el.bt_maxdd - (-0.22)) < 1e-9
|
||||
assert el.bt_as_of == "2026-05-30"
|
||||
|
||||
ls = next(f for f in fleet if f.strategy_id == "eqfactor_ls")
|
||||
assert ls.bt_status == "FAIL"
|
||||
assert abs(ls.bt_sharpe - (-0.05)) < 1e-9
|
||||
|
||||
|
||||
def test_fleet_no_backtest_renders_blank() -> None:
|
||||
fleet = DashboardService(_seeded()).fleet()
|
||||
combined = next(f for f in fleet if f.strategy_id == "combined")
|
||||
assert combined.bt_status == ""
|
||||
assert combined.bt_sharpe is None and combined.bt_dsr is None
|
||||
assert combined.bt_cagr is None and combined.bt_maxdd is None
|
||||
assert combined.bt_as_of == ""
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
|
||||
from fxhnt.adapters.web.app import create_app
|
||||
from fxhnt.application.forward_models import BacktestSummary
|
||||
from fxhnt.application.forward_models import ForwardNavRow as Row
|
||||
from fxhnt.application.forward_models import ForwardSummary
|
||||
|
||||
@@ -19,6 +20,9 @@ def _client() -> TestClient:
|
||||
Row("combined", "2026-06-06", 0.0099, 1.0201)], at)
|
||||
repo.upsert_summary(ForwardSummary("combined", "2026-06-06", 2, 1.0201, 0.0201, 1.2, -0.01),
|
||||
"WAIT", "2/20 forward days", at)
|
||||
repo.upsert_backtest_summary(BacktestSummary(
|
||||
"eqfactor_long", "2026-05-30", cagr=0.12, ann_vol=0.18, sharpe=0.85, max_drawdown=-0.22,
|
||||
passed=True, dsr=0.61, is_sharpe=0.9, oos_sharpe=0.7, pvalue=0.03), at)
|
||||
return TestClient(create_app(repo))
|
||||
|
||||
|
||||
@@ -32,6 +36,14 @@ def test_cockpit_lists_fleet() -> None:
|
||||
assert "Combined book" in r.text and "WAIT" in r.text
|
||||
|
||||
|
||||
def test_cockpit_shows_backtest_verdict_column() -> None:
|
||||
r = _client().get("/")
|
||||
assert r.status_code == 200
|
||||
assert "backtest" in r.text # column header present
|
||||
assert "badge PASS" in r.text # eqfactor_long verdict rendered
|
||||
assert "DSR" in r.text
|
||||
|
||||
|
||||
def test_strategy_detail_renders() -> None:
|
||||
r = _client().get("/strategy/combined")
|
||||
assert r.status_code == 200 and "2026-06-06" in r.text
|
||||
|
||||
Reference in New Issue
Block a user