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.

96 lines
3.8 KiB
Python

"""P22b: Stability check on best alternative-universe slice.
P22 highlighted $5-$10 / $5M-$25M ADV with +0.51% proxy expectancy / n=218.
Before recommending engine work on this slice, verify the positive expectancy
is stable across subperiods (not driven by 2-3 outliers).
"""
from __future__ import annotations
from pathlib import Path
import pandas as pd
DAILY_DIR = Path("data/cache/daily_snapshots/orb_daily_v46_20260423")
WINDOW_START = pd.Timestamp("2025-07-09")
WINDOW_END = pd.Timestamp("2026-04-23")
GAP_THR = 0.02
TOP_N = 5
PRICE_LO, PRICE_HI = 5.0, 10.0
ADV_LO, ADV_HI = 5e6, 25e6
def main() -> None:
files = sorted(DAILY_DIR.glob("*.parquet"))
rows = []
for fp in files:
try:
df = pd.read_parquet(fp)
except Exception:
continue
if len(df) < 25:
continue
sym = fp.stem
df = df.sort_values("date").reset_index(drop=True)
df["date"] = pd.to_datetime(df["date"])
df["prev_close"] = df["close"].shift(1)
df["gap"] = (df["open"] / df["prev_close"]) - 1.0
df["oc_ret"] = (df["close"] / df["open"]) - 1.0
df["dollar_vol"] = df["close"] * df["volume"]
df["adv20"] = df["dollar_vol"].rolling(20).mean()
sub = df[
(df["date"] >= WINDOW_START)
& (df["date"] <= WINDOW_END)
& df["gap"].notna()
& df["adv20"].notna()
& (df["close"] >= PRICE_LO) & (df["close"] < PRICE_HI)
& (df["adv20"] >= ADV_LO) & (df["adv20"] < ADV_HI)
& (df["gap"] >= GAP_THR)
].copy()
if len(sub):
sub["ticker"] = sym
rows.append(sub[["ticker", "date", "gap", "oc_ret"]])
big = pd.concat(rows, ignore_index=True)
picks = big.sort_values(["date", "gap"], ascending=[True, False]).groupby("date").head(TOP_N)
print(f"Total picks: {len(picks)} on {picks['date'].nunique()} days, {picks['ticker'].nunique()} unique tickers")
print(f"Mean expectancy: {picks['oc_ret'].mean()*100:+.4f}%")
print(f"Median: {picks['oc_ret'].median()*100:+.4f}%")
print(f"Std: {picks['oc_ret'].std()*100:.3f}%")
print(f"Cum: {picks['oc_ret'].sum()*100:+.2f}%")
print()
# Subperiod splits
picks["date"] = pd.to_datetime(picks["date"])
picks_sorted = picks.sort_values("date").reset_index(drop=True)
n = len(picks_sorted)
q1 = picks_sorted.iloc[: n // 4]
q2 = picks_sorted.iloc[n // 4 : n // 2]
q3 = picks_sorted.iloc[n // 2 : 3 * n // 4]
q4 = picks_sorted.iloc[3 * n // 4 :]
print(f"=== Subperiod expectancy (4 quartiles by trade index, ~chronological) ===")
for i, qq in enumerate([q1, q2, q3, q4], 1):
print(f"Q{i}: n={len(qq):3d} date_range={qq['date'].min().date()}{qq['date'].max().date()} expectancy={qq['oc_ret'].mean()*100:+.4f}% cum={qq['oc_ret'].sum()*100:+.2f}%")
# Outlier check — top 5 wins / losses contribution
s = picks_sorted["oc_ret"].sort_values(ascending=False)
top5_contrib = s.head(5).sum()
bot5_contrib = s.tail(5).sum()
total = s.sum()
print()
print(f"=== Outlier sensitivity ===")
print(f"Top 5 picks contribute: {top5_contrib*100:+.2f}% of {total*100:+.2f}% cum ({top5_contrib/total*100:+.1f}%)")
print(f"Bottom 5 picks contribute: {bot5_contrib*100:+.2f}% of {total*100:+.2f}% cum ({bot5_contrib/total*100:+.1f}%)")
print(f"Trimmed (drop top 5 + bottom 5) expectancy: {((total - top5_contrib - bot5_contrib) / (n - 10))*100:+.4f}% (vs full {picks['oc_ret'].mean()*100:+.4f}%)")
print()
print(f"=== Top 5 winners ===")
top5 = picks_sorted.sort_values("oc_ret", ascending=False).head(5)
print(top5.to_string(index=False))
print()
print(f"=== Top 5 losers ===")
print(picks_sorted.sort_values("oc_ret", ascending=True).head(5).to_string(index=False))
if __name__ == "__main__":
main()