Step 2: Risk and Stops - Generating reports with pdf. It is missing to reorder the PDF.

This commit is contained in:
DaM
2026-02-13 07:01:44 +01:00
parent 4d769af8bf
commit 44667df3dd
27 changed files with 4318 additions and 103 deletions

View File

View File

@@ -0,0 +1,444 @@
# 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):
fig, ax = plt.subplots(figsize=(6, 4))
ax.hist(
[d * 100 for d in stop_distances],
bins=40,
alpha=0.7,
)
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):
# Align and be robust
ts, ps = _align_xy(timestamps, position_sizes)
if not ps:
return None
x = list(range(len(ps))) # robust axis (avoid matplotlib categorical date issues)
y = [p * 100 for p in ps]
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(x, y, linewidth=0.8)
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):
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))
ax.plot(x, y, linewidth=0.8)
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))
img_buffer = _create_stop_histogram(stop_distances)
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)
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))
img_buffer = _create_effective_risk_plot(timestamps, effective_risks)
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)

View File

@@ -0,0 +1,449 @@
# src/calibration/risk_inspector.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, Dict, Any
import numpy as np
import pandas as pd
from src.data.storage import StorageManager
from src.utils.logger import log
# ============================================================
# Domain models (pueden moverse luego a models.py)
# ============================================================
StopType = Literal["fixed", "trailing", "atr"]
@dataclass
class StopConfig:
type: StopType
# fixed / trailing
stop_fraction: float | None = None
# atr
atr_period: int | None = None
atr_multiplier: float | None = None
@dataclass
class RiskConfig:
risk_fraction: float
max_position_fraction: float
@dataclass
class GlobalRiskRules:
max_drawdown_pct: float
# reservados v1+
daily_loss_limit_pct: float | None = None
max_consecutive_losses: int | None = None
cooldown_bars: int | None = None
# ============================================================
# Public entry point
# ============================================================
def inspect_risk_config(
*,
storage: StorageManager,
symbol: str,
timeframe: str,
stop: StopConfig,
risk: RiskConfig,
rules: GlobalRiskRules,
account_equity: float,
data_quality: Dict[str, Any],
) -> Dict[str, Any]:
"""
Inspecta si una configuración de riesgo + stop es viable
con los datos disponibles para un símbolo y timeframe.
No ejecuta estrategias.
No persiste estado.
"""
log.info("🔍 Inspecting risk & stop configuration")
# --------------------------------------------------
# Load market data (read-only)
# --------------------------------------------------
df = storage.load_ohlcv(symbol=symbol, timeframe=timeframe)
if df is None or df.empty:
return _fail_result("No OHLCV data available for risk inspection")
# --------------------------------------------------
# Run checks
# --------------------------------------------------
checks: Dict[str, Any] = {}
checks["data_compatibility"] = _check_data_compatibility(data_quality)
checks["stop_availability"] = _check_stop_availability(df, stop)
checks["stop_sanity"] = _check_stop_sanity(df, stop)
checks["sizer_feasibility"] = _check_sizer_feasibility(
df=df,
stop=stop,
risk=risk,
account_equity=account_equity,
)
# --------------------------------------------------
# Aggregate quality
# --------------------------------------------------
status = _aggregate_status(checks)
message = _build_human_message(status, checks)
series = _compute_position_series(
df=df,
stop=stop,
risk=risk,
account_equity=account_equity,
)
return {
"valid": status != "fail",
"status": status,
"checks": checks,
"message": message,
"series": series,
"config_snapshot": {
"symbol": symbol,
"timeframe": timeframe,
"account_equity": account_equity,
"stop": stop.__dict__,
"risk": risk.__dict__,
"rules": rules.__dict__,
},
}
# ============================================================
# Checks
# ============================================================
def _check_data_compatibility(data_quality: Dict[str, Any]) -> Dict[str, Any]:
"""
Evalúa si la calidad de datos permite usar stops con fiabilidad.
"""
status = data_quality.get("status")
if status == "fail":
return {
"status": "fail",
"message": "Data quality is insufficient for risk calibration",
}
gaps = data_quality.get("gaps", {}).get("count", 0)
if gaps > 0:
return {
"status": "warning",
"message": (
"Data contains gaps. Stops are evaluated on close price "
"and may trigger late."
),
}
return {
"status": "ok",
"message": "Data quality is compatible with stop evaluation",
}
def _check_stop_availability(df: pd.DataFrame, stop: StopConfig) -> Dict[str, Any]:
"""
Comprueba que el stop puede calcularse con los datos disponibles.
"""
if stop.type in ("fixed", "trailing"):
return {"status": "ok"}
if stop.type == "atr":
if stop.atr_period is None or stop.atr_multiplier is None:
return {
"status": "fail",
"reason": "atr_parameters_missing",
}
if len(df) < stop.atr_period + 5:
return {
"status": "fail",
"reason": "not_enough_candles_for_atr",
}
required_cols = {"high", "low", "close"}
if not required_cols.issubset(df.columns):
return {
"status": "fail",
"reason": "missing_ohlc_columns",
}
return {"status": "ok"}
return {"status": "fail", "reason": "unknown_stop_type"}
def _check_stop_sanity(df: pd.DataFrame, stop: StopConfig) -> Dict[str, Any]:
"""
Evalúa si el stop tiene una magnitud razonable.
"""
stop_distances = _compute_stop_distances(df, stop)
if stop_distances is None or len(stop_distances) == 0:
return {
"status": "fail",
"message": "Unable to compute stop distances",
}
quantiles = np.quantile(stop_distances, [0.5, 0.9, 0.99])
p50, p90, p99 = quantiles
status = "ok"
message = "Stop distance looks reasonable"
if p50 < 0.002:
status = "warning"
message = "Stop distance is very tight and may cause overtrading"
if p90 > 0.2:
status = "warning"
message = "Stop distance is very wide and may reduce trade frequency"
recommendation = None
if status == "warning":
# Recomendación basada en estructura del mercado
recommended_stop = float(np.clip(p75 := np.quantile(stop_distances, 0.75), 0.002, 0.2))
recommendation = {
"suggested_stop_fraction": recommended_stop,
"explanation": (
f"Based on market volatility, a stop around "
f"{recommended_stop:.2%} would better balance trade frequency and risk."
),
}
return {
"status": status,
"metrics": {
"p50": float(p50),
"p90": float(p90),
"p99": float(p99),
},
"message": message,
"recommendation": recommendation,
}
def _check_sizer_feasibility(
*,
df: pd.DataFrame,
stop: StopConfig,
risk: RiskConfig,
account_equity: float,
) -> Dict[str, Any]:
stop_distances = _compute_stop_distances(df, stop)
prices = df["close"].values
risk_amount = account_equity * risk.risk_fraction
max_position_value = account_equity * risk.max_position_fraction
effective_risks = []
effective_positions = []
ideal_positions = []
for price, stop_fraction in zip(prices, stop_distances):
if stop_fraction <= 0 or price <= 0:
continue
ideal_position_value = risk_amount / stop_fraction
ideal_positions.append(ideal_position_value)
position_value = min(ideal_position_value, max_position_value)
effective_positions.append(position_value)
effective_risk = position_value * stop_fraction / account_equity
effective_risks.append(effective_risk)
if not effective_risks:
return {
"status": "fail",
"message": "Unable to compute sizing feasibility",
}
eff_risk_median = float(np.median(effective_risks))
ideal_position_median = float(np.median(ideal_positions))
effective_position_median = float(np.median(effective_positions))
status = "ok"
message = "Risk sizing looks feasible"
if eff_risk_median < risk.risk_fraction * 0.1:
status = "warning"
message = (
"Effective risk is much lower than intended. "
"Position sizing is heavily capped by limits."
)
if eff_risk_median > risk.risk_fraction * 1.5:
status = "fail"
message = (
"Effective risk exceeds intended risk. "
"Risk configuration is unsafe."
)
return {
"status": status,
"metrics": {
"ideal_position_median": ideal_position_median,
"max_position_value": max_position_value,
"effective_position_median": effective_position_median,
"effective_risk_median": eff_risk_median,
},
"message": message,
}
def _compute_position_series(
*,
df: pd.DataFrame,
stop: StopConfig,
risk: RiskConfig,
account_equity: float,
) -> Dict[str, Any]:
"""
Calcula series temporales de sizing, riesgo efectivo
y distancias de stop (en fracción).
"""
stop_distances = _compute_stop_distances(df, stop)
prices = df["close"].values
timestamps = df.index.astype(str).tolist()
risk_amount = account_equity * risk.risk_fraction
max_position_value = account_equity * risk.max_position_fraction
position_size_pct = []
effective_risk_pct = []
stop_distances_out = []
for price, stop_fraction in zip(prices, stop_distances):
if stop_fraction is None or stop_fraction <= 0 or price <= 0:
position_size_pct.append(None)
effective_risk_pct.append(None)
stop_distances_out.append(None)
continue
position_value = risk_amount / stop_fraction
if position_value > max_position_value:
position_value = max_position_value
pos_pct = position_value / account_equity
eff_risk = position_value * stop_fraction / account_equity
position_size_pct.append(float(pos_pct))
effective_risk_pct.append(float(eff_risk))
stop_distances_out.append(float(stop_fraction))
return {
"timestamps": timestamps,
"position_size_pct": position_size_pct,
"effective_risk_pct": effective_risk_pct,
"stop_distances": stop_distances_out,
}
# ============================================================
# Utilities
# ============================================================
def _compute_stop_distances(df: pd.DataFrame, stop: StopConfig) -> np.ndarray:
"""
Devuelve un array con la distancia RELATIVA del stop respecto al precio.
Es decir: |stop_price - price| / price
"""
close = df["close"].values.astype(float)
if stop.type in ("fixed", "trailing"):
if stop.stop_fraction is None:
return None
# stop_fraction ya es relativa (ej. 0.01 = 1%)
return np.full_like(close, stop.stop_fraction, dtype=float)
if stop.type == "atr":
high = df["high"].astype(float)
low = df["low"].astype(float)
close_series = df["close"].astype(float)
tr = pd.concat(
[
high - low,
(high - close_series.shift()).abs(),
(low - close_series.shift()).abs(),
],
axis=1,
).max(axis=1)
atr = tr.rolling(stop.atr_period).mean()
# ATR stop distance RELATIVE to price
stop_distance = (atr * stop.atr_multiplier) / close_series
return stop_distance.dropna().values.astype(float)
return None
def _aggregate_status(checks: Dict[str, Any]) -> Literal["ok", "warning", "fail"]:
"""
Agrega los estados de los checks.
"""
statuses = [c["status"] for c in checks.values()]
if "fail" in statuses:
return "fail"
if "warning" in statuses:
return "warning"
return "ok"
def _build_human_message(status: str, checks: Dict[str, Any]) -> str:
"""
Construye un mensaje humano de alto nivel.
"""
if status == "ok":
return "Risk and stop configuration is compatible with the data"
if status == "warning":
return "Risk configuration has warnings. Review details before continuing"
return "Risk configuration is not usable with the current data"
def _fail_result(message: str) -> Dict[str, Any]:
"""
Construye una respuesta de fallo inmediato y consistente.
"""
return {
"valid": False,
"status": "fail",
"checks": {},
"message": message,
}

