Files
fxhnt/tests/integration/test_regime_execution.py
jgrusewski 8a63f69130 fix(factory): closure hardening — zero-sum HRP guard, read-only dry-run, single trial-count authority, funnel + E501
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>
2026-06-14 21:27:23 +02:00

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"