research/conversion/{__init__,dataset,signal_probe}.py — the leakage-safe probe
plan 009 Step 5 re-runs scoped to a live setup_version:
- dataset.load_dataset: setup_events JOIN outcome_labels; label =
reached_1r_before_minus_1r; features = trigger-time-knowable setup_events
columns ONLY, dropping >70%-null and <2-distinct columns. HARD leakage rule —
every outcome_labels column is blocklisted AND asserted absent from X, so a
schema change can't leak the answer in.
- signal_probe.run_probe: expanding-window CV over ~5 sequential session-date
blocks (whole days, train strictly precedes test), HistGradientBoostingClassifier
(max_iter=250, lr=0.05, max_depth=4, l2=1.0, categorical_features=indices),
per-fold + mean ROC-AUC, top-decile +1R lift vs base, permutation importance on
the last fold. Drops columns constant within a train slice (HistGBM binning).
CLI: python3 -m research.conversion.signal_probe [--setup-version v1].
Tests: the critical leakage guard (zero outcome columns in features), AUC>0.5 on a
synthetic signal + ~0.5 on pure noise, and a static live-path guard. All :memory:.
No live-path change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
90 lines
3.6 KiB
Python
90 lines
3.6 KiB
Python
"""Assemble the leakage-safe conversion dataset.
|
|
|
|
setup_events JOIN outcome_labels; label = reached_1r_before_minus_1r (int);
|
|
features = ONLY trigger-time-knowable setup_events columns. Drops columns that are
|
|
mostly null (>70%) or constant (<2 distinct) — both useless and the latter breaks
|
|
HistGBM's binning.
|
|
|
|
HARD LEAKAGE RULE: no outcome_labels column may ever be a feature. The features
|
|
come from `se.*` alone and every outcome column is additionally blocklisted +
|
|
asserted, so a schema change can't silently leak the answer into the inputs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pandas as pd
|
|
|
|
LABEL = "reached_1r_before_minus_1r"
|
|
|
|
# Everything in outcome_labels — derived from bars AFTER the decision point. None of
|
|
# these may be a feature. (Kept explicit so the leakage guard survives schema drift.)
|
|
OUTCOME_LABEL_COLUMNS = frozenset({
|
|
"setup_id", "label_version",
|
|
"max_upside_next_5m", "max_upside_next_15m", "max_upside_next_60m",
|
|
"max_drawdown_next_5m", "max_drawdown_next_15m", "max_drawdown_next_60m",
|
|
"reached_1r_before_minus_1r", "reached_2r_before_minus_1r",
|
|
"held_vwap_5m", "held_vwap_15m", "trend_day_flag", "failed_breakout_flag",
|
|
"time_to_max_upside_minutes", "time_to_max_drawdown_minutes",
|
|
})
|
|
|
|
# setup_events identifiers / bookkeeping — knowable but not features.
|
|
ID_COLUMNS = frozenset({"setup_id", "symbol", "setup_time", "session_date"})
|
|
|
|
# String columns to model as categoricals (setup_version is constant once filtered
|
|
# to one version and gets dropped by the constant guard — harmless to list).
|
|
CATEGORICAL = ("setup_name", "setup_version", "tape_regime_at_trigger",
|
|
"relative_volume_source")
|
|
|
|
NULL_FRAC_MAX = 0.70
|
|
|
|
|
|
def load_dataset(con, setup_version: str | None = None,
|
|
null_frac_max: float = NULL_FRAC_MAX):
|
|
"""Return (X, y, session_date, cat_cols).
|
|
|
|
X: trigger-time-knowable setup_events features only (numeric coerced; categoricals
|
|
as pandas 'category'). y: int label. session_date: for date-blocked CV.
|
|
cat_cols: the categorical column names present in X.
|
|
"""
|
|
where, params = "", []
|
|
if setup_version:
|
|
where, params = "WHERE se.setup_version = ?", [setup_version]
|
|
df = con.execute(
|
|
f"SELECT se.*, l.{LABEL} AS __label FROM setup_events se "
|
|
f"JOIN outcome_labels l ON l.setup_id = se.setup_id {where} "
|
|
"ORDER BY se.session_date, se.setup_time", params).df()
|
|
|
|
df = df[df["__label"].notna()].reset_index(drop=True)
|
|
if df.empty:
|
|
raise ValueError("no labeled setups for this filter")
|
|
y = df["__label"].astype(int)
|
|
session_date = df["session_date"]
|
|
|
|
drop = (ID_COLUMNS | OUTCOME_LABEL_COLUMNS | {"__label"})
|
|
feat_cols = [c for c in df.columns if c not in drop]
|
|
X = df[feat_cols].copy()
|
|
|
|
cat_cols = [c for c in CATEGORICAL if c in X.columns]
|
|
for c in X.columns:
|
|
if c not in cat_cols:
|
|
X[c] = pd.to_numeric(X[c], errors="coerce") # bool/Decimal/None -> float
|
|
|
|
# drop mostly-null and constant columns (constant breaks HistGBM binning)
|
|
keep = []
|
|
for c in X.columns:
|
|
if X[c].isna().mean() > null_frac_max:
|
|
continue
|
|
if X[c].nunique(dropna=True) < 2:
|
|
continue
|
|
keep.append(c)
|
|
X = X[keep]
|
|
cat_cols = [c for c in cat_cols if c in X.columns]
|
|
for c in cat_cols:
|
|
X[c] = X[c].astype("category")
|
|
|
|
# leakage assertion — belt and suspenders on top of selecting from se.* only
|
|
leaked = set(X.columns) & OUTCOME_LABEL_COLUMNS
|
|
assert not leaked, f"LEAKAGE: outcome columns in features: {sorted(leaked)}"
|
|
|
|
return X, y, session_date, cat_cols
|