feat(calibration): Step 3 - Stategies preparado conceptualmente

This commit is contained in:
DaM
2026-02-14 13:47:08 +01:00
parent f4f4e8e5be
commit 4365366e7d
9 changed files with 1664 additions and 3 deletions

View 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();