View File

@@ -10,6 +10,7 @@ import time
from .settings import settings
from src.web.api.v2.routers.calibration_data import router as calibration_data_router
from src.web.api.v2.routers.calibration_risk import router as calibration_risk_router
# --------------------------------------------------
# Logging
@@ -91,12 +92,25 @@ def create_app() -> FastAPI:
"step": 1,
},
)
@app.get("/calibration/risk", response_class=HTMLResponse)
def calibration_risk_page(request: Request):
return templates.TemplateResponse(
"pages/calibration/calibration_risk.html",
{
"request": request,
"page": "calibration",
"step": 2,
},
)
# --------------------------------------------------
# API routers (versionados)
# --------------------------------------------------
api_prefix = settings.api_prefix
app.include_router(calibration_data_router, prefix=api_prefix)
app.include_router(calibration_risk_router, prefix=api_prefix)
return app

View File

@@ -0,0 +1,285 @@
# src/web/api/v2/routers/calibration_risk.py
import logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pathlib import Path
import uuid
from src.data.storage import StorageManager
from src.calibration.risk_inspector import (
inspect_risk_config,
StopConfig,
RiskConfig,
GlobalRiskRules,
)
from ..schemas.calibration_risk import (
CalibrationRiskInspectRequest,
CalibrationRiskInspectResponse,
CalibrationRiskValidateResponse,
)
from src.calibration.reports.risk_report import generate_risk_report_pdf
logger = logging.getLogger("tradingbot.api.v2")
router = APIRouter(
prefix="/calibration/risk",
tags=["calibration"],
)
# =================================================
# Dependencies
# =================================================
def get_storage() -> StorageManager:
return StorageManager.from_env()
# =================================================
# INSPECT (RISK & STOPS)
# =================================================
@router.post(
"/inspect",
response_model=CalibrationRiskInspectResponse,
)
def inspect_calibration_risk(
payload: CalibrationRiskInspectRequest,
storage: StorageManager = Depends(get_storage),
):
logger.info(
f"🛡️ Inspecting risk | {payload.symbol} {payload.timeframe}"
)
# --------------------------------------------------
# Step 1 dependency check
# --------------------------------------------------
# Por ahora: recalculamos data_quality igual que Step 1
# (más adelante se puede cachear si quieres)
df = storage.load_ohlcv(
symbol=payload.symbol,
timeframe=payload.timeframe,
)
if df is None or df.empty:
raise HTTPException(
status_code=400,
detail="No OHLCV data found. Run Step 1 first.",
)
# reutilizamos la misma función que en calibration_data
from .calibration_data import analyze_data_quality
data_quality = analyze_data_quality(df, payload.timeframe)
# --------------------------------------------------
# Map schemas -> domain models
# --------------------------------------------------
stop = StopConfig(
type=payload.stop.type,
stop_fraction=payload.stop.stop_fraction,
atr_period=payload.stop.atr_period,
atr_multiplier=payload.stop.atr_multiplier,
)
risk = RiskConfig(
risk_fraction=payload.risk.risk_fraction,
max_position_fraction=payload.risk.max_position_fraction,
)
rules = GlobalRiskRules(
max_drawdown_pct=payload.global_rules.max_drawdown_pct,
daily_loss_limit_pct=payload.global_rules.daily_loss_limit_pct,
max_consecutive_losses=payload.global_rules.max_consecutive_losses,
cooldown_bars=payload.global_rules.cooldown_bars,
)
# --------------------------------------------------
# Domain inspection
# --------------------------------------------------
result = inspect_risk_config(
storage=storage,
symbol=payload.symbol,
timeframe=payload.timeframe,
stop=stop,
risk=risk,
rules=rules,
account_equity=payload.account_equity,
data_quality=data_quality,
)
logger.info(
f"🧪 Risk inspect result | status={result['status']}"
)
return CalibrationRiskInspectResponse(**result)
# =================================================
# VALIDATE (RISK & STOPS — with series for plots)
# =================================================
@router.post(
"/validate",
response_model=CalibrationRiskValidateResponse,
)
def validate_calibration_risk(
payload: CalibrationRiskInspectRequest,
storage: StorageManager = Depends(get_storage),
):
logger.info(
f"📊 Validating risk (with series) | {payload.symbol} {payload.timeframe}"
)
df = storage.load_ohlcv(
symbol=payload.symbol,
timeframe=payload.timeframe,
)
if df is None or df.empty:
raise HTTPException(
status_code=400,
detail="No OHLCV data found. Run Step 1 first.",
)
from .calibration_data import analyze_data_quality
data_quality = analyze_data_quality(df, payload.timeframe)
stop = StopConfig(
type=payload.stop.type,
stop_fraction=payload.stop.stop_fraction,
atr_period=payload.stop.atr_period,
atr_multiplier=payload.stop.atr_multiplier,
)
risk = RiskConfig(
risk_fraction=payload.risk.risk_fraction,
max_position_fraction=payload.risk.max_position_fraction,
)
rules = GlobalRiskRules(
max_drawdown_pct=payload.global_rules.max_drawdown_pct,
daily_loss_limit_pct=payload.global_rules.daily_loss_limit_pct,
max_consecutive_losses=payload.global_rules.max_consecutive_losses,
cooldown_bars=payload.global_rules.cooldown_bars,
)
result = inspect_risk_config(
storage=storage,
symbol=payload.symbol,
timeframe=payload.timeframe,
stop=stop,
risk=risk,
rules=rules,
account_equity=payload.account_equity,
data_quality=data_quality,
)
logger.info(
f"📈 Risk validate result | status={result['status']}"
)
return CalibrationRiskValidateResponse(**result)
# =================================================
# REPORT (PDF)
# =================================================
@router.post("/report")
def generate_risk_report(
payload: CalibrationRiskInspectRequest,
storage: StorageManager = Depends(get_storage),
):
logger.info(
f"🧾 Generating risk report | {payload.symbol} {payload.timeframe}"
)
df = storage.load_ohlcv(
symbol=payload.symbol,
timeframe=payload.timeframe,
)
if df is None or df.empty:
raise HTTPException(
status_code=400,
detail="No OHLCV data found. Run Step 1 first.",
)
from .calibration_data import analyze_data_quality
data_quality = analyze_data_quality(df, payload.timeframe)
stop = StopConfig(
type=payload.stop.type,
stop_fraction=payload.stop.stop_fraction,
atr_period=payload.stop.atr_period,
atr_multiplier=payload.stop.atr_multiplier,
)
risk = RiskConfig(
risk_fraction=payload.risk.risk_fraction,
max_position_fraction=payload.risk.max_position_fraction,
)
rules = GlobalRiskRules(
max_drawdown_pct=payload.global_rules.max_drawdown_pct,
daily_loss_limit_pct=payload.global_rules.daily_loss_limit_pct,
max_consecutive_losses=payload.global_rules.max_consecutive_losses,
cooldown_bars=payload.global_rules.cooldown_bars,
)
result = inspect_risk_config(
storage=storage,
symbol=payload.symbol,
timeframe=payload.timeframe,
stop=stop,
risk=risk,
rules=rules,
account_equity=payload.account_equity,
data_quality=data_quality,
)
# ---------------------------------------------
# Prepare PDF data
# ---------------------------------------------
context = {
"Symbol": payload.symbol,
"Timeframe": payload.timeframe,
"Account equity": payload.account_equity,
}
config = {
"Stop type": payload.stop.type,
"Risk per trade (%)": payload.risk.risk_fraction * 100,
"Max position fraction (%)": payload.risk.max_position_fraction * 100,
"Max drawdown (%)": payload.global_rules.max_drawdown_pct * 100,
}
results = result
# ---------------------------------------------
# Generate file
# ---------------------------------------------
reports_dir = Path("reports")
reports_dir.mkdir(exist_ok=True)
filename = f"risk_report_{uuid.uuid4().hex}.pdf"
output_path = reports_dir / filename
generate_risk_report_pdf(
output_path=output_path,
context=context,
config=config,
results=results,
)
return FileResponse(
path=str(output_path),
media_type="application/pdf",
filename=filename,
)

