Labeler phase 1: offline self-labeling / feature store

research/labeler.py turns each historical symbol-session into a (features ->
outcome) row so any forecasting signal's LIFT can be measured before it sizes
live. Reuses the LIVE feature fns (opening_range / compute_key_levels /
calculate_time_of_day_rvol / data-quality) so offline == live; STRICT time split
(features <= the ORB-completion decision point, labels strictly after);
idempotent + versioned; populates the previously-empty setup_events /
engineered_features / outcome_labels tables.

`build [--limit N] [--rebuild]` + `report` (base rates + runner rates via one
aggregate query). Core math is a pure compute_setup() — 3 unit tests cover the
time-split (a runner's feature VWAP can't see the post-setup run), the R-outcome,
failed-breakout, trend-day, and the <ORB-bars skip.

First real base rates (n=3,610 setups / 13 days): 24% reach +1R before -1R, 43%
fail outright, +20% runners 2.2%, +50% 0.4%, +100% (NXTS-class) just 0.1%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
momentum devandClaude Opus 4.8 committed 2026-06-22 22:37:30 -04:00
1 parent a86fa43aa7
commit 11e33bba91
2 files changed
+384

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
"""Labeler core: strict time-split + correct labels on synthetic sessions."""
from datetime import date, datetime
import pandas as pd
from research.labeler import compute_setup
COLS = ["timestamp", "session_date", "is_premarket", "is_regular_hours",
"is_afterhours", "open", "high", "low", "close", "volume", "vwap"]
D = date(2026, 6, 1)
def _bars(seq):
"""seq = list of (minute_offset_from_0930, open, high, low, close)."""
rows = []
for m, o, h, lo, c in seq:
ts = datetime(2026, 6, 1, 9, 30) + pd.Timedelta(minutes=m)
rows.append([ts, D, False, True, False, o, h, lo, c, 1000, c])
return pd.DataFrame(rows, columns=COLS)
def _runner_bars():
# ORB (bars 0-4): high 1.10 / low 1.00. Then runs 1.10 -> 2.00.
orb = [(0, 1.00, 1.05, 1.00, 1.04), (1, 1.04, 1.08, 1.02, 1.06),
(2, 1.06, 1.10, 1.04, 1.09), (3, 1.09, 1.10, 1.05, 1.08),
(4, 1.08, 1.10, 1.06, 1.09)]
run = [(5, 1.10, 1.30, 1.09, 1.28), (6, 1.28, 1.60, 1.25, 1.55),
(7, 1.55, 2.00, 1.50, 1.95), (8, 1.95, 2.00, 1.80, 1.90),
(9, 1.90, 1.95, 1.70, 1.75)]
return _bars(orb + run)
def test_runner_session_labels_and_timesplit():
setup, feat, label = compute_setup("RUN", D, _runner_bars(),
prior_close=0.80, avg_vol=50_000, cats={})
# geometry: entry = ORB high 1.10, invalidation = ORB low 1.00
assert setup["entry_reference_price"] == 1.10
assert setup["invalidation_price"] == 1.00
# gap% off prior close 0.80, open 1.00 -> +25%
assert abs(feat["gap_pct"] - 0.25) < 1e-6
# TIME SPLIT: the feature VWAP must reflect ONLY the ORB bars (~1.0x),
# NOT the post-setup run to 2.00 — proves no look-ahead.
assert feat["vwap"] < 1.15
# forward labels see the run
assert label["max_upside_next_15m"] > 0.5 # ran > +50% off entry
assert label["reached_1r_before_minus_1r"] is True # +1R (1.20) hit
assert label["reached_2r_before_minus_1r"] is True # +2R (1.30) hit
assert label["failed_breakout_flag"] is False # never lost the ORB low
assert label["trend_day_flag"] is True # HoD 2.00 vs open 1.00 = +100%
def test_fader_session_labels():
orb = [(0, 1.00, 1.05, 1.00, 1.04), (1, 1.04, 1.08, 1.02, 1.06),
(2, 1.06, 1.10, 1.04, 1.09), (3, 1.09, 1.10, 1.05, 1.08),
(4, 1.08, 1.10, 1.06, 1.09)]
fade = [(5, 1.10, 1.12, 1.05, 1.06), (6, 1.06, 1.07, 0.95, 0.96),
(7, 0.96, 0.98, 0.85, 0.88), (8, 0.88, 0.90, 0.84, 0.86),
(9, 0.86, 0.88, 0.82, 0.83)]
setup, feat, label = compute_setup("FADE", D, _bars(orb + fade),
prior_close=0.90, avg_vol=50_000, cats={})
assert label["failed_breakout_flag"] is True # poked >1.10 then lost 1.00
assert label["reached_1r_before_minus_1r"] is False # stopped before +1R
assert label["trend_day_flag"] is False # HoD 1.12 vs open 1.00 = +12%
assert label["max_drawdown_next_15m"] < 0 # went underwater
def test_skips_session_without_full_opening_range():
# only 3 RTH bars -> no opening range -> no setup
short = _bars([(0, 1.0, 1.1, 1.0, 1.05), (1, 1.05, 1.1, 1.0, 1.06),
(2, 1.06, 1.1, 1.0, 1.04)])
assert compute_setup("X", D, short, prior_close=1.0, avg_vol=1000, cats={}) is None