Files
momentum-system/research/ingestion/signals.py
T
momentum devandClaude Fable 5 5597250ff1 fix: marketable entries price from live last + time-of-day rvol arms fast path
Day-22 forensic (BIYA session): 6 entry submissions, 0 fills — IREX missed
+1.7R, IRE +1.65R by one cent. Two root causes fixed:

1. Slow-path limit rested AT the trigger while price ran 2% past it, and
   retries reused the same stale price. Marketable model now prices from
   max(trigger, live last) via price_provider; the anti-chase extension
   gate gets the live ref too (was an entry_ref==trigger no-op on this
   path). TRADING_ENTRY_FILL_MODEL flipped to marketable.

2. Fast trigger path armed nothing all day: legacy rvol (cum-so-far /
   full-day MEAN) is ~0.1-0.3 minutes after the open for a normal heavy
   name and its mean is poisoned by reverse-split/prior-squeeze days
   (BIYA legacy 1.27 vs real activity 481x median). New rvol_tod =
   cum / (MEDIAN(20d) * elapsed-session fraction) drives arm eligibility
   and the scanner keep-filter; legacy rvol untouched for validated
   boundaries (gap-squeeze rvol>=5).

Also: stagnation time-stop (TRADING_EXIT_STAGNATION_MIN/_R, default OFF)
in simulate_exit + manage_live with sweep_exits.py variants for
validation before any live enable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:40:15 -05:00

172 lines
6.3 KiB
Python

"""Signal scanning: derive gapper candidates and scanner snapshots.
Reads minute/daily bars from the market database, computes gap %% and
relative volume, and writes ranked rows into scanner_snapshots so the
dashboard and research notebooks can replay what the scanner saw.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import date, datetime, timezone
SCANNER_VERSION = "gapper-v2"
@dataclass
class GapperCandidate:
symbol: str
price: float
gap_pct: float # DYNAMIC: latest close vs prev close (display/rank)
cumulative_volume: int
relative_volume: float
vwap: float | None
prev_close: float | None = None # for the STATIC overnight gap (open vs prev close)
rank: int = 0
# Time-of-day rvol: cum volume vs MEDIAN(20d) prorated by session elapsed time.
# The legacy relative_volume divides cum-so-far by the full-day MEAN, which is
# (a) ~0.1-0.3 for a NORMAL heavy name minutes after the open (cum can't reach
# a full-day average yet) and (b) poisoned by reverse-split / prior-squeeze
# days in the mean (BIYA 07-20: legacy 1.27 vs tod 481 — median 1.5M, mean
# 29.4M). None when no baseline. Legacy relative_volume is kept untouched —
# validated boundaries (gap-squeeze rvol>=5) are calibrated in its units.
relative_volume_tod: float | None = None
def _session_elapsed_fraction(now: datetime | None = None) -> float:
"""Fraction of the 390-min regular session elapsed, floored at 0.05 so a
premarket/at-open scan doesn't divide by ~zero expected volume."""
try:
from zoneinfo import ZoneInfo
et = (now or datetime.now(ZoneInfo("America/New_York")))
if et.tzinfo is not None:
et = et.astimezone(ZoneInfo("America/New_York"))
elapsed = (et.hour - 9) * 60 + (et.minute - 30)
except Exception: # noqa: BLE001 — fall back to full-day (legacy behaviour)
return 1.0
return min(1.0, max(0.05, elapsed / 390.0))
def scan_gappers(
con,
session_date: date,
min_gap_pct: float = 5.0,
min_relative_volume: float = 2.0,
price_min: float = 1.0,
price_max: float = 20.0,
limit: int = 25,
now: datetime | None = None,
) -> list[GapperCandidate]:
"""Find symbols gapping up on elevated volume for a session."""
rows = con.execute(
"""
WITH latest AS (
SELECT symbol, close, vwap,
SUM(volume) OVER (PARTITION BY symbol) AS cum_volume,
ROW_NUMBER() OVER (
PARTITION BY symbol ORDER BY timestamp DESC
) AS rn
FROM minute_bars
WHERE session_date = ?
),
prev AS (
SELECT symbol, close AS prev_close,
ROW_NUMBER() OVER (
PARTITION BY symbol ORDER BY trade_date DESC
) AS rn
FROM daily_bars
WHERE trade_date < ?
),
avg_vol AS (
SELECT symbol, AVG(volume) AS adv,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY volume) AS med_vol
FROM (
SELECT symbol, volume,
ROW_NUMBER() OVER (
PARTITION BY symbol ORDER BY trade_date DESC
) AS rn
FROM daily_bars
WHERE trade_date < ?
)
WHERE rn <= 20
GROUP BY symbol
)
SELECT l.symbol, l.close, l.vwap, l.cum_volume,
p.prev_close, a.adv, a.med_vol
FROM latest l
JOIN prev p ON p.symbol = l.symbol AND p.rn = 1
LEFT JOIN avg_vol a ON a.symbol = l.symbol
WHERE l.rn = 1 AND l.close BETWEEN ? AND ?
""",
[session_date, session_date, session_date, price_min, price_max],
).fetchall()
frac = _session_elapsed_fraction(now)
candidates: list[GapperCandidate] = []
for symbol, close, vwap, cum_volume, prev_close, adv, med_vol in rows:
if not prev_close or prev_close <= 0:
continue
gap_pct = (float(close) - float(prev_close)) / float(prev_close) * 100.0
rvol = (float(cum_volume) / float(adv)) if adv and adv > 0 else 0.0
rvol_tod = (float(cum_volume) / (float(med_vol) * frac)
if med_vol and float(med_vol) > 0 else None)
# either rvol flavour clears the bar: legacy is ~0.1-0.3 early in the
# session even for a heavy name (cum-so-far vs full-day mean), so the
# tod variant keeps real early movers from being silently dropped
if gap_pct >= min_gap_pct and (
rvol >= min_relative_volume
or (rvol_tod or 0.0) >= min_relative_volume):
candidates.append(
GapperCandidate(
symbol=symbol,
price=float(close),
prev_close=float(prev_close),
gap_pct=gap_pct,
cumulative_volume=int(cum_volume),
relative_volume=rvol,
vwap=float(vwap) if vwap is not None else None,
relative_volume_tod=rvol_tod,
)
)
candidates.sort(key=lambda c: (c.gap_pct * max(c.relative_volume, 0.1)), reverse=True)
for i, candidate in enumerate(candidates[:limit], start=1):
candidate.rank = i
return candidates[:limit]
def store_scanner_snapshot(
con, candidates: list[GapperCandidate], snapshot_time: datetime | None = None
) -> int:
"""Persist a scan into scanner_snapshots. Returns rows written."""
ts = (snapshot_time or datetime.now(timezone.utc)).replace(tzinfo=None)
rows = [
(
str(uuid.uuid4()),
ts,
c.symbol,
c.rank,
c.price,
c.gap_pct,
c.cumulative_volume,
c.relative_volume,
c.vwap,
(c.price - c.vwap) / c.vwap * 100.0 if c.vwap else None,
SCANNER_VERSION,
)
for c in candidates
]
if not rows:
return 0
con.executemany(
"""
INSERT INTO scanner_snapshots (
id, snapshot_time, symbol, rank, price, gap_pct,
cumulative_volume, relative_volume, vwap, distance_from_vwap,
scanner_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
return len(rows)