Files
momentum-system/research/gapper_history.py
T
momentum devandClaude Fable 5 af8137dfd7 delete: dead DDL (10 tables), dead projections, pinned dead code; README/pyproject prune
pg_schema.sql: drop CREATE TABLE for 10 zero-reference tables (operator DROP
list in plans/002-deletions.log — production tables untouched until run).
projections.py: remove 4 zero-consumer query functions.
Clear all 'plan 002' ruff pins from the CI gate (files now F-clean);
api/main.py:F821 pin remains for plan 007. README repo-map/test-count fixed;
pydantic-settings removed (zero imports). Full receipts: plans/002-deletions.log

Suite: 351 passed (16 schwab/dead-module tests removed), 15 failures = the
known WSL-loopback dashboard set only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 21:09:35 -04:00

240 lines
11 KiB
Python

"""Year-long gapper-history reconstruction — statistical power for the core edge.
Every conclusion so far (including the LIVE confirmation gate) rests on 19-26 days of history.
The paid plan's 9+ years of SIP data lets us fix that surgically instead of ingesting the whole
market at minute resolution:
1. daily — pull ~1 year of DAILY bars (SIP) for the full listed US-equity universe
(~10-11k symbols; ~2.7M rows; cheap).
2. gappers — reconstruct each historical day's TOP GAPPERS from those dailies
(open/prev_close-1 >= 10%, open $1-20, day volume >= 300k; top 20/day)
into the `historical_gappers` table (~250 days x ~20 = ~4-5k symbol-days).
3. minutes — pull SIP minute bars (04:00-20:00 ET, premarket included) for JUST those
gapper symbol-days (~2-4M rows). Idempotent; skips symbol-days already present.
4. analyze — replay the ORB through the year: OR(5-min) trigger, first-cross minute,
pessimistic bracket outcome; report conversion BY CONFIRMATION-MINUTE bucket
and by month. PRE-REGISTERED BAR (locked before the data): the confirmation
gate GENERALIZES if the early(<=15min)-cross bucket beats the late bucket by
>= +0.25R expectancy AND is the best bucket in >= 70% of months.
python -m research.gapper_history all # chain 1-4
python -m research.gapper_history <step> # individual steps (resumable)
"""
import argparse
import time
from collections import defaultdict
from datetime import datetime, timedelta
import os
import pandas as pd
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
from alpaca_paper.client import AlpacaPaperClient # noqa: E402
from alpaca_paper.settings import AlpacaPaperSettings # noqa: E402
from research.ingestion.market_data import ( # noqa: E402
upsert_daily_bars, upsert_minute_bars)
from research.multi_schema import open_research_db # noqa: E402
LOOKBACK_DAYS = 380 # ~1 year of trading days + padding
GAP_MIN = 0.10
PRICE_LO, PRICE_HI = 1.0, 20.0
DAY_VOL_MIN = 300_000.0
TOP_N = 20
ADVERSE = 1.002 # +0.2% adverse fill in the replay
CONFIRM_BUCKETS = [(0, 15, "00-15m"), (15, 30, "15-30m"), (30, 60, "30-60m"),
(60, 120, "60-120m"), (120, 999, "120m+")]
def _client():
return AlpacaPaperClient(AlpacaPaperSettings.from_env())
def _universe(client) -> list[str]:
"""Listed US equities, junk filtered (no OTC per assets API; drop units/warrants)."""
out = []
for a in client.get_assets():
sym = (a.get("symbol") or "").strip()
if not sym or "." in sym or "/" in sym or len(sym) > 5:
continue
if len(sym) == 5 and sym[-1] in ("W", "R", "U"): # warrants/rights/units
continue
if a.get("exchange") == "OTC":
continue
out.append(sym)
return sorted(set(out))
def step_daily() -> None:
con = open_research_db("market")
client = _client()
syms = _universe(client)
start = (datetime.utcnow() - timedelta(days=LOOKBACK_DAYS)).strftime("%Y-%m-%dT00:00:00Z")
print(f"universe: {len(syms)} symbols; pulling dailies since {start[:10]}", flush=True)
t0, rows = time.time(), 0
for i in range(0, len(syms), 100):
chunk = syms[i:i + 100]
try:
by_symbol = client.get_daily_bars(chunk, start_iso=start, feed="sip")
except Exception as exc: # noqa: BLE001
print(f" ! chunk {i}: {str(exc)[:70]}", flush=True)
time.sleep(2)
continue
for sym, bars in by_symbol.items():
if bars:
rows += upsert_daily_bars(con, sym, bars)
if (i // 100) % 10 == 0:
print(f" ... {i + len(chunk)}/{len(syms)} symbols, {rows} rows ({time.time()-t0:.0f}s)", flush=True)
print(f"daily done: {rows} rows in {time.time()-t0:.0f}s", flush=True)
def step_gappers() -> None:
con = open_research_db("market")
con.execute("""CREATE TABLE IF NOT EXISTS historical_gappers (
session_date DATE NOT NULL, symbol TEXT NOT NULL,
gap_pct DOUBLE PRECISION, day_volume DOUBLE PRECISION, rank INT,
PRIMARY KEY (session_date, symbol))""")
rows = con.execute(
"SELECT symbol, trade_date, open, previous_close, volume FROM daily_bars "
"WHERE previous_close > 0 AND open BETWEEN ? AND ? AND volume >= ? "
"AND (open / previous_close - 1.0) >= ?",
[PRICE_LO, PRICE_HI, DAY_VOL_MIN, GAP_MIN]).fetchall()
by_day = defaultdict(list)
for sym, d, o, pc, v in rows:
by_day[d].append((sym, float(o) / float(pc) - 1.0, float(v)))
n = 0
for d, cands in by_day.items():
cands.sort(key=lambda x: -x[1])
for rank, (sym, gap, vol) in enumerate(cands[:TOP_N], 1):
con.execute(
"INSERT INTO historical_gappers (session_date, symbol, gap_pct, day_volume, rank) "
"VALUES (?, ?, ?, ?, ?) ON CONFLICT (session_date, symbol) DO UPDATE SET "
"gap_pct=EXCLUDED.gap_pct, day_volume=EXCLUDED.day_volume, rank=EXCLUDED.rank",
[d, sym, gap, vol, rank])
n += 1
print(f"gappers: {n} symbol-days across {len(by_day)} sessions", flush=True)
def step_minutes() -> None:
con = open_research_db("market")
client = _client()
todo = con.execute("""
SELECT hg.session_date, hg.symbol FROM historical_gappers hg
WHERE NOT EXISTS (SELECT 1 FROM minute_bars mb WHERE mb.symbol = hg.symbol
AND mb.session_date = hg.session_date
GROUP BY mb.symbol HAVING count(*) >= 50)
ORDER BY hg.session_date""").fetchall()
by_day = defaultdict(list)
for d, sym in todo:
by_day[d].append(sym)
print(f"minutes: {len(todo)} symbol-days to fetch across {len(by_day)} sessions", flush=True)
t0, rows = time.time(), 0
for j, (d, syms) in enumerate(sorted(by_day.items())):
start = f"{d.isoformat()}T08:00:00Z"
end = f"{(d + timedelta(days=1)).isoformat()}T00:00:00Z"
try:
by_symbol = client.get_minute_bars(syms, start_iso=start, end_iso=end, feed="sip")
except Exception as exc: # noqa: BLE001
print(f" ! {d}: {str(exc)[:70]}", flush=True)
time.sleep(2)
continue
for sym, bars in by_symbol.items():
if bars:
rows += upsert_minute_bars(con, sym, bars, source_provider="alpaca_sip")
if j % 20 == 0:
print(f" ... {j}/{len(by_day)} sessions, {rows} rows ({time.time()-t0:.0f}s)", flush=True)
print(f"minutes done: {rows} rows in {time.time()-t0:.0f}s", flush=True)
def step_analyze() -> None:
con = open_research_db("market")
days = [r[0] for r in con.execute(
"SELECT DISTINCT session_date FROM historical_gappers ORDER BY session_date").fetchall()]
results = []
t0 = time.time()
for d in days:
syms = [r[0] for r in con.execute(
"SELECT symbol FROM historical_gappers WHERE session_date=?", [d]).fetchall()]
if not syms:
continue
ph = ",".join(["?"] * len(syms))
b = pd.DataFrame(con.execute(
f"SELECT symbol, timestamp, open, high, low, close FROM minute_bars "
f"WHERE session_date=? AND is_regular_hours AND symbol IN ({ph}) "
f"ORDER BY symbol, timestamp", [d] + syms).fetchall(),
columns=["symbol", "ts", "open", "high", "low", "close"])
for col in ("open", "high", "low", "close"):
b[col] = b[col].astype(float)
for sym, bb in b.groupby("symbol"):
bb = bb.reset_index(drop=True)
if len(bb) < 20:
continue
orh = float(bb["high"].iloc[:5].max())
orl = float(bb["low"].iloc[:5].min())
if orh <= orl:
continue
fwd = bb.iloc[5:].reset_index(drop=True)
cross = fwd[fwd["high"] >= orh]
if cross.empty:
continue
ci = int(cross.index[0])
confirm_min = ci + 5 # minutes after the open
fill = orh * ADVERSE
R = fill - orl
if R <= 0:
continue
out_r = None
for _, row in fwd.iloc[ci + 1:].iterrows():
if row["low"] <= orl:
out_r = -1.0
break
if row["high"] >= fill + 2 * R:
out_r = 2.0
break
if out_r is None:
out_r = (float(fwd["close"].iloc[-1]) - fill) / R
results.append(dict(month=str(d)[:7], confirm=confirm_min, r=out_r))
df = pd.DataFrame(results)
print(f"\n=== YEAR-LONG ORB REPLAY (n={len(df)} gapper symbol-days, {time.time()-t0:.0f}s) ===")
print("\nBY CONFIRMATION MINUTE (the gate's core claim):")
for lo, hi, lab in CONFIRM_BUCKETS:
sub = df[(df["confirm"] > lo) & (df["confirm"] <= hi)]
if len(sub):
print(f" {lab:9} n={len(sub):5} expR={sub['r'].mean():+.3f} "
f"win%={100*(sub['r']>0).mean():.0f} medR={sub['r'].median():+.2f}")
early = df[df["confirm"] <= 15]
late = df[df["confirm"] > 15]
if len(early) and len(late):
delta = early["r"].mean() - late["r"].mean()
print(f"\nEARLY(<=15m) expR {early['r'].mean():+.3f} vs LATE {late['r'].mean():+.3f} "
f"-> delta {delta:+.3f}R [{'HOLDS' if delta >= 0.25 else 'FAILS'} the +0.25R bar]")
# month-by-month consistency: is early the best bucket most months?
wins = 0
months = sorted(df["month"].unique())
for m in months:
dm = df[df["month"] == m]
e, l = dm[dm["confirm"] <= 15], dm[dm["confirm"] > 15]
if len(e) >= 10 and len(l) >= 10 and e["r"].mean() > l["r"].mean():
wins += 1
print(f"month-by-month: early beats late in {wins}/{len(months)} months "
f"[{'HOLDS' if wins >= 0.7 * len(months) else 'FAILS'} the 70% consistency bar]")
def main() -> None:
ap = argparse.ArgumentParser(description="year-long gapper history reconstruction")
ap.add_argument("step", choices=["daily", "gappers", "minutes", "analyze", "all"])
args = ap.parse_args()
steps = {"daily": step_daily, "gappers": step_gappers,
"minutes": step_minutes, "analyze": step_analyze}
if args.step == "all":
for name in ("daily", "gappers", "minutes", "analyze"):
print(f"\n########## {name} ##########", flush=True)
steps[name]()
else:
steps[args.step]()
if __name__ == "__main__":
main()