I1 (crash): zero-sum HRP ZeroDivisionError — `hrp_sum = sum(...) or 0.0` was a no-op since `0.0 or 0.0 == 0.0`. Guard BOTH divide sites in `_size_active`: all-allocated branch and mixed branch now fall back to equal-weight when `hrp_sum <= 0`. I2 (honest dry-run): `factory-cycle --dry-run` previously ran the full discovery chain (gather+hunt+forward) and wrote to the store (mark_seen, upsert DISCOVERED, set_status RETIRED). Gate the entire GENERATE→JUDGE→PROMOTE→PERSIST block behind `if execute:`. In dry-run, only the FactoryLoop runs in kill-gated mode (promotes nothing) and reports the current pool state without any store writes. M1 (double-count + dedup namespace split): analyst fleet AND hunt each called `add_trials`, and used different key formats (`market:kind:k=v` vs `kind|market|params`). Fix: - `candidate_key(market, kind, params)` added to `fxhnt.domain.factory` as the single canonical key helper (format: `market:kind:k1=v1,k2=v2` — params sorted, 10g floats). - `Proposal.key()` delegates to `candidate_key` (unified namespace). - `FleetOrchestrator.hunt` uses `candidate_key` for its `cand_key` (was `kind|sym|params`). - `AnalystFleet.gather` is now read-only: no `add_trials`, no `mark_seen`. The hunt is the single counting authority. gather only filters proposals already in `store.is_seen`. M2 (funnel double-count): `forward` was sampled BEFORE `loop.allocate_and_promote()` so sleeves promoted this cycle appeared in both `forward` and `deployed`. Now `forward` is computed as `fwd_report.forward_tracking - len(res.promoted)` after promotion. M3 (E501): wrap `assemble` signature in `regime_execution.py` (was 123 chars). Pre-existing lint (UP042, E501) in `domain/factory/models.py` also fixed: `StrategyStatus(str, Enum)` → `StrategyStatus(StrEnum)`; `forward_returns` comment moved above the field to fit in 120 chars. Tests: 246 pass (was 241). New tests in test_regime_execution.py: test_hrp_zero_weights_all_allocated_no_crash test_hrp_zero_weight_single_leg_no_crash test_hrp_zero_weights_mixed_branch_no_crash Updated test_analyst_fleet.py: dedup-only contract assertions (trials==0, seen==0 after gather; fresh proposals not pre-emptively marked seen; canonical key format check). Updated test_factory_cycle_wiring.py: aligned with M1 — gather is read-only, hunt is the single counting authority. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
275 lines
14 KiB
Python
275 lines
14 KiB
Python
"""Regime-conditional execution: only specialists whose regime is live today are activated and netted."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from fxhnt.adapters.persistence import DuckDbAnalyticalStore, SqlStrategyStore
|
|
from fxhnt.adapters.persistence.factory_store import FactoryStore
|
|
from fxhnt.application import RegimeConditionalExecutor
|
|
from fxhnt.config import Settings
|
|
from fxhnt.domain.factory import RegimeFit, StrategyRecord, StrategyStatus, strategy_id
|
|
from fxhnt.domain.models import AssetClass, Market, PriceSeries, StrategySpec
|
|
|
|
_NOW = dt.datetime(2026, 6, 9, tzinfo=dt.timezone.utc)
|
|
|
|
|
|
class FakeData:
|
|
name = "fake"
|
|
|
|
def fetch(self, market: Market, start=None, end=None) -> PriceSeries:
|
|
close = 100.0 * np.cumprod(1.0 + np.full(600, 0.0015)) # steady uptrend -> today's regime is trend_up
|
|
return PriceSeries(market=market, dates=tuple(f"2026-{i}" for i in range(600)), close=close)
|
|
|
|
|
|
def _spec(kind: str) -> StrategySpec:
|
|
return StrategySpec(kind=kind, params={"window": 100.0} if kind == "trend" else {"window": 20.0, "z": 1.0})
|
|
|
|
|
|
def test_only_live_regime_specialists_are_activated(tmp_path) -> None:
|
|
settings = Settings(analytical_path=str(tmp_path / "an.duckdb"), operational_dsn=f"sqlite:///{tmp_path / 's.db'}")
|
|
store = SqlStrategyStore(settings.operational_dsn)
|
|
t, m = _spec("trend"), _spec("mean_reversion")
|
|
# trend specialist for trend-up regimes (matches today); mean-rev specialist for a panic regime (does not)
|
|
store.upsert(StrategyRecord(strategy_id=strategy_id(["AAA"], AssetClass.FUTURE, t), markets=["AAA"],
|
|
asset_class=AssetClass.FUTURE, spec=t, discovered_at=_NOW, updated_at=_NOW,
|
|
regime_fits=[RegimeFit(regime="trend_up|low_vol", dsr=0.97, is_sharpe=1.3, oos_sharpe=0.9, passed=True),
|
|
RegimeFit(regime="trend_up|high_vol", dsr=0.96, is_sharpe=1.2, oos_sharpe=0.8, passed=True)]))
|
|
store.upsert(StrategyRecord(strategy_id=strategy_id(["AAA"], AssetClass.FUTURE, m), markets=["AAA"],
|
|
asset_class=AssetClass.FUTURE, spec=m, discovered_at=_NOW, updated_at=_NOW,
|
|
regime_fits=[RegimeFit(regime="trend_down|high_vol", dsr=0.96, is_sharpe=1.2, oos_sharpe=0.8, passed=True)]))
|
|
|
|
ex = RegimeConditionalExecutor(FakeData(), DuckDbAnalyticalStore(settings.analytical_path), store, settings)
|
|
rb = ex.assemble(status=StrategyStatus.DISCOVERED)
|
|
|
|
assert any("trend" in a.spec and a.market == "AAA" for a in rb.active) # trend is live
|
|
assert any(d.strategy_id.startswith("mean_reversion") for d in rb.dormant) # mean-rev is dormant
|
|
assert rb.target_weights.get("AAA", 0.0) > 0 # long in the uptrend
|
|
|
|
|
|
# ── HRP weight sizing tests ───────────────────────────────────────────────────
|
|
|
|
|
|
def _make_two_deployed_specialists(store: SqlStrategyStore, market_a: str, market_b: str) -> tuple[str, str]:
|
|
"""Insert two DEPLOYED trend specialists for market_a and market_b; return their strategy IDs."""
|
|
t = _spec("trend")
|
|
sid_a = strategy_id([market_a], AssetClass.FUTURE, t)
|
|
sid_b = strategy_id([market_b], AssetClass.FUTURE, t)
|
|
for sid, mkt in [(sid_a, market_a), (sid_b, market_b)]:
|
|
store.upsert(StrategyRecord(
|
|
strategy_id=sid, markets=[mkt], asset_class=AssetClass.FUTURE, spec=t,
|
|
status=StrategyStatus.DEPLOYED, discovered_at=_NOW, updated_at=_NOW,
|
|
regime_fits=[RegimeFit(regime="trend_up|low_vol", dsr=0.97, is_sharpe=1.3, oos_sharpe=0.9, passed=True),
|
|
RegimeFit(regime="trend_up|high_vol", dsr=0.96, is_sharpe=1.2, oos_sharpe=0.8, passed=True)],
|
|
))
|
|
return sid_a, sid_b
|
|
|
|
|
|
def test_hrp_weights_favour_heavier_sleeve(tmp_path) -> None:
|
|
"""When a recorded HRP allocation favours sleeve A (weight 0.7) over B (weight 0.3),
|
|
assemble() must give A a larger absolute target weight than B.
|
|
Both legs are active in the current uptrend regime (trend_up)."""
|
|
settings = Settings(analytical_path=str(tmp_path / "an.duckdb"), operational_dsn=f"sqlite:///{tmp_path / 's.db'}")
|
|
store = SqlStrategyStore(settings.operational_dsn)
|
|
sid_a, sid_b = _make_two_deployed_specialists(store, "AAA", "BBB")
|
|
|
|
# HRP says A is the dominant sleeve
|
|
hrp = {sid_a: 0.7, sid_b: 0.3}
|
|
|
|
ex = RegimeConditionalExecutor(
|
|
FakeData(), DuckDbAnalyticalStore(settings.analytical_path), store, settings,
|
|
hrp_weights=hrp,
|
|
)
|
|
rb = ex.assemble(status=StrategyStatus.DEPLOYED, max_gross_leverage=1.0)
|
|
|
|
# Both should be active (both are trend specialists in an uptrend)
|
|
active_markets = {leg.market for leg in rb.active}
|
|
assert "AAA" in active_markets and "BBB" in active_markets
|
|
|
|
w_a = abs(rb.target_weights.get("AAA", 0.0))
|
|
w_b = abs(rb.target_weights.get("BBB", 0.0))
|
|
assert w_a > w_b, f"Expected |w_AAA|={w_a:.4f} > |w_BBB|={w_b:.4f} with HRP 0.7/0.3"
|
|
|
|
|
|
def test_no_allocation_falls_back_to_equal_weight(tmp_path) -> None:
|
|
"""When hrp_weights=None (no allocation provided), both legs are sized equally."""
|
|
settings = Settings(analytical_path=str(tmp_path / "an.duckdb"), operational_dsn=f"sqlite:///{tmp_path / 's.db'}")
|
|
store = SqlStrategyStore(settings.operational_dsn)
|
|
sid_a, sid_b = _make_two_deployed_specialists(store, "CCC", "DDD")
|
|
|
|
ex = RegimeConditionalExecutor(
|
|
FakeData(), DuckDbAnalyticalStore(settings.analytical_path), store, settings,
|
|
hrp_weights=None, # explicit equal-weight fallback
|
|
)
|
|
rb = ex.assemble(status=StrategyStatus.DEPLOYED, max_gross_leverage=1.0)
|
|
|
|
w_c = abs(rb.target_weights.get("CCC", 0.0))
|
|
w_d = abs(rb.target_weights.get("DDD", 0.0))
|
|
# Both legs have the same signal (steady uptrend position) so weights should be equal
|
|
assert abs(w_c - w_d) < 1e-9, f"Expected equal weights, got CCC={w_c:.4f} DDD={w_d:.4f}"
|
|
|
|
|
|
def test_gross_budget_respected_with_hrp_weights(tmp_path) -> None:
|
|
"""HRP-weighted book must never exceed max_gross_leverage in total gross exposure."""
|
|
settings = Settings(analytical_path=str(tmp_path / "an.duckdb"), operational_dsn=f"sqlite:///{tmp_path / 's.db'}")
|
|
store = SqlStrategyStore(settings.operational_dsn)
|
|
sid_a, sid_b = _make_two_deployed_specialists(store, "EEE", "FFF")
|
|
|
|
# Deliberately un-normalised HRP weights (sum > 1)
|
|
hrp = {sid_a: 0.9, sid_b: 0.8}
|
|
|
|
ex = RegimeConditionalExecutor(
|
|
FakeData(), DuckDbAnalyticalStore(settings.analytical_path), store, settings,
|
|
hrp_weights=hrp,
|
|
)
|
|
max_gross = 1.5
|
|
rb = ex.assemble(status=StrategyStatus.DEPLOYED, max_gross_leverage=max_gross)
|
|
gross = sum(abs(w) for w in rb.target_weights.values())
|
|
assert gross <= max_gross + 1e-9, f"Gross {gross:.4f} exceeds max_gross {max_gross}"
|
|
|
|
|
|
def test_unallocated_leg_gets_equal_weight_of_remainder(tmp_path) -> None:
|
|
"""A leg not in the HRP dict (newly promoted) gets an equal share of the unallocated budget;
|
|
the allocated leg gets its HRP-proportional share."""
|
|
settings = Settings(analytical_path=str(tmp_path / "an.duckdb"), operational_dsn=f"sqlite:///{tmp_path / 's.db'}")
|
|
store = SqlStrategyStore(settings.operational_dsn)
|
|
sid_a, sid_b = _make_two_deployed_specialists(store, "GGG", "HHH")
|
|
|
|
# Only sid_a is in the HRP allocation — sid_b is newly promoted, not yet recorded.
|
|
hrp = {sid_a: 1.0} # sid_b absent
|
|
|
|
ex = RegimeConditionalExecutor(
|
|
FakeData(), DuckDbAnalyticalStore(settings.analytical_path), store, settings,
|
|
hrp_weights=hrp,
|
|
)
|
|
rb = ex.assemble(status=StrategyStatus.DEPLOYED, max_gross_leverage=1.0)
|
|
|
|
# Both should be active
|
|
active_markets = {leg.market for leg in rb.active}
|
|
assert "GGG" in active_markets and "HHH" in active_markets
|
|
|
|
w_g = abs(rb.target_weights.get("GGG", 0.0))
|
|
w_h = abs(rb.target_weights.get("HHH", 0.0))
|
|
# With n_allocated=1, n_unallocated=1: each gets 0.5 of the budget → equal
|
|
# (The split is n_allocated/n_total and n_unallocated/n_total)
|
|
# They should be equal here since the HRP budget for 1 leg = ew budget for 1 leg = 0.5
|
|
assert abs(w_g - w_h) < 1e-9, f"With one allocated one unallocated: expected equal split, got GGG={w_g:.4f} HHH={w_h:.4f}"
|
|
|
|
|
|
def test_factory_store_latest_weights_no_cycle(tmp_path) -> None:
|
|
"""latest_weights() returns None when no cycle has ever been recorded."""
|
|
fs = FactoryStore(f"sqlite:///{tmp_path / 'fs.db'}")
|
|
assert fs.latest_weights() is None
|
|
|
|
|
|
def test_factory_store_latest_weights_returns_allocation(tmp_path) -> None:
|
|
"""latest_weights() returns the weights dict from the most recent cycle."""
|
|
fs = FactoryStore(f"sqlite:///{tmp_path / 'fs.db'}")
|
|
now = dt.datetime(2026, 6, 14)
|
|
fs.record_cycle(cycle_id="fc01", proposed=2, tested=2, survived=1, forward=1,
|
|
deployed=1, retired=0, global_trials=10, at=now)
|
|
fs.record_allocation("fc01", {"strat-abc": 0.6, "strat-xyz": 0.4}, now)
|
|
|
|
weights = fs.latest_weights()
|
|
assert weights == {"strat-abc": 0.6, "strat-xyz": 0.4}
|
|
|
|
|
|
def test_factory_store_latest_weights_empty_allocation(tmp_path) -> None:
|
|
"""latest_weights() returns {} (not None) when a cycle ran but no sleeves were allocated."""
|
|
fs = FactoryStore(f"sqlite:///{tmp_path / 'fs.db'}")
|
|
now = dt.datetime(2026, 6, 14)
|
|
fs.record_cycle(cycle_id="fc02", proposed=0, tested=0, survived=0, forward=0,
|
|
deployed=0, retired=0, global_trials=0, at=now)
|
|
# No record_allocation call → allocation table is empty for this cycle
|
|
|
|
weights = fs.latest_weights()
|
|
assert weights == {} # cycle exists, no allocation rows → empty dict (not None)
|
|
|
|
|
|
# ── I1: zero-sum HRP guard — must not crash, must produce equal-weight fallback ─
|
|
|
|
|
|
def test_hrp_zero_weights_all_allocated_no_crash(tmp_path) -> None:
|
|
"""When ALL active legs have HRP weight 0.0 (all-allocated path, hrp_sum=0),
|
|
assemble() must NOT raise ZeroDivisionError; it must fall back to equal-weight
|
|
within max_gross_leverage."""
|
|
settings = Settings(analytical_path=str(tmp_path / "an.duckdb"), operational_dsn=f"sqlite:///{tmp_path / 's.db'}")
|
|
store = SqlStrategyStore(settings.operational_dsn)
|
|
sid_a, sid_b = _make_two_deployed_specialists(store, "ZZA", "ZZB")
|
|
|
|
# Both legs have weight 0.0 → hrp_sum=0 → would crash without the guard
|
|
hrp = {sid_a: 0.0, sid_b: 0.0}
|
|
|
|
ex = RegimeConditionalExecutor(
|
|
FakeData(), DuckDbAnalyticalStore(settings.analytical_path), store, settings,
|
|
hrp_weights=hrp,
|
|
)
|
|
rb = ex.assemble(status=StrategyStatus.DEPLOYED, max_gross_leverage=1.0)
|
|
|
|
# Must not crash; both legs should be active
|
|
active_markets = {leg.market for leg in rb.active}
|
|
assert "ZZA" in active_markets and "ZZB" in active_markets
|
|
|
|
# Equal-weight fallback: each leg gets 0.5 of the gross budget
|
|
w_a = abs(rb.target_weights.get("ZZA", 0.0))
|
|
w_b = abs(rb.target_weights.get("ZZB", 0.0))
|
|
assert abs(w_a - w_b) < 1e-9, f"Expected equal-weight fallback; got ZZA={w_a:.4f} ZZB={w_b:.4f}"
|
|
gross = sum(abs(w) for w in rb.target_weights.values())
|
|
assert gross <= 1.0 + 1e-9, f"Gross {gross:.4f} exceeds max_gross_leverage=1.0"
|
|
|
|
|
|
def test_hrp_zero_weight_single_leg_no_crash(tmp_path) -> None:
|
|
"""Single-leg book with {'a': 0.0} (all-allocated, hrp_sum=0) must not crash
|
|
and must produce a valid equal-weight result within max_gross_leverage."""
|
|
settings = Settings(analytical_path=str(tmp_path / "an.duckdb"), operational_dsn=f"sqlite:///{tmp_path / 's.db'}")
|
|
store = SqlStrategyStore(settings.operational_dsn)
|
|
t = _spec("trend")
|
|
sid_a = strategy_id(["ZZ1"], AssetClass.FUTURE, t)
|
|
store.upsert(StrategyRecord(
|
|
strategy_id=sid_a, markets=["ZZ1"], asset_class=AssetClass.FUTURE, spec=t,
|
|
status=StrategyStatus.DEPLOYED, discovered_at=_NOW, updated_at=_NOW,
|
|
regime_fits=[RegimeFit(regime="trend_up|low_vol", dsr=0.97, is_sharpe=1.3, oos_sharpe=0.9, passed=True),
|
|
RegimeFit(regime="trend_up|high_vol", dsr=0.96, is_sharpe=1.2, oos_sharpe=0.8, passed=True)],
|
|
))
|
|
|
|
hrp = {sid_a: 0.0} # single leg, weight zero → hrp_sum=0
|
|
|
|
ex = RegimeConditionalExecutor(
|
|
FakeData(), DuckDbAnalyticalStore(settings.analytical_path), store, settings,
|
|
hrp_weights=hrp,
|
|
)
|
|
rb = ex.assemble(status=StrategyStatus.DEPLOYED, max_gross_leverage=1.0)
|
|
|
|
assert "ZZ1" in {leg.market for leg in rb.active}
|
|
gross = sum(abs(w) for w in rb.target_weights.values())
|
|
assert gross <= 1.0 + 1e-9, f"Gross {gross:.4f} exceeds max_gross_leverage=1.0"
|
|
|
|
|
|
def test_hrp_zero_weights_mixed_branch_no_crash(tmp_path) -> None:
|
|
"""Mixed branch: one leg has HRP weight 0.0 (hrp_sum=0) and one is unallocated.
|
|
Must not crash and must produce equal-weight fallback."""
|
|
settings = Settings(analytical_path=str(tmp_path / "an.duckdb"), operational_dsn=f"sqlite:///{tmp_path / 's.db'}")
|
|
store = SqlStrategyStore(settings.operational_dsn)
|
|
sid_a, sid_b = _make_two_deployed_specialists(store, "ZZX", "ZZY")
|
|
|
|
# sid_a has weight 0.0; sid_b is absent (unallocated) → mixed branch, hrp_sum=0
|
|
hrp = {sid_a: 0.0}
|
|
|
|
ex = RegimeConditionalExecutor(
|
|
FakeData(), DuckDbAnalyticalStore(settings.analytical_path), store, settings,
|
|
hrp_weights=hrp,
|
|
)
|
|
rb = ex.assemble(status=StrategyStatus.DEPLOYED, max_gross_leverage=1.0)
|
|
|
|
active_markets = {leg.market for leg in rb.active}
|
|
assert "ZZX" in active_markets and "ZZY" in active_markets
|
|
|
|
# Equal-weight fallback when hrp_sum <= 0
|
|
w_x = abs(rb.target_weights.get("ZZX", 0.0))
|
|
w_y = abs(rb.target_weights.get("ZZY", 0.0))
|
|
assert abs(w_x - w_y) < 1e-9, f"Expected equal-weight fallback; got ZZX={w_x:.4f} ZZY={w_y:.4f}"
|
|
gross = sum(abs(w) for w in rb.target_weights.values())
|
|
assert gross <= 1.0 + 1e-9, f"Gross {gross:.4f} exceeds max_gross_leverage=1.0"
|