feat(calibration): Step 3 - Stategies preparado conceptualmente
This commit is contained in:
94
src/calibration/reports/strategies_report.py
Normal file
94
src/calibration/reports/strategies_report.py
Normal file
@@ -0,0 +1,94 @@
|
||||
# src/calibration/reports/strategies_report.py
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.lib import colors
|
||||
|
||||
|
||||
def _simple_kv_table(title: str, dct: Dict[str, Any]):
|
||||
rows = [["Key", "Value"]] + [[str(k), str(v)] for k, v in dct.items()]
|
||||
t = Table(rows, hAlign="LEFT")
|
||||
t.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
]))
|
||||
return [Paragraph(title, getSampleStyleSheet()["Heading2"]), t, Spacer(1, 12)]
|
||||
|
||||
|
||||
def generate_strategies_report_pdf(
|
||||
*,
|
||||
output_path: Path,
|
||||
context: Dict[str, Any],
|
||||
config: Dict[str, Any],
|
||||
results: Dict[str, Any],
|
||||
):
|
||||
"""Minimal v1 report. We'll enrich with charts/tables next iterations."""
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
doc = SimpleDocTemplate(str(output_path))
|
||||
|
||||
story = []
|
||||
story.append(Paragraph("Calibration · Step 3 · Strategies", styles["Title"]))
|
||||
story.append(Spacer(1, 12))
|
||||
|
||||
story.append(Paragraph(f"Status: {results.get('status')}", styles["Normal"]))
|
||||
story.append(Spacer(1, 12))
|
||||
|
||||
for block in _simple_kv_table("Context", context):
|
||||
story.append(block)
|
||||
for block in _simple_kv_table("Configuration", config):
|
||||
story.append(block)
|
||||
|
||||
story.append(Paragraph("Results (per strategy)", styles["Heading2"]))
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
for r in results.get("results", []):
|
||||
story.append(Paragraph(
|
||||
f"<b>{r.get('strategy_id')}</b> — {r.get('status').upper()} — {r.get('message','')}",
|
||||
styles["Normal"],
|
||||
))
|
||||
story.append(Spacer(1, 4))
|
||||
|
||||
summary = {
|
||||
"n_windows": r.get("n_windows"),
|
||||
"oos_final_equity": r.get("oos_final_equity"),
|
||||
"oos_total_return_pct": r.get("oos_total_return_pct"),
|
||||
"oos_max_dd_worst_pct": r.get("oos_max_dd_worst_pct"),
|
||||
}
|
||||
for block in _simple_kv_table("Summary", summary):
|
||||
story.append(block)
|
||||
|
||||
# Window table (first 20 to keep PDF light)
|
||||
windows = r.get("windows", [])
|
||||
if windows:
|
||||
rows = [["Window", "Test return %", "Sharpe", "Max DD %", "Trades", "Params"]]
|
||||
for w in windows[:20]:
|
||||
rows.append([
|
||||
str(w.get("window")),
|
||||
f"{float(w.get('return_pct',0.0)):.2f}",
|
||||
f"{float(w.get('sharpe',0.0)):.2f}",
|
||||
f"{float(w.get('max_dd_pct',0.0)):.2f}",
|
||||
str(w.get("trades")),
|
||||
str(w.get("params")),
|
||||
])
|
||||
t = Table(rows, hAlign="LEFT", colWidths=[45, 75, 55, 65, 55, 240])
|
||||
t.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
]))
|
||||
story.append(Paragraph("Walk-Forward windows (first 20)", styles["Heading3"]))
|
||||
story.append(t)
|
||||
story.append(Spacer(1, 12))
|
||||
|
||||
story.append(PageBreak())
|
||||
|
||||
doc.build(story)
|
||||
326
src/calibration/strategies_inspector.py
Normal file
326
src/calibration/strategies_inspector.py
Normal file
@@ -0,0 +1,326 @@
|
||||
# src/calibration/strategies_inspector.py
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.data.storage import StorageManager
|
||||
from src.utils.logger import log
|
||||
|
||||
from src.core.walk_forward import WalkForwardValidator
|
||||
|
||||
from src.risk.stops.fixed_stop import FixedStop
|
||||
from src.risk.stops.trailing_stop import TrailingStop
|
||||
from src.risk.stops.atr_stop import ATRStop
|
||||
|
||||
from src.risk.sizing.percent_risk import PercentRiskSizer
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# Strategy registry (con metadata de parámetros)
|
||||
# --------------------------------------------------
|
||||
from src.strategies.moving_average import MovingAverageCrossover
|
||||
from src.strategies.rsi_strategy import RSIStrategy
|
||||
from src.strategies.buy_and_hold import BuyAndHold
|
||||
|
||||
|
||||
STRATEGY_REGISTRY = {
|
||||
"moving_average": {
|
||||
"class": MovingAverageCrossover,
|
||||
"params": ["fast_period", "slow_period"],
|
||||
},
|
||||
"rsi": {
|
||||
"class": RSIStrategy,
|
||||
"params": ["rsi_period", "overbought", "oversold"],
|
||||
},
|
||||
"buy_and_hold": {
|
||||
"class": BuyAndHold,
|
||||
"params": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# Helpers
|
||||
# --------------------------------------------------
|
||||
|
||||
def list_available_strategies() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Devuelve metadata completa para UI.
|
||||
"""
|
||||
out = []
|
||||
|
||||
for sid, entry in STRATEGY_REGISTRY.items():
|
||||
out.append({
|
||||
"strategy_id": sid,
|
||||
"name": entry["class"].__name__,
|
||||
"params": entry["params"],
|
||||
"tags": [], # puedes rellenar más adelante
|
||||
})
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _build_stop_loss(stop_schema) -> object | None:
|
||||
if stop_schema.type == "fixed":
|
||||
return FixedStop(stop_fraction=float(stop_schema.stop_fraction))
|
||||
if stop_schema.type == "trailing":
|
||||
return TrailingStop(stop_fraction=float(stop_schema.stop_fraction))
|
||||
if stop_schema.type == "atr":
|
||||
return ATRStop(
|
||||
atr_period=int(stop_schema.atr_period),
|
||||
multiplier=float(stop_schema.atr_multiplier),
|
||||
)
|
||||
raise ValueError(f"Unknown stop type: {stop_schema.type}")
|
||||
|
||||
|
||||
def _build_position_sizer(risk_schema) -> PercentRiskSizer:
|
||||
return PercentRiskSizer(risk_fraction=float(risk_schema.risk_fraction))
|
||||
|
||||
|
||||
def _cap_units_by_max_position_fraction(units: float, capital: float, entry_price: float, max_position_fraction: float) -> float:
|
||||
max_units = (capital * max_position_fraction) / entry_price
|
||||
return float(min(units, max_units))
|
||||
|
||||
|
||||
def _accumulate_equity(initial: float, returns_pct: List[float]) -> List[float]:
|
||||
eq = [float(initial)]
|
||||
cur = float(initial)
|
||||
for r in returns_pct:
|
||||
cur *= (1.0 + float(r) / 100.0)
|
||||
eq.append(float(cur))
|
||||
return eq
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# Main
|
||||
# --------------------------------------------------
|
||||
|
||||
def inspect_strategies_config(
|
||||
*,
|
||||
storage: StorageManager,
|
||||
payload,
|
||||
data_quality: Dict[str, Any],
|
||||
include_series: bool,
|
||||
progress_callback=None,
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
df = storage.load_ohlcv(symbol=payload.symbol, timeframe=payload.timeframe)
|
||||
if df is None or df.empty:
|
||||
return {
|
||||
"valid": False,
|
||||
"status": "fail",
|
||||
"checks": {},
|
||||
"message": "No OHLCV data",
|
||||
"results": [],
|
||||
}
|
||||
|
||||
checks: Dict[str, Any] = {}
|
||||
checks["data_quality"] = {
|
||||
"status": data_quality.get("status", "unknown"),
|
||||
"message": data_quality.get("message", ""),
|
||||
}
|
||||
|
||||
if data_quality.get("status") == "fail":
|
||||
return {
|
||||
"valid": False,
|
||||
"status": "fail",
|
||||
"checks": checks,
|
||||
"message": "Step 1 data quality is FAIL. Strategies cannot be validated.",
|
||||
"results": [],
|
||||
"series": {} if include_series else None,
|
||||
}
|
||||
|
||||
stop_loss = _build_stop_loss(payload.stop)
|
||||
base_sizer = _build_position_sizer(payload.risk)
|
||||
|
||||
train_td = pd.Timedelta(days=int(payload.wf.train_days))
|
||||
test_td = pd.Timedelta(days=int(payload.wf.test_days))
|
||||
step_td = pd.Timedelta(days=int(payload.wf.step_days or payload.wf.test_days))
|
||||
|
||||
overall_status = "ok"
|
||||
results: List[Dict[str, Any]] = []
|
||||
series: Dict[str, Any] = {"strategies": {}} if include_series else {}
|
||||
|
||||
for sel in payload.strategies:
|
||||
|
||||
sid = sel.strategy_id
|
||||
entry = STRATEGY_REGISTRY.get(sid)
|
||||
|
||||
if entry is None:
|
||||
results.append({
|
||||
"strategy_id": sid,
|
||||
"status": "fail",
|
||||
"message": f"Unknown strategy_id: {sid}",
|
||||
"n_windows": 0,
|
||||
"oos_final_equity": payload.account_equity,
|
||||
"oos_total_return_pct": 0.0,
|
||||
"oos_max_dd_worst_pct": 0.0,
|
||||
"degradation_sharpe": None,
|
||||
"windows": [],
|
||||
})
|
||||
overall_status = "fail"
|
||||
continue
|
||||
|
||||
strategy_class = entry["class"]
|
||||
valid_params = set(entry["params"])
|
||||
grid_params = set(sel.param_grid.keys())
|
||||
|
||||
# 🔒 Validación estricta de parámetros
|
||||
if grid_params != valid_params:
|
||||
msg = f"Param grid keys {grid_params} do not match expected {valid_params}"
|
||||
results.append({
|
||||
"strategy_id": sid,
|
||||
"status": "fail",
|
||||
"message": msg,
|
||||
"n_windows": 0,
|
||||
"oos_final_equity": payload.account_equity,
|
||||
"oos_total_return_pct": 0.0,
|
||||
"oos_max_dd_worst_pct": 0.0,
|
||||
"degradation_sharpe": None,
|
||||
"windows": [],
|
||||
})
|
||||
overall_status = "fail"
|
||||
continue
|
||||
|
||||
# Wrapper sizer
|
||||
class _CappedSizer(type(base_sizer)):
|
||||
def __init__(self, inner):
|
||||
self.inner = inner
|
||||
|
||||
def calculate_size(self, *, capital, entry_price, stop_price=None, max_capital=None, volatility=None):
|
||||
u = self.inner.calculate_size(
|
||||
capital=capital,
|
||||
entry_price=entry_price,
|
||||
stop_price=stop_price,
|
||||
max_capital=max_capital,
|
||||
volatility=volatility,
|
||||
)
|
||||
return _cap_units_by_max_position_fraction(
|
||||
units=float(u),
|
||||
capital=float(capital),
|
||||
entry_price=float(entry_price),
|
||||
max_position_fraction=float(payload.risk.max_position_fraction),
|
||||
)
|
||||
|
||||
capped_sizer = _CappedSizer(base_sizer)
|
||||
|
||||
log.info(f"🧠 Step3 | WF run | strategy={sid}")
|
||||
|
||||
try:
|
||||
wf = WalkForwardValidator(
|
||||
strategy_class=strategy_class,
|
||||
param_grid=sel.param_grid,
|
||||
data=df,
|
||||
train_window=train_td,
|
||||
test_window=test_td,
|
||||
step_size=step_td,
|
||||
initial_capital=float(payload.account_equity),
|
||||
commission=float(payload.commission),
|
||||
slippage=float(payload.slippage),
|
||||
optimizer_metric=str(payload.optimization.optimizer_metric),
|
||||
position_sizer=capped_sizer,
|
||||
stop_loss=stop_loss,
|
||||
max_combinations=int(payload.optimization.max_combinations),
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
wf_res = wf.run()
|
||||
win_df: pd.DataFrame = wf_res["windows"]
|
||||
|
||||
if win_df is None or win_df.empty:
|
||||
status = "fail"
|
||||
msg = "WF produced no valid windows"
|
||||
overall_status = "fail"
|
||||
windows_out = []
|
||||
oos_returns = []
|
||||
else:
|
||||
trades = win_df["trades"].astype(int).tolist()
|
||||
too_few = sum(t < int(payload.optimization.min_trades_test) for t in trades)
|
||||
|
||||
if too_few > 0:
|
||||
status = "warning"
|
||||
msg = f"{too_few} windows below min_trades_test"
|
||||
if overall_status == "ok":
|
||||
overall_status = "warning"
|
||||
else:
|
||||
status = "ok"
|
||||
msg = "WF OK"
|
||||
|
||||
windows_out = []
|
||||
for _, r in win_df.iterrows():
|
||||
windows_out.append({
|
||||
"window": int(r["window"]),
|
||||
"train_start": str(r["train_start"]),
|
||||
"train_end": str(r["train_end"]),
|
||||
"test_start": str(r["test_start"]),
|
||||
"test_end": str(r["test_end"]),
|
||||
"return_pct": float(r["return_pct"]),
|
||||
"sharpe": float(r["sharpe"]),
|
||||
"max_dd_pct": float(r["max_dd_pct"]),
|
||||
"trades": int(r["trades"]),
|
||||
"params": dict(r["params"]) if isinstance(r["params"], dict) else r["params"],
|
||||
})
|
||||
|
||||
oos_returns = win_df["return_pct"].astype(float).tolist()
|
||||
|
||||
eq_curve = _accumulate_equity(float(payload.account_equity), oos_returns)
|
||||
oos_final = float(eq_curve[-1]) if eq_curve else float(payload.account_equity)
|
||||
oos_total_return = (oos_final / float(payload.account_equity) - 1.0) * 100.0
|
||||
oos_max_dd = float(np.min(win_df["max_dd_pct"])) if (win_df is not None and not win_df.empty) else 0.0
|
||||
|
||||
results.append({
|
||||
"strategy_id": sid,
|
||||
"status": status,
|
||||
"message": msg,
|
||||
"n_windows": int(len(windows_out)),
|
||||
"oos_final_equity": oos_final,
|
||||
"oos_total_return_pct": float(oos_total_return),
|
||||
"oos_max_dd_worst_pct": float(oos_max_dd),
|
||||
"degradation_sharpe": None,
|
||||
"windows": windows_out,
|
||||
})
|
||||
|
||||
if include_series:
|
||||
series["strategies"][sid] = {
|
||||
"window_returns_pct": oos_returns,
|
||||
"window_equity": eq_curve,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"❌ Step3 WF error | strategy={sid} | {e}")
|
||||
results.append({
|
||||
"strategy_id": sid,
|
||||
"status": "fail",
|
||||
"message": f"Exception: {e}",
|
||||
"n_windows": 0,
|
||||
"oos_final_equity": float(payload.account_equity),
|
||||
"oos_total_return_pct": 0.0,
|
||||
"oos_max_dd_worst_pct": 0.0,
|
||||
"degradation_sharpe": None,
|
||||
"windows": [],
|
||||
})
|
||||
overall_status = "fail"
|
||||
|
||||
valid = overall_status != "fail"
|
||||
|
||||
human_msg = {
|
||||
"ok": "Strategies validation OK",
|
||||
"warning": "Strategies validation has warnings",
|
||||
"fail": "Strategies validation FAILED",
|
||||
}[overall_status]
|
||||
|
||||
out = {
|
||||
"valid": valid,
|
||||
"status": overall_status,
|
||||
"checks": checks,
|
||||
"message": human_msg,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
if include_series:
|
||||
out["series"] = series
|
||||
|
||||
return out
|
||||
@@ -4,11 +4,13 @@ Optimizador de parámetros para estrategias
|
||||
"""
|
||||
import os
|
||||
import pandas as pd
|
||||
from typing import Dict, List, Any, Type
|
||||
from typing import Dict, List, Any, Type, Optional
|
||||
from itertools import product
|
||||
from ..utils.logger import log
|
||||
from .engine import Engine
|
||||
from .strategy import Strategy
|
||||
from src.risk.sizing.base import PositionSizer
|
||||
from src.risk.stops.base import StopLoss
|
||||
|
||||
class ParameterOptimizer:
|
||||
"""
|
||||
@@ -21,7 +23,10 @@ class ParameterOptimizer:
|
||||
initial_capital: float = 10000,
|
||||
commission: float = 0.001,
|
||||
slippage: float = 0.0005,
|
||||
position_size: float = 0.95):
|
||||
position_size: float = 0.95,
|
||||
position_sizer: Optional[PositionSizer] = None,
|
||||
stop_loss: Optional[StopLoss] = None,
|
||||
max_combinations: Optional[int] = None):
|
||||
"""
|
||||
Args:
|
||||
strategy_class: Clase de estrategia (no instancia)
|
||||
@@ -37,6 +42,9 @@ class ParameterOptimizer:
|
||||
self.commission = commission
|
||||
self.slippage = slippage
|
||||
self.position_size = position_size
|
||||
self.position_sizer = position_sizer
|
||||
self.stop_loss = stop_loss
|
||||
self.max_combinations = max_combinations
|
||||
|
||||
self.results: List[Dict] = []
|
||||
|
||||
@@ -62,6 +70,9 @@ class ParameterOptimizer:
|
||||
param_names = list(param_grid.keys())
|
||||
param_values = list(param_grid.values())
|
||||
combinations = list(product(*param_values))
|
||||
|
||||
if self.max_combinations is not None:
|
||||
combinations = combinations[: int(self.max_combinations)]
|
||||
|
||||
total_tests = len(combinations)
|
||||
log.info(f"🔧 Iniciando optimización: {total_tests} combinaciones")
|
||||
@@ -87,7 +98,9 @@ class ParameterOptimizer:
|
||||
initial_capital=self.initial_capital,
|
||||
commission=self.commission,
|
||||
slippage=self.slippage,
|
||||
position_size=self.position_size
|
||||
position_size=self.position_size,
|
||||
position_sizer=self.position_sizer,
|
||||
stop_loss=self.stop_loss,
|
||||
)
|
||||
|
||||
results = engine.run(self.data)
|
||||
|
||||
@@ -3,6 +3,8 @@ import pandas as pd
|
||||
from typing import List, Dict, Optional
|
||||
from src.core.optimizer import ParameterOptimizer
|
||||
from src.core.engine import Engine
|
||||
from src.risk.sizing.base import PositionSizer
|
||||
from src.risk.stops.base import StopLoss
|
||||
from ..utils.logger import log
|
||||
|
||||
class WalkForwardValidator:
|
||||
@@ -28,6 +30,10 @@ class WalkForwardValidator:
|
||||
position_size: float = 0.95,
|
||||
optimizer_metric: str = "sharpe_ratio",
|
||||
verbose: bool = True,
|
||||
position_sizer: Optional[PositionSizer] = None,
|
||||
stop_loss: Optional[StopLoss] = None,
|
||||
max_combinations: Optional[int] = None,
|
||||
progress_callback: Optional[callable] = None,
|
||||
):
|
||||
self.strategy_class = strategy_class
|
||||
self.param_grid = param_grid
|
||||
@@ -43,6 +49,11 @@ class WalkForwardValidator:
|
||||
self.position_size = position_size
|
||||
self.optimizer_metric = optimizer_metric
|
||||
self.verbose = verbose
|
||||
self.position_sizer = position_sizer
|
||||
self.stop_loss = stop_loss
|
||||
self.max_combinations = max_combinations
|
||||
|
||||
self.progress_callback = progress_callback
|
||||
|
||||
# Validaciones básicas
|
||||
if not isinstance(self.data.index, pd.DatetimeIndex):
|
||||
@@ -157,6 +168,14 @@ class WalkForwardValidator:
|
||||
|
||||
for w in self.windows:
|
||||
wid = w["window_id"]
|
||||
|
||||
if self.progress_callback:
|
||||
self.progress_callback(
|
||||
window_id=wid,
|
||||
total_windows=len(self.windows)
|
||||
)
|
||||
|
||||
wid = w["window_id"]
|
||||
log.info(f"▶️ WF #{wid} en ejecución")
|
||||
|
||||
train_data = self.data.loc[w["train_start"]:w["train_end"]]
|
||||
@@ -174,6 +193,9 @@ class WalkForwardValidator:
|
||||
commission=self.commission,
|
||||
slippage=self.slippage,
|
||||
position_size=self.position_size,
|
||||
position_sizer=self.position_sizer,
|
||||
stop_loss=self.stop_loss,
|
||||
max_combinations=self.max_combinations,
|
||||
)
|
||||
|
||||
opt_df = optimizer.optimize(self.param_grid)
|
||||
@@ -194,6 +216,8 @@ class WalkForwardValidator:
|
||||
commission=self.commission,
|
||||
slippage=self.slippage,
|
||||
position_size=self.position_size,
|
||||
position_sizer=self.position_sizer,
|
||||
stop_loss=self.stop_loss,
|
||||
)
|
||||
|
||||
test_results = engine.run(test_data)
|
||||
|
||||
@@ -11,6 +11,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
|
||||
from src.web.api.v2.routers.calibration_strategies import router as calibration_strategies_router
|
||||
|
||||
# --------------------------------------------------
|
||||
# Logging
|
||||
@@ -115,6 +116,17 @@ def create_app() -> FastAPI:
|
||||
"step": 2,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/calibration/strategies", response_class=HTMLResponse)
|
||||
def calibration_risk_page(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"pages/calibration/calibration_strategies.html",
|
||||
{
|
||||
"request": request,
|
||||
"page": "calibration",
|
||||
"step": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
@@ -123,6 +135,7 @@ def create_app() -> FastAPI:
|
||||
api_prefix = settings.api_prefix
|
||||
app.include_router(calibration_data_router, prefix=api_prefix)
|
||||
app.include_router(calibration_risk_router, prefix=api_prefix)
|
||||
app.include_router(calibration_strategies_router, prefix=api_prefix)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
191
src/web/api/v2/routers/calibration_strategies.py
Normal file
191
src/web/api/v2/routers/calibration_strategies.py
Normal file
@@ -0,0 +1,191 @@
|
||||
# src/web/api/v2/routers/calibration_strategies.py
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, HTMLResponse
|
||||
|
||||
from src.data.storage import StorageManager
|
||||
from src.calibration.strategies_inspector import (
|
||||
inspect_strategies_config,
|
||||
list_available_strategies,
|
||||
)
|
||||
from src.calibration.reports.strategies_report import generate_strategies_report_pdf
|
||||
|
||||
from ..schemas.calibration_strategies import (
|
||||
CalibrationStrategiesInspectRequest,
|
||||
CalibrationStrategiesInspectResponse,
|
||||
CalibrationStrategiesValidateResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("tradingbot.api.v2")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/calibration/strategies",
|
||||
tags=["calibration"],
|
||||
)
|
||||
|
||||
WF_JOBS: Dict[str, Dict] = {}
|
||||
|
||||
def get_storage() -> StorageManager:
|
||||
return StorageManager.from_env()
|
||||
|
||||
|
||||
@router.get("/available")
|
||||
def available_strategies():
|
||||
return {"strategies": list_available_strategies()}
|
||||
|
||||
@router.post("/inspect", response_model=CalibrationStrategiesInspectResponse)
|
||||
def inspect_strategies(
|
||||
payload: CalibrationStrategiesInspectRequest,
|
||||
storage: StorageManager = Depends(get_storage),
|
||||
):
|
||||
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)
|
||||
|
||||
result = inspect_strategies_config(
|
||||
storage=storage,
|
||||
payload=payload,
|
||||
data_quality=data_quality,
|
||||
include_series=False,
|
||||
)
|
||||
return CalibrationStrategiesInspectResponse(**result)
|
||||
|
||||
@router.post("/validate", response_model=CalibrationStrategiesValidateResponse)
|
||||
def validate_strategies(
|
||||
payload: CalibrationStrategiesInspectRequest,
|
||||
storage: StorageManager = Depends(get_storage),
|
||||
):
|
||||
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)
|
||||
|
||||
result = inspect_strategies_config(
|
||||
storage=storage,
|
||||
payload=payload,
|
||||
data_quality=data_quality,
|
||||
include_series=True,
|
||||
)
|
||||
return CalibrationStrategiesValidateResponse(**result)
|
||||
|
||||
@router.post("/report")
|
||||
def report_strategies(
|
||||
payload: CalibrationStrategiesInspectRequest,
|
||||
storage: StorageManager = Depends(get_storage),
|
||||
):
|
||||
logger.info(f"🧾 Generating strategies 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)
|
||||
|
||||
result = inspect_strategies_config(
|
||||
storage=storage,
|
||||
payload=payload,
|
||||
data_quality=data_quality,
|
||||
include_series=True,
|
||||
)
|
||||
|
||||
# ---------------------------------------------
|
||||
# Prepare PDF output path (outside src)
|
||||
# ---------------------------------------------
|
||||
project_root = Path(__file__).resolve().parents[4] # .../src
|
||||
# project_root currently points to src/web/api/v2/routers -> parents[4] == src
|
||||
project_root = project_root.parent # repo root
|
||||
|
||||
reports_dir = project_root / "reports" / "strategies"
|
||||
reports_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
safe_symbol = re.sub(r"[^a-zA-Z0-9_-]", "_", payload.symbol)
|
||||
filename = f"strategies_report_{safe_symbol}_{payload.timeframe}_{uuid.uuid4().hex}.pdf"
|
||||
|
||||
symbol_dir = reports_dir / safe_symbol
|
||||
symbol_dir.mkdir(exist_ok=True)
|
||||
|
||||
output_path = symbol_dir / filename
|
||||
|
||||
generate_strategies_report_pdf(
|
||||
output_path=output_path,
|
||||
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,
|
||||
"WF train_days": payload.wf.train_days,
|
||||
"WF test_days": payload.wf.test_days,
|
||||
"WF step_days": payload.wf.step_days or payload.wf.test_days,
|
||||
"Optimizer metric": payload.optimization.optimizer_metric,
|
||||
"Max combinations": payload.optimization.max_combinations,
|
||||
},
|
||||
results=result,
|
||||
)
|
||||
|
||||
public_url = f"/reports/strategies/{safe_symbol}/{filename}"
|
||||
return JSONResponse(content={"status": result.get("status", "ok"), "url": public_url})
|
||||
|
||||
@router.post("/run")
|
||||
def run_strategies_async(
|
||||
payload: CalibrationStrategiesInspectRequest,
|
||||
storage: StorageManager = Depends(get_storage),
|
||||
):
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
job_id = uuid.uuid4().hex
|
||||
|
||||
WF_JOBS[job_id] = {
|
||||
"status": "running",
|
||||
"progress": 0,
|
||||
"current_window": 0,
|
||||
"total_windows": 0,
|
||||
"current_strategy": None,
|
||||
"result": None,
|
||||
}
|
||||
|
||||
def background_job():
|
||||
|
||||
def progress_cb(window_id, total_windows):
|
||||
WF_JOBS[job_id]["current_window"] = window_id
|
||||
WF_JOBS[job_id]["total_windows"] = total_windows
|
||||
WF_JOBS[job_id]["progress"] = int(
|
||||
window_id / total_windows * 100
|
||||
)
|
||||
|
||||
result = inspect_strategies_config(
|
||||
storage=storage,
|
||||
payload=payload,
|
||||
data_quality={"status": "ok"},
|
||||
include_series=True,
|
||||
progress_callback=progress_cb, # ← lo pasamos
|
||||
)
|
||||
|
||||
WF_JOBS[job_id]["status"] = "done"
|
||||
WF_JOBS[job_id]["progress"] = 100
|
||||
WF_JOBS[job_id]["result"] = result
|
||||
|
||||
thread = threading.Thread(target=background_job)
|
||||
thread.start()
|
||||
|
||||
return {"job_id": job_id}
|
||||
|
||||
@router.get("/status/{job_id}")
|
||||
def get_status(job_id: str):
|
||||
return WF_JOBS.get(job_id, {"status": "unknown"})
|
||||
83
src/web/api/v2/schemas/calibration_strategies.py
Normal file
83
src/web/api/v2/schemas/calibration_strategies.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# src/web/api/v2/schemas/calibration_strategies.py
|
||||
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .calibration_risk import StopConfigSchema, RiskConfigSchema, GlobalRiskRulesSchema
|
||||
|
||||
|
||||
class WalkForwardConfigSchema(BaseModel):
|
||||
train_days: int = Field(..., gt=0)
|
||||
test_days: int = Field(..., gt=0)
|
||||
step_days: Optional[int] = Field(None, gt=0) # if None => step = test_days
|
||||
|
||||
|
||||
class OptimizationConfigSchema(BaseModel):
|
||||
optimizer_metric: str = Field("sharpe_ratio")
|
||||
max_combinations: int = Field(500, gt=0)
|
||||
min_trades_train: int = Field(30, ge=0)
|
||||
min_trades_test: int = Field(10, ge=0)
|
||||
|
||||
|
||||
class StrategySelectionSchema(BaseModel):
|
||||
strategy_id: str
|
||||
param_grid: Dict[str, List[Any]]
|
||||
|
||||
|
||||
class CalibrationStrategiesInspectRequest(BaseModel):
|
||||
symbol: str
|
||||
timeframe: str
|
||||
|
||||
# snapshot from Step 2 (closed)
|
||||
stop: StopConfigSchema
|
||||
risk: RiskConfigSchema
|
||||
global_rules: GlobalRiskRulesSchema
|
||||
account_equity: float = Field(..., gt=0)
|
||||
|
||||
strategies: List[StrategySelectionSchema]
|
||||
wf: WalkForwardConfigSchema
|
||||
optimization: OptimizationConfigSchema
|
||||
|
||||
commission: float = Field(0.001, ge=0)
|
||||
slippage: float = Field(0.0005, ge=0)
|
||||
|
||||
|
||||
class WindowRowSchema(BaseModel):
|
||||
window: int
|
||||
train_start: str
|
||||
train_end: str
|
||||
test_start: str
|
||||
test_end: str
|
||||
|
||||
return_pct: float
|
||||
sharpe: float
|
||||
max_dd_pct: float
|
||||
trades: int
|
||||
params: Dict[str, Any]
|
||||
|
||||
|
||||
class StrategyRunResultSchema(BaseModel):
|
||||
strategy_id: str
|
||||
status: Literal["ok", "warning", "fail"]
|
||||
message: str
|
||||
|
||||
n_windows: int
|
||||
oos_final_equity: float
|
||||
oos_total_return_pct: float
|
||||
oos_max_dd_worst_pct: float
|
||||
degradation_sharpe: Optional[float] = None
|
||||
|
||||
windows: List[WindowRowSchema]
|
||||
|
||||
|
||||
class CalibrationStrategiesInspectResponse(BaseModel):
|
||||
valid: bool
|
||||
status: Literal["ok", "warning", "fail"]
|
||||
checks: Dict[str, Any]
|
||||
message: str
|
||||
|
||||
results: List[StrategyRunResultSchema]
|
||||
|
||||
|
||||
class CalibrationStrategiesValidateResponse(CalibrationStrategiesInspectResponse):
|
||||
series: Dict[str, Any]
|
||||
595
src/web/ui/v2/static/js/pages/calibration_strategies.js
Normal file
595
src/web/ui/v2/static/js/pages/calibration_strategies.js
Normal file
@@ -0,0 +1,595 @@
|
||||
// src/web/ui/v2/static/js/pages/calibration_strategies.js
|
||||
|
||||
console.log("[calibration_strategies] script loaded ✅", new Date().toISOString());
|
||||
|
||||
// =================================================
|
||||
// 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");
|
||||
}
|
||||
|
||||
// =================================================
|
||||
// UTILS
|
||||
// =================================================
|
||||
|
||||
function loadContextFromLocalStorage() {
|
||||
const symbol = localStorage.getItem("calibration.symbol");
|
||||
const timeframe = localStorage.getItem("calibration.timeframe");
|
||||
if (symbol) setVal("symbol", symbol);
|
||||
if (timeframe) setVal("timeframe", timeframe);
|
||||
|
||||
// Step 2 snapshot (if stored)
|
||||
const stop_type = localStorage.getItem("calibration.stop.type");
|
||||
const stop_fraction = localStorage.getItem("calibration.stop.stop_fraction");
|
||||
const atr_period = localStorage.getItem("calibration.stop.atr_period");
|
||||
const atr_multiplier = localStorage.getItem("calibration.stop.atr_multiplier");
|
||||
const risk_fraction = localStorage.getItem("calibration.risk.risk_fraction");
|
||||
const max_position_fraction = localStorage.getItem("calibration.risk.max_position_fraction");
|
||||
const max_drawdown_pct = localStorage.getItem("calibration.rules.max_drawdown_pct");
|
||||
const daily_loss_limit_pct = localStorage.getItem("calibration.rules.daily_loss_limit_pct");
|
||||
const max_consecutive_losses = localStorage.getItem("calibration.rules.max_consecutive_losses");
|
||||
const cooldown_bars = localStorage.getItem("calibration.rules.cooldown_bars");
|
||||
const account_equity = localStorage.getItem("calibration.account_equity");
|
||||
|
||||
if (account_equity) setVal("account_equity", account_equity);
|
||||
|
||||
if (stop_type) setVal("stop_type", stop_type);
|
||||
if (stop_fraction) setVal("stop_fraction", stop_fraction);
|
||||
if (atr_period) setVal("atr_period", atr_period);
|
||||
if (atr_multiplier) setVal("atr_multiplier", atr_multiplier);
|
||||
if (risk_fraction) setVal("risk_fraction", risk_fraction);
|
||||
if (max_position_fraction) setVal("max_position_fraction", max_position_fraction);
|
||||
if (max_drawdown_pct) setVal("max_drawdown_pct", max_drawdown_pct);
|
||||
if (daily_loss_limit_pct) setVal("daily_loss_limit_pct", daily_loss_limit_pct);
|
||||
if (max_consecutive_losses) setVal("max_consecutive_losses", max_consecutive_losses);
|
||||
if (cooldown_bars) setVal("cooldown_bars", cooldown_bars);
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
const symbol = str("symbol");
|
||||
const timeframe = str("timeframe");
|
||||
const stopType = str("stop_type");
|
||||
|
||||
if (!symbol || !timeframe) {
|
||||
throw new Error("symbol/timeframe missing");
|
||||
}
|
||||
|
||||
const stop = { type: stopType };
|
||||
|
||||
if (stopType === "fixed" || stopType === "trailing") {
|
||||
stop.stop_fraction = (num("stop_fraction") ?? 1.0) / 100;
|
||||
}
|
||||
if (stopType === "atr") {
|
||||
stop.atr_period = num("atr_period") ?? 14;
|
||||
stop.atr_multiplier = num("atr_multiplier") ?? 3.0;
|
||||
}
|
||||
|
||||
const risk_fraction = (num("risk_fraction") ?? 1.0) / 100;
|
||||
const max_position_fraction = (num("max_position_fraction") ?? 95) / 100;
|
||||
|
||||
const global_rules = {
|
||||
max_drawdown_pct: (num("max_drawdown_pct") ?? 20) / 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"),
|
||||
};
|
||||
|
||||
const wf_train_days = num("wf_train_days") ?? 120;
|
||||
const wf_test_days = num("wf_test_days") ?? 30;
|
||||
const wf_step_days = num("wf_step_days");
|
||||
|
||||
const strategies = collectSelectedStrategies();
|
||||
|
||||
return {
|
||||
symbol,
|
||||
timeframe,
|
||||
account_equity: num("account_equity") ?? 10000,
|
||||
|
||||
stop,
|
||||
risk: {
|
||||
risk_fraction,
|
||||
max_position_fraction,
|
||||
},
|
||||
global_rules,
|
||||
|
||||
strategies,
|
||||
wf: {
|
||||
train_days: wf_train_days,
|
||||
test_days: wf_test_days,
|
||||
step_days: wf_step_days,
|
||||
},
|
||||
optimization: {
|
||||
optimizer_metric: str("opt_metric") ?? "sharpe_ratio",
|
||||
max_combinations: num("opt_max_combinations") ?? 300,
|
||||
min_trades_train: num("opt_min_trades_train") ?? 30,
|
||||
min_trades_test: num("opt_min_trades_test") ?? 10,
|
||||
},
|
||||
|
||||
commission: num("commission") ?? 0.001,
|
||||
slippage: num("slippage") ?? 0.0005,
|
||||
};
|
||||
}
|
||||
|
||||
function collectSelectedStrategies() {
|
||||
const items = document.querySelectorAll("[data-strategy-item]");
|
||||
const out = [];
|
||||
|
||||
items.forEach((node) => {
|
||||
const checkbox = node.querySelector("input[type=checkbox]");
|
||||
if (!checkbox || !checkbox.checked) return;
|
||||
|
||||
const sid = checkbox.getAttribute("data-strategy-id");
|
||||
const textarea = node.querySelector("textarea");
|
||||
let grid = {};
|
||||
if (textarea && textarea.value.trim()) {
|
||||
try {
|
||||
grid = JSON.parse(textarea.value);
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid JSON param_grid for ${sid}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
out.push({ strategy_id: sid, param_grid: grid });
|
||||
});
|
||||
|
||||
if (out.length === 0) {
|
||||
throw new Error("Select at least 1 strategy");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchAvailableStrategies() {
|
||||
const res = await fetch("/api/v2/calibration/strategies/available");
|
||||
const data = await res.json();
|
||||
return data.strategies || [];
|
||||
}
|
||||
|
||||
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 str(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return null;
|
||||
const v = el.value;
|
||||
return v === null || v === undefined ? null : String(v);
|
||||
}
|
||||
|
||||
function setVal(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.value = value ?? "";
|
||||
}
|
||||
|
||||
// =================================================
|
||||
// PROGRESS BAR
|
||||
// =================================================
|
||||
|
||||
function startWF() {
|
||||
document
|
||||
.getElementById("wf_progress_card")
|
||||
.classList.remove("d-none");
|
||||
|
||||
document.getElementById("wfProgressBar").style.width = "0%";
|
||||
document.getElementById("wfProgressBar").innerText = "0%";
|
||||
}
|
||||
|
||||
async function pollStatus(jobId) {
|
||||
const interval = setInterval(async () => {
|
||||
const res = await fetch(`/api/v2/calibration/strategies/status/${jobId}`);
|
||||
const data = await res.json();
|
||||
|
||||
const bar = document.getElementById("wfProgressBar");
|
||||
bar.style.width = data.progress + "%";
|
||||
bar.innerText = data.progress + "%";
|
||||
|
||||
if (data.status === "done") {
|
||||
clearInterval(interval);
|
||||
bar.classList.remove("progress-bar-animated");
|
||||
console.log("WF finished");
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// =================================================
|
||||
// RENDER RESULTS
|
||||
// =================================================
|
||||
|
||||
function renderStrategiesList(strategies) {
|
||||
const list = document.getElementById("strategies_list");
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = "";
|
||||
|
||||
strategies.forEach((s) => {
|
||||
const col = document.createElement("div");
|
||||
col.className = "col-12 col-lg-6";
|
||||
col.setAttribute("data-strategy-item", "1");
|
||||
|
||||
const defaultGrid = s.default_grid || {};
|
||||
const defaultGridText = JSON.stringify(defaultGrid, null, 2);
|
||||
|
||||
col.innerHTML = `
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<label class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" data-strategy-id="${s.strategy_id}">
|
||||
<span class="form-check-label">
|
||||
<b>${s.name}</b>
|
||||
<span class="text-secondary ms-2">${(s.tags || []).join(" · ")}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="form-label">param_grid (JSON)</label>
|
||||
<textarea class="form-control" rows="7" spellcheck="false">${defaultGridText}</textarea>
|
||||
<div class="form-hint">Tip: usa listas. Ej: {"fast":[10,20],"slow":[50,100]}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
list.appendChild(col);
|
||||
});
|
||||
}
|
||||
|
||||
function setBadge(status) {
|
||||
const badge = document.getElementById("strategies_status_badge");
|
||||
if (!badge) return;
|
||||
|
||||
badge.classList.remove("bg-secondary", "bg-success", "bg-warning", "bg-danger");
|
||||
badge.classList.add(
|
||||
status === "ok" ? "bg-success" : status === "warning" ? "bg-warning" : status === "fail" ? "bg-danger" : "bg-secondary"
|
||||
);
|
||||
badge.textContent = status ? status.toUpperCase() : "—";
|
||||
}
|
||||
|
||||
function renderResultsTable(data) {
|
||||
const wrap = document.getElementById("strategies_table_wrap");
|
||||
if (!wrap) return;
|
||||
|
||||
const rows = [];
|
||||
(data.results || []).forEach((r) => {
|
||||
rows.push(`
|
||||
<tr>
|
||||
<td><b>${r.strategy_id}</b></td>
|
||||
<td>${r.status}</td>
|
||||
<td>${r.n_windows}</td>
|
||||
<td>${Number(r.oos_total_return_pct).toFixed(2)}%</td>
|
||||
<td>${Number(r.oos_max_dd_worst_pct).toFixed(2)}%</td>
|
||||
<td>${Number(r.oos_final_equity).toFixed(2)}</td>
|
||||
<td class="text-secondary">${r.message || ""}</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
|
||||
wrap.innerHTML = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Strategy</th>
|
||||
<th>Status</th>
|
||||
<th>Windows</th>
|
||||
<th>OOS return</th>
|
||||
<th>Worst DD</th>
|
||||
<th>Final equity</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function populatePlotSelector(data) {
|
||||
const sel = document.getElementById("plot_strategy_select");
|
||||
if (!sel) return;
|
||||
|
||||
sel.innerHTML = "";
|
||||
const ids = Object.keys((data.series && data.series.strategies) ? data.series.strategies : {});
|
||||
ids.forEach((sid) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = sid;
|
||||
opt.textContent = sid;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
|
||||
sel.onchange = () => renderPlotsForSelected(data);
|
||||
|
||||
if (ids.length > 0) {
|
||||
sel.value = ids[0];
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlotsForSelected(data) {
|
||||
const sel = document.getElementById("plot_strategy_select");
|
||||
const sid = sel ? sel.value : null;
|
||||
if (!sid) return;
|
||||
|
||||
const s = data.series?.strategies?.[sid];
|
||||
if (!s) return;
|
||||
|
||||
const equity = s.window_equity || [];
|
||||
const returns = s.window_returns_pct || [];
|
||||
const xEq = [...Array(equity.length).keys()];
|
||||
const xRet = [...Array(returns.length).keys()].map((i) => i + 1);
|
||||
|
||||
Plotly.newPlot("plot_equity", [
|
||||
{ x: xEq, y: equity, type: "scatter", mode: "lines", name: "Equity (OOS)" },
|
||||
], {
|
||||
title: `WF OOS equity · ${sid}`,
|
||||
margin: { t: 40, l: 50, r: 20, b: 40 },
|
||||
xaxis: { title: "Window index" },
|
||||
yaxis: { title: "Equity" },
|
||||
}, { displayModeBar: false });
|
||||
|
||||
Plotly.newPlot("plot_returns", [
|
||||
{ x: xRet, y: returns, type: "bar", name: "Return % (per window)" },
|
||||
], {
|
||||
title: `WF returns per window · ${sid}`,
|
||||
margin: { t: 40, l: 50, r: 20, b: 40 },
|
||||
xaxis: { title: "Window" },
|
||||
yaxis: { title: "Return (%)" },
|
||||
}, { displayModeBar: false });
|
||||
}
|
||||
|
||||
function renderValidateResponse(data) {
|
||||
|
||||
// -------------------------------
|
||||
// 1️⃣ Badge + message
|
||||
// -------------------------------
|
||||
const badge = document.getElementById("strategies_status_badge");
|
||||
const msg = document.getElementById("strategies_message");
|
||||
|
||||
badge.textContent = data.status ?? "—";
|
||||
|
||||
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");
|
||||
|
||||
msg.textContent = data.message ?? "";
|
||||
|
||||
// -------------------------------
|
||||
// 2️⃣ Debug JSON
|
||||
// -------------------------------
|
||||
document.getElementById("strategies_debug").textContent =
|
||||
JSON.stringify(data, null, 2);
|
||||
|
||||
// -------------------------------
|
||||
// 3️⃣ Plots (primera estrategia por ahora)
|
||||
// -------------------------------
|
||||
if (data.series && data.series.strategies) {
|
||||
const keys = Object.keys(data.series.strategies);
|
||||
if (keys.length > 0) {
|
||||
const s = data.series.strategies[keys[0]];
|
||||
|
||||
Plotly.newPlot("plot_equity", [{
|
||||
y: s.window_equity,
|
||||
type: "scatter",
|
||||
mode: "lines",
|
||||
name: "Equity"
|
||||
}], { margin: { t: 20 } });
|
||||
|
||||
Plotly.newPlot("plot_returns", [{
|
||||
y: s.window_returns_pct,
|
||||
type: "bar",
|
||||
name: "Window returns %"
|
||||
}], { margin: { t: 20 } });
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
// 4️⃣ Table
|
||||
// -------------------------------
|
||||
const wrap = document.getElementById("strategies_table_wrap");
|
||||
wrap.innerHTML = "";
|
||||
|
||||
if (data.results) {
|
||||
let html = `<table class="table table-sm table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Strategy</th>
|
||||
<th>Status</th>
|
||||
<th>OOS Return %</th>
|
||||
<th>OOS Max DD %</th>
|
||||
<th>Windows</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const r of data.results) {
|
||||
html += `
|
||||
<tr>
|
||||
<td>${r.strategy_id}</td>
|
||||
<td>${r.status}</td>
|
||||
<td>${r.oos_total_return_pct?.toFixed(2)}</td>
|
||||
<td>${r.oos_max_dd_worst_pct?.toFixed(2)}</td>
|
||||
<td>${r.n_windows}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
html += "</tbody></table>";
|
||||
wrap.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateStrategies() {
|
||||
console.log("[calibration_strategies] validateStrategies() START");
|
||||
|
||||
const bar = document.getElementById("wfProgressBar");
|
||||
const txt = document.getElementById("wf_progress_text");
|
||||
|
||||
const setProgress = (pct, text) => {
|
||||
const p = Math.max(0, Math.min(100, Number(pct || 0)));
|
||||
bar.style.width = `${p}%`;
|
||||
bar.textContent = `${p}%`;
|
||||
if (text) txt.textContent = text;
|
||||
};
|
||||
|
||||
try {
|
||||
// 0) Reset UI
|
||||
setProgress(0, "Starting...");
|
||||
|
||||
// 1) Construye payload igual que antes (usa tu función existente)
|
||||
const payload = buildPayload(); // <-- NO CAMBIES tu builder, reutilízalo
|
||||
|
||||
// 2) Arranca job async
|
||||
const runResp = await fetch("/api/v2/calibration/strategies/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!runResp.ok) {
|
||||
const errText = await runResp.text();
|
||||
throw new Error(`Run failed: ${runResp.status} ${errText}`);
|
||||
}
|
||||
|
||||
const { job_id } = await runResp.json();
|
||||
if (!job_id) throw new Error("No job_id returned from /run");
|
||||
|
||||
// 3) Poll status
|
||||
const pollEveryMs = 500;
|
||||
const maxMinutes = 30;
|
||||
const maxPolls = Math.ceil((maxMinutes * 60 * 1000) / pollEveryMs);
|
||||
|
||||
for (let i = 0; i < maxPolls; i++) {
|
||||
await new Promise((r) => setTimeout(r, pollEveryMs));
|
||||
|
||||
const stResp = await fetch(`/api/v2/calibration/strategies/status/${job_id}`);
|
||||
if (!stResp.ok) continue;
|
||||
|
||||
const st = await stResp.json();
|
||||
|
||||
const pct = st.progress ?? 0;
|
||||
const cw = st.current_window ?? 0;
|
||||
const tw = st.total_windows ?? 0;
|
||||
|
||||
const label =
|
||||
tw > 0
|
||||
? `WF running... window ${cw}/${tw}`
|
||||
: "WF running...";
|
||||
|
||||
setProgress(pct, label);
|
||||
|
||||
if (st.status === "done") {
|
||||
setProgress(100, "WF completed ✅");
|
||||
|
||||
// 4) Renderiza resultados usando el MISMO renderer que usabas con /validate
|
||||
// (ojo: el resultado viene dentro de st.result)
|
||||
if (!st.result) throw new Error("Job done but no result in status payload");
|
||||
|
||||
renderValidateResponse(st.result); // <-- usa tu función existente de render (plots, tablas, etc.)
|
||||
|
||||
console.log("[calibration_strategies] validateStrategies() DONE ok");
|
||||
return;
|
||||
}
|
||||
|
||||
if (st.status === "unknown") {
|
||||
setProgress(0, "Unknown job (server lost state?)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Timeout waiting for WF job to finish");
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
// deja un estado visible
|
||||
const txt = document.getElementById("wf_progress_text");
|
||||
if (txt) txt.textContent = `Error: ${err.message}`;
|
||||
console.log("[calibration_strategies] validateStrategies() DONE fail");
|
||||
}
|
||||
}
|
||||
|
||||
async function generateReport() {
|
||||
console.log("[calibration_strategies] generateReport() START");
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = buildPayload();
|
||||
} catch (e) {
|
||||
alert(e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/v2/calibration/strategies/report", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.url) {
|
||||
const viewer = document.getElementById("pdf_viewer_section");
|
||||
const frame = document.getElementById("pdf_frame");
|
||||
frame.src = data.url;
|
||||
viewer.classList.remove("d-none");
|
||||
viewer.scrollIntoView({ behavior: "smooth" });
|
||||
} else {
|
||||
alert("Failed to generate report");
|
||||
}
|
||||
}
|
||||
|
||||
function wireButtons() {
|
||||
document.getElementById("validate_strategies_btn")?.addEventListener("click", validateStrategies);
|
||||
document.getElementById("report_strategies_btn")?.addEventListener("click", generateReport);
|
||||
|
||||
document.getElementById("refresh_strategies_btn")?.addEventListener("click", async () => {
|
||||
const strategies = await fetchAvailableStrategies();
|
||||
renderStrategiesList(strategies);
|
||||
});
|
||||
|
||||
document.getElementById("load_step2_btn")?.addEventListener("click", () => {
|
||||
loadContextFromLocalStorage();
|
||||
});
|
||||
|
||||
document.getElementById("close_pdf_btn")?.addEventListener("click", () => {
|
||||
const viewer = document.getElementById("pdf_viewer_section");
|
||||
const frame = document.getElementById("pdf_frame");
|
||||
frame.src = "";
|
||||
viewer.classList.add("d-none");
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
loadContextFromLocalStorage();
|
||||
wireButtons();
|
||||
|
||||
const strategies = await fetchAvailableStrategies();
|
||||
renderStrategiesList(strategies);
|
||||
|
||||
// Pre-select 1 strategy by default (moving_average) if exists
|
||||
setTimeout(() => {
|
||||
const first = document.querySelector('input[type=checkbox][data-strategy-id="moving_average"]');
|
||||
if (first) first.checked = true;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,322 @@
|
||||
{% 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/risk" 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"/>
|
||||
<path d="M15 6l-6 6l6 6"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1 text-center">
|
||||
<h2 class="mb-0">Calibración · Paso 3 · Strategies</h2>
|
||||
<div class="text-secondary">Optimización + Walk Forward (OOS)</div>
|
||||
</div>
|
||||
|
||||
<!-- Forward arrow (disabled until OK) -->
|
||||
<div class="ms-3">
|
||||
<a
|
||||
id="next-step-btn"
|
||||
href="#"
|
||||
class="btn btn-outline-secondary btn-icon"
|
||||
aria-disabled="true"
|
||||
title="Next step not implemented yet"
|
||||
>
|
||||
<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"/>
|
||||
<path d="M9 6l6 6l-6 6"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- Context -->
|
||||
<!-- ========================= -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Context</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Symbol</label>
|
||||
<input id="symbol" class="form-control" placeholder="BTC/USDT">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Timeframe</label>
|
||||
<input id="timeframe" class="form-control" placeholder="1h">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Account equity</label>
|
||||
<input id="account_equity" class="form-control" type="number" step="0.01" value="10000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-secondary">
|
||||
Tip: Symbol y timeframe se cargan desde Step 1 (localStorage). Si no aparecen, rellénalos manualmente.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- Risk & Stops snapshot -->
|
||||
<!-- ========================= -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Risk & Stops snapshot (Step 2)</h3>
|
||||
<div class="card-actions">
|
||||
<button id="load_step2_btn" class="btn btn-sm btn-outline-primary">Load from Step 2</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Stop type</label>
|
||||
<select id="stop_type" class="form-select">
|
||||
<option value="fixed">fixed</option>
|
||||
<option value="trailing">trailing</option>
|
||||
<option value="atr">atr</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Stop fraction (%)</label>
|
||||
<input id="stop_fraction" class="form-control" type="number" step="0.01" value="1.0">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">ATR period</label>
|
||||
<input id="atr_period" class="form-control" type="number" step="1" value="14">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">ATR multiplier</label>
|
||||
<input id="atr_multiplier" class="form-control" type="number" step="0.1" value="3.0">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Risk per trade (%)</label>
|
||||
<input id="risk_fraction" class="form-control" type="number" step="0.01" value="1.0">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max position fraction (%)</label>
|
||||
<input id="max_position_fraction" class="form-control" type="number" step="0.1" value="95">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max DD (%)</label>
|
||||
<input id="max_drawdown_pct" class="form-control" type="number" step="0.1" value="20">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Daily loss limit (%) (optional)</label>
|
||||
<input id="daily_loss_limit_pct" class="form-control" type="number" step="0.1" value="">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max consecutive losses (optional)</label>
|
||||
<input id="max_consecutive_losses" class="form-control" type="number" step="1" value="">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Cooldown bars (optional)</label>
|
||||
<input id="cooldown_bars" class="form-control" type="number" step="1" value="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-secondary">
|
||||
Este snapshot se envía al backend para reproducibilidad y para que WF/optimizer use el mismo sizing/stop.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- WF + Optimizer config -->
|
||||
<!-- ========================= -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Walk-Forward & Optimization</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Train days</label>
|
||||
<input id="wf_train_days" class="form-control" type="number" step="1" value="120">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Test days</label>
|
||||
<input id="wf_test_days" class="form-control" type="number" step="1" value="30">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Step days (optional)</label>
|
||||
<input id="wf_step_days" class="form-control" type="number" step="1" value="">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Metric</label>
|
||||
<select id="opt_metric" class="form-select">
|
||||
<option value="sharpe_ratio">sharpe_ratio</option>
|
||||
<option value="total_return">total_return</option>
|
||||
<option value="max_drawdown">max_drawdown</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Max combinations</label>
|
||||
<input id="opt_max_combinations" class="form-control" type="number" step="1" value="300">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Min trades (train)</label>
|
||||
<input id="opt_min_trades_train" class="form-control" type="number" step="1" value="30">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Min trades (test)</label>
|
||||
<input id="opt_min_trades_test" class="form-control" type="number" step="1" value="10">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Commission</label>
|
||||
<input id="commission" class="form-control" type="number" step="0.0001" value="0.001">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Slippage</label>
|
||||
<input id="slippage" class="form-control" type="number" step="0.0001" value="0.0005">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- Strategy selection -->
|
||||
<!-- ========================= -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Strategies</h3>
|
||||
<div class="card-actions">
|
||||
<button id="refresh_strategies_btn" class="btn btn-sm btn-outline-secondary">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="strategies_list" class="row g-3"></div>
|
||||
<div class="mt-3 text-secondary">
|
||||
Cada estrategia incluye un <b>param_grid</b> en JSON.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- Actions -->
|
||||
<!-- ========================= -->
|
||||
<div class="d-flex gap-2 mb-4">
|
||||
<button id="validate_strategies_btn" class="btn btn-primary">
|
||||
Validate (WF)
|
||||
</button>
|
||||
<button id="report_strategies_btn" class="btn btn-outline-primary">
|
||||
Generate PDF report
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- Prograss Bar -->
|
||||
<!-- ========================= -->
|
||||
<div id="wf_progress_card" class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Walk-Forward Progress</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div class="progress mb-2">
|
||||
<div
|
||||
id="wfProgressBar"
|
||||
class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar"
|
||||
style="width: 0%"
|
||||
>
|
||||
0%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wf_progress_text" class="text-secondary small">
|
||||
Waiting to start...
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- Results -->
|
||||
<!-- ========================= -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Results</h3>
|
||||
<div class="card-actions">
|
||||
<span id="strategies_status_badge" class="badge bg-secondary">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="strategies_message" class="mb-3 text-secondary">Run validation to see results.</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Strategy plot</label>
|
||||
<select id="plot_strategy_select" class="form-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div id="plot_equity" style="height: 320px;"></div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div id="plot_returns" style="height: 320px;"></div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div id="strategies_table_wrap"></div>
|
||||
|
||||
<details class="mt-3">
|
||||
<summary class="text-secondary">Debug JSON</summary>
|
||||
<pre id="strategies_debug" class="mt-2" style="max-height: 300px; overflow:auto;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- PDF Viewer -->
|
||||
<!-- ========================= -->
|
||||
<div id="pdf_viewer_section" class="card mb-4 d-none">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Strategies Report (PDF)</h3>
|
||||
<div class="card-actions">
|
||||
<button id="close_pdf_btn" class="btn btn-sm btn-outline-secondary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<iframe id="pdf_frame" style="width: 100%; height: 800px; border: none;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
||||
<script src="/static/js/pages/calibration_strategies.js"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user