View File

@@ -0,0 +1,84 @@
# src/web/api/v2/schemas/calibration_risk.py
from typing import Literal, Optional, Dict, Any
from pydantic import BaseModel, Field, model_validator
class StopConfigSchema(BaseModel):
type: Literal["fixed", "trailing", "atr"]
stop_fraction: Optional[float] = Field(None, gt=0)
atr_period: Optional[int] = Field(None, gt=1)
atr_multiplier: Optional[float] = Field(None, gt=0)
@model_validator(mode="after")
def validate_by_type(self):
if self.type in ("fixed", "trailing"):
if self.stop_fraction is None:
raise ValueError(
"stop_fraction required for fixed/trailing stop"
)
if self.type == "atr":
if self.atr_period is None:
raise ValueError(
"atr_period required for ATR stop"
)
if self.atr_multiplier is None:
raise ValueError(
"atr_multiplier required for ATR stop"
)
return self
# class StopConfigSchema(BaseModel):
# type: Literal["fixed", "trailing", "atr"]
# stop_fraction: Optional[float] = Field(None, gt=0)
# atr_period: Optional[int] = Field(None, gt=1)
# atr_multiplier: Optional[float] = Field(None, gt=0)
class RiskConfigSchema(BaseModel):
risk_fraction: float = Field(..., gt=0, lt=1)
max_position_fraction: float = Field(..., gt=0, lt=1)
class GlobalRiskRulesSchema(BaseModel):
max_drawdown_pct: float = Field(..., gt=0, lt=1)
daily_loss_limit_pct: Optional[float] = Field(None, gt=0, lt=1)
max_consecutive_losses: Optional[int] = Field(None, gt=0)
cooldown_bars: Optional[int] = Field(None, ge=0)
class CalibrationRiskInspectRequest(BaseModel):
symbol: str
timeframe: str
stop: StopConfigSchema
risk: RiskConfigSchema
global_rules: GlobalRiskRulesSchema
account_equity: float = Field(..., gt=0)
class CalibrationRiskInspectResponse(BaseModel):
valid: bool
status: Literal["ok", "warning", "fail"]
checks: Dict[str, Any]
message: str
class CalibrationRiskValidateResponse(BaseModel):
valid: bool
status: Literal["ok", "warning", "fail"]
checks: Dict[str, Any]
message: str
# ⬇️ NUEVO
series: Dict[str, Any]

