spread_pct P0: real decision-time bid/ask spread as an observe-only metric

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
This commit is contained in:
Claude committed 2026-06-29 21:47:41 +00:00
1 parent 31c35f01c1
commit 37f39299ad
6 files changed
+178 -2

No files matched your search

+21 -2
View File
@@ -81,6 +81,7 @@ class Watcher:
provider: WatchlistProvider,
config: WatcherConfig | None = None,
catalyst_score_provider: Callable[[str], float | None] | None = None,
spread_provider: Callable[[str], float | None] | None = None,
):
self.store = store
self.provider = provider
@@ -88,6 +89,11 @@ class Watcher:
# optional symbol -> 0..1 catalyst score (or None). Injected so the pure
# evaluator stays DB-free; None provider == today's behavior exactly.
self.catalyst_score_provider = catalyst_score_provider
# optional symbol -> decision-time bid/ask spread fraction (or None).
# Observe-only: it is logged onto the evaluation/ready events but does NOT
# gate. None provider == today's behavior exactly (spread_pct logs as None,
# matching the always-null minute_bars.spread_pct column).
self.spread_provider = spread_provider
if not self.config.session_id:
self.config.session_id = f"session-{date.today().isoformat()}"
# symbol -> current state for this session
@@ -205,6 +211,16 @@ class Watcher:
)
result.evaluated += 1
# Decision-time bid/ask spread (observe-only). Guarded like the
# catalyst provider so a quote blip never kills the loop; None
# when no provider is injected or the quote is missing/bad.
spread_pct = None
if self.spread_provider is not None:
try:
spread_pct = self.spread_provider(symbol)
except Exception: # noqa: BLE001
spread_pct = None
self.store.emit(
CriteriaEvaluatedEvent(
timestamp=datetime.now(),
@@ -222,6 +238,7 @@ class Watcher:
"detail": evaluation.criteria_detail,
"gap_pct": evaluation.gap_pct,
"relative_volume": evaluation.relative_volume,
"spread_pct": spread_pct,
"status": evaluation.status,
"reason": evaluation.reason,
},
@@ -235,6 +252,7 @@ class Watcher:
"reason": evaluation.reason,
"gap_pct": evaluation.gap_pct,
"relative_volume": evaluation.relative_volume,
"spread_pct": spread_pct,
},
)
)
@@ -244,6 +262,7 @@ class Watcher:
if self._states.get(symbol) != "ready":
setup = evaluation.setups[0]
self._transition(symbol, "ready", setup["setup_type"])
signal_data = {**setup, "spread_pct": spread_pct}
self.store.emit(
SignalReadyEvent(
timestamp=datetime.now(),
@@ -257,12 +276,12 @@ class Watcher:
symbol=symbol,
signal_type=setup["setup_type"],
confidence=setup["confidence"],
signal_data=setup,
signal_data=signal_data,
payload={
"symbol": symbol,
"signal_type": setup["setup_type"],
"confidence": setup["confidence"],
"signal_data": setup,
"signal_data": signal_data,
},
)
)