feat(calibration): finalize Step 2 Risk & Stops with inline PDF reports and visual validation

This commit is contained in:
DaM
2026-02-13 20:56:34 +01:00
parent 44667df3dd
commit f4f4e8e5be
20 changed files with 184 additions and 1925 deletions

View File

@@ -24,7 +24,12 @@ from reportlab.platypus import PageBreak
# HELPERS
# ============================================================
def _create_stop_histogram(stop_distances):
def _create_stop_histogram(
stop_distances,
p50=None,
p90=None,
p99=None,
):
fig, ax = plt.subplots(figsize=(6, 4))
@@ -34,6 +39,16 @@ def _create_stop_histogram(stop_distances):
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")
@@ -46,18 +61,30 @@ def _create_stop_histogram(stop_distances):
return buf
def _create_position_size_plot(timestamps, position_sizes):
# Align and be robust
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))) # robust axis (avoid matplotlib categorical date issues)
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")
@@ -67,9 +94,19 @@ def _create_position_size_plot(timestamps, position_sizes):
plt.savefig(buf, format="png", dpi=150)
plt.close(fig)
buf.seek(0)
return buf
def _create_effective_risk_plot(timestamps, effective_risks):
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
@@ -78,8 +115,18 @@ def _create_effective_risk_plot(timestamps, effective_risks):
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")
@@ -89,6 +136,7 @@ def _create_effective_risk_plot(timestamps, effective_risks):
plt.savefig(buf, format="png", dpi=150)
plt.close(fig)
buf.seek(0)
return buf
def _align_xy(x, y):
@@ -386,7 +434,18 @@ def generate_risk_report_pdf(
story.append(Paragraph("6. Stop Distance Distribution", heading_style))
story.append(Spacer(1, 8))
img_buffer = _create_stop_histogram(stop_distances)
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)
@@ -405,7 +464,12 @@ def generate_risk_report_pdf(
story.append(Paragraph("7. Position Size Over Time", heading_style))
story.append(Spacer(1, 8))
img_buffer = _create_position_size_plot(timestamps, position_sizes)
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)
@@ -422,7 +486,14 @@ def generate_risk_report_pdf(
story.append(Paragraph("8. Effective Risk Over Time", heading_style))
story.append(Spacer(1, 8))
img_buffer = _create_effective_risk_plot(timestamps, effective_risks)
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)

View File

@@ -68,6 +68,18 @@ def create_app() -> FastAPI:
name="static",
)
# -------------------------
# Reports folder (public access)
# -------------------------
reports_root = PROJECT_ROOT / "reports"
reports_root.mkdir(exist_ok=True)
app.mount(
"/reports",
StaticFiles(directory=str(reports_root)),
name="reports",
)
# ==================================================
# ROUTES — UI ONLY (TEMPORAL)
# ==================================================

View File

@@ -3,9 +3,10 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse
from pathlib import Path
import uuid
import re
from src.data.storage import StorageManager
from src.calibration.risk_inspector import (
@@ -261,15 +262,29 @@ def generate_risk_report(
results = result
# ---------------------------------------------
# Generate file
# ---------------------------------------------
# ============================================================
# BUILD SAFE OUTPUT PATH (OUTSIDE src/)
# ============================================================
reports_dir = Path("reports")
reports_dir.mkdir(exist_ok=True)
# Project root (3 niveles arriba desde router)
project_root = Path(__file__).resolve().parents[5]
filename = f"risk_report_{uuid.uuid4().hex}.pdf"
output_path = reports_dir / filename
# Reports folder outside src
reports_dir = project_root / "reports" / "risk"
reports_dir.mkdir(parents=True, exist_ok=True)
# Sanitize symbol for filesystem
safe_symbol = re.sub(r"[^a-zA-Z0-9_-]", "_", payload.symbol)
# Unique filename
file_id = uuid.uuid4().hex
filename = f"risk_report_{safe_symbol}_{payload.timeframe}_{file_id}.pdf"
symbol_dir = reports_dir / safe_symbol
symbol_dir.mkdir(exist_ok=True)
output_path = symbol_dir / filename
generate_risk_report_pdf(
output_path=output_path,
@@ -278,8 +293,24 @@ def generate_risk_report(
results=results,
)
return FileResponse(
path=str(output_path),
media_type="application/pdf",
filename=filename,
# ---------------------------------------------
# Create public URL
# ---------------------------------------------
public_url = f"/reports/risk/{safe_symbol}/{filename}"
return JSONResponse(
content={
"status": "ok",
"url": public_url,
}
)
# return FileResponse(
# path=str(output_path),
# media_type="application/pdf",
# filename=output_path.name,
# headers={
# "Content-Disposition": f'inline; filename="{output_path.name}"'
# },
# )

View File

@@ -212,7 +212,7 @@ async function validateCalibrationRisk() {
async function generateRiskReport() {
console.log("[calibration_risk] generateRiskReport() START");
const payload = buildRiskPayload(); // reutiliza la misma función que validate
const payload = buildRiskPayload();
const res = await fetch("/api/v2/calibration/risk/report", {
method: "POST",
@@ -220,25 +220,23 @@ async function generateRiskReport() {
body: JSON.stringify(payload),
});
if (!res.ok) {
console.error("Failed to generate report");
return;
const data = await res.json();
if (data.url) {
// Mostrar visor
const viewer = document.getElementById("pdf_viewer_section");
const frame = document.getElementById("pdf_frame");
frame.src = data.url;
viewer.classList.remove("d-none");
// Scroll suave hacia el visor
viewer.scrollIntoView({ behavior: "smooth" });
} else {
alert("Failed to generate report");
}
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "risk_report.pdf";
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
}
// =================================================
// WIZARD NAVIGATION
// =================================================
@@ -804,6 +802,13 @@ document.addEventListener("DOMContentLoaded", () => {
document
.getElementById("generate_report_btn")
?.addEventListener("click", generateRiskReport);
document
.getElementById("close_pdf_btn")
?.addEventListener("click", () => {
document.getElementById("pdf_viewer_section").classList.add("d-none");
document.getElementById("pdf_frame").src = "";
});
});

View File

@@ -238,6 +238,27 @@
<div id="stop_distance_plot" style="height: 360px;"></div>
</div>
<!-- ========================= -->
<!-- PDF REPORT VIEWER -->
<!-- ========================= -->
<div id="pdf_viewer_section" class="mt-5 d-none">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="card-title mb-0">Risk Report Preview</h4>
<button id="close_pdf_btn" class="btn btn-sm btn-outline-secondary">
Close
</button>
</div>
<iframe
id="pdf_frame"
style="width: 100%; height: 800px; border: none;"
></iframe>
</div>
</div>
</div>
</div>
<!-- Plotly -->
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>