# src/calibration/reports/risk_report.py import matplotlib.pyplot as plt from reportlab.platypus import Image import io from datetime import datetime from pathlib import Path from typing import Dict, Any from reportlab.platypus import ( SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, ) from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.platypus import PageBreak # ============================================================ # HELPERS # ============================================================ def _create_stop_histogram( stop_distances, p50=None, p90=None, p99=None, ): fig, ax = plt.subplots(figsize=(6, 4)) ax.hist( [d * 100 for d in stop_distances], bins=40, alpha=0.7, ) # Percentile lines if p50 is not None: ax.axvline(p50 * 100, linestyle=":", linewidth=1) if p90 is not None: ax.axvline(p90 * 100, linestyle=":", linewidth=1) if p99 is not None: ax.axvline(p99 * 100, linestyle=":", linewidth=1) ax.set_title("Stop Distance Distribution") ax.set_xlabel("Stop Distance (%)") ax.set_ylabel("Frequency") buf = io.BytesIO() plt.tight_layout() plt.savefig(buf, format="png") plt.close(fig) buf.seek(0) return buf def _create_position_size_plot( timestamps, position_sizes, *, max_position_fraction=None, ): ts, ps = _align_xy(timestamps, position_sizes) if not ps: return None x = list(range(len(ps))) y = [p * 100 for p in ps] fig, ax = plt.subplots(figsize=(6, 4)) ax.plot(x, y, linewidth=0.8) if max_position_fraction is not None: ax.axhline( max_position_fraction * 100, linestyle="--", linewidth=1, ) ax.set_title("Position Size Over Time") ax.set_ylabel("Position Size (% of equity)") ax.set_xlabel("Samples") buf = io.BytesIO() plt.tight_layout() plt.savefig(buf, format="png", dpi=150) plt.close(fig) buf.seek(0) return buf def _create_effective_risk_plot( timestamps, effective_risks, *, risk_target=None, p50=None, p90=None, p99=None, ): ts, er = _align_xy(timestamps, effective_risks) if not er: return None x = list(range(len(er))) y = [r * 100 for r in er] fig, ax = plt.subplots(figsize=(6, 4)) # Main curve ax.plot(x, y, linewidth=0.8) # Risk target line if risk_target is not None: ax.axhline( risk_target * 100, linestyle="--", linewidth=1, ) ax.set_title("Effective Risk Over Time") ax.set_ylabel("Effective Risk (%)") ax.set_xlabel("Samples") buf = io.BytesIO() plt.tight_layout() plt.savefig(buf, format="png", dpi=150) plt.close(fig) buf.seek(0) return buf def _align_xy(x, y): """ Ensures x and y have the same length. Trims to the shortest length and drops None/NaN pairs. Returns (x_aligned, y_aligned). """ if not x or not y: return [], [] n = min(len(x), len(y)) x = x[:n] y = y[:n] x2, y2 = [], [] for xi, yi in zip(x, y): if yi is None: continue try: # Filter NaN if isinstance(yi, float) and yi != yi: continue except Exception: pass x2.append(xi) y2.append(yi) return x2, y2 # ============================================================ # Footer (page number) # ============================================================ def _add_footer(canvas, doc): canvas.saveState() footer_text = f"Trading Bot · Calibration Report · Page {doc.page}" canvas.setFont("Helvetica", 8) canvas.drawRightString(200 * mm, 10 * mm, footer_text) canvas.restoreState() # ============================================================ # Main PDF generator # ============================================================ def generate_risk_report_pdf( *, output_path: Path, context: Dict[str, Any], config: Dict[str, Any], results: Dict[str, Any], ): styles = getSampleStyleSheet() title_style = styles["Title"] heading_style = styles["Heading2"] normal_style = styles["Normal"] story = [] # ============================================================ # TITLE # ============================================================ story.append( Paragraph( "Calibration Report · Step 2 · Risk & Stops", title_style, ) ) story.append(Spacer(1, 12)) story.append( Paragraph( f"Generated at: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC", normal_style, ) ) story.append(Spacer(1, 24)) # ============================================================ # CONTEXT # ============================================================ story.append(Paragraph("1. Context", heading_style)) story.append(Spacer(1, 8)) context_table = Table( [[k, str(v)] for k, v in context.items()], colWidths=[180, 300], ) context_table.setStyle( TableStyle([ ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), ("BACKGROUND", (0, 0), (-1, 0), colors.whitesmoke), ]) ) story.append(context_table) story.append(Spacer(1, 24)) # ============================================================ # CONFIGURATION # ============================================================ story.append(Paragraph("2. Configuration", heading_style)) story.append(Spacer(1, 8)) config_table = Table( [[k, str(v)] for k, v in config.items()], colWidths=[180, 300], ) config_table.setStyle( TableStyle([ ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), ]) ) story.append(config_table) story.append(Spacer(1, 24)) # ============================================================ # VALIDATION RESULT # ============================================================ story.append(Paragraph("3. Risk Validation Result", heading_style)) story.append(Spacer(1, 8)) status = results.get("status", "unknown").upper() status_color = { "OK": colors.green, "WARNING": colors.orange, "FAIL": colors.red, }.get(status, colors.black) status_table = Table( [["Status", status]], colWidths=[180, 300], ) status_table.setStyle( TableStyle([ ("TEXTCOLOR", (1, 0), (1, 0), status_color), ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), ]) ) story.append(status_table) story.append(Spacer(1, 12)) story.append( Paragraph( results.get("message", ""), normal_style, ) ) story.append(Spacer(1, 24)) # ============================================================ # CHECKS # ============================================================ checks = results.get("checks", {}) if checks: story.append(Paragraph("4. Detailed Checks", heading_style)) story.append(Spacer(1, 8)) check_rows = [["Check", "Status", "Message"]] for name, data in checks.items(): check_rows.append([ name, data.get("status", "").upper(), data.get("message", ""), ]) check_table = Table( check_rows, colWidths=[150, 80, 250], ) check_table.setStyle( TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey), ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), ]) ) story.append(check_table) story.append(Spacer(1, 24)) # ============================================================ # STOP DISTANCE STATISTICS # ============================================================ stop_metrics = ( results.get("checks", {}) .get("stop_sanity", {}) .get("metrics", {}) ) if stop_metrics: story.append(Paragraph("5. Stop Distance Statistics", heading_style)) story.append(Spacer(1, 8)) stats_table = Table( [ ["Metric", "Value (%)"], ["P50 (typical)", f"{stop_metrics.get('p50', 0) * 100:.2f}%"], ["P90 (wide)", f"{stop_metrics.get('p90', 0) * 100:.2f}%"], ["P99 (extreme)", f"{stop_metrics.get('p99', 0) * 100:.2f}%"], ], colWidths=[180, 180], ) stats_table.setStyle( TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey), ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), ]) ) story.append(stats_table) story.append(Spacer(1, 24)) # ============================================================ # RISK SIZING BREAKDOWN (WITH FORMULAS) # ============================================================ sizing_metrics = ( results.get("checks", {}) .get("sizer_feasibility", {}) .get("metrics", {}) ) stop_metrics = ( results.get("checks", {}) .get("stop_sanity", {}) .get("metrics", {}) ) if sizing_metrics and stop_metrics: story.append(Paragraph("6. Risk Sizing Breakdown", heading_style)) story.append(Spacer(1, 8)) equity = context.get("Account equity", 0) risk_pct = config.get("Risk per trade (%)", 0) max_pos_pct = config.get("Max position fraction (%)", 0) p50 = stop_metrics.get("p50", 0) ideal_position = sizing_metrics.get("ideal_position_median", 0) max_position_value = sizing_metrics.get("max_position_value", 0) effective_position = sizing_metrics.get("effective_position_median", 0) effective_risk = sizing_metrics.get("effective_risk_median", 0) breakdown_lines = [ f"Position size (ideal) = {equity:,.0f} × {risk_pct:.2f}% ÷ {p50*100:.2f}% ≈ {ideal_position:,.0f}", f"Max position size = {equity:,.0f} × {max_pos_pct:.2f}% = {max_position_value:,.0f}", f"Effective position = min({ideal_position:,.0f}, {max_position_value:,.0f}) = {effective_position:,.0f}", f"Effective risk = {effective_position:,.0f} × {p50*100:.2f}% ÷ {equity:,.0f} ≈ {effective_risk*100:.2f}%", ] for line in breakdown_lines: story.append(Paragraph(line, normal_style)) story.append(Spacer(1, 4)) story.append(Spacer(1, 24)) # ============================================================ # STOP CONFIGURATION DETAILS # ============================================================ stop_snapshot = results.get("config_snapshot", {}).get("stop", {}) story.append(Paragraph("7. Stop Configuration Details", heading_style)) story.append(Spacer(1, 8)) for k, v in stop_snapshot.items(): story.append(Paragraph(f"{k}: {v}", normal_style)) story.append(Spacer(1, 4)) # ============================================================ # STOP DISTANCE HISTOGRAM # ============================================================ series = results.get("series", {}) stop_distances = series.get("stop_distances") if stop_distances: story.append(Paragraph("6. Stop Distance Distribution", heading_style)) story.append(Spacer(1, 8)) stop_metrics = ( results.get("checks", {}) .get("stop_sanity", {}) .get("metrics", {}) ) img_buffer = _create_stop_histogram( stop_distances, p50=stop_metrics.get("p50"), p90=stop_metrics.get("p90"), p99=stop_metrics.get("p99"), ) img = Image(img_buffer, width=400, height=250) story.append(img) story.append(Spacer(1, 24)) # ============================================================ # POSITION SIZE OVER TIME # ============================================================ position_sizes = series.get("position_size_pct") timestamps = series.get("timestamps") if position_sizes and timestamps: story.append(PageBreak()) story.append(Paragraph("7. Position Size Over Time", heading_style)) story.append(Spacer(1, 8)) img_buffer = _create_position_size_plot( timestamps, position_sizes, max_position_fraction=config.get("Max position fraction (%)", 0) / 100, ) if img_buffer: img = Image(img_buffer, width=400, height=250) story.append(img) story.append(Spacer(1, 24)) # ============================================================ # EFFECTIVE RISK OVER TIME # ============================================================ effective_risks = series.get("effective_risk_pct") if effective_risks and timestamps: story.append(Paragraph("8. Effective Risk Over Time", heading_style)) story.append(Spacer(1, 8)) risk_target = config.get("Risk per trade (%)", 0) / 100 img_buffer = _create_effective_risk_plot( timestamps, effective_risks, risk_target=risk_target, ) if img_buffer: img = Image(img_buffer, width=400, height=250) story.append(img) story.append(Spacer(1, 24)) # ============================================================ # BUILD # ============================================================ doc = SimpleDocTemplate( str(output_path), pagesize=A4, rightMargin=36, leftMargin=36, topMargin=36, bottomMargin=36, ) doc.build(story, onFirstPage=_add_footer, onLaterPages=_add_footer)