Files
Trading-Bot/src/calibration/reports/risk_report.py

516 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)