Files
momentum-system/tests/unit/research/test_snapshot_scan.py
T
momentum devandClaude Opus 5.5 1cead3f29b feat: whole-market premarket squeeze scan via Alpaca snapshots
Discovery finds premarket squeezes far too late. Across 53 trades by a
professional small-cap momentum trader, our scanner had found only 4 of his
names before his entry, and 27 of his 28 entries were premarket (07:00-09:30
ET). KITT on 2026-09-25 ran $3.50 -> $8.39 at 09:00-09:01 ET (prior close
$0.49); the loop first "Discovered KITT via manual" at 09:35. The existing
sources (Alpaca movers / most-actives screeners) only reflect the regular
session, so a premarket squeeze is invisible until the open.

research/ingestion/snapshot_scan.py sweeps every active, tradable, listed US
equity (~11.9k names; OTC, warrants/units/rights and DISCOVER_EXCLUDE dropped;
asset list cached per ET day) through GET /v2/stocks/snapshots in 2,000-symbol
batches. The endpoint is bound by URL length rather than symbol count (8,000
worked; ~12.7k returned 414). A name qualifies when:
  - the LATEST TRADE price is inside WATCHER_PRICE_MIN/MAX (never the prior
    close, which is routinely < $1 on these names),
  - % change vs the prior close >= DISCOVER_SNAPSHOT_MIN_PCT (20),
  - it is trading now: last trade <= 180s old, latest minute bar <= 300s old
    with >= 5,000 shares, and >= 100,000 shares since 04:00 ET (summed from
    minute bars for the short list, one extra request).
Qualifiers are ranked by % change, capped per sweep, and announced once per
day. dailyBar OHLC ignores extended-hours prints (KITT's dailyBar high was
$3.24 against a premarket high of $8.39), so in premarket it can still be
yesterday's bar. The prior close is prevDailyBar.c only when dailyBar is dated
today, otherwise dailyBar.c.

run_live_paper.py runs it on its own daemon thread (04:00-16:00 ET weekdays,
every 45s), using the plan-019 pattern: its own DB connection, the thread-safe
Watchlist and the generic interval driver. Each new name emits
symbol_discovered with source="snapshot_scan" and price/prev_close/pct/minute
and cumulative volume in symbol_data, so first-sighting latency can be
measured. It gets a daily-history backfill and joins the watchlist, so the
normal new-symbol ingest path gives it its first full-session pull. This is
discovery only; no trading gate changes.

Gated by DISCOVER_SNAPSHOT_SCAN_ENABLED (default 1); knobs in .env.example.
Live read-only sweep at 13:10 ET: 11,871 names in 8 requests / 6.3s cold
(7 requests / 3.5s warm); top hits KITT +376%, MSGY +262%, INLF +60%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:11:45 -05:00

214 lines
9.3 KiB
Python

