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:
6 files changed
+178
-2
No files matched your search
+21
-2
@@ -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,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user