diff --git a/momentum_cli.py b/momentum_cli.py index 32b22e9..ed19b34 100644 --- a/momentum_cli.py +++ b/momentum_cli.py @@ -138,9 +138,18 @@ def doctor(): try: ev = _events_of(con, module="discovery", stage="completed") if ev: - m = json.loads(ev[0][2]).get("metrics", {}) - _row(results, "discovery", "PASS", - f"{len(m.get('universe', []))} sub-$20 names, {len(m.get('gappers', []))} gappers @ {ev[0][0]}") + payload = json.loads(ev[0][2]) + m = payload.get("metrics", {}) + n_uni, n_gap = len(m.get("universe", [])), len(m.get("gappers", [])) + errs = payload.get("errors", []) + if n_uni == 0: + _row(results, "discovery", "FAIL", + f"screener returned 0 names @ {ev[0][0]} — check Alpaca screener/limits" + + (f"; {errs}" if errs else "")) + else: + _row(results, "discovery", "PASS" if not errs else "WARN", + f"{n_uni} sub-$20 names, {n_gap} gappers @ {ev[0][0]}" + + (f"; errors={errs}" if errs else "")) else: _row(results, "discovery", "WARN", "no discovery events yet (run the app)") except Exception as e: diff --git a/research/ingestion/discovery.py b/research/ingestion/discovery.py index ccbb994..1d556f8 100644 --- a/research/ingestion/discovery.py +++ b/research/ingestion/discovery.py @@ -168,6 +168,12 @@ def run_discovery( for ticker in result.news: if ticker not in result.universe: result.universe.append(ticker) + if not result.universe: + # Make a dead screener LOUD (this was silent for a whole session once): + # surfaces in the discovery event's errors and `momentum doctor`. + result.errors.append( + "screener returned 0 names (Alpaca cap/limit, or no qualifying movers)" + ) # Backfill daily history for the universe so gap%/RVOL have a baseline. if backfill_daily and result.universe and client is not None: diff --git a/research/ingestion/market_data.py b/research/ingestion/market_data.py index 72ca1fb..238be09 100644 --- a/research/ingestion/market_data.py +++ b/research/ingestion/market_data.py @@ -13,6 +13,7 @@ hardcoded ``is_regular_hours = TRUE`` on every row. Here we: from __future__ import annotations +import logging from dataclasses import dataclass, field from datetime import date, datetime, time, timedelta, timezone from zoneinfo import ZoneInfo @@ -23,6 +24,8 @@ REGULAR_CLOSE = time(16, 0) PREMARKET_OPEN = time(4, 0) AFTERHOURS_CLOSE = time(20, 0) +logger = logging.getLogger(__name__) + def classify_session(ts_utc: datetime) -> tuple[date, bool, bool, bool]: """Return (session_date, is_premarket, is_regular_hours, is_afterhours).""" @@ -251,5 +254,6 @@ def discover_active_symbols( if len(in_band) >= top: break return in_band - except Exception: # noqa: BLE001 - screener needs a paid feed sometimes + except Exception as exc: # noqa: BLE001 - screener needs a paid feed sometimes + logger.warning("most-actives screener failed: %s", exc) return [] diff --git a/tests/unit/research/test_discovery.py b/tests/unit/research/test_discovery.py new file mode 100644 index 0000000..817eb35 --- /dev/null +++ b/tests/unit/research/test_discovery.py @@ -0,0 +1,54 @@ +"""Discovery screener: top-cap + ETF/band filtering. + +Regression for the bug where screen_universe's top*2 headroom made +discover_active_symbols request get_most_actives(top*3=120), exceeding Alpaca's +hard cap of 100 -> HTTP 400 -> swallowed -> empty universe for a whole session. +""" + +from research.ingestion.market_data import discover_active_symbols +from research.ingestion.discovery import screen_universe, LEVERAGED_ETFS + + +class _FakeScreener: + """Records the `top` it is asked for and prices a few fake symbols.""" + + PRICES = {"AAA": 5.0, "BBB": 500.0, "SOXL": 10.0, "CCC": 15.0, "DDD": 2.0} + + def __init__(self): + self.last_top = None + + def get_most_actives(self, top, by="volume"): + self.last_top = top + if top > 100: # mirror Alpaca: reject top > 100 + raise ValueError("invalid top: should not be larger than 100") + return [{"symbol": s} for s in self.PRICES] + + def get_latest_trades(self, symbols): + return {s: {"p": self.PRICES[s]} for s in symbols if s in self.PRICES} + + +def test_most_actives_top_never_exceeds_100(): + fake = _FakeScreener() + # top=50 -> top*3=150 historically; must be capped to 100 + out = discover_active_symbols(fake, top=50, price_min=1.0, price_max=20.0) + assert fake.last_top is not None and fake.last_top <= 100 + assert out, "should return names, not swallow a 400 into []" + + +def test_discover_active_symbols_filters_price_band(): + fake = _FakeScreener() + out = discover_active_symbols(fake, top=10, price_min=1.0, price_max=20.0) + assert "BBB" not in out # $500 is out of the $1-20 band + assert {"AAA", "CCC", "DDD"} <= set(out) + + +def test_screen_universe_filters_leveraged_etfs(): + fake = _FakeScreener() + out = screen_universe(fake, price_min=1.0, price_max=20.0, top=20) + assert "SOXL" in LEVERAGED_ETFS # sanity: it's a known leveraged ETF + assert "SOXL" not in out # ...and it's filtered out of the universe + assert "AAA" in out + + +def test_screen_universe_empty_when_client_none(): + assert screen_universe(None, 1.0, 20.0, 20) == []