GO_LIVE flags `spread_pct` as a not-yet-real field. The reason it was null is structural: OHLCV bars carry no NBBO, so `minute_bars.spread_pct` can never be populated from the bars endpoint. The execution-relevant figure is the spread at the moment a setup is evaluated — a wide book quietly eats R on the thin sub-$20 names this system trades. This makes that metric real, shadow-first (measure before gating), mirroring how the catalyst score was introduced: - `strategy/evaluation/liquidity.py`: pure `compute_spread_pct(bid, ask)` → (ask-bid)/mid, or None for missing/crossed/non-positive quotes (None never collapses to a real 0.0). Unit-tested. - `AlpacaPaperClient.get_latest_quotes`: batched latest-NBBO fetch, mirrors `get_latest_trades`. - `Watcher`: optional injected `spread_provider` (same shape as `catalyst_score_provider`); default None == prior behavior byte-for-byte. Computed spread is logged onto the criteria_evaluated and signal_ready events. Observe-only — it does NOT gate ready/blocked. - `run_live_paper`: TTL-cached spread provider off the client; degrades to None on any fault, SPREAD_PCT_ENABLED=0 to silence. The dead `scanner.spread_pct_max` knob is intentionally left for a later promotion, once the logged data shows spread separates outcomes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019psgRHdtTXz6TCnnqPiqGw
188 lines
6.4 KiB
Python
188 lines
6.4 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 _criteria_spread(store, symbol="GOOD"):
|
|
import json
|
|
events = store.query_events(event_type="criteria_evaluated", symbol=symbol)
|
|
assert events, "expected a criteria_evaluated event"
|
|
payload = json.loads(events[-1]["payload_json"])
|
|
return payload["payload"]["spread_pct"]
|
|
|
|
|
|
def test_spread_provider_logged_observe_only(store, provider):
|
|
# a spread provider attaches spread_pct to the evaluation/ready events but
|
|
# must NOT change the ready/blocked outcome (observe-only).
|
|
watcher = Watcher(
|
|
store, provider,
|
|
WatcherConfig(session_id="ts", mode=EventMode.PAPER, min_quality=0.0),
|
|
spread_provider=lambda sym: 0.012 if sym == "GOOD" else None,
|
|
)
|
|
result = watcher.tick(SESSION_DATE)
|
|
# outcome identical to the no-provider baseline
|
|
assert "GOOD" in result.ready and "FADE" in result.blocked
|
|
# and the metric is carried onto the events
|
|
assert _criteria_spread(store, "GOOD") == 0.012
|
|
import json
|
|
ev = store.query_events(event_type="signal_ready", symbol="GOOD")
|
|
p = json.loads(ev[-1]["payload_json"])
|
|
sig = p.get("signal_data") or p.get("payload", {}).get("signal_data")
|
|
assert sig["spread_pct"] == 0.012
|
|
|
|
|
|
def test_no_spread_provider_logs_none(store, provider):
|
|
# default (no provider) -> spread_pct logs as None, matching prior behavior
|
|
make_watcher(store, provider).tick(SESSION_DATE)
|
|
assert _criteria_spread(store, "GOOD") is None
|
|
|
|
|
|
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 == []
|