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.
339 lines
12 KiB
Python
339 lines
12 KiB
Python
"""ORB Continuation-Value Labels via Longstaff-Schwartz Backward Induction.
|
|
|
|
Reads the Phase B per-bar state dataset and computes, for each (trade, bar):
|
|
|
|
sell_now_r(t) = next-bar open R (executable price if you decide at t close)
|
|
continue_r(t) = cross-fitted ĉ(state_t) — predicted value of *not selling now*
|
|
V_t = max(sell_now_r(t), continue_r(t)) # LSM optimal value
|
|
optimal_action = "sell" if sell_now_r(t) >= continue_r(t) else "hold"
|
|
|
|
Backward induction:
|
|
|
|
V_{T_i} = sell_now_r(T_i) # last bar: forced sale
|
|
V_t = max(sell_now_r(t), ĉ(state_t)) # for t < T_i
|
|
|
|
ĉ is a single LightGBM regressor trained per backward step on (state_t, V_{t+1})
|
|
pairs across all trades with a bar at index t+1, with **trade-level K-fold
|
|
cross-fitting** so each trade's ĉ prediction comes from a model not trained on
|
|
that trade.
|
|
|
|
Features intentionally exclude any "future-looking" columns. Trade-level static
|
|
context is broadcast to each bar.
|
|
|
|
Usage:
|
|
python scripts/orb_continuation_labels.py \
|
|
--input tmp/orb_perbar_v49_91_200d/per_bar_states.parquet \
|
|
--out tmp/orb_perbar_v49_91_200d_lsm
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import lightgbm as lgb
|
|
from sklearn.model_selection import KFold
|
|
|
|
|
|
# ---------- Feature configuration ----------
|
|
|
|
# Per-bar dynamic state features (visible at decision time t).
|
|
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",
|
|
]
|
|
|
|
# Trade-level static features (broadcast — known at entry, fixed for trade).
|
|
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.05,
|
|
"num_leaves": 31,
|
|
"min_data_in_leaf": 30,
|
|
"feature_fraction": 0.9,
|
|
"bagging_fraction": 0.8,
|
|
"bagging_freq": 1,
|
|
"verbose": -1,
|
|
"seed": seed,
|
|
}
|
|
|
|
|
|
def cross_fit_predict(
|
|
X: np.ndarray, y: np.ndarray, trade_ids: np.ndarray, n_splits: int = 5, seed: int = 0
|
|
) -> np.ndarray:
|
|
"""Trade-level K-fold cross-fit: each trade's prediction comes from a fold not containing it.
|
|
|
|
With small n_trades, num_boost_round is kept short and trees shallow.
|
|
"""
|
|
unique_trades = np.unique(trade_ids)
|
|
kf = KFold(n_splits=n_splits, shuffle=True, random_state=seed)
|
|
preds = np.zeros(len(y))
|
|
|
|
for fold, (train_t_idx, val_t_idx) in enumerate(kf.split(unique_trades)):
|
|
train_trades = set(unique_trades[train_t_idx])
|
|
val_trades = set(unique_trades[val_t_idx])
|
|
train_mask = np.isin(trade_ids, list(train_trades))
|
|
val_mask = np.isin(trade_ids, list(val_trades))
|
|
|
|
if train_mask.sum() < 50 or val_mask.sum() == 0:
|
|
preds[val_mask] = y.mean()
|
|
continue
|
|
|
|
ds = lgb.Dataset(X[train_mask], label=y[train_mask])
|
|
model = lgb.train(
|
|
lgb_params(seed + fold),
|
|
ds,
|
|
num_boost_round=120,
|
|
)
|
|
preds[val_mask] = model.predict(X[val_mask])
|
|
|
|
return preds
|
|
|
|
|
|
def run_lsm(
|
|
per_bar: pd.DataFrame,
|
|
n_splits: int = 5,
|
|
max_iters: int = 8,
|
|
tol: float = 0.02,
|
|
seed: int = 0,
|
|
) -> tuple[pd.DataFrame, list[float]]:
|
|
"""LSM via global-pooled fitted-value-iteration.
|
|
|
|
Pools (state_t, V_{t+1}) pairs across all (trade, bar) rather than fitting one
|
|
model per backward step (which underfits on 146 trades). At each outer iter:
|
|
1. Compute V_next per row via groupby-shift within trade.
|
|
2. Cross-fit a global LightGBM on (state, V_next) with trade-level K-fold.
|
|
3. continue_r ← cross-fit prediction; V ← max(sell_now_r, continue_r).
|
|
Iterate until max |ΔV| < tol.
|
|
|
|
Returns (labeled_df, [delta_history]).
|
|
"""
|
|
df = per_bar.sort_values(["trade_id", "bar_idx"]).reset_index(drop=True).copy()
|
|
df["sell_now_r"] = df["next_open_r"].astype(float)
|
|
|
|
grp = df.groupby("trade_id")
|
|
df["n_bars"] = grp["bar_idx"].transform("size").astype(int)
|
|
df["is_last_bar"] = (df["bar_idx"] == df["n_bars"] - 1).astype(bool)
|
|
|
|
# Initial V: assume immediate sale at every bar (a defensible starting policy).
|
|
df["V"] = df["sell_now_r"].copy()
|
|
df["continue_r"] = np.nan
|
|
|
|
X_full = df[ALL_FEATURES].astype(float).to_numpy()
|
|
trade_ids_full = df["trade_id"].to_numpy()
|
|
|
|
deltas: list[float] = []
|
|
sn = df["sell_now_r"].to_numpy()
|
|
is_last = df["is_last_bar"].to_numpy()
|
|
|
|
for it in range(max_iters):
|
|
# V_next within trade (NaN for the last bar of each trade).
|
|
df["V_next"] = df.groupby("trade_id")["V"].shift(-1)
|
|
valid_mask = df["V_next"].notna().to_numpy()
|
|
if valid_mask.sum() < n_splits * 50:
|
|
print(f" iter {it+1}: not enough samples ({int(valid_mask.sum())}); abort")
|
|
break
|
|
|
|
X = X_full[valid_mask]
|
|
y = df.loc[valid_mask, "V_next"].to_numpy()
|
|
tids = trade_ids_full[valid_mask]
|
|
|
|
preds = cross_fit_predict(X, y, tids, n_splits=n_splits, seed=seed + it)
|
|
cr = np.full(len(df), np.nan)
|
|
cr[valid_mask] = preds
|
|
df["continue_r"] = cr
|
|
|
|
# V_t = max(sell_now_r, continue_r) for non-last bars; sell_now_r at last bar.
|
|
new_V = sn.copy()
|
|
non_last_with_pred = (~is_last) & ~np.isnan(cr)
|
|
new_V[non_last_with_pred] = np.maximum(sn[non_last_with_pred], cr[non_last_with_pred])
|
|
|
|
delta = float(np.nanmax(np.abs(new_V - df["V"].to_numpy())))
|
|
deltas.append(delta)
|
|
df["V"] = new_V
|
|
print(f" iter {it+1}: max |ΔV| = {delta:.4f}")
|
|
if delta < tol:
|
|
print(f" converged at iter {it+1}")
|
|
break
|
|
|
|
df["optimal_action"] = np.where(
|
|
is_last,
|
|
"sell",
|
|
np.where(
|
|
sn >= df["continue_r"].fillna(-np.inf).to_numpy(),
|
|
"sell",
|
|
"hold",
|
|
),
|
|
)
|
|
df.drop(columns=["V_next"], errors="ignore", inplace=True)
|
|
return df, deltas
|
|
|
|
|
|
def policy_simulate(df: pd.DataFrame) -> pd.DataFrame:
|
|
"""For each trade, walk forward bar-by-bar and apply the LSM policy:
|
|
sell at first bar where optimal_action == 'sell' (or last bar). Returns
|
|
one row per trade with policy realized R and stopping bar."""
|
|
rows = []
|
|
for tid, g in df.sort_values(["trade_id", "bar_idx"]).groupby("trade_id"):
|
|
g = g.reset_index(drop=True)
|
|
first_sell = g.index[g["optimal_action"] == "sell"]
|
|
idx = int(first_sell[0]) if len(first_sell) else len(g) - 1
|
|
rows.append(
|
|
{
|
|
"trade_id": tid,
|
|
"ticker": g.iloc[0]["ticker"],
|
|
"date": g.iloc[0]["date"],
|
|
"policy_exit_bar_idx": idx,
|
|
"policy_exit_minutes_since_entry": float(g.iloc[idx]["minutes_since_entry"]),
|
|
"policy_realized_r": float(g.iloc[idx]["sell_now_r"]),
|
|
"baseline_exit_bar_idx": int(g.iloc[0]["baseline_exit_bar_idx"]),
|
|
"baseline_realized_r": float(g.iloc[0]["baseline_realized_r"]),
|
|
"n_bars": int(g.iloc[0]["n_bars"]),
|
|
"mfe_r_full_path": float(g["mfe_so_far_r"].iloc[-1]),
|
|
"oracle_close_r_full_path": float(g["current_close_r"].max()),
|
|
}
|
|
)
|
|
return pd.DataFrame(rows)
|
|
|
|
|
|
def build_summary(per_bar: pd.DataFrame, policy_df: pd.DataFrame) -> str:
|
|
base_total = float(policy_df["baseline_realized_r"].sum())
|
|
base_mean = float(policy_df["baseline_realized_r"].mean())
|
|
pol_total = float(policy_df["policy_realized_r"].sum())
|
|
pol_mean = float(policy_df["policy_realized_r"].mean())
|
|
delta = policy_df["policy_realized_r"] - policy_df["baseline_realized_r"]
|
|
|
|
# Feature-importance: train one supervisor model on (state_t, V) for diagnostics.
|
|
valid = per_bar.dropna(subset=["continue_r"]).copy()
|
|
X = valid[ALL_FEATURES].astype(float).to_numpy()
|
|
y = valid["V"].to_numpy()
|
|
ds = lgb.Dataset(X, label=y, feature_name=ALL_FEATURES)
|
|
diag_model = lgb.train(lgb_params(seed=42), ds, num_boost_round=200)
|
|
importances = sorted(
|
|
zip(ALL_FEATURES, diag_model.feature_importance(importance_type="gain")),
|
|
key=lambda x: -x[1],
|
|
)
|
|
|
|
n_trades = policy_df["trade_id"].nunique()
|
|
sell_rows = (per_bar["optimal_action"] == "sell").sum()
|
|
hold_rows = (per_bar["optimal_action"] == "hold").sum()
|
|
|
|
lines = [
|
|
"# Phase C — LSM Continuation Labels",
|
|
"",
|
|
f"Trades: **{n_trades}** | per-bar rows: **{len(per_bar):,}** "
|
|
f"(sell labels: {sell_rows:,}, hold labels: {hold_rows:,})",
|
|
"",
|
|
"## In-sample LSM policy vs V49.91 baseline",
|
|
"",
|
|
f"- baseline total_R: **{base_total:.2f}** (mean {base_mean:.3f})",
|
|
f"- LSM in-sample total_R: **{pol_total:.2f}** (mean {pol_mean:.3f})",
|
|
f"- Δtotal_R: **{pol_total - base_total:+.2f}** "
|
|
f"(mean Δ per trade {(delta.mean()):+.3f})",
|
|
f"- Trades where LSM beats baseline: "
|
|
f"**{int((delta > 0).sum())}/{len(delta)} ({(delta > 0).mean():.1%})**",
|
|
f"- Trades where LSM ties: **{int((delta == 0).sum())}**",
|
|
"",
|
|
"_⚠ In-sample numbers — Phase D walk-forward is the real test._",
|
|
"",
|
|
"## Policy hold-time vs baseline (bars from entry)",
|
|
"",
|
|
"| stat | baseline | LSM policy |",
|
|
"|---|---|---|",
|
|
f"| mean | {policy_df['baseline_exit_bar_idx'].mean():.1f} | "
|
|
f"{policy_df['policy_exit_bar_idx'].mean():.1f} |",
|
|
f"| median | {policy_df['baseline_exit_bar_idx'].median():.0f} | "
|
|
f"{policy_df['policy_exit_bar_idx'].median():.0f} |",
|
|
f"| p25 | {policy_df['baseline_exit_bar_idx'].quantile(0.25):.0f} | "
|
|
f"{policy_df['policy_exit_bar_idx'].quantile(0.25):.0f} |",
|
|
f"| p75 | {policy_df['baseline_exit_bar_idx'].quantile(0.75):.0f} | "
|
|
f"{policy_df['policy_exit_bar_idx'].quantile(0.75):.0f} |",
|
|
"",
|
|
"## Feature importance (gain, supervisor diagnostic on state→V)",
|
|
"",
|
|
"| 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("--n-splits", type=int, default=5)
|
|
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)
|
|
|
|
per_bar = pd.read_parquet(args.input)
|
|
print(f"Loaded {len(per_bar):,} bar rows from {per_bar['trade_id'].nunique()} trades")
|
|
|
|
print("Running LSM via global-pooled fitted value iteration...")
|
|
labeled, deltas = run_lsm(per_bar, n_splits=args.n_splits, seed=args.seed)
|
|
out_parquet = out_dir / "per_bar_lsm.parquet"
|
|
labeled.to_parquet(out_parquet, index=False)
|
|
print(f"Wrote {out_parquet} (delta history: {[round(d,4) for d in deltas]})")
|
|
|
|
policy_df = policy_simulate(labeled)
|
|
pol_parquet = out_dir / "policy_per_trade.parquet"
|
|
policy_df.to_parquet(pol_parquet, index=False)
|
|
print(f"Wrote {pol_parquet}")
|
|
|
|
md = build_summary(labeled, policy_df)
|
|
(out_dir / "summary.md").write_text(md)
|
|
print(f"Wrote {out_dir / 'summary.md'}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|