Files
momentum-system/tests/unit/runtime/test_watcher.py
T
Claude 767bd75a8d Wire catalyst score into the live watcher and arming rank
Completes the Phase 2 loop: the catalyst score now actually reaches the runtime
decision, not just the pure functions. Gated behind its own flag so enabling the
Phase 1 advisory/dashboard never silently changes trade scoring.

- OllamaConfig.catalyst_score_enabled (NEWS_CATALYST_SCORE_ENABLED, default off).
- Watcher accepts an optional catalyst_score_provider (symbol -> 0..1 | None) and
  passes it into evaluate_setup; a None provider reproduces today's behavior. The
  provider is injected so runtime/ stays free of research/ imports.
- run_live_paper: one TTL-cached catalyst provider shared by the watcher and the
  execution service; the watcher gets the score provider only when the blend is
  enabled. step_arm gains a gated graded ranking multiplier (bullish catalyst
  boosts by 1+gain*score; a confirmed dilutive offering is sunk below the armed
  cut), falling back to the existing binary news boost when off.
- Add the enrich task to the run_pass pipeline order so a single --once pass also
  runs enrichment (previously only the continuous loop did).
- .env.example documents the new flags; watcher tests cover the blend + the
  no-op None-provider case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5HTMkaadGDnAPtXe2hWwg
2026-06-19 18:45:18 +00:00

154 lines
5.0 KiB
Python

"""Watcher state machine tests (Milestone 4)."""
from datetime import date
import pandas as pd
import pytest
from runtime.watcher import WatchCandidate, Watcher, WatcherConfig
from storage.event_schema import EventMode
from storage.event_store import EventStore
from tests.synthetic import SESSION_DATE, bull_flag_bars, fading_bars, tiny_bars
class FakeProvider:
def __init__(self):
self.candidates: list[WatchCandidate] = []
self.bars: dict[str, pd.DataFrame] = {}
def get_candidates(self, session_date: date):
return self.candidates
def get_bars(self, symbol: str, session_date: date):
return self.bars.get(symbol, pd.DataFrame())
@pytest.fixture
def store():
s = EventStore(":memory:")
yield s
s.close()
@pytest.fixture
def provider():
p = FakeProvider()
p.candidates = [
WatchCandidate("GOOD", previous_close=10.0, avg_daily_volume=500_000),
WatchCandidate("FADE", previous_close=10.0, avg_daily_volume=500_000),
WatchCandidate("TINY", previous_close=5.0),
]
p.bars = {
"GOOD": bull_flag_bars("GOOD"),
"FADE": fading_bars("FADE"),
"TINY": tiny_bars("TINY"),
}
return p
def make_watcher(store, provider, **kw):
config = WatcherConfig(
session_id="test-session", mode=EventMode.PAPER, min_quality=0.0, **kw
)
return Watcher(store, provider, config)
def test_tick_classifies_ready_blocked(store, provider):
watcher = make_watcher(store, provider)
result = watcher.tick(SESSION_DATE)
# TINY has < min_bars, so it stays "watching" and is not evaluated
assert result.evaluated == 2
assert "GOOD" in result.ready
assert "FADE" in result.blocked
assert "TINY" not in result.ready and "TINY" not in result.blocked
assert set(result.discovered) == {"GOOD", "FADE", "TINY"}
def test_tick_emits_canonical_events(store, provider):
make_watcher(store, provider).tick(SESSION_DATE)
assert len(store.query_events(event_type="symbol_discovered")) == 3
assert len(store.query_events(event_type="signal_ready", symbol="GOOD")) == 1
assert len(store.query_events(event_type="criteria_evaluated")) == 2
assert len(store.query_events(event_type="signal_blocked")) >= 1
states = store.query_events(event_type="symbol_state_changed", symbol="GOOD")
assert states # discovered -> ... -> ready transitions recorded
def _ready_quality(store, symbol="GOOD"):
import json
events = store.query_events(event_type="signal_ready", symbol=symbol)
assert events, "expected a signal_ready event"
payload = json.loads(events[-1]["payload_json"])
data = payload.get("signal_data") or payload.get("payload", {}).get("signal_data")
return data["quality_score"]
def test_catalyst_score_provider_lifts_quality(store, provider):
# baseline: no provider -> today's exact quality score
base = make_watcher(store, provider)
base.tick(SESSION_DATE)
baseline_q = _ready_quality(store)
# a strong bullish catalyst for GOOD raises its blended quality score
store2 = EventStore(":memory:")
try:
watcher = Watcher(
store2, provider,
WatcherConfig(session_id="t2", mode=EventMode.PAPER, min_quality=0.0),
catalyst_score_provider=lambda sym: 1.0 if sym == "GOOD" else None,
)
watcher.tick(SESSION_DATE)
boosted_q = _ready_quality(store2)
finally:
store2.close()
assert boosted_q > baseline_q
def test_none_catalyst_provider_is_unchanged(store, provider):
# an explicit provider that returns None must reproduce the baseline exactly
base = make_watcher(store, provider)
base.tick(SESSION_DATE)
baseline_q = _ready_quality(store)
store2 = EventStore(":memory:")
try:
watcher = Watcher(
store2, provider,
WatcherConfig(session_id="t3", mode=EventMode.PAPER, min_quality=0.0),
catalyst_score_provider=lambda sym: None,
)
watcher.tick(SESSION_DATE)
assert _ready_quality(store2) == baseline_q
finally:
store2.close()
def test_signal_ready_debounced_across_ticks(store, provider):
watcher = make_watcher(store, provider)
watcher.tick(SESSION_DATE)
watcher.tick(SESSION_DATE)
# ready emitted once even though GOOD stays ready
assert len(store.query_events(event_type="signal_ready", symbol="GOOD")) == 1
# discovery also only once
assert len(store.query_events(event_type="symbol_discovered", symbol="GOOD")) == 1
def test_max_symbols_cap(store, provider):
watcher = make_watcher(store, provider, max_symbols=1)
result = watcher.tick(SESSION_DATE)
assert result.evaluated == 1
def test_provider_failure_is_caught(store):
class Broken:
def get_candidates(self, session_date):
return [WatchCandidate("BOOM")]
def get_bars(self, symbol, session_date):
raise RuntimeError("data source down")
result = make_watcher(store, Broken()).tick(SESSION_DATE)
assert result.errors
assert result.ready == []