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.
fithia2/apps/tools/entry_timing_experiment.py

392 lines
15 KiB
Python

"""Diagnostic Experiments: Fixed-holding & entry-timing analysis.
Experiment 1 — Stop-off + fixed holding period exit (3/5/10/20d)
Purpose: Does the raw signal carry time-based alpha?
Method: Use pre-computed fwd_return_Xd (no stop, no target, pure hold).
Report: Mean return, median, win rate, by score bucket and event type.
Experiment 2 — Reaction-day close entry vs next-open entry
Purpose: Is the overnight gap eating the edge?
Method: Compare fwd_return_Xd (next-open base) with
close_entry_return = (entry_price / event_close) * (1 + fwd_return_Xd) - 1
Constraint: Only pre-market/intraday filings (reaction_date == event_date)
are eligible for close entry; after-close filings shown separately.
Usage:
python -m dev.analysis.entry_timing_experiment \
--snapshot-dir data/datasets/snapshots/a6687401-afdd-4bb7-9bb1-4cd32c5190bb \
--split train
"""
from __future__ import annotations
import argparse
import statistics
from pathlib import Path
from typing import Any
import pyarrow.parquet as pq
from libs.backtest.scoring import compute_entry_score
FWD_HORIZONS = ["fwd_return_3d", "fwd_return_5d", "fwd_return_10d", "fwd_return_20d"]
HORIZON_LABELS = ["3d", "5d", "10d", "20d"]
def _load_rows(snapshot_dir: Path, split: str) -> list[dict[str, Any]]:
table = pq.read_table(snapshot_dir / f"{split}.parquet")
return table.to_pylist()
def _stats(values: list[float]) -> dict[str, float | None]:
if not values:
return {"mean": None, "median": None, "win_rate": None, "n": 0}
return {
"mean": statistics.mean(values),
"median": statistics.median(values),
"win_rate": sum(1 for v in values if v > 0) / len(values),
"n": len(values),
}
def _fmt(val: float | None, pct: bool = True) -> str:
if val is None:
return " N/A"
if pct:
return f"{val:+.2%}"
return f"{val:.1%}"
def _score_bucket(score: float) -> str:
if score < 0.3:
return "low(<0.3)"
if score < 0.5:
return "mid(0.3-0.5)"
if score < 0.7:
return "high(0.5-0.7)"
return "top(>=0.7)"
BUCKET_ORDER = ["low(<0.3)", "mid(0.3-0.5)", "high(0.5-0.7)", "top(>=0.7)"]
def _is_intraday_filing(row: dict) -> bool:
"""Pre-market / regular-hours filing: reaction_date == event_date."""
return row.get("reaction_date") == row.get("event_date")
# ── Experiment 1 ──────────────────────────────────────────────────────
def experiment_1(rows: list[dict[str, Any]]) -> None:
"""Stop-off + fixed holding period: raw forward returns."""
print(f"\n{'='*100}")
print("EXPERIMENT 1: Stop-off + Fixed Holding Period Exit")
print(f"{'='*100}")
# Enrich with score
for r in rows:
r["_score"] = compute_entry_score(r)
r["_bucket"] = _score_bucket(r["_score"])
# ── 1a. ALL events ──
print(f"\n--- 1a. ALL events (N={len(rows)}) ---")
_print_horizon_table("ALL", rows)
# ── 1b. By score bucket ──
print(f"\n--- 1b. By score bucket ---")
by_bucket: dict[str, list] = {b: [] for b in BUCKET_ORDER}
for r in rows:
by_bucket[r["_bucket"]].append(r)
header = f"{'Bucket':<16} {'N':>5}"
for lbl in HORIZON_LABELS:
header += f" {lbl+'_mean':>8} {lbl+'_med':>8} {lbl+'_wr':>7}"
print(header)
print("-" * len(header))
for bucket in BUCKET_ORDER:
items = by_bucket[bucket]
line = f"{bucket:<16} {len(items):>5}"
for h in FWD_HORIZONS:
vals = [float(r[h]) for r in items if r.get(h) is not None]
s = _stats(vals)
line += f" {_fmt(s['mean']):>8} {_fmt(s['median']):>8} {_fmt(s['win_rate'], pct=False):>7}"
print(line)
# ── 1c. By event_type ──
print(f"\n--- 1c. By event_type ---")
by_type: dict[str, list] = {}
for r in rows:
et = r.get("event_type", "unknown")
by_type.setdefault(et, []).append(r)
header = f"{'EventType':<25} {'N':>5} {'AvgScr':>7}"
for lbl in HORIZON_LABELS:
header += f" {lbl+'_mean':>8} {lbl+'_wr':>7}"
print(header)
print("-" * len(header))
for et in sorted(by_type, key=lambda k: -len(by_type[k])):
items = by_type[et]
avg_score = statistics.mean(r["_score"] for r in items)
line = f"{et:<25} {len(items):>5} {avg_score:>7.3f}"
for h in FWD_HORIZONS:
vals = [float(r[h]) for r in items if r.get(h) is not None]
s = _stats(vals)
line += f" {_fmt(s['mean']):>8} {_fmt(s['win_rate'], pct=False):>7}"
print(line)
# ── 1d. MFE/MAE analysis (reward:risk) ──
print(f"\n--- 1d. MFE/MAE analysis (max favorable / max adverse excursion) ---")
mfe_mae_horizons = [("3d", "mfe_3d", "mae_3d"),
("5d", "mfe_5d", "mae_5d"),
("10d", "mfe_10d", "mae_10d"),
("20d", "mfe_20d", "mae_20d")]
header = f"{'Horizon':<10} {'MFE_mean':>10} {'MAE_mean':>10} {'R:R':>8} {'MFE_med':>10} {'MAE_med':>10}"
print(header)
print("-" * len(header))
for lbl, mfe_col, mae_col in mfe_mae_horizons:
mfe_vals = [float(r[mfe_col]) for r in rows if r.get(mfe_col) is not None]
mae_vals = [float(r[mae_col]) for r in rows if r.get(mae_col) is not None]
if mfe_vals and mae_vals:
mfe_mean = statistics.mean(mfe_vals)
mae_mean = statistics.mean(mae_vals)
rr = mfe_mean / abs(mae_mean) if mae_mean != 0 else 0
mfe_med = statistics.median(mfe_vals)
mae_med = statistics.median(mae_vals)
print(f"{lbl:<10} {mfe_mean:>+10.2%} {mae_mean:>+10.2%} {rr:>8.2f} {mfe_med:>+10.2%} {mae_med:>+10.2%}")
# ── 1e. Score monotonicity per horizon ──
print(f"\n--- 1e. Score monotonicity (does higher score → better return?) ---")
for h, lbl in zip(FWD_HORIZONS, HORIZON_LABELS):
bucket_means = []
for bucket in BUCKET_ORDER:
vals = [float(r[h]) for r in by_bucket[bucket] if r.get(h) is not None]
bucket_means.append(statistics.mean(vals) if vals else None)
valid_means = [m for m in bucket_means if m is not None]
monotonic = all(a <= b for a, b in zip(valid_means, valid_means[1:])) if len(valid_means) >= 2 else False
direction = "MONOTONIC" if monotonic else "NOT monotonic"
means_str = "".join(f"{m:+.2%}" if m is not None else "N/A" for m in bucket_means)
print(f" {lbl}: {means_str} [{direction}]")
# ── Experiment 2 ──────────────────────────────────────────────────────
def experiment_2(rows: list[dict[str, Any]]) -> None:
"""Reaction-close entry vs next-open entry."""
print(f"\n{'='*100}")
print("EXPERIMENT 2: Reaction-day Close Entry vs Next-Open Entry")
print(f"{'='*100}")
# Split by filing timing
intraday = [r for r in rows if _is_intraday_filing(r)]
after_close = [r for r in rows if not _is_intraday_filing(r)]
print(f"\nFiling timing split:")
print(f" Pre-market / intraday (reaction_date == event_date): {len(intraday)}")
print(f" After-close (reaction_date > event_date): {len(after_close)}")
# ── 2a. Overnight gap analysis (intraday filings only) ──
print(f"\n--- 2a. Overnight gap cost (intraday filings, N={len(intraday)}) ---")
gaps = []
for r in intraday:
ep = r.get("entry_price")
ec = r.get("event_close")
if ep and ec and ec > 0:
gap = ep / ec - 1.0
gaps.append(gap)
if gaps:
s = _stats(gaps)
print(f" Overnight gap (next_open / reaction_close - 1):")
print(f" Mean: {_fmt(s['mean'])}")
print(f" Median: {_fmt(s['median'])}")
print(f" Gap up rate (open > close): {_fmt(s['win_rate'], pct=False)}")
print(f" Std: {statistics.stdev(gaps):+.2%}" if len(gaps) > 1 else "")
# Distribution
gap_buckets = {"< -2%": 0, "-2% to 0%": 0, "0% to +1%": 0,
"+1% to +3%": 0, "+3% to +5%": 0, "> +5%": 0}
for g in gaps:
if g < -0.02:
gap_buckets["< -2%"] += 1
elif g < 0:
gap_buckets["-2% to 0%"] += 1
elif g < 0.01:
gap_buckets["0% to +1%"] += 1
elif g < 0.03:
gap_buckets["+1% to +3%"] += 1
elif g < 0.05:
gap_buckets["+3% to +5%"] += 1
else:
gap_buckets["> +5%"] += 1
print(f"\n Gap distribution:")
for label, count in gap_buckets.items():
pct = count / len(gaps) * 100
bar = "#" * int(pct / 2)
print(f" {label:>12}: {count:>4} ({pct:5.1f}%) {bar}")
# ── 2b. Close entry vs next-open returns (intraday only) ──
print(f"\n--- 2b. Close entry vs Next-open entry returns (intraday, N={len(intraday)}) ---")
header = f"{'Horizon':<10} {'NextOpen_mean':>13} {'NextOpen_wr':>11} {'CloseEntry_mean':>15} {'CloseEntry_wr':>13} {'Diff':>8}"
print(header)
print("-" * len(header))
for h, lbl in zip(FWD_HORIZONS, HORIZON_LABELS):
next_open_vals = []
close_entry_vals = []
for r in intraday:
fwd = r.get(h)
ep = r.get("entry_price")
ec = r.get("event_close")
if fwd is not None and ep and ec and ec > 0:
fwd_f = float(fwd)
next_open_vals.append(fwd_f)
# close_entry_return = (entry_price / event_close) * (1 + fwd) - 1
close_ret = (ep / ec) * (1.0 + fwd_f) - 1.0
close_entry_vals.append(close_ret)
no_s = _stats(next_open_vals)
ce_s = _stats(close_entry_vals)
diff = (ce_s["mean"] - no_s["mean"]) if ce_s["mean"] is not None and no_s["mean"] is not None else None
diff_str = _fmt(diff) if diff is not None else " N/A"
print(f"{lbl:<10} {_fmt(no_s['mean']):>13} {_fmt(no_s['win_rate'], pct=False):>11}"
f" {_fmt(ce_s['mean']):>15} {_fmt(ce_s['win_rate'], pct=False):>13} {diff_str:>8}")
# ── 2c. After-close filings (separate analysis) ──
if after_close:
print(f"\n--- 2c. After-close filings (N={len(after_close)}) ---")
print(f" (These events filed after market close; reaction_date = next trading day)")
header = f"{'Horizon':<10} {'NextOpen_mean':>13} {'NextOpen_wr':>11}"
print(f" {header}")
print(f" {'-' * len(header)}")
for h, lbl in zip(FWD_HORIZONS, HORIZON_LABELS):
vals = [float(r[h]) for r in after_close if r.get(h) is not None]
s = _stats(vals)
print(f" {lbl:<10} {_fmt(s['mean']):>13} {_fmt(s['win_rate'], pct=False):>11}")
# ── 2d. Close entry by event type (intraday only) ──
print(f"\n--- 2d. Close-entry returns by event type (intraday only) ---")
by_type: dict[str, list] = {}
for r in intraday:
et = r.get("event_type", "unknown")
by_type.setdefault(et, []).append(r)
header = f"{'EventType':<25} {'N':>5} {'Gap_mean':>9}"
for lbl in HORIZON_LABELS:
header += f" {lbl+'_CE':>8} {lbl+'_NO':>8}"
print(header)
print("-" * len(header))
for et in sorted(by_type, key=lambda k: -len(by_type[k])):
items = by_type[et]
# Gap
type_gaps = []
for r in items:
ep = r.get("entry_price")
ec = r.get("event_close")
if ep and ec and ec > 0:
type_gaps.append(ep / ec - 1.0)
gap_mean = statistics.mean(type_gaps) if type_gaps else None
line = f"{et:<25} {len(items):>5} {_fmt(gap_mean):>9}"
for h in FWD_HORIZONS:
ce_vals = []
no_vals = []
for r in items:
fwd = r.get(h)
ep = r.get("entry_price")
ec = r.get("event_close")
if fwd is not None and ep and ec and ec > 0:
fwd_f = float(fwd)
no_vals.append(fwd_f)
ce_vals.append((ep / ec) * (1.0 + fwd_f) - 1.0)
ce_s = _stats(ce_vals)
no_s = _stats(no_vals)
line += f" {_fmt(ce_s['mean']):>8} {_fmt(no_s['mean']):>8}"
print(line)
# ── 2e. Score-filtered close entry (score >= 0.5, intraday) ──
filtered = [r for r in intraday if r.get("_score", 0) >= 0.5]
if filtered:
print(f"\n--- 2e. Score-filtered (>= 0.5) close entry (intraday, N={len(filtered)}) ---")
header = f"{'Horizon':<10} {'CloseEntry_mean':>15} {'CE_wr':>8} {'NextOpen_mean':>14} {'NO_wr':>8}"
print(header)
print("-" * len(header))
for h, lbl in zip(FWD_HORIZONS, HORIZON_LABELS):
ce_vals = []
no_vals = []
for r in filtered:
fwd = r.get(h)
ep = r.get("entry_price")
ec = r.get("event_close")
if fwd is not None and ep and ec and ec > 0:
fwd_f = float(fwd)
no_vals.append(fwd_f)
ce_vals.append((ep / ec) * (1.0 + fwd_f) - 1.0)
ce_s = _stats(ce_vals)
no_s = _stats(no_vals)
print(f"{lbl:<10} {_fmt(ce_s['mean']):>15} {_fmt(ce_s['win_rate'], pct=False):>8}"
f" {_fmt(no_s['mean']):>14} {_fmt(no_s['win_rate'], pct=False):>8}")
def _print_horizon_table(label: str, items: list[dict]) -> None:
header = f"{'Horizon':<10} {'Mean':>10} {'Median':>10} {'WinRate':>8} {'N':>6} {'StdDev':>10}"
print(header)
print("-" * len(header))
for h, lbl in zip(FWD_HORIZONS, HORIZON_LABELS):
vals = [float(r[h]) for r in items if r.get(h) is not None]
s = _stats(vals)
std = statistics.stdev(vals) if len(vals) > 1 else 0
print(f"{lbl:<10} {_fmt(s['mean']):>10} {_fmt(s['median']):>10}"
f" {_fmt(s['win_rate'], pct=False):>8} {s['n']:>6} {std:>10.2%}")
def main() -> None:
parser = argparse.ArgumentParser(description="Entry Timing Experiments")
parser.add_argument("--snapshot-dir", required=True)
parser.add_argument("--split", default="train")
args = parser.parse_args()
rows = _load_rows(Path(args.snapshot_dir), args.split)
if not rows:
print("No data found.")
return
print(f"Loaded {len(rows)} events from {args.split} split")
experiment_1(rows)
experiment_2(rows)
# ── Summary ──
print(f"\n{'='*100}")
print("KEY DIAGNOSTIC QUESTIONS")
print(f"{'='*100}")
print("""
Exp 1 — Time-based alpha:
• Are any fixed-horizon returns consistently positive?
• Does score monotonicity hold at any horizon?
• Which event types carry actual alpha (mean > 0, win rate > 50%)?
Exp 2 — Entry timing:
• Is the overnight gap systematically positive (eating the edge)?
• Does close-entry improve returns vs next-open?
• Is the gap effect uniform across event types, or concentrated?
""")
if __name__ == "__main__":
main()