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>
114 lines
5.1 KiB
Python
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
|