You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
273 lines
9.2 KiB
Python
273 lines
9.2 KiB
Python
"""Phase D — Walk-forward evaluation of the LSM continuation model.
|
|
|
|
Trains a LightGBM regressor on LSM-labeled per-bar data (train set: trades ≤ CUTOFF)
|
|
to predict the continuation value, then applies the trained model to the test set
|
|
(trades > CUTOFF) to make exit decisions.
|
|
|
|
Decision rule at each bar: sell when sell_now_r >= predicted_continue_r.
|
|
|
|
Outputs:
|
|
model.txt — trained LightGBM model
|
|
test_per_trade.parquet — one row per test trade: model vs baseline
|
|
train_per_trade.parquet — one row per train trade: model vs baseline (in-sample)
|
|
summary.md — comparison table + feature importance
|
|
|
|
Usage:
|
|
python scripts/orb_phase_d_walkforward.py \
|
|
--input tmp/orb_perbar_v49_91_200d_lsm/per_bar_lsm.parquet \
|
|
--out tmp/orb_phase_d \
|
|
--cutoff 2026-02-01
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import lightgbm as lgb
|
|
|
|
|
|
# Same feature set as Phase C (V49 entries have all, including candidate_score).
|
|
DYN_FEATURES = [
|
|
"minutes_since_entry",
|
|
"minutes_to_close",
|
|
"minutes_since_open",
|
|
"bar_return_pct",
|
|
"bar_close_loc",
|
|
"current_close_r",
|
|
"current_high_r",
|
|
"current_low_r",
|
|
"mfe_so_far_r",
|
|
"mae_so_far_r",
|
|
"giveback_from_peak_r",
|
|
"bars_since_peak",
|
|
"vwap_dev_pct",
|
|
"vol_vs_first_bar",
|
|
"vol_vs_avg_dvol30d",
|
|
"spy_return_since_entry",
|
|
"spy_return_since_open",
|
|
"qqq_return_since_entry",
|
|
"qqq_return_since_open",
|
|
]
|
|
|
|
STATIC_FEATURES = [
|
|
"atr_at_entry",
|
|
"gap_pct",
|
|
"rvol",
|
|
"morning_gain_pct",
|
|
"entropy_20d",
|
|
"ret_5d",
|
|
"candidate_score",
|
|
"score_rank_pct",
|
|
"sector_confirmation_score",
|
|
"entry_dollar_volume",
|
|
"avg_dollar_vol_30d",
|
|
"premarket_dollar_vol",
|
|
"first_bar_dollar_vol",
|
|
"body_ratio",
|
|
"close_location",
|
|
"gap_zscore_20d",
|
|
"obv_slope_20",
|
|
"obv_slope_5",
|
|
"orb_return",
|
|
]
|
|
|
|
ALL_FEATURES = DYN_FEATURES + STATIC_FEATURES
|
|
|
|
|
|
def lgb_params(seed: int = 0) -> dict:
|
|
return {
|
|
"objective": "regression",
|
|
"metric": "rmse",
|
|
"learning_rate": 0.03,
|
|
"num_leaves": 31,
|
|
"min_data_in_leaf": 20,
|
|
"feature_fraction": 0.8,
|
|
"bagging_fraction": 0.8,
|
|
"bagging_freq": 5,
|
|
"verbose": -1,
|
|
"seed": seed,
|
|
}
|
|
|
|
|
|
def train_model(train_df: pd.DataFrame, seed: int = 0) -> lgb.Booster:
|
|
"""Train LightGBM regressor on (state, continue_r) pairs from non-last bars."""
|
|
rows = train_df[train_df["is_last_bar"] == False].dropna(subset=["continue_r"])
|
|
print(f"Training on {len(rows):,} rows from {rows['trade_id'].nunique()} trades")
|
|
X = rows[ALL_FEATURES].astype(float).values
|
|
y = rows["continue_r"].astype(float).values
|
|
ds = lgb.Dataset(X, label=y, feature_name=ALL_FEATURES)
|
|
model = lgb.train(
|
|
lgb_params(seed),
|
|
ds,
|
|
num_boost_round=300,
|
|
callbacks=[lgb.log_evaluation(50)],
|
|
)
|
|
return model
|
|
|
|
|
|
def apply_policy(model: lgb.Booster, df: pd.DataFrame) -> pd.DataFrame:
|
|
"""For each trade, walk forward and sell at first bar where sell_now_r >= predicted_continue_r.
|
|
|
|
Returns one row per trade with model exit vs baseline.
|
|
"""
|
|
rows_out = []
|
|
for tid, g in df.sort_values(["trade_id", "bar_idx"]).groupby("trade_id"):
|
|
g = g.reset_index(drop=True)
|
|
n = len(g)
|
|
|
|
X = g[ALL_FEATURES].astype(float).values
|
|
pred_cr = model.predict(X)
|
|
|
|
exit_idx = n - 1 # default: last bar
|
|
for i in range(n):
|
|
sn = float(g.iloc[i]["sell_now_r"])
|
|
cr = float(pred_cr[i])
|
|
is_last = bool(g.iloc[i]["is_last_bar"])
|
|
if is_last or sn >= cr:
|
|
exit_idx = i
|
|
break
|
|
|
|
baseline_r = g.iloc[0]["baseline_realized_r"]
|
|
baseline_bar = g.iloc[0]["baseline_exit_bar_idx"]
|
|
|
|
rows_out.append({
|
|
"trade_id": tid,
|
|
"ticker": g.iloc[0]["ticker"],
|
|
"date": g.iloc[0]["date"],
|
|
"model_exit_bar_idx": exit_idx,
|
|
"model_exit_minutes_since_entry": float(g.iloc[exit_idx]["minutes_since_entry"]),
|
|
"model_realized_r": float(g.iloc[exit_idx]["sell_now_r"]),
|
|
"baseline_exit_bar_idx": float(baseline_bar),
|
|
"baseline_realized_r": float(baseline_r),
|
|
"n_bars": n,
|
|
"lsm_optimal_bar": int(g.index[g["optimal_action"] == "sell"][0])
|
|
if (g["optimal_action"] == "sell").any()
|
|
else n - 1,
|
|
"lsm_realized_r": float(
|
|
g.iloc[
|
|
int(g.index[g["optimal_action"] == "sell"][0])
|
|
if (g["optimal_action"] == "sell").any()
|
|
else n - 1
|
|
]["sell_now_r"]
|
|
),
|
|
"mfe_r": float(g["mfe_so_far_r"].iloc[-1]),
|
|
"oracle_r": float(g["current_close_r"].max()),
|
|
})
|
|
|
|
return pd.DataFrame(rows_out)
|
|
|
|
|
|
def sharpe_ratio(returns: pd.Series) -> float:
|
|
if len(returns) < 2 or returns.std() == 0:
|
|
return float("nan")
|
|
return float(returns.mean() / returns.std() * np.sqrt(252))
|
|
|
|
|
|
def max_drawdown(returns: pd.Series) -> float:
|
|
cum = returns.cumsum()
|
|
running_max = cum.cummax()
|
|
dd = cum - running_max
|
|
return float(dd.min())
|
|
|
|
|
|
def build_summary(
|
|
train_df: pd.DataFrame,
|
|
test_df: pd.DataFrame,
|
|
model: lgb.Booster,
|
|
) -> str:
|
|
lines = ["# Phase D — Walk-Forward Evaluation", ""]
|
|
|
|
for label, df in [("Train (in-sample)", train_df), ("Test (walk-forward)", test_df)]:
|
|
n = len(df)
|
|
valid = df.dropna(subset=["baseline_realized_r"])
|
|
|
|
model_r = valid["model_realized_r"]
|
|
base_r = valid["baseline_realized_r"]
|
|
delta = model_r - base_r
|
|
|
|
lines += [
|
|
f"## {label} — {n} trades",
|
|
"",
|
|
"| metric | baseline | model | delta |",
|
|
"|---|---|---|---|",
|
|
f"| total_R | {base_r.sum():.2f} | {model_r.sum():.2f} | {delta.sum():+.2f} |",
|
|
f"| mean_R | {base_r.mean():.3f} | {model_r.mean():.3f} | {delta.mean():+.3f} |",
|
|
f"| Sharpe (trade-level) | {sharpe_ratio(base_r):.2f} | {sharpe_ratio(model_r):.2f} | — |",
|
|
f"| max_DD | {max_drawdown(base_r):.3f} | {max_drawdown(model_r):.3f} | — |",
|
|
f"| model beats baseline | — | {int((delta>0).sum())}/{n} ({(delta>0).mean():.1%}) | — |",
|
|
"",
|
|
"| hold-time stat | baseline | model |",
|
|
"|---|---|---|",
|
|
f"| mean bars | {valid['baseline_exit_bar_idx'].mean():.1f} | {valid['model_exit_bar_idx'].mean():.1f} |",
|
|
f"| median bars | {valid['baseline_exit_bar_idx'].median():.0f} | {valid['model_exit_bar_idx'].median():.0f} |",
|
|
f"| p25 bars | {valid['baseline_exit_bar_idx'].quantile(0.25):.0f} | {valid['model_exit_bar_idx'].quantile(0.25):.0f} |",
|
|
f"| p75 bars | {valid['baseline_exit_bar_idx'].quantile(0.75):.0f} | {valid['model_exit_bar_idx'].quantile(0.75):.0f} |",
|
|
"",
|
|
]
|
|
|
|
# Feature importance
|
|
importances = sorted(
|
|
zip(ALL_FEATURES, model.feature_importance(importance_type="gain")),
|
|
key=lambda x: -x[1],
|
|
)
|
|
lines += [
|
|
"## Feature importance (gain)",
|
|
"",
|
|
"| rank | feature | gain |",
|
|
"|---|---|---|",
|
|
]
|
|
for i, (f, g) in enumerate(importances[:20], 1):
|
|
lines.append(f"| {i} | `{f}` | {g:.0f} |")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--input", required=True)
|
|
ap.add_argument("--out", required=True)
|
|
ap.add_argument("--cutoff", default="2026-02-01", help="Train/test split date (inclusive train)")
|
|
ap.add_argument("--seed", type=int, default=0)
|
|
args = ap.parse_args()
|
|
|
|
out_dir = Path(args.out)
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
df = pd.read_parquet(args.input)
|
|
print(f"Loaded {len(df):,} rows from {df['trade_id'].nunique()} trades "
|
|
f"({df['date'].min()} → {df['date'].max()})")
|
|
|
|
# Time split by trade date
|
|
trade_first_date = df.groupby("trade_id")["date"].first()
|
|
train_ids = trade_first_date[trade_first_date <= args.cutoff].index
|
|
test_ids = trade_first_date[trade_first_date > args.cutoff].index
|
|
|
|
train_df_rows = df[df["trade_id"].isin(train_ids)]
|
|
test_df_rows = df[df["trade_id"].isin(test_ids)]
|
|
print(f"Train: {len(train_ids)} trades ({len(train_df_rows):,} rows)")
|
|
print(f"Test: {len(test_ids)} trades ({len(test_df_rows):,} rows)")
|
|
|
|
print("\nTraining model...")
|
|
model = train_model(train_df_rows, seed=args.seed)
|
|
model.save_model(str(out_dir / "model.txt"))
|
|
print(f"Saved model to {out_dir / 'model.txt'}")
|
|
|
|
print("\nApplying policy to train set (in-sample)...")
|
|
train_results = apply_policy(model, train_df_rows)
|
|
train_results.to_parquet(out_dir / "train_per_trade.parquet", index=False)
|
|
|
|
print("Applying policy to test set (walk-forward)...")
|
|
test_results = apply_policy(model, test_df_rows)
|
|
test_results.to_parquet(out_dir / "test_per_trade.parquet", index=False)
|
|
|
|
md = build_summary(train_results, test_results, model)
|
|
(out_dir / "summary.md").write_text(md)
|
|
print(f"\nWrote {out_dir / 'summary.md'}")
|
|
print(md)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|