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
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()
|