diff --git a/src/fxhnt/adapters/web/templates/base.html b/src/fxhnt/adapters/web/templates/base.html
index d9b936c..d40b081 100644
--- a/src/fxhnt/adapters/web/templates/base.html
+++ b/src/fxhnt/adapters/web/templates/base.html
@@ -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; }
diff --git a/src/fxhnt/adapters/web/templates/cockpit.html b/src/fxhnt/adapters/web/templates/cockpit.html
index fbdeca1..52f1663 100644
--- a/src/fxhnt/adapters/web/templates/cockpit.html
+++ b/src/fxhnt/adapters/web/templates/cockpit.html
@@ -3,7 +3,7 @@
{% block body %}
| strategy | sleeve | days | ret% | Sharpe |
- maxDD | nav | gate |
+ maxDD | nav | gate | backtest |
{% for f in fleet %}
@@ -15,6 +15,14 @@
| {{ '%.1f'|format(100*f.maxdd) }}% |
{{ spark(f.strategy_id)|safe }} |
{{ f.gate_status }} |
+
+ {% if f.bt_status %}
+ {{ f.bt_status }}
+ Sh {{ '%.2f'|format(f.bt_sharpe) }} / DSR {{ '%.2f'|format(f.bt_dsr) }}
+ {% else %}
+ —
+ {% endif %}
+ |
{% endfor %}
diff --git a/src/fxhnt/adapters/web/templates/strategy.html b/src/fxhnt/adapters/web/templates/strategy.html
index 68a4ce6..93c5d37 100644
--- a/src/fxhnt/adapters/web/templates/strategy.html
+++ b/src/fxhnt/adapters/web/templates/strategy.html
@@ -9,6 +9,15 @@
· Sharpe {{ '%.2f'|format(d.sharpe) }} · maxDD {{ '%.1f'|format(100*d.maxdd) }}%
{{ curve|safe }}
+{% if d.bt_status %}
+backtest verdict {{ d.bt_status }}
+ as of {{ d.bt_as_of or '—' }}
+CAGR {{ '%+.1f'|format(100*d.bt_cagr) }}%
+ · 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) }}
+{% endif %}
+
per-period returns & rolling mean (raw — no normalization)
{% for period in ["daily", "weekly", "monthly"] %}
diff --git a/src/fxhnt/application/dashboard_service.py b/src/fxhnt/application/dashboard_service.py
index afe4cb2..cb04c1d 100644
--- a/src/fxhnt/application/dashboard_service.py
+++ b/src/fxhnt/application/dashboard_service.py
@@ -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 "",
)
diff --git a/src/fxhnt/ports/dashboard.py b/src/fxhnt/ports/dashboard.py
index 3153122..7a6ed38 100644
--- a/src/fxhnt/ports/dashboard.py
+++ b/src/fxhnt/ports/dashboard.py
@@ -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):
diff --git a/tests/integration/test_dashboard_service.py b/tests/integration/test_dashboard_service.py
index 1acb31a..7ff786a 100644
--- a/tests/integration/test_dashboard_service.py
+++ b/tests/integration/test_dashboard_service.py
@@ -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 == ""
diff --git a/tests/integration/test_web.py b/tests/integration/test_web.py
index 315b118..9b2880d 100644
--- a/tests/integration/test_web.py
+++ b/tests/integration/test_web.py
@@ -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