"""Whole-market premarket squeeze scanner (research/ingestion/snapshot_scan.py).
The screener-based discovery only sees the regular session: KITT ran
$3.50 -> $8.39 at 09:00 ET on 2026-09-25 (prior close $0.49) and we first saw
it at 09:35. These pin the qualification rules with a fake client (no network).
"""
from datetime import datetime, timedelta, timezone
from research.ingestion.snapshot_scan import (
SOURCE,
SnapshotScanConfig,
SnapshotScanner,
build_universe,
emit_discoveries,
fetch_snapshots,
in_scan_window,
prior_close,
qualify,
)
from storage.event_schema import EventMode, EventType
# 2026-09-25 09:00 ET (EDT = UTC-4) — premarket
NOW = datetime(2026, 9, 25, 13, 0, 0, tzinfo=timezone.utc)
def _iso(dt):
return dt.isoformat().replace("+00:00", "Z")
def snap(price, prev_close, *, trade_age=5, bar_age=60, bar_vol=50_000,
daily_today=False, prev2_close=None):
"""Premarket-shaped snapshot: dailyBar is YESTERDAY's (regular-session-only)
unless daily_today, in which case prevDailyBar is yesterday's."""
today_bar = {"c": price, "t": "2026-09-25T04:00:00Z", "v": 1_000_000}
yday_bar = {"c": prev_close, "t": "2026-09-24T04:00:00Z", "v": 700_000}
older = {"c": prev2_close or prev_close * 0.9, "t": "2026-09-23T04:00:00Z", "v": 1}
return {
"latestTrade": {"p": price, "t": _iso(NOW - timedelta(seconds=trade_age))},
"minuteBar": {"c": price, "v": bar_vol, "t": _iso(NOW - timedelta(seconds=bar_age))},
"dailyBar": today_bar if daily_today else yday_bar,
"prevDailyBar": yday_bar if daily_today else older,
}
CFG = SnapshotScanConfig(price_min=1.0, price_max=20.0, min_pct=20.0, max_per_sweep=2,
min_bar_volume=5_000, min_cum_volume=100_000,
max_trade_age_s=180, max_bar_age_s=300)
def test_price_band_uses_latest_trade_not_prior_close():
# KITT: prior close $0.49 (below the $1 band floor), trading $3.50 premarket
hits = qualify({"KITT": snap(3.50, 0.49)}, CFG, NOW)
assert [h.symbol for h in hits] == ["KITT"]
assert round(hits[0].pct_change) == 614
# latest price outside the band is rejected even with a huge move
assert qualify({"BIG": snap(25.0, 10.0)}, CFG, NOW) == []
assert qualify({"PENNY": snap(0.80, 0.30)}, CFG, NOW) == []
def test_pct_threshold():
assert qualify({"A": snap(5.9, 5.0)}, CFG, NOW) == [] # +18%
assert [h.symbol for h in qualify({"A": snap(6.0, 5.0)}, CFG, NOW)] == ["A"] # +20%
def test_prior_close_premarket_vs_rth():
# premarket: dailyBar is still yesterday's -> prior close = dailyBar.c
assert prior_close(snap(6.0, 5.0), NOW.date()) == 5.0
# after the first RTH print: dailyBar is today's -> prior close = prevDailyBar.c
s = snap(6.0, 5.0, daily_today=True)
assert prior_close(s, NOW.date()) == 5.0
assert [h.symbol for h in qualify({"A": s}, CFG, NOW)] == ["A"]
def test_stale_or_dead_names_rejected():
snaps = {
"OLDTRADE": snap(6.0, 4.0, trade_age=3600), # last print an hour ago
"OLDBAR": snap(6.0, 4.0, bar_age=900), # no minute bar for 15 min
"THIN": snap(6.0, 4.0, bar_vol=300), # 300 shares last minute
"LIVE": snap(6.0, 4.0),
}
snaps["NOTRADE"] = {**snap(6.0, 4.0), "latestTrade": {}}
snaps["NOBAR"] = {**snap(6.0, 4.0), "minuteBar": None}
assert [h.symbol for h in qualify(snaps, CFG, NOW)] == ["LIVE"]
def test_universe_excludes_warrants_units_rights_otc_and_excludes():
assets = [
{"symbol": "KITT", "exchange": "NASDAQ", "status": "active", "tradable": True,
"name": "Nauticus Robotics, Inc. Common Stock"},
{"symbol": "ABCDW", "exchange": "NASDAQ", "status": "active", "tradable": True,
"name": "ABCD Corp Warrant"},
{"symbol": "ABCDU", "exchange": "NASDAQ", "status": "active", "tradable": True,
"name": "ABCD Corp Unit"},
{"symbol": "ABCDR", "exchange": "NASDAQ", "status": "active", "tradable": True,
"name": "ABCD Corp Right"},
{"symbol": "FOO.WS", "exchange": "NYSE", "status": "active", "tradable": True,
"name": "Foo Redeemable warrants"},
{"symbol": "BCTXZ", "exchange": "NASDAQ", "status": "active", "tradable": True,
"name": "BriaCell Therapeutics Corp. Warrant expiring 2030"},
{"symbol": "ET", "exchange": "NYSE", "status": "active", "tradable": True,
"name": "Energy Transfer LP Common Units representing limited partner interests"},
{"symbol": "OTCX", "exchange": "OTC", "status": "active", "tradable": True, "name": "x"},
{"symbol": "HALT", "exchange": "NYSE", "status": "active", "tradable": False, "name": "x"},
{"symbol": "SOXL", "exchange": "ARCA", "status": "active", "tradable": True, "name": "x"},
]
assert build_universe(assets, excluded={"SOXL"}) == ["ET", "KITT"]
def test_ranking_cap_dedupe_and_cum_volume():
class FakeData:
def __init__(self):
self.settings = type("S", (), {"feed": "sip"})()
self.snapshot_calls = 0
self.bar_calls = []
self.snaps = {
"AAA": snap(3.0, 1.0), # +200%
"BBB": snap(4.0, 2.0), # +100%
"CCC": snap(6.0, 4.0), # +50%
"DDD": snap(5.5, 5.0), # +10% (below threshold)
"LOW": snap(9.0, 3.0), # +200%, but under the cum-volume floor
}
def get_assets(self):
return [{"symbol": s, "exchange": "NASDAQ", "status": "active",
"tradable": True, "name": s} for s in self.snaps]
def _data(self, method, path, params=None):
assert path == "/v2/stocks/snapshots" and params["feed"] == "sip"
self.snapshot_calls += 1
return {s: self.snaps[s] for s in params["symbols"].split(",")}
def get_minute_bars(self, symbols, start_iso, end_iso=None, **kw):
self.bar_calls.append((tuple(symbols), start_iso))
return {s: [{"v": 10_000 if s == "LOW" else 400_000}] for s in symbols}
class Wrapper: # TradierClient-style: data delegated to data_client
def __init__(self, dc):
self.data_client = dc
dc = FakeData()
cfg = SnapshotScanConfig(**{**CFG.__dict__, "batch_size": 2})
scanner = SnapshotScanner(Wrapper(dc), cfg, excluded=set())
res = scanner.sweep(NOW)
assert dc.snapshot_calls == 3 # 5 symbols / batch 2
assert res.requests == 1 + 3 + 1 # assets + snapshots + cum-volume bars
assert res.scanned == 5
assert [h.symbol for h in res.hits][:2] in (["AAA", "LOW"], ["LOW", "AAA"])
assert "DDD" not in {h.symbol for h in res.hits}
# LOW dropped by the cum-volume floor; cap of 2 keeps the top two by pct
assert [h.symbol for h in res.injected] == ["AAA", "BBB"]
assert res.injected[0].cum_volume == 400_000
assert dc.bar_calls[0][1] == "2026-09-25T08:00:00Z" # 04:00 ET
# next sweep: already-announced names aren't re-injected; watched ones skipped
res2 = scanner.sweep(NOW + timedelta(seconds=45), already_watched={"CCC"})
assert res2.injected == []
assert res2.requests == 3 + 1 # universe cached for the day
def test_fetch_snapshots_survives_a_failed_batch():
class Flaky:
settings = type("S", (), {"feed": "sip"})()
n = 0
def _data(self, method, path, params=None):
Flaky.n += 1
if Flaky.n == 1:
raise RuntimeError("503")
return {s: {} for s in params["symbols"].split(",")}
out, reqs, errs = fetch_snapshots(Flaky(), ["A", "B", "C"], batch_size=2)
assert reqs == 2 and len(errs) == 1 and set(out) == {"C"}
def test_emitted_event_source_and_evidence():
class Store:
def __init__(self):
self.events = []
def emit(self, ev):
self.events.append(ev)
store = Store()
hits = qualify({"KITT": snap(3.50, 0.49)}, CFG, NOW)
hits[0].cum_volume = 2_500_000
emit_discoveries(store, hits, mode=EventMode.PAPER, correlation_id="s1",
scan_duration_s=3.2)
(ev,) = store.events
assert ev.event_type == EventType.SYMBOL_DISCOVERED
assert ev.symbol == "KITT"
assert ev.symbol_data["source"] == SOURCE == "snapshot_scan"
assert ev.payload["source"] == "snapshot_scan"
assert ev.symbol_data["price"] == 3.5
assert ev.symbol_data["prev_close"] == 0.49
assert ev.symbol_data["pct_change"] > 600
assert ev.symbol_data["minute_volume"] == 50_000
assert ev.symbol_data["cum_volume"] == 2_500_000
assert "via snapshot_scan" in ev.message
def test_scan_window_04_to_16_et_weekdays():
assert in_scan_window(datetime(2026, 9, 25, 8, 0, tzinfo=timezone.utc)) # 04:00 ET
assert not in_scan_window(datetime(2026, 9, 25, 7, 59, tzinfo=timezone.utc)) # 03:59 ET
assert not in_scan_window(datetime(2026, 9, 25, 20, 0, tzinfo=timezone.utc)) # 16:00 ET
assert not in_scan_window(datetime(2026, 9, 26, 13, 0, tzinfo=timezone.utc)) # Saturday
def test_config_from_env(monkeypatch):
monkeypatch.setenv("WATCHER_PRICE_MIN", "2")
monkeypatch.setenv("DISCOVER_SNAPSHOT_MIN_PCT", "35")
monkeypatch.setenv("DISCOVER_SNAPSHOT_MAX_PER_SWEEP", "3")
cfg = SnapshotScanConfig.from_env()
assert (cfg.price_min, cfg.min_pct, cfg.max_per_sweep) == (2.0, 35.0, 3)