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/label_horizon_analysis.py

246 lines
9.6 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""MFE/MAE Distribution Analysis by Event Type and Horizon.
Reads EventLabel records from the database and analyzes:
- MFE/MAE distributions per event_type per horizon (3d, 5d, 10d, 20d)
- Optimal holding period per event type (where MFE peaks)
- Forward return distributions
Outputs:
- Matplotlib plots (saved to output directory)
- Summary CSV with statistics per event_type × horizon
Usage:
python -m dev.analysis.label_horizon_analysis [--output-dir ./data/analysis]
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import datetime as dt
from pathlib import Path
from typing import Any
from libs.common.logging import configure_logging, get_logger
logger = get_logger(__name__)
HORIZONS = ["3d", "5d", "10d", "20d"]
async def _load_label_data() -> list[dict[str, Any]]:
"""Load EventLabel + Event data from DB."""
from sqlalchemy import select
from libs.db.models import Event, EventLabel
from libs.db.session import get_session
rows: list[dict[str, Any]] = []
async with get_session() as session:
result = await session.execute(
select(EventLabel, Event.event_type)
.join(Event, EventLabel.event_id == Event.event_id)
.where(EventLabel.label_status == "ok")
.where(EventLabel.invalid_event_for_labeling.is_(False))
)
for lbl, event_type in result.all():
rows.append({
"event_id": lbl.event_id,
"event_type": event_type,
"fwd_return_3d": float(lbl.fwd_return_3d) if lbl.fwd_return_3d is not None else None,
"fwd_return_5d": float(lbl.fwd_return_5d) if lbl.fwd_return_5d is not None else None,
"fwd_return_10d": float(lbl.fwd_return_10d) if lbl.fwd_return_10d is not None else None,
"fwd_return_20d": float(lbl.fwd_return_20d) if lbl.fwd_return_20d is not None else None,
"mfe_3d": float(lbl.mfe_3d) if lbl.mfe_3d is not None else None,
"mae_3d": float(lbl.mae_3d) if lbl.mae_3d is not None else None,
"mfe_5d": float(lbl.mfe_5d) if lbl.mfe_5d is not None else None,
"mae_5d": float(lbl.mae_5d) if lbl.mae_5d is not None else None,
"mfe_10d": float(lbl.mfe_10d) if lbl.mfe_10d is not None else None,
"mae_10d": float(lbl.mae_10d) if lbl.mae_10d is not None else None,
"mfe_20d": float(lbl.mfe_20d) if lbl.mfe_20d is not None else None,
"mae_20d": float(lbl.mae_20d) if lbl.mae_20d is not None else None,
})
return rows
def _compute_stats(values: list[float]) -> dict[str, float | None]:
"""Compute summary statistics for a list of values."""
if not values:
return {"count": 0, "mean": None, "median": None, "std": None, "min": None, "max": None}
import statistics
return {
"count": len(values),
"mean": statistics.mean(values),
"median": statistics.median(values),
"std": statistics.stdev(values) if len(values) > 1 else 0.0,
"min": min(values),
"max": max(values),
}
def analyze_and_write(rows: list[dict[str, Any]], output_dir: Path) -> None:
"""Compute statistics and generate plots."""
output_dir.mkdir(parents=True, exist_ok=True)
# Group by event_type
by_type: dict[str, list[dict[str, Any]]] = {}
for r in rows:
et = r["event_type"]
by_type.setdefault(et, []).append(r)
# Summary CSV
csv_rows: list[dict[str, Any]] = []
optimal_holding: dict[str, str] = {}
for event_type, type_rows in sorted(by_type.items()):
mfe_means: dict[str, float] = {}
for horizon in HORIZONS:
mfe_vals = [r[f"mfe_{horizon}"] for r in type_rows if r.get(f"mfe_{horizon}") is not None]
mae_vals = [r[f"mae_{horizon}"] for r in type_rows if r.get(f"mae_{horizon}") is not None]
fwd_vals = [r[f"fwd_return_{horizon}"] for r in type_rows if r.get(f"fwd_return_{horizon}") is not None]
mfe_stats = _compute_stats(mfe_vals)
mae_stats = _compute_stats(mae_vals)
fwd_stats = _compute_stats(fwd_vals)
if mfe_stats["mean"] is not None:
mfe_means[horizon] = mfe_stats["mean"]
csv_rows.append({
"event_type": event_type,
"horizon": horizon,
"n": mfe_stats["count"],
"mfe_mean": mfe_stats["mean"],
"mfe_median": mfe_stats["median"],
"mae_mean": mae_stats["mean"],
"mae_median": mae_stats["median"],
"fwd_return_mean": fwd_stats["mean"],
"fwd_return_median": fwd_stats["median"],
"fwd_return_std": fwd_stats["std"],
"reward_risk_ratio": (
mfe_stats["mean"] / abs(mae_stats["mean"])
if mfe_stats["mean"] is not None and mae_stats["mean"] and abs(mae_stats["mean"]) > 0
else None
),
})
# Optimal holding period: horizon with highest mean MFE
if mfe_means:
best_h = max(mfe_means, key=lambda h: mfe_means[h])
optimal_holding[event_type] = best_h
# Write CSV
csv_path = output_dir / "mfe_mae_summary.csv"
if csv_rows:
fieldnames = list(csv_rows[0].keys())
with open(csv_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(csv_rows)
logger.info("summary_csv_written", path=str(csv_path), rows=len(csv_rows))
# Print summary table
print(f"\n{'='*80}")
print(f"MFE/MAE Distribution Analysis — {len(rows)} events, {len(by_type)} event types")
print(f"{'='*80}")
for event_type in sorted(by_type):
n = len(by_type[event_type])
best_h = optimal_holding.get(event_type, "N/A")
print(f"\n {event_type} (n={n}, optimal holding={best_h})")
for horizon in HORIZONS:
matching = [r for r in csv_rows if r["event_type"] == event_type and r["horizon"] == horizon]
if matching:
m = matching[0]
mfe_str = f"{m['mfe_mean']:+.2%}" if m["mfe_mean"] is not None else "N/A"
mae_str = f"{m['mae_mean']:+.2%}" if m["mae_mean"] is not None else "N/A"
fwd_str = f"{m['fwd_return_mean']:+.2%}" if m["fwd_return_mean"] is not None else "N/A"
rr_str = f"{m['reward_risk_ratio']:.2f}" if m["reward_risk_ratio"] is not None else "N/A"
print(f" {horizon}: MFE={mfe_str} MAE={mae_str} FwdRet={fwd_str} R:R={rr_str}")
# Generate plots (if matplotlib available)
try:
_generate_plots(by_type, output_dir)
except ImportError:
logger.info("matplotlib_not_available", msg="Skipping plot generation")
print("\n(matplotlib not available — skipping plot generation)")
def _generate_plots(
by_type: dict[str, list[dict[str, Any]]],
output_dir: Path,
) -> None:
"""Generate MFE/MAE distribution plots using matplotlib."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
event_types = sorted(by_type.keys())
# Plot 1: MFE by horizon per event type
fig, axes = plt.subplots(
len(event_types), len(HORIZONS),
figsize=(4 * len(HORIZONS), 3 * len(event_types)),
squeeze=False,
)
fig.suptitle("MFE Distribution by Event Type and Horizon", fontsize=14)
for i, et in enumerate(event_types):
for j, h in enumerate(HORIZONS):
ax = axes[i][j]
vals = [r[f"mfe_{h}"] for r in by_type[et] if r.get(f"mfe_{h}") is not None]
if vals:
ax.hist(vals, bins=max(5, len(vals) // 3), alpha=0.7, color="steelblue", edgecolor="white")
ax.axvline(x=sum(vals) / len(vals), color="red", linestyle="--", linewidth=1)
ax.set_title(f"{et}\n{h}" if i == 0 else h, fontsize=8)
if j == 0:
ax.set_ylabel(et[:15], fontsize=8)
ax.tick_params(labelsize=6)
plt.tight_layout()
mfe_path = output_dir / "mfe_distributions.png"
fig.savefig(str(mfe_path), dpi=150)
plt.close(fig)
print(f"\nMFE plot saved: {mfe_path}")
# Plot 2: Mean MFE across horizons (optimal holding period)
fig2, ax2 = plt.subplots(figsize=(10, 6))
horizon_days = [3, 5, 10, 20]
for et in event_types:
means = []
for h in HORIZONS:
vals = [r[f"mfe_{h}"] for r in by_type[et] if r.get(f"mfe_{h}") is not None]
means.append(sum(vals) / len(vals) * 100 if vals else 0)
ax2.plot(horizon_days, means, marker="o", label=et)
ax2.set_xlabel("Horizon (trading days)")
ax2.set_ylabel("Mean MFE (%)")
ax2.set_title("Optimal Holding Period — Mean MFE by Horizon")
ax2.legend(fontsize=8, loc="upper left")
ax2.grid(True, alpha=0.3)
plt.tight_layout()
optimal_path = output_dir / "optimal_holding_period.png"
fig2.savefig(str(optimal_path), dpi=150)
plt.close(fig2)
print(f"Optimal holding plot saved: {optimal_path}")
def main() -> None:
parser = argparse.ArgumentParser(description="MFE/MAE Distribution Analysis")
parser.add_argument(
"--output-dir",
default="./data/analysis",
help="Output directory for plots and CSV (default: ./data/analysis)",
)
args = parser.parse_args()
configure_logging("info")
rows = asyncio.run(_load_label_data())
if not rows:
print("No label data found. Run the labeler pipeline first.")
return
analyze_and_write(rows, Path(args.output_dir))
if __name__ == "__main__":
main()