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>
137 lines
4.3 KiB
Python
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)
|