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.

142 lines
6.7 KiB
Python

"""P18: Regime-aware sizing diagnostic.
Pull V49 daily PnL + QQQ daily return + breadth proxy. Identify worst-10% loss days,
test whether a simple QQQ-return threshold classifier (applied as size-down filter)
improves V49 net Sharpe/DD without sacrificing return.
"""
from __future__ import annotations
import json
import statistics
import sys
from pathlib import Path
import pandas as pd
V49_BASELINE_RUN = "runs/v50_sweep12/v49_baseline_200d.json/intraday_20260424_215409_73036072.json"
QQQ_PARQUET = "data/cache/daily/QQQ.parquet"
def load_v49_daily() -> pd.DataFrame:
with open(V49_BASELINE_RUN) as f:
d = json.load(f)
rows = d["daily_summary"]
df = pd.DataFrame(rows)
df["date"] = pd.to_datetime(df["date"])
return df[["date", "daily_pnl", "trades", "skip_reason", "regime_scaler",
"breadth_scaler", "market_orb_quality_scaler"]]
def load_qqq() -> pd.DataFrame:
qqq = pd.read_parquet(QQQ_PARQUET)
qqq["date"] = pd.to_datetime(qqq["date"])
qqq = qqq.sort_values("date").reset_index(drop=True)
qqq["qqq_ret"] = (qqq["close"] / qqq["open"]) - 1.0 # intraday open-to-close
qqq["qqq_overnight_ret"] = (qqq["open"] / qqq["close"].shift(1)) - 1.0
qqq["qqq_full_ret"] = (qqq["close"] / qqq["close"].shift(1)) - 1.0
return qqq[["date", "qqq_ret", "qqq_overnight_ret", "qqq_full_ret"]]
def main() -> None:
v49 = load_v49_daily()
qqq = load_qqq()
df = v49.merge(qqq, on="date", how="left")
df = df.dropna(subset=["qqq_full_ret"]) # drop pre-window days
print(f"=== V49 200d daily PnL stats (n={len(df)}) ===")
print(f"Total PnL: ${df['daily_pnl'].sum():,.2f}")
print(f"Mean daily PnL: ${df['daily_pnl'].mean():.2f}")
print(f"Median: ${df['daily_pnl'].median():.2f}")
print(f"Std: ${df['daily_pnl'].std():.2f}")
print(f"Active days (trades>0): {(df['trades']>0).sum()}")
print(f"Pos days: {(df['daily_pnl']>0).sum()}, Neg days: {(df['daily_pnl']<0).sum()}, Zero days: {(df['daily_pnl']==0).sum()}")
# Sort worst → best
sorted_df = df.sort_values("daily_pnl").reset_index(drop=True)
worst10 = sorted_df.head(int(len(df) * 0.10))
worst20 = sorted_df.head(int(len(df) * 0.20))
best10 = sorted_df.tail(int(len(df) * 0.10))
print(f"\n=== Worst-10% days (n={len(worst10)}) ===")
print(f"Total PnL contribution: ${worst10['daily_pnl'].sum():,.2f}")
print(f"Avg QQQ intraday ret on worst-10%: {worst10['qqq_ret'].mean()*100:.3f}%")
print(f"Avg QQQ overnight ret on worst-10%: {worst10['qqq_overnight_ret'].mean()*100:.3f}%")
print(f"Avg QQQ full ret on worst-10%: {worst10['qqq_full_ret'].mean()*100:.3f}%")
print(f"Worst-10% with QQQ_full_ret < 0: {(worst10['qqq_full_ret']<0).sum()}/{len(worst10)}")
print(f"Worst-10% with QQQ_intraday < 0: {(worst10['qqq_ret']<0).sum()}/{len(worst10)}")
print(f"\n=== Best-10% days (n={len(best10)}) ===")
print(f"Total PnL contribution: ${best10['daily_pnl'].sum():,.2f}")
print(f"Avg QQQ intraday ret on best-10%: {best10['qqq_ret'].mean()*100:.3f}%")
print(f"Avg QQQ full ret on best-10%: {best10['qqq_full_ret'].mean()*100:.3f}%")
# Correlation
active = df[df["trades"] > 0]
if len(active) > 5:
corr_intraday = active[["daily_pnl", "qqq_ret"]].corr().iloc[0, 1]
corr_full = active[["daily_pnl", "qqq_full_ret"]].corr().iloc[0, 1]
print(f"\n=== Correlations (active days only, n={len(active)}) ===")
print(f"V49 PnL vs QQQ intraday ret: {corr_intraday:.4f}")
print(f"V49 PnL vs QQQ full ret: {corr_full:.4f}")
# Test classifier: skip days where QQQ_full_ret prior bar (proxy for entry-time regime) was very negative
# Use overnight + so-far signal: QQQ_overnight_ret as the entry-time signal (known by 9:30)
print(f"\n=== Classifier sweep: skip when QQQ_overnight_ret < threshold ===")
print(f"{'thr':>8} | {'days_skipped':>12} | {'pnl_saved':>12} | {'pnl_lost':>12} | {'net':>10} | {'frac_worst10_caught':>20}")
for thr in [-0.020, -0.015, -0.010, -0.005, -0.002, 0.0]:
skip_mask = df["qqq_overnight_ret"] < thr
days_skipped = skip_mask.sum()
pnl_skipped = df.loc[skip_mask, "daily_pnl"]
pnl_saved = -pnl_skipped[pnl_skipped < 0].sum() # losses avoided
pnl_lost = pnl_skipped[pnl_skipped > 0].sum() # gains foregone
net = pnl_saved - pnl_lost
# Worst-10% days caught by this filter
caught = skip_mask & df["daily_pnl"].isin(worst10["daily_pnl"])
frac = caught.sum() / len(worst10) if len(worst10) > 0 else 0.0
print(f"{thr:>8.4f} | {days_skipped:>12d} | ${pnl_saved:>10,.0f} | ${pnl_lost:>10,.0f} | ${net:>8,.0f} | {frac*100:>18.1f}%")
# Same with intraday signal (cheating — uses look-ahead but useful as upper bound)
print(f"\n=== UPPER BOUND (look-ahead): skip when QQQ_intraday_ret < threshold ===")
print(f"{'thr':>8} | {'days_skipped':>12} | {'pnl_saved':>12} | {'pnl_lost':>12} | {'net':>10}")
for thr in [-0.015, -0.010, -0.005, -0.002, 0.0, 0.002]:
skip_mask = df["qqq_ret"] < thr
days_skipped = skip_mask.sum()
pnl_skipped = df.loc[skip_mask, "daily_pnl"]
pnl_saved = -pnl_skipped[pnl_skipped < 0].sum()
pnl_lost = pnl_skipped[pnl_skipped > 0].sum()
net = pnl_saved - pnl_lost
print(f"{thr:>8.4f} | {days_skipped:>12d} | ${pnl_saved:>10,.0f} | ${pnl_lost:>10,.0f} | ${net:>8,.0f}")
# Sharpe / DD calculation under best causal classifier
print(f"\n=== Counterfactual: V49 with QQQ_overnight_ret >= 0 filter ===")
causal_thr = 0.0
keep_mask = df["qqq_overnight_ret"] >= causal_thr
cf_pnl = df["daily_pnl"].copy()
cf_pnl[~keep_mask] = 0.0 # filter trades on skip days
base_total = df["daily_pnl"].sum()
cf_total = cf_pnl.sum()
print(f"Base total PnL: ${base_total:,.2f}")
print(f"Filtered total PnL: ${cf_total:,.2f}")
print(f"Net effect: ${cf_total - base_total:+,.2f} ({(cf_total/base_total - 1)*100:+.2f}%)")
# Active-day sharpe
base_active = df[df["trades"] > 0]["daily_pnl"]
cf_active = cf_pnl[(df["trades"] > 0) & keep_mask]
if len(base_active) > 1 and len(cf_active) > 1:
base_sharpe = base_active.mean() / base_active.std() * (252 ** 0.5)
cf_sharpe = cf_active.mean() / cf_active.std() * (252 ** 0.5)
print(f"Base active-day Sharpe: {base_sharpe:.3f}")
print(f"Filtered active-day Sharpe: {cf_sharpe:.3f}")
# Drawdown
base_eq = (1.0 + df["daily_pnl"] / 10000).cumprod()
cf_eq = (1.0 + cf_pnl / 10000).cumprod()
base_dd = ((base_eq - base_eq.cummax()) / base_eq.cummax()).min() * 100
cf_dd = ((cf_eq - cf_eq.cummax()) / cf_eq.cummax()).min() * 100
print(f"Base max DD: {base_dd:.2f}%")
print(f"Filtered max DD: {cf_dd:.2f}%")
if __name__ == "__main__":
main()