Files
momentum-system/tests/unit/runtime/test_watcher_population_gate.py
T
momentum devandClaude Fable 5.1 5f0625fb6f fix: watcher spends its 25-slot evaluation budget on in-population names first
The watcher evaluates candidates[:max_symbols] each tick. The live provider puts
dashboard/scan-injected symbols first (they never expire) and ranks the rest by
bar count, so on 2026-10-08 fifteen injected intraday movers plus liquid names
filled all 25 slots and every in-population gapper stopped being scored at 09:43
while 50 already-rejected names were re-rejected every 8 s. Entries were not
affected (the trigger book arms independently and injects its armed names), but
the state board and the judge's reasoning trail went stale for the session.

Names already in out_of_population now go to the back of the list and rotate, so
they are still re-checked when budget remains (a squeeze can be re-admitted) but
can never crowd out a name that can still trade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LXxioW7HA18AY1gqnzA2HN
2026-10-10 10:04:04 -05:00

262 lines
11 KiB
Python

"""Population pre-filter on the intraday watcher (2026-09-13).
The watcher scored every candidate on every tick and emitted a fat
``criteria_evaluated`` receipt each time — including for names the entry path
can never accept. On 2026-09-10 that was 191,458 receipts across 57 symbols,
154,260 of them (80.6%) for gaps outside the tradeable band: large caps like
NOK, F and SOFI that sit inside the $2-20 price filter but can never clear the
gap floor. ``criteria_evaluated`` + ``signal_blocked`` held 3.1 GB of a 7.6 GB
events table.
These tests use a fake store rather than EventStore so they run without a
Postgres — the rest of the watcher suite needs one.
"""
from datetime import date
import pandas as pd
from runtime.watcher import WatchCandidate, Watcher, WatcherConfig
from storage.event_schema import EventMode
from strategy.population import PopulationConfig
SESSION = date(2026, 9, 10)
class FakeStore:
"""Records emits instead of writing to Postgres."""
def __init__(self):
self.events = []
def emit(self, event):
self.events.append(event)
return "id"
def count(self, type_name: str) -> int:
return sum(1 for e in self.events if type(e).__name__ == type_name)
class FakeProvider:
def __init__(self, candidates, bars):
self._candidates = candidates
self._bars = bars
def get_candidates(self, session_date):
return self._candidates
def get_bars(self, symbol, session_date):
return self._bars.get(symbol, pd.DataFrame())
def _bars(open_px: float, n: int = 30, volume: int = 50_000,
premarket_open: float | None = None) -> pd.DataFrame:
"""n regular-hours minute bars from open_px, optionally preceded by premarket.
``premarket_open`` prepends 5 PREMARKET bars at that price. The session open
must still be read off the first REGULAR-HOURS bar — reading bars[0] instead
fabricates a gap (FTFT, 2026-09-14).
"""
rows = []
if premarket_open is not None:
rows += [{"timestamp": pd.Timestamp("2026-09-10 09:00") + pd.Timedelta(minutes=i),
"is_regular_hours": False, "open": premarket_open,
"high": premarket_open * 1.01, "low": premarket_open * 0.99,
"close": premarket_open, "volume": volume} for i in range(5)]
rows += [{"timestamp": pd.Timestamp("2026-09-10 13:30") + pd.Timedelta(minutes=i),
"is_regular_hours": True, "open": open_px,
"high": open_px * 1.01, "low": open_px * 0.99,
"close": open_px, "volume": volume} for i in range(n)]
return pd.DataFrame(rows)
def _premarket_only(open_px: float, n: int = 10) -> pd.DataFrame:
"""Bars before the session opens — the gap is not yet knowable."""
return pd.DataFrame([{
"timestamp": pd.Timestamp("2026-09-10 09:00") + pd.Timedelta(minutes=i),
"is_regular_hours": False, "open": open_px, "high": open_px * 1.01,
"low": open_px * 0.99, "close": open_px, "volume": 1000} for i in range(n)])
def _watcher(store, candidates, bars, population=None):
return Watcher(
store, FakeProvider(candidates, bars),
WatcherConfig(session_id="s", mode=EventMode.PAPER, min_bars=5,
population=population),
)
GAPPER = PopulationConfig(price_min=2.0, price_max=20.0, min_entry_gap_pct=0.10)
def test_gate_off_by_default_every_candidate_is_evaluated():
"""No population passed == the historical behaviour, exactly."""
store = FakeStore()
w = _watcher(store, [WatchCandidate("NOK", previous_close=4.0)], {"NOK": _bars(4.0)})
r = w.tick(SESSION)
assert r.skipped == 0
assert r.evaluated == 1
assert store.count("CriteriaEvaluatedEvent") == 1
def test_flat_large_cap_is_skipped_before_any_receipt():
"""NOK opening 4.00 against a 4.00 close is a 0% gap — inside the price band,
permanently outside the population. This is the 80.6%."""
store = FakeStore()
w = _watcher(store, [WatchCandidate("NOK", previous_close=4.0)],
{"NOK": _bars(4.0)}, population=GAPPER)
r = w.tick(SESSION)
assert r.skipped == 1
assert r.evaluated == 0
assert store.count("CriteriaEvaluatedEvent") == 0
def test_repeated_ticks_on_a_skipped_name_cost_nothing():
"""_transition only fires on change, so the receipt is one event per session,
not 3,359 per symbol per day."""
store = FakeStore()
w = _watcher(store, [WatchCandidate("NOK", previous_close=4.0)],
{"NOK": _bars(4.0)}, population=GAPPER)
w.tick(SESSION)
after_first = len(store.events)
for _ in range(10):
w.tick(SESSION)
assert len(store.events) == after_first
assert store.count("CriteriaEvaluatedEvent") == 0
def test_real_gapper_is_still_evaluated():
"""A $2.50 name opening +25% must pass and be scored as before. 25% is inside
the tradeable 10-35% band; 35-150% is the deliberately excluded dead band."""
store = FakeStore()
w = _watcher(store, [WatchCandidate("SKYQ", previous_close=2.0,
avg_daily_volume=500_000)],
{"SKYQ": _bars(2.5)}, population=GAPPER)
r = w.tick(SESSION)
assert r.skipped == 0
assert r.evaluated == 1
assert store.count("CriteriaEvaluatedEvent") == 1
def test_price_outside_the_band_is_skipped():
store = FakeStore()
w = _watcher(store, [WatchCandidate("NVDA", previous_close=100.0)],
{"NVDA": _bars(180.0)}, population=GAPPER)
assert w.tick(SESSION).skipped == 1
def test_missing_previous_close_fails_open():
"""A data gap must never silently bench a symbol: gap unknowable, price in
band -> still evaluated."""
store = FakeStore()
w = _watcher(store, [WatchCandidate("ABCD", previous_close=None)],
{"ABCD": _bars(5.0)}, population=GAPPER)
assert w.tick(SESSION).evaluated == 1
def test_verdict_is_not_cached_so_a_squeeze_can_be_admitted_later():
"""A >150% gapper reads 'squeeze unverified' on thin volume and is admitted
once rvol confirms. Caching the first rejection would bench it all session."""
cfg = PopulationConfig(price_min=2.0, price_max=20.0, min_entry_gap_pct=0.10,
gap_dead_band_lo=0.35, gap_dead_band_hi=1.50,
squeeze_rvol_min=5.0)
cand = WatchCandidate("WYHG", previous_close=2.0, avg_daily_volume=1_000_000)
thin = {"WYHG": _bars(6.0, volume=1_000)} # +200% gap, rvol 0.03
store = FakeStore()
w = _watcher(store, [cand], thin, population=cfg)
assert w.tick(SESSION).skipped == 1 # unverified -> skipped
w.provider._bars = {"WYHG": _bars(6.0, volume=500_000)} # rvol now 15
assert w.tick(SESSION).evaluated == 1 # re-admitted, not benched
def test_dead_band_gap_is_skipped():
"""35-150% is excluded by design (+0.02R at year scale), so a +50% gapper is
correctly benched even though it clears the 10% floor."""
store = FakeStore()
w = _watcher(store, [WatchCandidate("MID", previous_close=2.0,
avg_daily_volume=500_000)],
{"MID": _bars(3.0)}, population=GAPPER)
assert w.tick(SESSION).skipped == 1
# --- the FTFT bug (2026-09-14) ------------------------------------------------
# The gap was read off bars["open"].iloc[0], which premarket is a PREMARKET bar.
# FTFT opened 3.30 against a 2.88 previous close (+14.6%, comfortably tradeable)
# but its premarket bars started near 2.52, so the filter computed -12.5% and
# benched it. It never self-corrected: the frame keeps its premarket bars all
# session, and _transition only emits on change.
def test_gap_is_measured_from_the_session_open_not_a_premarket_bar():
"""FTFT: premarket 2.52, session open 3.30, previous close 2.88 -> +14.6%."""
store = FakeStore()
w = _watcher(store, [WatchCandidate("FTFT", previous_close=2.88,
avg_daily_volume=500_000)],
{"FTFT": _bars(3.30, premarket_open=2.52)}, population=GAPPER)
r = w.tick(SESSION)
assert r.skipped == 0 # +14.6% is inside the 10-35% band
assert r.evaluated == 1
def test_premarket_bars_alone_fail_open():
"""Before the session opens the static gap is unknowable — never bench on it."""
store = FakeStore()
w = _watcher(store, [WatchCandidate("FTFT", previous_close=2.88)],
{"FTFT": _premarket_only(2.52)}, population=GAPPER)
r = w.tick(SESSION)
assert r.skipped == 0
assert store.count("SymbolStateChangedEvent") >= 0 # never out_of_population
def test_a_genuinely_flat_name_is_still_skipped_with_premarket_present():
"""The filter must still work when premarket bars exist — NOK opens flat."""
store = FakeStore()
w = _watcher(store, [WatchCandidate("NOK", previous_close=4.0)],
{"NOK": _bars(4.0, premarket_open=3.6)}, population=GAPPER)
assert w.tick(SESSION).skipped == 1
def test_squeeze_verified_against_median_when_a_spike_day_inflates_the_mean():
"""KITT 2026-09-25: the 20d mean (1.66M) carried two 11-14M squeeze days, so
6.7M shares read as rvol ~4 and the +557% squeeze was benched. Against the
333k median it is ~20x — verified."""
cfg = PopulationConfig(price_min=2.0, price_max=20.0, min_entry_gap_pct=0.10,
gap_dead_band_lo=0.35, gap_dead_band_hi=1.50,
squeeze_rvol_min=5.0)
bars = {"KITT": _bars(3.23, volume=223_000)} # 30 bars ~= 6.7M shares
mean_only = WatchCandidate("KITT", previous_close=0.4915, avg_daily_volume=1_663_373)
store = FakeStore()
assert _watcher(store, [mean_only], bars, population=cfg).tick(SESSION).skipped == 1
both = WatchCandidate("KITT", previous_close=0.4915, avg_daily_volume=1_663_373,
median_daily_volume=333_146)
store = FakeStore()
assert _watcher(store, [both], bars, population=cfg).tick(SESSION).evaluated == 1
def test_rejected_names_do_not_crowd_out_live_candidates():
"""2026-10-08: 51 out-of-population names sat ahead of every real gapper in the
provider's list, so with max_symbols=25 the gappers were never scored after
09:43. Rejected names must yield their slots to names that can still trade."""
store = FakeStore()
flat = [WatchCandidate(f"FLAT{i:02d}", previous_close=10.0, avg_daily_volume=500_000) for i in range(30)]
gapper = WatchCandidate("GAP", previous_close=10.0, avg_daily_volume=500_000)
bars = {c.symbol: _bars(10.1) for c in flat} # +1%: rejected by the gate
bars["GAP"] = _bars(12.0) # +20%: a real gapper
w = _watcher(store, flat + [gapper], bars, population=GAPPER)
w.config.max_symbols = 25
# tick 1: the list is cold, so the first 25 slots go to the flat names and the
# gapper (position 31) is not reached — exactly the live failure mode
r1 = w.tick(SESSION)
assert r1.evaluated == 0 and r1.skipped == 25
# tick 2: the 25 rejected names are known and move to the back; GAP is scored
r2 = w.tick(SESSION)
assert "GAP" in [e.symbol for e in store.events if type(e).__name__ == "CriteriaEvaluatedEvent"]
assert r2.evaluated == 1
# the remaining budget still re-checks rejected names, rotating through them
assert r2.skipped == 24
# tick 3: still scored, and no re-discovery churn (state only emits on change)
before = store.count("CriteriaEvaluatedEvent")
w.tick(SESSION)
assert store.count("CriteriaEvaluatedEvent") == before + 1