Files
momentum-system/research/ingestion/signals.py
T
momentum devandClaude Opus 4.8 007cd95c79 Baseline before Postgres migration (WS0)
Snapshot before the DuckDB->PostgreSQL migration. data/, archive/, the 232MB
rebuild zip, the Windows .venv, and .env are excluded via .gitignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:39:29 -04:00

137 lines
4.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
cumulative_volume: int
relative_volume: float
vwap: float | None
rank: int = 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,
) -> 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
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
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()
candidates: list[GapperCandidate] = []
for symbol, close, vwap, cum_volume, prev_close, adv 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
if gap_pct >= min_gap_pct and rvol >= min_relative_volume:
candidates.append(
GapperCandidate(
symbol=symbol,
price=float(close),
gap_pct=gap_pct,
cumulative_volume=int(cum_volume),
relative_volume=rvol,
vwap=float(vwap) if vwap is not None else None,
)
)
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)