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
This commit is contained in:
momentum devandClaude Fable 5.1 committed 2026-10-10 10:04:04 -05:00
1 parent b0fb0582d7
commit 5f0625fb6f
2 files changed
+43 -1

No files matched your search

@@ -232,3 +232,30 @@ def test_squeeze_verified_against_median_when_a_spike_day_inflates_the_mean():
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