View File

@@ -0,0 +1,9 @@
/* =================================================
Wizard navigation (Calibration steps)
================================================= */
a[aria-disabled="true"] {
pointer-events: none;
opacity: 0.4;
cursor: not-allowed;
}

View File

@@ -32,6 +32,9 @@ async function inspectCalibrationData() {
resultEl.textContent = "⏳ Inspeccionando datos en DB...";
}
// por si vienes de un estado previo OK y ahora inspeccionas otra cosa
disableNextStep();
try {
const res = await fetch("/api/v2/calibration/data/inspect", {
method: "POST",
@@ -48,11 +51,22 @@ async function inspectCalibrationData() {
renderDataSummary(data);
// ✅ habilita el paso 2 si:
// - valid = true
// - data_quality no es FAIL (warnings dejan pasar)
const dqStatus = data.data_quality?.status ?? "ok";
if (data.valid && dqStatus !== "fail") {
enableNextStep();
localStorage.setItem("calibration.symbol", data.symbol);
localStorage.setItem("calibration.timeframe", data.timeframe);
}
} catch (err) {
console.error("[calibration_data] inspect FAILED", err);
if (resultEl) {
resultEl.textContent = "❌ Error inspeccionando datos";
}
disableNextStep();
}
}
@@ -174,8 +188,28 @@ function renderDownloadProgress(job) {
text.textContent = job.message || job.status;
}
function enableNextStep() {
const btn = document.getElementById("next-step-btn");
if (!btn) return;
btn.style.display = "inline-flex";
btn.classList.remove("btn-outline-secondary");
btn.classList.add("btn-outline-primary");
btn.setAttribute("aria-disabled", "false");
}
function disableNextStep() {
const btn = document.getElementById("next-step-btn");
if (!btn) return;
btn.style.display = "inline-flex";
btn.classList.remove("btn-outline-primary");
btn.classList.add("btn-outline-secondary");
btn.setAttribute("aria-disabled", "true");
}
// =================================================
// DATA SUMMARY (YA EXISTENTE)
// DATA SUMMARY
// =================================================
function renderDataSummary(data) {
@@ -183,7 +217,7 @@ function renderDataSummary(data) {
if (!card) return;
card.classList.remove("d-none");
document.getElementById("first-ts").textContent =
data.first_available ?? "";
document.getElementById("last-ts").textContent =
@@ -209,6 +243,9 @@ function renderDataSummary(data) {
logEl.textContent =
`❌ No hay datos para ${data.symbol} @ ${data.timeframe}`;
}
// si no es válido, ocultamos data quality
document.getElementById("data-quality-card")?.classList.add("d-none");
return;
}
@@ -241,8 +278,9 @@ function renderDataSummary(data) {
`${(dq.checks.coverage.ratio * 100).toFixed(2)}%`;
document.getElementById("dq-volume").textContent =
dq.checks.volume;
} else {
dqCard?.classList.add("d-none");
}
}
// =================================================

View File

@@ -0,0 +1,809 @@
// src/web/ui/v2/static/js/pages/calibration_risk.js
console.log(
"[calibration_risk] script loaded ✅",
new Date().toISOString()
);
// =================================================
// INSPECT RISK & STOPS
// =================================================
async function inspectCalibrationRisk() {
console.log("[calibration_risk] inspectCalibrationRisk() START ✅");
// --------------------------------------------------
// Load calibration context from Step 1
// --------------------------------------------------
const symbol = localStorage.getItem("calibration.symbol");
const timeframe = localStorage.getItem("calibration.timeframe");
if (!symbol || !timeframe) {
alert("Calibration context not found. Please complete Step 1 (Data) first.");
return;
}
// --------------------------------------------------
// Read form inputs
// --------------------------------------------------
const accountEquityEl = document.getElementById("account_equity");
const riskFractionEl = document.getElementById("risk_fraction");
const maxPositionEl = document.getElementById("max_position_fraction");
const stopTypeEl = document.getElementById("stop_type");
const stopFractionEl = document.getElementById("stop_fraction");
const atrPeriodEl = document.getElementById("atr_period");
const atrMultiplierEl = document.getElementById("atr_multiplier");
const maxDrawdownEl = document.getElementById("max_drawdown_pct");
if (!accountEquityEl || !riskFractionEl || !maxPositionEl || !stopTypeEl || !maxDrawdownEl) {
console.error("[calibration_risk] Missing required form elements");
alert("Internal error: risk form is incomplete.");
return;
}
// --------------------------------------------------
// Build stop config
// --------------------------------------------------
const stopType = stopTypeEl.value;
let stopConfig = { type: stopType };
if (stopType === "fixed" || stopType === "trailing") {
if (!stopFractionEl) {
alert("Stop fraction input missing");
return;
}
stopConfig.stop_fraction = parseFloat(stopFractionEl.value) / 100;
}
if (stopType === "atr") {
if (!atrPeriodEl || !atrMultiplierEl) {
alert("ATR parameters missing");
return;
}
stopConfig.atr_period = parseInt(atrPeriodEl.value);
stopConfig.atr_multiplier = parseFloat(atrMultiplierEl.value);
}
// --------------------------------------------------
// Build payload
// --------------------------------------------------
const payload = buildRiskPayload();
console.log("[calibration_risk] inspect payload:", payload);
// --------------------------------------------------
// Disable next step while inspecting
// --------------------------------------------------
disableNextStep();
// --------------------------------------------------
// Call API
// --------------------------------------------------
try {
const res = await fetch("/api/v2/calibration/risk/inspect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
console.log("[calibration_risk] inspect response:", data);
renderRiskResult(payload, data);
// --------------------------------------------------
// Enable next step if OK or WARNING
// --------------------------------------------------
if (data.valid && data.status !== "fail") {
enableNextStep();
}
} catch (err) {
console.error("[calibration_risk] inspect FAILED", err);
alert("Error inspecting risk configuration");
disableNextStep();
}
}
// =================================================
// VALIDATE RISK & STOPS
// =================================================
async function validateCalibrationRisk() {
console.log("[calibration_risk] inspectCalibrationRisk() START ✅");
// --------------------------------------------------
// Load calibration context from Step 1
// --------------------------------------------------
const symbol = localStorage.getItem("calibration.symbol");
const timeframe = localStorage.getItem("calibration.timeframe");
if (!symbol || !timeframe) {
alert("Calibration context not found. Please complete Step 1 (Data) first.");
return;
}
// --------------------------------------------------
// Read form inputs
// --------------------------------------------------
const accountEquityEl = document.getElementById("account_equity");
const riskFractionEl = document.getElementById("risk_fraction");
const maxPositionEl = document.getElementById("max_position_fraction");
const stopTypeEl = document.getElementById("stop_type");
const stopFractionEl = document.getElementById("stop_fraction");
const atrPeriodEl = document.getElementById("atr_period");
const atrMultiplierEl = document.getElementById("atr_multiplier");
const maxDrawdownEl = document.getElementById("max_drawdown_pct");
if (!accountEquityEl || !riskFractionEl || !maxPositionEl || !stopTypeEl || !maxDrawdownEl) {
console.error("[calibration_risk] Missing required form elements");
alert("Internal error: risk form is incomplete.");
return;
}
// --------------------------------------------------
// Build stop config
// --------------------------------------------------
const stopType = stopTypeEl.value;
let stopConfig = { type: stopType };
if (stopType === "fixed" || stopType === "trailing") {
if (!stopFractionEl) {
alert("Stop fraction input missing");
return;
}
stopConfig.stop_fraction = parseFloat(stopFractionEl.value) / 100;
}
if (stopType === "atr") {
if (!atrPeriodEl || !atrMultiplierEl) {
alert("ATR parameters missing");
return;
}
stopConfig.atr_period = parseInt(atrPeriodEl.value);
stopConfig.atr_multiplier = parseFloat(atrMultiplierEl.value);
}
// --------------------------------------------------
// Build payload
// --------------------------------------------------
const payload = buildRiskPayload();
console.log("[calibration_risk] inspect payload:", payload);
// --------------------------------------------------
// Disable next step while inspecting
// --------------------------------------------------
disableNextStep();
// --------------------------------------------------
// Call API
// --------------------------------------------------
try {
const res = await fetch("/api/v2/calibration/risk/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
console.log("[calibration_risk] inspect response:", data);
renderRiskResult(payload, data);
// --------------------------------------------------
// Enable next step if OK or WARNING
// --------------------------------------------------
if (data.valid && data.status !== "fail") {
enableNextStep();
}
} catch (err) {
console.error("[calibration_risk] inspect FAILED", err);
alert("Error inspecting risk configuration");
disableNextStep();
}
}
// =================================================
// PDF REPORT RISK & STOPS
// =================================================
async function generateRiskReport() {
console.log("[calibration_risk] generateRiskReport() START");
const payload = buildRiskPayload(); // reutiliza la misma función que validate
const res = await fetch("/api/v2/calibration/risk/report", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
console.error("Failed to generate report");
return;
}
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
// =================================================
function enableNextStep() {
const btn = document.getElementById("next-step-btn");
if (!btn) return;
btn.classList.remove("btn-outline-secondary");
btn.classList.add("btn-outline-primary");
btn.setAttribute("aria-disabled", "false");
}
function disableNextStep() {
const btn = document.getElementById("next-step-btn");
if (!btn) return;
btn.classList.remove("btn-outline-primary");
btn.classList.add("btn-outline-secondary");
btn.setAttribute("aria-disabled", "true");
}
// =================================================
// RENDER RESULT
// =================================================
function renderRiskResult(payload, data) {
const container = document.getElementById("risk_result");
const badge = document.getElementById("risk_status_badge");
const message = document.getElementById("risk_message");
const debug = document.getElementById("risk_debug");
if (!container || !badge || !message || !debug) {
console.warn("[calibration_risk] Result elements missing");
return;
}
container.classList.remove("d-none");
badge.className = "badge";
if (data.status === "ok") {
badge.classList.add("bg-success");
} else if (data.status === "warning") {
badge.classList.add("bg-warning");
} else {
badge.classList.add("bg-danger");
}
badge.textContent = data.status.toUpperCase();
message.textContent = data.message;
debug.textContent = JSON.stringify(data.checks, null, 2);
renderRiskChecks(data.checks);
if (data.checks?.stop_sanity?.metrics) {
renderStopQuantiles(data.checks.stop_sanity.metrics);
}
renderRiskFormulas(payload, data)
if (!data.series) {
console.warn("[calibration_risk] No series returned, skipping plots");
return;
}
if (data.series?.timestamps && data.series?.position_size_pct) {
renderPositionSizePlot({
timestamps: data.series.timestamps,
positionSizePct: data.series.position_size_pct,
maxPositionFraction: payload.risk.max_position_fraction
});
renderEffectiveRiskPlot({
timestamps: data.series.timestamps,
effectiveRiskPct: data.series.effective_risk_pct,
targetRiskFraction: payload.risk.risk_fraction
});
renderStopDistanceDistribution({
stopDistances: data.series.stop_distances,
quantiles: data.checks?.stop_sanity?.metrics
});
}
}
// =================================================
// RENDER RISK CHECKS
// =================================================
function renderRiskChecks(checks) {
const list = document.getElementById("risk_checks_list");
if (!list || !checks) return;
list.innerHTML = "";
Object.entries(checks).forEach(([key, check]) => {
const li = document.createElement("li");
li.classList.add("mb-1");
let icon = "❌";
let color = "text-danger";
if (check.status === "ok") {
icon = "✅";
color = "text-success";
} else if (check.status === "warning") {
icon = "⚠️";
color = "text-warning";
}
const title = key.replace(/_/g, " ");
li.innerHTML = `
<span class="${color}">
${icon} <strong>${title}</strong>
</span>
${check.message ? `<span class="text-muted"> — ${check.message}</span>` : ""}
`;
list.appendChild(li);
});
}
function renderStopQuantiles(metrics) {
const el = document.getElementById("stop_quantiles");
if (!el || !metrics) return;
el.innerHTML = `
<li>P50 (typical): ${(metrics.p50 * 100).toFixed(2)}%</li>
<li>P90 (wide): ${(metrics.p90 * 100).toFixed(2)}%</li>
<li>P99 (extreme): ${(metrics.p99 * 100).toFixed(2)}%</li>
`;
}
// =================================================
// RENDER RISK FORMULAS
// =================================================
function renderRiskFormulas(payload, result) {
const el = document.getElementById("risk_formulas");
if (!el) return;
const equity = payload.account_equity;
const riskFraction = payload.risk.risk_fraction;
const maxPositionFraction = payload.risk.max_position_fraction;
const stopMetrics = result.checks?.stop_sanity?.metrics;
if (!stopMetrics || stopMetrics.p50 == null) {
el.textContent = "Unable to compute risk formulas (missing stop metrics)";
return;
}
const stopP50 = stopMetrics.p50;
const idealPosition = (equity * riskFraction) / stopP50;
const maxPosition = equity * maxPositionFraction;
const effectivePosition = Math.min(idealPosition, maxPosition);
const effectiveRisk = (effectivePosition * stopP50) / equity;
el.innerHTML = `
<li>Position size (ideal)
= ${equity.toLocaleString()} × ${(riskFraction * 100).toFixed(2)}% ÷ ${(stopP50 * 100).toFixed(2)}%
${idealPosition.toFixed(0)}</li>
<li>Max position size
= ${equity.toLocaleString()} × ${(maxPositionFraction * 100).toFixed(2)}%
= ${maxPosition.toFixed(0)}</li>
<li>Effective position
= min(${idealPosition.toFixed(0)}, ${maxPosition.toFixed(0)})
= ${effectivePosition.toFixed(0)}</li>
<li>Effective risk
= ${effectivePosition.toFixed(0)} × ${(stopP50 * 100).toFixed(2)}% ÷ ${equity.toLocaleString()}
${(effectiveRisk * 100).toFixed(2)}%</li>
`;
}
// =================================================
// STOP TYPE UI LOGIC
// =================================================
function updateStopParamsUI() {
const stopType = document.getElementById("stop_type")?.value;
if (!stopType) return;
// Hide all stop param blocks
document.querySelectorAll(".stop-param").forEach(el => {
el.classList.add("d-none");
});
// Show relevant blocks
if (stopType === "fixed" || stopType === "trailing") {
document.querySelectorAll(".stop-fixed, .stop-trailing").forEach(el => {
el.classList.remove("d-none");
});
}
if (stopType === "atr") {
document.querySelectorAll(".stop-atr").forEach(el => {
el.classList.remove("d-none");
});
}
}
// =================================================
// RENDER PLOTS
// =================================================
function renderPositionSizePlot({
timestamps,
positionSizePct,
maxPositionFraction
}) {
const el = document.getElementById("position_size_plot");
if (!el) {
console.warn("[calibration_risk] position_size_plot not found");
return;
}
if (!timestamps || !positionSizePct || timestamps.length === 0) {
el.innerHTML = "<em>No position size data available</em>";
return;
}
const trace = {
x: timestamps,
y: positionSizePct.map(v => v * 100),
type: "scatter",
mode: "lines",
name: "Position size",
line: {
color: "#59a14f",
width: 2
}
};
const layout = {
yaxis: {
title: "Position size (% equity)",
rangemode: "tozero"
},
xaxis: {
title: "Time"
},
shapes: [
{
type: "line",
xref: "paper",
x0: 0,
x1: 1,
y0: maxPositionFraction * 100,
y1: maxPositionFraction * 100,
line: {
dash: "dot",
color: "#e15759",
width: 2
}
}
],
annotations: [
{
xref: "paper",
x: 1,
y: maxPositionFraction * 100,
xanchor: "left",
text: "Max position cap",
showarrow: false,
font: {
size: 11,
color: "#e15759"
}
}
],
margin: { t: 20 }
};
Plotly.newPlot(el, [trace], layout, {
responsive: true,
displayModeBar: true
});
}
function renderEffectiveRiskPlot({
timestamps,
effectiveRiskPct,
targetRiskFraction
}) {
const el = document.getElementById("effective_risk_plot");
if (!el) {
console.warn("[calibration_risk] effective_risk_plot not found");
return;
}
if (!timestamps || !effectiveRiskPct || timestamps.length === 0) {
el.innerHTML = "<em>No effective risk data available</em>";
return;
}
const trace = {
x: timestamps,
y: effectiveRiskPct.map(v => v * 100),
type: "scatter",
mode: "lines",
name: "Effective risk",
line: {
color: "#f28e2b",
width: 2
}
};
const layout = {
yaxis: {
title: "Effective risk (% equity)",
rangemode: "tozero"
},
xaxis: {
title: "Time"
},
shapes: [
{
type: "line",
xref: "paper",
x0: 0,
x1: 1,
y0: targetRiskFraction * 100,
y1: targetRiskFraction * 100,
line: {
dash: "dot",
color: "#4c78a8",
width: 2
}
}
],
annotations: [
{
xref: "paper",
x: 1,
y: targetRiskFraction * 100,
xanchor: "left",
text: "Target risk",
showarrow: false,
font: {
size: 11,
color: "#4c78a8"
}
}
],
margin: { t: 20 }
};
Plotly.newPlot(el, [trace], layout, {
responsive: true,
displayModeBar: true
});
}
function renderStopDistanceDistribution({
stopDistances,
quantiles
}) {
const el = document.getElementById("stop_distance_plot");
if (!el) {
console.warn("[calibration_risk] stop_distance_plot not found");
return;
}
if (!stopDistances || stopDistances.length === 0) {
el.innerHTML = "<em>No stop distance data available</em>";
return;
}
const valuesPct = stopDistances
.filter(v => v != null && v > 0)
.map(v => v * 100);
const trace = {
x: valuesPct,
type: "histogram",
nbinsx: 40,
name: "Stop distance",
marker: {
color: "#bab0ac"
}
};
const shapes = [];
const annotations = [];
if (quantiles) {
const qMap = [
{ key: "p50", label: "P50 (typical)", color: "#4c78a8" },
{ key: "p90", label: "P90 (wide)", color: "#f28e2b" },
{ key: "p99", label: "P99 (extreme)", color: "#e15759" }
];
qMap.forEach(q => {
const val = quantiles[q.key];
if (val != null) {
shapes.push({
type: "line",
xref: "x",
yref: "paper",
x0: val * 100,
x1: val * 100,
y0: 0,
y1: 1,
line: {
dash: "dot",
width: 2,
color: q.color
}
});
annotations.push({
x: val * 100,
y: 1,
yref: "paper",
xanchor: "left",
text: q.label,
showarrow: false,
font: {
size: 11,
color: q.color
}
});
}
});
}
const layout = {
xaxis: {
title: "Stop distance (% price)"
},
yaxis: {
title: "Frequency"
},
shapes: shapes,
annotations: annotations,
margin: { t: 20 }
};
Plotly.newPlot(el, [trace], layout, {
responsive: true,
displayModeBar: true
});
}
// =================================================
// UTILS
// =================================================
function num(id) {
const el = document.getElementById(id);
if (!el) return null;
const val = el.value;
if (val === "" || val === null || val === undefined) return null;
const n = Number(val);
return Number.isFinite(n) ? n : null;
}
function buildRiskPayload() {
const symbol = localStorage.getItem("calibration.symbol");
const timeframe = localStorage.getItem("calibration.timeframe");
const stopType = document.getElementById("stop_type")?.value;
const stop = { type: stopType };
if (stopType === "fixed" || stopType === "trailing") {
stop.stop_fraction = num("stop_fraction");
}
if (stopType === "atr") {
stop.atr_period = num("atr_period");
stop.atr_multiplier = num("atr_multiplier");
}
const payload = {
symbol,
timeframe,
account_equity: num("account_equity"),
risk: {
risk_fraction: num("risk_fraction") / 100,
max_position_fraction: num("max_position_fraction") / 100,
},
stop,
global_rules: {
max_drawdown_pct: num("max_drawdown_pct") / 100,
daily_loss_limit_pct: num("daily_loss_limit_pct")
? num("daily_loss_limit_pct") / 100
: null,
max_consecutive_losses: num("max_consecutive_losses"),
cooldown_bars: num("cooldown_bars"),
},
};
console.log("[calibration_risk] FINAL PAYLOAD:", payload);
return payload;
}
// =================================================
// INIT
// =================================================
document.addEventListener("DOMContentLoaded", () => {
console.log("[calibration_risk] DOMContentLoaded ✅");
document
.getElementById("inspect_risk_btn")
?.addEventListener("click", inspectCalibrationRisk);
const stopTypeSelect = document.getElementById("stop_type");
stopTypeSelect?.addEventListener("change", updateStopParamsUI);
const symbol = localStorage.getItem("calibration.symbol");
const timeframe = localStorage.getItem("calibration.timeframe");
if (symbol && timeframe) {
const symbolEl = document.getElementById("ctx_symbol");
const timeframeEl = document.getElementById("ctx_timeframe");
if (symbolEl) symbolEl.textContent = symbol;
if (timeframeEl) timeframeEl.textContent = timeframe;
}
// Initial state
updateStopParamsUI();
});
document.addEventListener("DOMContentLoaded", () => {
console.log("[calibration_risk] DOMContentLoaded ✅");
document
.getElementById("validate_risk_btn")
?.addEventListener("click", validateCalibrationRisk);
const stopTypeSelect = document.getElementById("stop_type");
stopTypeSelect?.addEventListener("change", updateStopParamsUI);
const symbol = localStorage.getItem("calibration.symbol");
const timeframe = localStorage.getItem("calibration.timeframe");
if (symbol && timeframe) {
const symbolEl = document.getElementById("ctx_symbol");
const timeframeEl = document.getElementById("ctx_timeframe");
if (symbolEl) symbolEl.textContent = symbol;
if (timeframeEl) timeframeEl.textContent = timeframe;
}
// Initial state
updateStopParamsUI();
});
document.addEventListener("DOMContentLoaded", () => {
console.log("[calibration_risk] DOMContentLoaded ✅");
document
.getElementById("generate_report_btn")
?.addEventListener("click", generateRiskReport);
});

View File

@@ -3,7 +3,55 @@
{% block content %}
<div class="container-xl">
<h2 class="mb-4">Calibración · Paso 1 · Datos</h2>
<!-- ========================= -->
<!-- Wizard header -->
<!-- ========================= -->
<div class="d-flex align-items-center mb-4">
<!-- Back arrow (disabled on step 1) -->
<div class="me-3">
<button class="btn btn-outline-secondary btn-icon" disabled>
<svg xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-arrow-left"
width="24" height="24" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor"
fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<line x1="19" y1="12" x2="5" y2="12" />
<line x1="11" y1="18" x2="5" y2="12" />
<line x1="11" y1="6" x2="5" y2="12" />
</svg>
</button>
</div>
<!-- Center title -->
<div class="flex-grow-1 text-center">
<h2 class="mb-0">Calibración · Paso 1 · Datos</h2>
</div>
<!-- Forward arrow (JS will enable when Step1 is OK) -->
<div class="ms-3">
<a
id="next-step-btn"
href="/calibration/risk"
class="btn btn-outline-secondary btn-icon"
aria-disabled="true"
>
<svg xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-arrow-right"
width="24" height="24" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor"
fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<line x1="5" y1="12" x2="19" y2="12" />
<line x1="13" y1="6" x2="19" y2="12" />
<line x1="13" y1="18" x2="19" y2="12" />
</svg>
</a>
</div>
</div>
<!-- FORMULARIO -->
<div class="card mb-4">

View File

@@ -1,99 +0,0 @@
{% extends "layout.html" %}
{% block content %}
<div class="container-xl">
<!-- Page header -->
<div class="page-header d-print-none mb-4">
<div class="row align-items-center">
<div class="col">
<h2 class="page-title">
Calibration · Step 1 — Data
</h2>
<div class="text-muted mt-1">
Select market data used for calibration
</div>
</div>
</div>
</div>
<!-- Data selection card -->
<div class="row">
<div class="col-12 col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">Market data</h3>
</div>
<div class="card-body">
<div class="row g-3">
<!-- Symbol -->
<div class="col-md-6">
<label class="form-label">Symbol</label>
<select class="form-select">
<option selected>BTC/USDT</option>
<option>ETH/USDT</option>
</select>
</div>
<!-- Timeframe -->
<div class="col-md-6">
<label class="form-label">Timeframe</label>
<select class="form-select">
<option>1m</option>
<option>5m</option>
<option>15m</option>
<option selected>1h</option>
<option>4h</option>
<option>1d</option>
</select>
</div>
<!-- Start date -->
<div class="col-md-6">
<label class="form-label">Start date</label>
<input type="date" class="form-control">
</div>
<!-- End date -->
<div class="col-md-6">
<label class="form-label">End date</label>
<input type="date" class="form-control">
</div>
</div>
</div>
<div class="card-footer text-end">
<a href="/calibration/step2" class="btn btn-primary">
Continue
</a>
</div>
</div>
</div>
<!-- Help / info -->
<div class="col-12 col-lg-4">
<div class="card">
<div class="card-body">
<h4>What happens in this step?</h4>
<p class="text-muted">
You define the historical market data that will be used to:
</p>
<ul class="text-muted">
<li>download candles</li>
<li>compute indicators</li>
<li>calibrate strategies</li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,246 @@
{% extends "layout.html" %}
{% block content %}
<div class="container-xl">
<!-- ========================= -->
<!-- Wizard header -->
<!-- ========================= -->
<div class="d-flex align-items-center mb-4">
<!-- Back arrow -->
<div class="me-3">
<a href="/calibration/data" class="btn btn-outline-secondary btn-icon">
<svg xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-arrow-left"
width="24" height="24" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor"
fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<line x1="19" y1="12" x2="5" y2="12" />
<line x1="11" y1="18" x2="5" y2="12" />
<line x1="11" y1="6" x2="5" y2="12" />
</svg>
</a>
</div>
<!-- Center title -->
<div class="flex-grow-1 text-center">
<h2 class="mb-0">Calibración · Paso 2 · Risk & Stops</h2>
</div>
<!-- Forward arrow (disabled until Step2 OK) -->
<div class="ms-3">
<a
id="next-step-btn"
href="/calibration/strategies"
class="btn btn-outline-secondary btn-icon"
aria-disabled="true"
>
<svg xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-arrow-right"
width="24" height="24" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor"
fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<line x1="5" y1="12" x2="19" y2="12" />
<line x1="13" y1="6" x2="19" y2="12" />
<line x1="13" y1="18" x2="19" y2="12" />
</svg>
</a>
</div>
</div>
<!-- ========================= -->
<!-- Risk configuration form -->
<!-- ========================= -->
<div class="card mb-4">
<div class="card-body">
<h3 class="card-title">Data Context</h3>
<div class="row g-3">
<!-- Symbol -->
<div class="col-md-4">
<label class="form-label">Símbolo</label>
<div
class="form-control bg-light text-muted"
style="cursor: not-allowed;"
>
<span id="ctx_symbol">-</span>
</div>
</div>
<!-- Timeframe -->
<div class="col-md-4">
<label class="form-label">Timeframe</label>
<div
class="form-control bg-light text-muted"
style="cursor: not-allowed;"
>
<span id="ctx_timeframe">-</span>
</div>
</div>
</div>
<hr class="my-4">
<h3 class="card-title">Risk configuration</h3>
<div class="row g-3">
<!-- Account equity -->
<div class="col-md-4">
<label class="form-label">Account equity</label>
<input id="account_equity" class="form-control" type="number" value="10000" min="0">
</div>
<!-- Risk per trade -->
<div class="col-md-4">
<label class="form-label">Risk per trade (%)</label>
<input id="risk_fraction" class="form-control" type="number" step="0.01" value="0.5">
</div>
<!-- Max position -->
<div class="col-md-4">
<label class="form-label">Max position size (%)</label>
<input id="max_position_fraction" class="form-control" type="number" step="1" value="25">
</div>
</div>
<hr class="my-4">
<!-- Stop configuration -->
<h3 class="card-title">Stop configuration</h3>
<div class="row g-3">
<!-- Stop type -->
<div class="col-md-4">
<label class="form-label">Stop type</label>
<select id="stop_type" class="form-select">
<option value="fixed">Fixed stop</option>
<option value="trailing">Trailing stop</option>
<option value="atr">ATR stop</option>
</select>
</div>
<!-- Fixed / trailing -->
<div class="col-md-4 stop-param stop-fixed stop-trailing">
<label class="form-label">Stop distance (%)</label>
<input id="stop_fraction" class="form-control" type="number" step="0.1" value="2">
</div>
<!-- ATR params -->
<div class="col-md-4 stop-param stop-atr d-none">
<label class="form-label">ATR period</label>
<input id="atr_period" class="form-control" type="number" value="14">
</div>
<div class="col-md-4 stop-param stop-atr d-none">
<label class="form-label">ATR multiplier</label>
<input id="atr_multiplier" class="form-control" type="number" step="0.1" value="3">
</div>
</div>
<hr class="my-4">
<!-- Global rules -->
<h3 class="card-title">Global rules</h3>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Max drawdown (%)</label>
<input id="max_drawdown_pct" class="form-control" type="number" step="1" value="20">
</div>
</div>
<div class="mt-4">
<button id="inspect_risk_btn" class="btn btn-primary">
Inspect Risk & Stops
</button>
<button id="validate_risk_btn" class="btn btn-primary">
Validate Risk & Stops
</button>
<button id="generate_report_btn" class="btn btn-outline-dark">
Generate PDF Report
</button>
</div>
</div>
</div>
<!-- ========================= -->
<!-- Risk quality result -->
<!-- ========================= -->
<div id="risk_result" class="d-none">
<div class="card">
<div class="card-body">
<h3 class="card-title">Risk quality</h3>
<div id="risk_status_badge" class="mb-3"></div>
<p id="risk_message" class="mb-3"></p>
<pre id="risk_debug" class="bg-light p-3 rounded small d-none"></pre>
</div>
</div>
<!-- RISK CHECKS -->
<div class="mt-3">
<ul id="risk_checks_list" class="list-unstyled mb-0"></ul>
</div>
<!-- STOP QUANTILES -->
<div class="mt-3">
<h5 class="mb-2">Stop distance statistics</h5>
<ul class="list-unstyled text-muted" id="stop_quantiles">
<li>P50: </li>
<li>P90: </li>
<li>P99: </li>
</ul>
</div>
<!-- RISK FORMULAS -->
<div class="mt-3">
<h5 class="mb-2">Risk sizing breakdown</h5>
<ul id="risk_formulas" class="list-unstyled text-muted">
Calculating…
</ul>
</div>
</div>
<!-- ========================= -->
<!-- Gráficas -->
<!-- ========================= -->
<!-- POSITION SIZE OVER TIME -->
<div class="mt-4">
<h5 class="mb-2">Position size over time</h5>
<div id="position_size_plot" style="height: 360px;"></div>
</div>
<!-- EFFECTIVE RISK OVER TIME -->
<div class="mt-4">
<h5 class="mb-2">Effective risk over time</h5>
<div id="effective_risk_plot" style="height: 360px;"></div>
</div>
<!-- STOP DISTANCE DISTRIBUTION -->
<div class="mt-4">
<h5 class="mb-2">Stop distance distribution</h5>
<div id="stop_distance_plot" style="height: 360px;"></div>
</div>
</div>
<!-- Plotly -->
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<!-- Page logic -->
<script src="/static/js/pages/calibration_risk.js"></script>
{% endblock %}