Files
momentum devandClaude Opus 5.5 81e505b845 fix: verify squeezes on a median volume baseline and ingest from the premarket
KITT on 2026-09-25 (+557%, 9.3M shares, the day's biggest mover) was benched
as "gap 557% squeeze unverified (rvol 1.54)". Two separate faults:

1. The rvol baseline was the 20-day MEAN, and two 11-14M-share days on
   09-01/09-02 put it at 1.66M against a 333k median. Squeeze verification
   (gap >= 150% needs rvol >= 5) now takes the stronger of the mean-baseline
   reading and the median-baseline one. Both are cumulative volume over a
   full day's baseline, not prorated, so they share the units the rvol >= 5
   boundary was calibrated in. A reverse-split fake still reads ~0.01 on
   both. This applies in the watcher pre-filter, the trigger book's squeeze
   exception and the fast-path population gate. The ranking and the tod-rvol
   arm signal are unchanged.

2. A name's first ingest reached back only to ~09:15 (09:30 + 15 min, or
   the 20-minute refresh floor). KITT was found at 09:35, so its stored day
   began at 09:13 and missed the 09:00 squeeze and ~6M premarket shares.
   The first pass now covers the session from 04:00 ET.

With both fixed, KITT's volume at 09:35 is ~20x its median day.

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

202 lines
7.7 KiB
Python

"""Watchlist providers and watcher task glue.
Two providers implement the WatchlistProvider protocol:
* ResearchWatchlistProvider — reads candidates and bars from the research
market schema (populated by research.ingestion.market_data). The price
band is configurable via WATCHER_PRICE_MIN / WATCHER_PRICE_MAX, fixing
the old hardcoded ``close BETWEEN 1 AND 20`` filter that silently
excluded every ingested symbol.
* LiveWatchlistProvider — takes an explicit symbol list and pulls bars
straight from Alpaca, for running without a research database.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
import pandas as pd
from research import query as research_query
from research.ingestion.market_data import classify_session, parse_alpaca_timestamp
from runtime.watcher import WatchCandidate, Watcher, WatcherTickResult
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name, "").strip()
try:
return float(raw) if raw else default
except ValueError:
return default
@dataclass
class ResearchWatchlistProvider:
"""Candidates + bars from the research market database."""
con: object
price_min: float = field(default_factory=lambda: _env_float("WATCHER_PRICE_MIN", 1.0))
price_max: float = field(default_factory=lambda: _env_float("WATCHER_PRICE_MAX", 20.0))
limit: int = 50
# symbols manually injected from the dashboard; always evaluated regardless
# of the price-band query (e.g. a ticker not yet ingested)
extra_symbols: set = field(default_factory=set)
def add_symbol(self, symbol: str) -> None:
self.extra_symbols.add(symbol.upper())
def get_candidates(self, session_date: date) -> list[WatchCandidate]:
rows = research_query.query_session_symbols(
self.con,
session_date,
price_min=self.price_min,
price_max=self.price_max,
limit=self.limit,
)
# Injected symbols FIRST: the watcher truncates at max_symbols, so
# extras appended after a full research list were silently dropped —
# exactly the armed-trigger names the reasoning panel needs scored.
candidates = []
seen = set()
for symbol in sorted(self.extra_symbols):
seen.add(symbol)
candidates.append(
WatchCandidate(
symbol=symbol,
previous_close=research_query.query_previous_close(
self.con, symbol, session_date
),
avg_daily_volume=research_query.query_avg_daily_volume(
self.con, symbol, session_date
),
median_daily_volume=research_query.query_median_daily_volume(
self.con, symbol, session_date
),
source="manual",
)
)
for row in rows:
symbol = row["symbol"]
if symbol in seen:
continue
seen.add(symbol)
candidates.append(
WatchCandidate(
symbol=symbol,
last_price=row.get("last_price"),
previous_close=research_query.query_previous_close(
self.con, symbol, session_date
),
avg_daily_volume=research_query.query_avg_daily_volume(
self.con, symbol, session_date
),
median_daily_volume=research_query.query_median_daily_volume(
self.con, symbol, session_date
),
source="research",
)
)
return candidates
def get_bars(self, symbol: str, session_date: date) -> pd.DataFrame:
return research_query.query_minute_bars(self.con, symbol, session_date)
@dataclass
class LiveWatchlistProvider:
"""Explicit symbols, bars fetched live from Alpaca (no database needed)."""
client: object
symbols: list[str]
lookback_minutes: int = 420
_daily_cache: dict[str, list[dict]] = field(default_factory=dict)
def _daily_bars(self, symbol: str) -> list[dict]:
if symbol not in self._daily_cache:
start = (datetime.now(timezone.utc) - timedelta(days=60)).date()
try:
payload = self.client.get_daily_bars(
[symbol], start_iso=f"{start.isoformat()}T00:00:00Z"
)
self._daily_cache[symbol] = payload.get(symbol, [])
except Exception: # noqa: BLE001
self._daily_cache[symbol] = []
return self._daily_cache[symbol]
def get_candidates(self, session_date: date) -> list[WatchCandidate]:
candidates = []
for symbol in self.symbols:
daily = self._daily_bars(symbol)
prev_close = None
adv = med = None
if daily:
# last bar strictly before the session date
prior = [
b for b in daily
if parse_alpaca_timestamp(b["t"]).date() < session_date
]
if prior:
prev_close = float(prior[-1]["c"])
recent = prior[-20:]
adv = sum(float(b["v"]) for b in recent) / len(recent)
vols = sorted(float(b["v"]) for b in recent)
mid = len(vols) // 2
med = vols[mid] if len(vols) % 2 else (vols[mid - 1] + vols[mid]) / 2
candidates.append(
WatchCandidate(
symbol=symbol,
previous_close=prev_close,
avg_daily_volume=adv,
median_daily_volume=med,
source="live",
)
)
return candidates
def get_bars(self, symbol: str, session_date: date) -> pd.DataFrame:
now = datetime.now(timezone.utc)
start = now - timedelta(minutes=self.lookback_minutes)
try:
payload = self.client.get_minute_bars(
[symbol],
start_iso=start.isoformat().replace("+00:00", "Z"),
end_iso=(now - timedelta(seconds=30)).isoformat().replace("+00:00", "Z"),
)
bars = payload.get(symbol, [])
except Exception: # noqa: BLE001
bars = []
rows = []
for bar in bars:
ts = parse_alpaca_timestamp(bar["t"])
bar_date, is_pre, is_reg, _ = classify_session(ts)
if bar_date != session_date:
continue
rows.append(
{
"symbol": symbol,
"timestamp": ts.astimezone(timezone.utc).replace(tzinfo=None),
"session_date": bar_date,
"is_premarket": is_pre,
"is_regular_hours": is_reg,
"open": float(bar["o"]),
"high": float(bar["h"]),
"low": float(bar["l"]),
"close": float(bar["c"]),
"volume": int(bar["v"]),
"vwap": float(bar.get("vw") or bar["c"]),
"quality_score": 1.0,
}
)
return pd.DataFrame(rows)
def run_watcher_tick(
watcher: Watcher, session_date: date | None = None
) -> WatcherTickResult:
"""One watcher tick with a safe default session date (US/Eastern today)."""
if session_date is None:
session_date, _, _, _ = classify_session(datetime.now(timezone.utc))
return watcher.tick(session_date)