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 %} - + {% for f in fleet %} @@ -15,6 +15,14 @@ + {% 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
strategysleevedaysret%SharpemaxDDnavgate
maxDDnavgatebacktest
{{ '%.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 %} +