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.
392 lines
15 KiB
Python
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()
|