"""Write all output artifacts for a backtest run.""" from __future__ import annotations import csv import datetime as dt import json from collections import defaultdict from pathlib import Path from typing import Any import pyarrow as pa import pyarrow.parquet as pq from libs.backtest.domain import ( BacktestConfig, DailyPortfolioState, ExperimentManifest, ExperimentResult, FilledTrade, MetricsBundle, OpenPosition, ) from libs.common.logging import get_logger logger = get_logger(__name__) def create_run_directory(output_root: str | Path, run_id: str) -> Path: """Create the full run directory tree. Returns the run root path.""" run_dir = Path(output_root) / run_id for subdir in ["logs", "metrics", "artifacts", "plots", "notes"]: (run_dir / subdir).mkdir(parents=True, exist_ok=True) logger.info("run_directory_created", path=str(run_dir)) return run_dir def write_metadata( run_dir: Path, run_id: str, started_at: dt.datetime, finished_at: dt.datetime, git_hash: str, total_trading_days: int, total_candidates_seen: int, total_orders_rejected: int, ) -> Path: """Write metadata.json.""" meta = { "run_id": run_id, "started_at": started_at.isoformat(), "finished_at": finished_at.isoformat(), "elapsed_seconds": (finished_at - started_at).total_seconds(), "git_commit_hash": git_hash, "total_trading_days": total_trading_days, "total_candidates_seen": total_candidates_seen, "total_orders_rejected": total_orders_rejected, } out = run_dir / "metadata.json" out.write_text(json.dumps(meta, indent=2)) return out def write_metrics_summary(run_dir: Path, metrics: MetricsBundle) -> Path: """Write metrics/metrics_summary.json.""" out = run_dir / "metrics" / "metrics_summary.json" out.write_text(metrics.model_dump_json(indent=2)) return out def write_trade_blotter( run_dir: Path, trades: list[FilledTrade], ) -> Path | None: """Write artifacts/trade_blotter.parquet.""" if not trades: return None rows = [ { "trade_id": t.trade_id, "position_id": t.position_id, "event_id": t.event_id, "symbol": t.symbol, "entry_date": t.entry_date.isoformat(), "exit_date": t.exit_date.isoformat(), "entry_price": t.entry_price, "exit_price": t.exit_price, "exit_reason": t.exit_reason.value, "shares": t.shares, "commission": t.commission, "slippage_bps": t.slippage_bps, "gross_pnl": t.gross_pnl, "net_pnl": t.net_pnl, "pnl_pct": t.pnl_pct, "r_multiple": t.r_multiple, "holding_days": t.holding_days, } for t in trades ] out = run_dir / "artifacts" / "trade_blotter.parquet" _write_parquet(rows, out) return out def write_daily_equity_curve( run_dir: Path, equity_curve: list[DailyPortfolioState], ) -> Path | None: """Write artifacts/daily_equity_curve.parquet.""" if not equity_curve: return None rows = [ { "date": s.date.isoformat(), "equity": s.equity, "cash_available": s.cash_available, "gross_exposure": s.gross_exposure, "net_exposure": s.net_exposure, "unrealized_pnl": s.unrealized_pnl, "realized_pnl": s.realized_pnl, "open_position_count": len(s.open_positions), "daily_new_risk_used": s.daily_new_risk_used, "peak_equity": s.peak_equity, "current_drawdown_pct": s.current_drawdown_pct, } for s in equity_curve ] out = run_dir / "artifacts" / "daily_equity_curve.parquet" _write_parquet(rows, out) return out def write_position_timeline( run_dir: Path, trades: list[FilledTrade], open_positions: list[OpenPosition] | None = None, ) -> Path | None: """Write artifacts/position_timeline.parquet (one row per position).""" rows = [] for t in trades: rows.append( { "position_id": t.position_id, "event_id": t.event_id, "symbol": t.symbol, "entry_date": t.entry_date.isoformat(), "exit_date": t.exit_date.isoformat(), "entry_price": t.entry_price, "exit_price": t.exit_price, "exit_reason": t.exit_reason.value, "shares": t.shares, "net_pnl": t.net_pnl, "r_multiple": t.r_multiple, "holding_days": t.holding_days, "status": "closed", } ) if open_positions: for p in open_positions: rows.append( { "position_id": p.position_id, "event_id": p.plan.candidate.event_id, "symbol": p.plan.candidate.symbol, "entry_date": p.entry_date.isoformat(), "exit_date": None, "entry_price": p.entry_price, "exit_price": None, "exit_reason": None, "shares": p.shares_total, "net_pnl": None, "r_multiple": None, "holding_days": p.days_held, "status": p.status.value, } ) if not rows: return None out = run_dir / "artifacts" / "position_timeline.parquet" _write_parquet(rows, out) return out def write_attribution_by_event_type( run_dir: Path, trades: list[FilledTrade], candidate_map: dict[str, Any], ) -> Path: """Write metrics/attribution_by_event_type.csv.""" bucket_data: dict[str, dict[str, float | int]] = defaultdict( lambda: {"count": 0, "wins": 0, "net_pnl": 0.0, "avg_r": 0.0, "_r_sum": 0.0} ) for t in trades: cand = candidate_map.get(t.trade_id) et = getattr(cand, "event_type", "unknown") if cand else "unknown" d = bucket_data[et] d["count"] = int(d["count"]) + 1 if t.net_pnl > 0: d["wins"] = int(d["wins"]) + 1 d["net_pnl"] = float(d["net_pnl"]) + t.net_pnl d["_r_sum"] = float(d["_r_sum"]) + t.r_multiple out = run_dir / "metrics" / "attribution_by_event_type.csv" with open(out, "w", newline="") as f: writer = csv.DictWriter( f, fieldnames=["event_type", "count", "wins", "win_rate", "net_pnl", "avg_r"] ) writer.writeheader() for et, d in sorted(bucket_data.items()): count = int(d["count"]) wins = int(d["wins"]) writer.writerow( { "event_type": et, "count": count, "wins": wins, "win_rate": wins / count if count > 0 else 0.0, "net_pnl": round(float(d["net_pnl"]), 4), "avg_r": round(float(d["_r_sum"]) / count if count > 0 else 0.0, 4), } ) return out def write_attribution_by_sector( run_dir: Path, trades: list[FilledTrade], candidate_map: dict[str, Any], ) -> Path: """Write metrics/attribution_by_sector.csv.""" bucket_data: dict[str, dict[str, float | int]] = defaultdict( lambda: {"count": 0, "wins": 0, "net_pnl": 0.0, "_r_sum": 0.0} ) for t in trades: cand = candidate_map.get(t.trade_id) sector = getattr(cand, "sector", "UNKNOWN") if cand else "UNKNOWN" d = bucket_data[sector] d["count"] = int(d["count"]) + 1 if t.net_pnl > 0: d["wins"] = int(d["wins"]) + 1 d["net_pnl"] = float(d["net_pnl"]) + t.net_pnl d["_r_sum"] = float(d["_r_sum"]) + t.r_multiple out = run_dir / "metrics" / "attribution_by_sector.csv" with open(out, "w", newline="") as f: writer = csv.DictWriter( f, fieldnames=["sector", "count", "wins", "win_rate", "net_pnl", "avg_r"] ) writer.writeheader() for sector, d in sorted(bucket_data.items()): count = int(d["count"]) wins = int(d["wins"]) writer.writerow( { "sector": sector, "count": count, "wins": wins, "win_rate": wins / count if count > 0 else 0.0, "net_pnl": round(float(d["net_pnl"]), 4), "avg_r": round(float(d["_r_sum"]) / count if count > 0 else 0.0, 4), } ) return out def write_score_bucket_report( run_dir: Path, score_bucket_hit_rate: dict[str, float], trades: list[FilledTrade], candidate_map: dict[str, Any], ) -> Path: """Write metrics/score_bucket_report.csv.""" bucket_counts: dict[str, int] = defaultdict(int) for t in trades: cand = candidate_map.get(t.trade_id) bucket = getattr(cand, "score_bucket", "unknown") if cand else "unknown" bucket_counts[bucket] += 1 out = run_dir / "metrics" / "score_bucket_report.csv" with open(out, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=["score_bucket", "trade_count", "win_rate"]) writer.writeheader() for bucket in sorted(set(list(score_bucket_hit_rate.keys()) + list(bucket_counts.keys()))): writer.writerow( { "score_bucket": bucket, "trade_count": bucket_counts.get(bucket, 0), "win_rate": round(score_bucket_hit_rate.get(bucket, 0.0), 4), } ) return out def write_plots(run_dir: Path, generate: bool = False) -> Path: """Create plots directory. generate=True logs a warning (matplotlib not available).""" plots_dir = run_dir / "plots" plots_dir.mkdir(exist_ok=True) if generate: logger.warning( "plots_not_implemented", message="generate_plots=True is a no-op; matplotlib is not in dependencies.", ) return plots_dir def write_run_notes(run_dir: Path, notes: str = "") -> Path: """Write notes/run_notes.md.""" out = run_dir / "notes" / "run_notes.md" out.write_text(notes or "# Run Notes\n\n_No notes provided._\n") return out def write_all_artifacts( run_dir: Path, run_id: str, manifest: ExperimentManifest, config: BacktestConfig, metrics: MetricsBundle, trades: list[FilledTrade], equity_curve: list[DailyPortfolioState], open_positions: list[OpenPosition], candidate_map: dict[str, Any], started_at: dt.datetime, finished_at: dt.datetime, git_hash: str, total_trading_days: int, total_candidates_seen: int, total_orders_rejected: int, ) -> dict[str, str]: """Write all output files. Returns mapping of artifact_name → file_path.""" from libs.backtest.manifests import save_manifest, save_resolved_config paths: dict[str, str] = {} # Core files paths["manifest"] = str(save_manifest(manifest, run_dir)) paths["resolved_config"] = str(save_resolved_config(config, run_dir)) paths["metadata"] = str( write_metadata( run_dir, run_id, started_at, finished_at, git_hash, total_trading_days, total_candidates_seen, total_orders_rejected, ) ) # Metrics if config.reporting.write_metrics_summary: paths["metrics_summary"] = str(write_metrics_summary(run_dir, metrics)) paths["attribution_by_event_type"] = str( write_attribution_by_event_type(run_dir, trades, candidate_map) ) paths["attribution_by_sector"] = str( write_attribution_by_sector(run_dir, trades, candidate_map) ) paths["score_bucket_report"] = str( write_score_bucket_report( run_dir, metrics.score_bucket_hit_rate, trades, candidate_map ) ) # Trade data if config.reporting.write_trade_blotter: p = write_trade_blotter(run_dir, trades) if p: paths["trade_blotter"] = str(p) if config.reporting.write_equity_curve: p = write_daily_equity_curve(run_dir, equity_curve) if p: paths["daily_equity_curve"] = str(p) p = write_position_timeline(run_dir, trades, open_positions) if p: paths["position_timeline"] = str(p) # Plots (no-op) paths["plots_dir"] = str(write_plots(run_dir, config.reporting.generate_plots)) # Notes paths["run_notes"] = str(write_run_notes(run_dir, manifest.notes or "")) return paths # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _write_parquet(rows: list[dict[str, Any]], path: Path) -> None: """Write list of row dicts to Parquet.""" if not rows: return keys = list(rows[0].keys()) columns: dict[str, list[Any]] = {k: [] for k in keys} for row in rows: for k in keys: columns[k].append(row.get(k)) table = pa.table({k: pa.array(v) for k, v in columns.items()}) pq.write_table(table, str(path)) logger.debug("parquet_written", path=str(path), rows=len(rows))