diff --git a/alpaca_paper/client.py b/alpaca_paper/client.py index 1702ba7..d0e2b63 100644 --- a/alpaca_paper/client.py +++ b/alpaca_paper/client.py @@ -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", diff --git a/run_live_paper.py b/run_live_paper.py index 202a2a2..36e4b65 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -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() diff --git a/runtime/watcher.py b/runtime/watcher.py index 56c1db5..df291f8 100644 --- a/runtime/watcher.py +++ b/runtime/watcher.py @@ -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, }, ) ) diff --git a/strategy/evaluation/liquidity.py b/strategy/evaluation/liquidity.py new file mode 100644 index 0000000..a44a1ef --- /dev/null +++ b/strategy/evaluation/liquidity.py @@ -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 diff --git a/tests/unit/runtime/test_watcher.py b/tests/unit/runtime/test_watcher.py index 7009e63..bc2cca4 100644 --- a/tests/unit/runtime/test_watcher.py +++ b/tests/unit/runtime/test_watcher.py @@ -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) diff --git a/tests/unit/strategy/test_liquidity.py b/tests/unit/strategy/test_liquidity.py new file mode 100644 index 0000000..4f252b9 --- /dev/null +++ b/tests/unit/strategy/test_liquidity.py @@ -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