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

Merged
fez5587 merged 1 commits from claude/float-recreation-trading-gcn42l into master 2026-06-29 17:48:39 -04:00
6 changed files with 178 additions and 2 deletions
Showing only changes of commit 37f39299ad - Show all commits

No files matched your search

+16
View File
@@ -329,6 +329,22 @@ class AlpacaPaperClient:
trades.update(payload.get("trades") or {})
return trades
def get_latest_quotes(self, symbols: list[str]) -> dict[str, dict]:
"""Latest NBBO quote per symbol (bid/ask). Mirrors ``get_latest_trades``;
each quote dict carries Alpaca's raw keys — ``bp``/``ap`` (bid/ask price)
and ``bs``/``as`` (sizes). On the free IEX feed quotes are thinner than
SIP, so a symbol may be absent; callers must treat a missing entry as
"unknown spread", not a tight one."""
quotes: dict[str, dict] = {}
for chunk in self._chunked(symbols, 100):
payload = self._data(
"GET",
"/v2/stocks/quotes/latest",
params={"symbols": ",".join(chunk), "feed": self.settings.feed},
)
quotes.update(payload.get("quotes") or {})
return quotes
def get_most_actives(self, top: int = 20, by: str = "volume") -> list[dict]:
payload = self._data(
"GET",
+31
View File
@@ -238,6 +238,36 @@ def build_runtime(args: argparse.Namespace) -> dict:
from research.ingestion.news_enrichment import catalyst_score
return catalyst_score(catalyst_provider(symbol))
# Decision-time bid/ask spread provider (observe-only). One per-symbol quote,
# TTL-cached so a watch pass doesn't re-fetch, mapped to spread_pct. Returns
# None on any fault / missing quote / when disabled, so the watcher logs the
# metric without ever blocking or breaking on a quote blip. Default ON when a
# client exists; set SPREAD_PCT_ENABLED=0 to silence the extra quote calls.
_spread_cache: dict[str, tuple[float, float | None]] = {}
_SPREAD_TTL = 15.0
def _spread_enabled() -> bool:
return os.environ.get("SPREAD_PCT_ENABLED", "1").strip().lower() in {
"1", "true", "yes", "on"}
def spread_provider(symbol: str):
if client is None or not _spread_enabled():
return None
import time as _time
from strategy.evaluation.liquidity import compute_spread_pct
now = _time.monotonic()
hit = _spread_cache.get(symbol)
if hit is not None and (now - hit[0]) < _SPREAD_TTL:
return hit[1]
val = None
try:
quote = (client.get_latest_quotes([symbol]) or {}).get(symbol) or {}
val = compute_spread_pct(quote.get("bp"), quote.get("ap"))
except Exception: # noqa: BLE001
val = None
_spread_cache[symbol] = (now, val)
return val
watcher = Watcher(
store,
ResearchWatchlistProvider(
@@ -258,6 +288,7 @@ def build_runtime(args: argparse.Namespace) -> dict:
catalyst_score_provider=(
catalyst_score_provider if ollama_cfg.catalyst_score_enabled else None
),
spread_provider=spread_provider,
)
exec_settings = ExecutionSettings.from_env()
+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,
},
)
)
+34
View File
@@ -0,0 +1,34 @@
"""Liquidity metrics: bid/ask spread.
Spread is a *quote* metric, not a *bar* metric — OHLCV bars carry no NBBO, which
is why ``minute_bars.spread_pct`` is null. The real, 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, so it is logged as a decision-time metric.
"""
from __future__ import annotations
def compute_spread_pct(bid: float | None, ask: float | None) -> float | None:
"""Relative bid/ask spread as a fraction of the mid price.
Returns ``(ask - bid) / mid`` (e.g. ``0.01`` == a 1% spread) or ``None`` when
the quote is missing or nonsensical (non-positive prices, or a crossed book
where ``ask < bid``). ``None`` means "unknown" and must never collapse to
``0.0`` — a genuinely tight book and a missing quote are different facts a
downstream liquidity gate has to tell apart. A locked book (``bid == ask``)
is a real, valid ``0.0``.
"""
if bid is None or ask is None:
return None
try:
bid_f = float(bid)
ask_f = float(ask)
except (TypeError, ValueError):
return None
if bid_f <= 0 or ask_f <= 0 or ask_f < bid_f:
return None
mid = (bid_f + ask_f) / 2.0
if mid <= 0:
return None
return (ask_f - bid_f) / mid
+34
View File
@@ -124,6 +124,40 @@ def test_none_catalyst_provider_is_unchanged(store, provider):
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)
+42
View File
@@ -0,0 +1,42 @@
"""Bid/ask spread metric tests."""
import math
from strategy.evaluation.liquidity import compute_spread_pct
def test_normal_spread_is_fraction_of_mid():
# bid 9.99 / ask 10.01 -> 0.02 / 10.0 mid = 0.002 (20 bps)
assert math.isclose(compute_spread_pct(9.99, 10.01), 0.002, rel_tol=1e-9)
def test_wide_spread_on_thin_name():
# bid 4.00 / ask 4.40 -> 0.40 / 4.20 mid ~= 0.0952 (a 9.5% spread)
assert math.isclose(compute_spread_pct(4.00, 4.40), 0.40 / 4.20, rel_tol=1e-9)
def test_locked_book_is_zero_not_none():
# bid == ask is a real, valid tight market, distinct from a missing quote
assert compute_spread_pct(5.0, 5.0) == 0.0
def test_missing_side_is_none():
assert compute_spread_pct(None, 10.0) is None
assert compute_spread_pct(10.0, None) is None
assert compute_spread_pct(None, None) is None
def test_crossed_book_is_none():
# ask < bid is a bad/stale quote, not a negative spread
assert compute_spread_pct(10.01, 9.99) is None
def test_non_positive_prices_are_none():
assert compute_spread_pct(0.0, 1.0) is None
assert compute_spread_pct(1.0, 0.0) is None
assert compute_spread_pct(-1.0, 1.0) is None
def test_unparseable_inputs_are_none():
assert compute_spread_pct("n/a", 10.0) is None
assert compute_spread_pct(10.0, "n/a") is None