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:
1 parent
b0fb0582d7
commit
5f0625fb6f
2 files changed
+43
-1
No files matched your search
+16
-1
@@ -111,6 +111,7 @@ class Watcher:
|
||||
self.config.session_id = f"session-{date.today().isoformat()}"
|
||||
# symbol -> current state for this session
|
||||
self._states: dict[str, str] = {}
|
||||
self._tick_no = 0 # rotates which out-of-population names get re-checked
|
||||
|
||||
# -- state helpers -------------------------------------------------
|
||||
|
||||
@@ -220,7 +221,21 @@ class Watcher:
|
||||
result.errors.append(f"provider: {exc}")
|
||||
return result
|
||||
|
||||
for candidate in candidates[: self.config.max_symbols]:
|
||||
# The evaluation budget is max_symbols candidates per tick. Spend it on
|
||||
# names that can still trade: on 2026-10-08 the live list was 15 scan-
|
||||
# injected intraday movers (placed first by the provider, never expiring)
|
||||
# plus research rows ranked by bar count, so every in-population gapper
|
||||
# fell past position 25 and stopped being scored at 09:43 while 50
|
||||
# already-rejected names were re-rejected every 8 s. Rejected names go to
|
||||
# the back and rotate, so a squeeze can still be re-admitted when there is
|
||||
# budget left, but they never crowd out a live candidate.
|
||||
front = [c for c in candidates if self._states.get(c.symbol) != "out_of_population"]
|
||||
back = [c for c in candidates if self._states.get(c.symbol) == "out_of_population"]
|
||||
if back:
|
||||
k = self._tick_no % len(back)
|
||||
back = back[k:] + back[:k]
|
||||
self._tick_no += 1
|
||||
for candidate in (front + back)[: self.config.max_symbols]:
|
||||
symbol = candidate.symbol
|
||||
try:
|
||||
if symbol not in self._states:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user