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.
456 lines
19 KiB
Python
456 lines
19 KiB
Python
"""ORB Exit Opportunity Audit.
|
|
|
|
For each trade in an ORB intraday backtest run, joins entry-to-EOD 5-min bars and
|
|
computes MFE / MAE / capture-ratio / giveback / time-to-peak. Writes per-trade
|
|
parquet plus a summary markdown with stratifications by exit_reason, quality
|
|
buckets, and regime context.
|
|
|
|
Usage:
|
|
python scripts/orb_exit_audit.py \
|
|
--run tmp/v49_91_baseline_200_20260506/intraday_20260506_081008_effaac09.json \
|
|
--out tmp/orb_exit_audit_v49_91_200d
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
ET = "America/New_York"
|
|
DEFAULT_INTRADAY_CACHE = Path("data/cache/intraday")
|
|
DEFAULT_ATR_STOP_MULT = 0.75 # V49.91 orb_strategy.atr_stop_multiplier
|
|
|
|
|
|
def _infer_risk_per_share(t: dict) -> float:
|
|
"""Recover initial-stop dollars per share from realized R, with ATR fallback."""
|
|
r = t.get("r_multiple_at_exit")
|
|
entry = t["entry_price"]
|
|
exit_p = t["exit_price"]
|
|
direction = t.get("orb_direction", "long")
|
|
if r not in (None, 0) and abs(r) > 1e-6:
|
|
if direction == "long":
|
|
return (exit_p - entry) / r
|
|
return (entry - exit_p) / r
|
|
atr = t.get("atr_at_entry") or 0.0
|
|
return max(atr * DEFAULT_ATR_STOP_MULT, 1e-6)
|
|
|
|
|
|
def _favorable_r(prices: np.ndarray, entry: float, risk: float, direction: str) -> np.ndarray:
|
|
"""Return per-bar R-multiple in the favorable direction."""
|
|
if direction == "long":
|
|
return (prices - entry) / risk
|
|
return (entry - prices) / risk
|
|
|
|
|
|
def _adverse_r(prices: np.ndarray, entry: float, risk: float, direction: str) -> np.ndarray:
|
|
"""Return per-bar R-multiple in the adverse direction."""
|
|
if direction == "long":
|
|
return (prices - entry) / risk
|
|
return (entry - prices) / risk
|
|
|
|
|
|
def _first_cross_minutes(
|
|
favorable_high_r: np.ndarray, ts_minutes: np.ndarray, threshold: float
|
|
) -> float:
|
|
"""Minutes after entry when favorable_high_r first reaches threshold; NaN if never."""
|
|
hits = np.where(favorable_high_r >= threshold)[0]
|
|
if hits.size == 0:
|
|
return np.nan
|
|
return float(ts_minutes[hits[0]])
|
|
|
|
|
|
def _audit_trade(t: dict, intraday_root: Path) -> dict | None:
|
|
ticker = t["ticker"]
|
|
date = t["date"]
|
|
bar_path = intraday_root / ticker / f"{date}.parquet"
|
|
if not bar_path.exists():
|
|
return {"ticker": ticker, "date": date, "missing_bars": True}
|
|
|
|
entry_time = pd.Timestamp(t["entry_time"]).tz_convert("UTC")
|
|
exit_time = pd.Timestamp(t["exit_time"]).tz_convert("UTC")
|
|
entry_price = float(t["entry_price"])
|
|
exit_price = float(t["exit_price"])
|
|
direction = t.get("orb_direction", "long")
|
|
realized_r = float(t.get("r_multiple_at_exit") or 0.0)
|
|
risk = _infer_risk_per_share(t)
|
|
|
|
# Trade-day window: entry → 16:00 ET on the trade date (covers max-hold and EOD exits).
|
|
et_close_local = pd.Timestamp(date, tz=ET) + pd.Timedelta(hours=16)
|
|
et_close_utc = et_close_local.tz_convert("UTC")
|
|
|
|
bars = pd.read_parquet(bar_path)
|
|
bars["ts"] = pd.to_datetime(bars["timestamp"], utc=True)
|
|
win = bars[(bars["ts"] >= entry_time) & (bars["ts"] <= et_close_utc)].sort_values("ts").reset_index(drop=True)
|
|
if win.empty:
|
|
return {"ticker": ticker, "date": date, "missing_bars": True}
|
|
|
|
ts_min = ((win["ts"] - entry_time).dt.total_seconds() / 60.0).to_numpy()
|
|
highs = win["high"].to_numpy()
|
|
lows = win["low"].to_numpy()
|
|
closes = win["close"].to_numpy()
|
|
# Next-bar open: shift open by -1; last bar falls back to its own close (final actionable price).
|
|
opens = win["open"].to_numpy()
|
|
next_open = np.append(opens[1:], closes[-1])
|
|
|
|
# Favorable-direction price arrays (long uses high/low; short flips).
|
|
if direction == "long":
|
|
fav_high = (highs - entry_price) / risk
|
|
fav_low = (lows - entry_price) / risk
|
|
fav_close = (closes - entry_price) / risk
|
|
fav_next_open = (next_open - entry_price) / risk
|
|
peak_idx = int(np.argmax(highs))
|
|
trough_idx = int(np.argmin(lows))
|
|
peak_price = float(highs[peak_idx])
|
|
trough_price = float(lows[trough_idx])
|
|
else:
|
|
fav_high = (entry_price - lows) / risk # high in favorable space = adverse low's mirror
|
|
fav_low = (entry_price - highs) / risk
|
|
fav_close = (entry_price - closes) / risk
|
|
fav_next_open = (entry_price - next_open) / risk
|
|
peak_idx = int(np.argmin(lows))
|
|
trough_idx = int(np.argmax(highs))
|
|
peak_price = float(lows[peak_idx])
|
|
trough_price = float(highs[trough_idx])
|
|
|
|
mfe_r = float(fav_high.max())
|
|
mae_r = float(fav_low.min())
|
|
oracle_close_r = float(fav_close.max())
|
|
oracle_next_open_r = float(fav_next_open.max())
|
|
|
|
peak_ts = win.loc[peak_idx, "ts"]
|
|
trough_ts = win.loc[trough_idx, "ts"]
|
|
minutes_to_peak = (peak_ts - entry_time).total_seconds() / 60.0
|
|
minutes_to_trough = (trough_ts - entry_time).total_seconds() / 60.0
|
|
minutes_in_trade = (exit_time - entry_time).total_seconds() / 60.0
|
|
minutes_window = (et_close_utc - entry_time).total_seconds() / 60.0
|
|
|
|
capture_vs_high = realized_r / mfe_r if mfe_r > 0.05 else np.nan
|
|
capture_vs_close = realized_r / oracle_close_r if oracle_close_r > 0.05 else np.nan
|
|
capture_vs_next_open = realized_r / oracle_next_open_r if oracle_next_open_r > 0.05 else np.nan
|
|
giveback_r = mfe_r - realized_r
|
|
giveback_close_r = oracle_close_r - realized_r
|
|
|
|
# MFE / max-close within early windows (lookback-free at horizon t).
|
|
def _within(mins: float) -> tuple[float, float]:
|
|
mask = ts_min <= mins
|
|
if not mask.any():
|
|
return np.nan, np.nan
|
|
return float(fav_high[mask].max()), float(fav_close[mask].max())
|
|
|
|
mfe_before_30m, max_close_before_30m = _within(30)
|
|
mfe_before_60m, max_close_before_60m = _within(60)
|
|
|
|
# First-touch times to favorable thresholds.
|
|
first_profit_0_3r = _first_cross_minutes(fav_high, ts_min, 0.3)
|
|
first_profit_0_5r = _first_cross_minutes(fav_high, ts_min, 0.5)
|
|
first_profit_0_8r = _first_cross_minutes(fav_high, ts_min, 0.8)
|
|
first_profit_1_0r = _first_cross_minutes(fav_high, ts_min, 1.0)
|
|
|
|
# Did price reach +0.5R then give back ≥0.5R from its running peak (long)?
|
|
if mfe_r >= 0.5:
|
|
running_peak = np.maximum.accumulate(fav_high)
|
|
giveback_after_peak = running_peak - fav_low # bar-low against running peak
|
|
gave_back_0_5r_after_profit = bool((giveback_after_peak >= 0.5).any())
|
|
else:
|
|
gave_back_0_5r_after_profit = False
|
|
|
|
stop_after_mfe_0_5r = bool(t.get("exit_reason") == "stop_loss" and mfe_r >= 0.5)
|
|
|
|
# Early-failure flag: hit ≤-0.5R within first 25 minutes.
|
|
mask_25 = ts_min <= 25
|
|
if mask_25.any():
|
|
early_mae_r = float(fav_low[mask_25].min())
|
|
else:
|
|
early_mae_r = np.nan
|
|
early_failure = bool(early_mae_r <= -0.5) if not np.isnan(early_mae_r) else False
|
|
|
|
return {
|
|
"ticker": ticker,
|
|
"date": date,
|
|
"direction": direction,
|
|
"entry_time": entry_time,
|
|
"exit_time": exit_time,
|
|
"entry_price": entry_price,
|
|
"exit_price": exit_price,
|
|
"risk_per_share": risk,
|
|
"atr_at_entry": t.get("atr_at_entry"),
|
|
"realized_r": realized_r,
|
|
# Oracle benchmarks
|
|
"mfe_r": mfe_r, # = oracle_high_r (theoretical upper bound)
|
|
"mae_r": mae_r,
|
|
"oracle_close_r": oracle_close_r,
|
|
"oracle_next_open_r": oracle_next_open_r,
|
|
# Capture ratios at three execution assumptions
|
|
"capture_vs_high": capture_vs_high,
|
|
"capture_vs_close": capture_vs_close,
|
|
"capture_vs_next_open": capture_vs_next_open,
|
|
"giveback_from_peak_r": giveback_r,
|
|
"giveback_vs_close_r": giveback_close_r,
|
|
# Early MFE / close milestones
|
|
"mfe_before_30m": mfe_before_30m,
|
|
"mfe_before_60m": mfe_before_60m,
|
|
"max_close_before_30m": max_close_before_30m,
|
|
"max_close_before_60m": max_close_before_60m,
|
|
# First-touch times (minutes from entry)
|
|
"first_profit_0_3r_min": first_profit_0_3r,
|
|
"first_profit_0_5r_min": first_profit_0_5r,
|
|
"first_profit_0_8r_min": first_profit_0_8r,
|
|
"first_profit_1_0r_min": first_profit_1_0r,
|
|
# Pattern flags
|
|
"gave_back_0_5r_after_profit": gave_back_0_5r_after_profit,
|
|
"stop_after_mfe_0_5r": stop_after_mfe_0_5r,
|
|
"early_mae_r_25min": early_mae_r,
|
|
"early_failure_25min": early_failure,
|
|
# Timing
|
|
"minutes_to_peak": minutes_to_peak,
|
|
"minutes_to_trough": minutes_to_trough,
|
|
"minutes_in_trade": minutes_in_trade,
|
|
"minutes_window": minutes_window,
|
|
# Pass-through trade context
|
|
"exit_reason": t.get("exit_reason"),
|
|
"stop_level_at_exit": t.get("stop_level_at_exit"),
|
|
"pnl_pct": t.get("pnl_pct"),
|
|
"rvol": t.get("rvol"),
|
|
"gap_pct": t.get("gap_pct"),
|
|
"morning_gain_pct": t.get("morning_gain_pct"),
|
|
"entropy_20d": t.get("entropy_20d"),
|
|
"entry_dollar_volume": t.get("entry_dollar_volume"),
|
|
"avg_dollar_vol_30d": t.get("avg_dollar_vol_30d"),
|
|
"sector_confirmation_active": t.get("sector_confirmation_active"),
|
|
"is_liquid_largecap": t.get("is_liquid_largecap"),
|
|
"candidate_score": t.get("candidate_score"),
|
|
"score_rank_pct": t.get("score_rank_pct"),
|
|
"missing_bars": False,
|
|
}
|
|
|
|
|
|
def _quartile_bucket(s: pd.Series) -> pd.Series:
|
|
"""Return 'Q1'..'Q4' labels by quartile rank, NaN-safe."""
|
|
try:
|
|
return pd.qcut(s, 4, labels=["Q1", "Q2", "Q3", "Q4"], duplicates="drop")
|
|
except ValueError:
|
|
return pd.Series([pd.NA] * len(s), index=s.index, dtype="object")
|
|
|
|
|
|
def _fmt(x, p=3):
|
|
if pd.isna(x):
|
|
return "n/a"
|
|
if isinstance(x, (int, np.integer)):
|
|
return f"{int(x)}"
|
|
return f"{float(x):.{p}f}"
|
|
|
|
|
|
def _summary_section(df: pd.DataFrame, title: str, group_col: str) -> str:
|
|
g = df.groupby(group_col, dropna=False, observed=False).agg(
|
|
n=("realized_r", "size"),
|
|
win_rate=("realized_r", lambda s: float((s > 0).mean())),
|
|
realized_mean=("realized_r", "mean"),
|
|
oracle_close_mean=("oracle_close_r", "mean"),
|
|
oracle_next_open_mean=("oracle_next_open_r", "mean"),
|
|
mfe_mean=("mfe_r", "mean"),
|
|
cap_close_med=("capture_vs_close", "median"),
|
|
cap_next_open_med=("capture_vs_next_open", "median"),
|
|
cap_high_med=("capture_vs_high", "median"),
|
|
giveback_close_med=("giveback_vs_close_r", "median"),
|
|
giveback_high_med=("giveback_from_peak_r", "median"),
|
|
min_to_peak_med=("minutes_to_peak", "median"),
|
|
min_held_med=("minutes_in_trade", "median"),
|
|
)
|
|
lines = [f"### {title}", ""]
|
|
lines.append(
|
|
"| group | n | win | realR | oCloseR | oNextOpR | mfeR | "
|
|
"cap_close | cap_nextOp | cap_high | gb_close | gb_high | "
|
|
"min→peak | min held |"
|
|
)
|
|
lines.append("|" + "---|" * 14)
|
|
for k, row in g.iterrows():
|
|
lines.append(
|
|
f"| {k} | {int(row.n)} | {_fmt(row.win_rate)} | "
|
|
f"{_fmt(row.realized_mean)} | "
|
|
f"{_fmt(row.oracle_close_mean)} | "
|
|
f"{_fmt(row.oracle_next_open_mean)} | "
|
|
f"{_fmt(row.mfe_mean)} | "
|
|
f"{_fmt(row.cap_close_med)} | "
|
|
f"{_fmt(row.cap_next_open_med)} | "
|
|
f"{_fmt(row.cap_high_med)} | "
|
|
f"{_fmt(row.giveback_close_med)} | "
|
|
f"{_fmt(row.giveback_high_med)} | "
|
|
f"{_fmt(row.min_to_peak_med, 0)} | "
|
|
f"{_fmt(row.min_held_med, 0)} |"
|
|
)
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def build_summary(df: pd.DataFrame, run_meta: dict) -> str:
|
|
n_missing = int(df["missing_bars"].sum()) if "missing_bars" in df else 0
|
|
df = df[~df["missing_bars"].fillna(False)].copy() if "missing_bars" in df else df
|
|
|
|
win_rate = float((df["realized_r"] > 0).mean())
|
|
mfe_pos = (df["mfe_r"] > 0).sum()
|
|
cap_close = df["capture_vs_close"].dropna()
|
|
cap_next_open = df["capture_vs_next_open"].dropna()
|
|
cap_high = df["capture_vs_high"].dropna()
|
|
early_fail_rate = float(df["early_failure_25min"].mean())
|
|
|
|
lines = [
|
|
"# ORB Exit Opportunity Audit",
|
|
"",
|
|
f"**Run:** `{run_meta.get('run_id')}` ({run_meta.get('start_date')} → {run_meta.get('end_date')}, "
|
|
f"{run_meta.get('trading_days')} days, {run_meta.get('total_trades')} trades)",
|
|
"",
|
|
"## Headline (3 capture benchmarks)",
|
|
"",
|
|
f"- Trades audited: **{len(df)}** (missing intraday bars: {n_missing})",
|
|
f"- Win rate: **{win_rate:.1%}** | trades with positive MFE: **{int(mfe_pos)}/{len(df)}**",
|
|
"",
|
|
"Capture = realized_R / oracle_R. Compare three execution assumptions:",
|
|
"",
|
|
"| benchmark | description | median | mean | p25 | p75 |",
|
|
"|---|---|---|---|---|---|",
|
|
f"| **capture_vs_close** | exit at any future bar **close** (most actionable) | "
|
|
f"**{_fmt(cap_close.median())}** | {_fmt(cap_close.mean())} | "
|
|
f"{_fmt(cap_close.quantile(0.25))} | {_fmt(cap_close.quantile(0.75))} |",
|
|
f"| capture_vs_next_open | exit at any future **next-bar open** (executable) | "
|
|
f"{_fmt(cap_next_open.median())} | {_fmt(cap_next_open.mean())} | "
|
|
f"{_fmt(cap_next_open.quantile(0.25))} | {_fmt(cap_next_open.quantile(0.75))} |",
|
|
f"| capture_vs_high | exit at any future bar **high** (theoretical upper bound) | "
|
|
f"{_fmt(cap_high.median())} | {_fmt(cap_high.mean())} | "
|
|
f"{_fmt(cap_high.quantile(0.25))} | {_fmt(cap_high.quantile(0.75))} |",
|
|
"",
|
|
f"- Mean realized_R: **{_fmt(df['realized_r'].mean())}** | "
|
|
f"oracle_close_R: **{_fmt(df['oracle_close_r'].mean())}** | "
|
|
f"oracle_next_open_R: **{_fmt(df['oracle_next_open_r'].mean())}** | "
|
|
f"mfe_R: **{_fmt(df['mfe_r'].mean())}**",
|
|
f"- Avg giveback vs close: **{_fmt(df['giveback_vs_close_r'].mean())} R** | "
|
|
f"vs high: **{_fmt(df['giveback_from_peak_r'].mean())} R**",
|
|
f"- Early-failure (≤-0.5R within 25min): **{early_fail_rate:.1%}** | "
|
|
f"Trades that hit +0.5R then gave back ≥0.5R: "
|
|
f"**{df['gave_back_0_5r_after_profit'].mean():.1%}**",
|
|
f"- Stop-loss exits with prior MFE ≥ +0.5R: "
|
|
f"**{df['stop_after_mfe_0_5r'].sum()}/{(df['exit_reason']=='stop_loss').sum()}** "
|
|
f"(of stop_loss trades)",
|
|
f"- Median minutes to peak: **{_fmt(df['minutes_to_peak'].median(), 0)}** | "
|
|
f"Median hold: **{_fmt(df['minutes_in_trade'].median(), 0)} min**",
|
|
"",
|
|
"## First-touch profit times (median minutes from entry)",
|
|
"",
|
|
"| level | hit rate | median minutes | p25 | p75 |",
|
|
"|---|---|---|---|---|",
|
|
]
|
|
for col, lvl in [
|
|
("first_profit_0_3r_min", "+0.3R"),
|
|
("first_profit_0_5r_min", "+0.5R"),
|
|
("first_profit_0_8r_min", "+0.8R"),
|
|
("first_profit_1_0r_min", "+1.0R"),
|
|
]:
|
|
s = df[col]
|
|
lines.append(
|
|
f"| {lvl} | {s.notna().mean():.1%} | {_fmt(s.median(), 0)} | "
|
|
f"{_fmt(s.quantile(0.25), 0)} | {_fmt(s.quantile(0.75), 0)} |"
|
|
)
|
|
lines += [
|
|
"",
|
|
"## Distribution snapshots",
|
|
"",
|
|
f"- realized_R quartiles: {df['realized_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
|
|
f"- oracle_close_R: {df['oracle_close_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
|
|
f"- oracle_next_open_R: {df['oracle_next_open_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
|
|
f"- mfe_R: {df['mfe_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
|
|
f"- mae_R: {df['mae_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
|
|
f"- capture_vs_close: {cap_close.quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
|
|
f"- giveback_vs_close_R: {df['giveback_vs_close_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
|
|
"",
|
|
"## Stratifications",
|
|
"",
|
|
]
|
|
|
|
lines.append(_summary_section(df, "By exit_reason", "exit_reason"))
|
|
lines.append(_summary_section(df, "By stop_level_at_exit", "stop_level_at_exit"))
|
|
|
|
df["rvol_q"] = _quartile_bucket(df["rvol"])
|
|
df["gap_q"] = _quartile_bucket(df["gap_pct"])
|
|
df["dvol_q"] = _quartile_bucket(df["entry_dollar_volume"])
|
|
df["score_q"] = _quartile_bucket(df["candidate_score"])
|
|
|
|
lines.append(_summary_section(df, "By RVol quartile", "rvol_q"))
|
|
lines.append(_summary_section(df, "By gap_pct quartile", "gap_q"))
|
|
lines.append(_summary_section(df, "By entry_dollar_volume quartile (proxy for Stocks-in-Play)", "dvol_q"))
|
|
lines.append(_summary_section(df, "By candidate_score quartile", "score_q"))
|
|
lines.append(_summary_section(df, "By is_liquid_largecap", "is_liquid_largecap"))
|
|
lines.append(_summary_section(df, "By sector_confirmation_active", "sector_confirmation_active"))
|
|
|
|
# Time-to-peak histogram
|
|
bins = [-1, 5, 15, 30, 60, 120, 240, 1000]
|
|
labels = ["≤5m", "5-15m", "15-30m", "30-60m", "1-2h", "2-4h", ">4h"]
|
|
df["peak_bucket"] = pd.cut(df["minutes_to_peak"], bins=bins, labels=labels)
|
|
lines.append(_summary_section(df, "By time-to-peak bucket", "peak_bucket"))
|
|
|
|
# Reachability of fixed R-targets under high-touch vs close-confirm execution.
|
|
lines.append("## Target-reachability (high-touch vs close-confirm)")
|
|
lines.append("")
|
|
lines.append(
|
|
"How many trades **could have hit** target T under each execution assumption. "
|
|
"high-touch assumes any wick at T fills (optimistic); close-confirm requires a 5-min bar "
|
|
"to *close* at or above T (actionable lower bound)."
|
|
)
|
|
lines.append("")
|
|
lines.append("| target | high-touch | close-confirm | next-open exec |")
|
|
lines.append("|---|---|---|---|")
|
|
for tgt in [0.3, 0.5, 0.8, 1.0, 1.2, 1.5, 2.0]:
|
|
hit_high = (df["mfe_r"] >= tgt).mean()
|
|
hit_close = (df["oracle_close_r"] >= tgt).mean()
|
|
hit_next_open = (df["oracle_next_open_r"] >= tgt).mean()
|
|
lines.append(
|
|
f"| {tgt:.1f}R | {hit_high:.1%} | {hit_close:.1%} | {hit_next_open:.1%} |"
|
|
)
|
|
lines.append("")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--run", required=True, help="Path to intraday_*.json run output")
|
|
ap.add_argument("--out", required=True, help="Output directory")
|
|
ap.add_argument("--intraday-cache", default=str(DEFAULT_INTRADAY_CACHE))
|
|
args = ap.parse_args()
|
|
|
|
run_path = Path(args.run)
|
|
out_dir = Path(args.out)
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
intraday_root = Path(args.intraday_cache)
|
|
|
|
payload = json.loads(run_path.read_text())
|
|
trades = payload["trades"]
|
|
metrics = payload.get("metrics", {})
|
|
print(f"Loaded {len(trades)} trades from {run_path}")
|
|
|
|
rows = []
|
|
for i, t in enumerate(trades):
|
|
try:
|
|
r = _audit_trade(t, intraday_root)
|
|
if r is not None:
|
|
rows.append(r)
|
|
except Exception as e:
|
|
print(f" trade {i} {t.get('ticker')} {t.get('date')}: {e}")
|
|
if (i + 1) % 25 == 0:
|
|
print(f" audited {i+1}/{len(trades)}")
|
|
|
|
df = pd.DataFrame(rows)
|
|
parquet_path = out_dir / "trade_audit.parquet"
|
|
df.to_parquet(parquet_path, index=False)
|
|
print(f"Wrote {parquet_path} ({len(df)} rows)")
|
|
|
|
summary = build_summary(df, metrics)
|
|
md_path = out_dir / "summary.md"
|
|
md_path.write_text(summary)
|
|
print(f"Wrote {md_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|