Files
fxhnt/tests/integration/test_factory_loop.py
jgrusewski 86eadae42f fix(factory): capital-correct loop — record_cycle, sequential HRP-weighted admission, kill-switch, tests
Fix 1: wire record_cycle in factory-cycle --execute so the funnel table is populated.
  LoopResult gains deployed_before/deployed_after/forward_count; cli.py calls
  fs.record_cycle(proposed=forward_count, ...) and record_allocation after the loop.

Fix 2+4: replace equal-weight book proxy with HRP-weighted blend of deployed sleeves;
  rewrite admission as sequential — each candidate is re-gated against the running mean
  of already-admitted-this-cycle candidates, so two candidates uncorrelated to the book
  but correlated to each other cannot both be admitted in one cycle.

Fix 3: HRP inclusion now requires >= min_forward_days (was >= 2). Folded into the
  final rets comprehension in allocate_and_promote.

Fix 5: StrategyStoreLoopAdapter.set_status now asserts status == "DEPLOYED" so a future
  demotion path cannot silently promote via the loop adapter.

Fix 6: persisted kill-switch — FactoryFlagRow ORM model added to cockpit_models.py;
  FactoryStore.set_kill(on)/is_killed() read/write a key="kill" row; factory-cycle
  folds db kill with env kill; factory-flatten renamed to factory-halt; factory-resume
  and factory-status commands added.

Fix 7: sleeve returns fetched once per sleeve in the final HRP comprehension via a
  shared dict, avoiding double store I/O.

Fix 8: three new integration tests covering min_forward_days filter, empty-book
  founding-admit-then-gate, and mutual-correlation blocking within a cycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:24:50 +02:00

114 lines
5.1 KiB
Python

"""Factory loop: a forward-survivor uncorrelated to the deployed book promotes (within governance) and the
deployed book is HRP-allocated; a tail-correlated survivor does NOT promote; kill-switch blocks promotion."""
from __future__ import annotations
import numpy as np
from fxhnt.application.factory.factory_loop import FactoryLoop, LoopConfig
class FakeStore:
"""Minimal StrategyStore stand-in keyed by strategy_id with status + forward_returns."""
def __init__(self, records): self._r = {r["id"]: r for r in records}
def deployed(self): return [r for r in self._r.values() if r["status"] == "DEPLOYED"]
def forward(self): return [r for r in self._r.values() if r["status"] == "FORWARD"]
def set_status(self, sid, status): self._r[sid]["status"] = status
def returns(self, sid): return np.array(self._r[sid]["returns"])
def _cfg(**kw):
base = dict(max_tail_corr=0.5, min_marginal=0.0, tail_frac=0.1, min_forward_days=20,
max_deployed=20, max_new_per_cycle=2, kill_switch=False)
base.update(kw); return LoopConfig(**base)
def _rec(id, status, returns):
return {"id": id, "status": status, "returns": returns}
def test_uncorrelated_survivor_promotes_and_allocates() -> None:
rng = np.random.default_rng(0)
book = list(rng.normal(0.0005, 0.01, 300))
cand = list(rng.normal(0.0005, 0.01, 300)) # independent
store = FakeStore([_rec("DEP", "DEPLOYED", book), _rec("CAND", "FORWARD", cand)])
loop = FactoryLoop(store, _cfg())
res = loop.allocate_and_promote()
assert "CAND" in res.promoted
assert store._r["CAND"]["status"] == "DEPLOYED"
assert abs(sum(res.weights.values()) - 1.0) < 1e-9 and set(res.weights) == {"DEP", "CAND"}
def test_tail_correlated_survivor_blocked() -> None:
rng = np.random.default_rng(1)
book = list(rng.normal(0.0005, 0.01, 300))
store = FakeStore([_rec("DEP", "DEPLOYED", book), _rec("CAND", "FORWARD", list(book))]) # identical
loop = FactoryLoop(store, _cfg())
res = loop.allocate_and_promote()
assert "CAND" not in res.promoted and store._r["CAND"]["status"] == "FORWARD"
def test_kill_switch_blocks_promotion() -> None:
rng = np.random.default_rng(2)
book = list(rng.normal(0.0005, 0.01, 300)); cand = list(rng.normal(0.0005, 0.01, 300))
store = FakeStore([_rec("DEP", "DEPLOYED", book), _rec("CAND", "FORWARD", cand)])
loop = FactoryLoop(store, _cfg(kill_switch=True))
res = loop.allocate_and_promote()
assert res.promoted == [] and store._r["CAND"]["status"] == "FORWARD"
# Fix 8 — new tests -----------------------------------------------------------
def test_below_min_forward_days_not_promoted() -> None:
"""A forward survivor with fewer than min_forward_days returns must be silently skipped."""
rng = np.random.default_rng(42)
book = list(rng.normal(0.0005, 0.01, 300))
# Only 10 days of history — below the min_forward_days=20 threshold
short = list(rng.normal(0.0005, 0.01, 10))
store = FakeStore([_rec("DEP", "DEPLOYED", book), _rec("SHORT", "FORWARD", short)])
loop = FactoryLoop(store, _cfg(min_forward_days=20))
res = loop.allocate_and_promote()
assert "SHORT" not in res.promoted
assert store._r["SHORT"]["status"] == "FORWARD"
assert res.forward_count == 0 # it never reached the gate
def test_empty_book_admits_first_then_gates_rest() -> None:
"""Empty deployed book: first uncorrelated survivor gets a founding admit, but a duplicate of it
must be blocked by the running-blend re-gate on the second candidate."""
rng = np.random.default_rng(7)
a_returns = list(rng.normal(0.001, 0.01, 100))
b_returns = list(a_returns) # identical to A → corr ≈ 1.0 → must be blocked after A is admitted
store = FakeStore([
_rec("A", "FORWARD", a_returns),
_rec("B", "FORWARD", b_returns),
])
loop = FactoryLoop(store, _cfg(max_new_per_cycle=2))
res = loop.allocate_and_promote()
# Exactly one should be admitted; B is a duplicate of A so it must be blocked
assert len(res.promoted) == 1
promoted_id = res.promoted[0]
other_id = "B" if promoted_id == "A" else "A"
assert store._r[promoted_id]["status"] == "DEPLOYED"
assert store._r[other_id]["status"] == "FORWARD"
def test_mutually_correlated_same_cycle_admits_blocked() -> None:
"""Two forward survivors uncorrelated to the book but nearly identical to each other:
only ONE should be admitted per cycle even though max_new_per_cycle=2."""
rng = np.random.default_rng(99)
book = list(rng.normal(0.0005, 0.01, 300))
# Candidates are independent of the book but almost perfectly correlated with each other
base = rng.normal(0.001, 0.01, 300)
noise = rng.normal(0, 1e-6, 300)
cand_a = list(base)
cand_b = list(base + noise) # corr(cand_a, cand_b) ≈ 1.0
store = FakeStore([
_rec("DEP", "DEPLOYED", book),
_rec("CA", "FORWARD", cand_a),
_rec("CB", "FORWARD", cand_b),
])
loop = FactoryLoop(store, _cfg(max_new_per_cycle=2))
res = loop.allocate_and_promote()
# Both pass the gate vs the book, but the second must fail the running-blend re-gate
assert len(res.promoted) == 1