Main changes:
- Implemented multi-horizon trend regime detection in src/core/market_regime.py
- EMA(20, 50, 100, 200) trend score model
- 5 regime states: bull_strong, bull_moderate, sideways, bear_moderate, bear_strong
- asymmetric persistence filter (bull 5 bars, sideways 3 bars, bear 2 bars)
- window regime classification using average trend score
- Extended walk-forward outputs
- window_regimes now include:
- regime_detail
- bull_strong_pct
- bull_moderate_pct
- sideways_detail_pct
- bear_moderate_pct
- bear_strong_pct
- avg_score
- UI improvements in Step 3
- regime analysis cards redesigned to show 5 regimes
- visual "M layout": bull regimes on top, sideways + bear regimes below
- table updated to display detailed regime percentages
- equity chart background colored by regime (colorblind-friendly palette)
- trade density chart improved with aligned Y-axis zero levels
- UX improvements
- automatic scroll to charts when selecting a strategy
- better regime badges and labeling
- colorblind-friendly visualization
Result:
Step 3 now provides full strategy inspection including:
- OOS performance
- regime behaviour
- regime distribution per WF window
- visual regime overlays
Next step:
Implement strategy promotion / selection layer before Step 4 (parameter refinement).
2017 lines
57 KiB
JavaScript
2017 lines
57 KiB
JavaScript
// src/web/ui/v2/static/js/pages/calibration_strategies.js
|
||
|
||
console.log("[calibration_strategies] script loaded ✅", new Date().toISOString());
|
||
|
||
let STRATEGY_CATALOG = [];
|
||
let strategySlots = [];
|
||
let selectedStrategyId = null;
|
||
let lastValidationResult = null;
|
||
const MAX_STRATEGIES = 10;
|
||
|
||
// =================================================
|
||
// 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 (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);
|
||
if (account_equity) setVal("account_equity", account_equity);
|
||
|
||
// WF defaults (if stored)
|
||
const wf_train_days = localStorage.getItem("calibration.wf.train_days");
|
||
const wf_test_days = localStorage.getItem("calibration.wf.test_days");
|
||
const wf_step_days = localStorage.getItem("calibration.wf.step_days");
|
||
const wf_min_trades_test = localStorage.getItem("calibration.wf.min_trades_test");
|
||
|
||
if (wf_train_days) setVal("wf_train_days", wf_train_days);
|
||
if (wf_test_days) setVal("wf_test_days", wf_test_days);
|
||
if (wf_step_days) setVal("wf_step_days", wf_step_days);
|
||
if (wf_min_trades_test) setVal("wf_min_trades_test", wf_min_trades_test);
|
||
|
||
// Optional fees
|
||
const commission = localStorage.getItem("calibration.commission");
|
||
const slippage = localStorage.getItem("calibration.slippage");
|
||
if (commission) setVal("commission", commission);
|
||
if (slippage) setVal("slippage", slippage);
|
||
}
|
||
|
||
function setVal(id, v) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = v;
|
||
}
|
||
|
||
function str(id) {
|
||
const el = document.getElementById(id);
|
||
return el ? String(el.value || "").trim() : "";
|
||
}
|
||
|
||
function num(id) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return null;
|
||
const v = parseFloat(el.value);
|
||
return Number.isFinite(v) ? v : null;
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise(res => setTimeout(res, ms));
|
||
}
|
||
|
||
// =================================================
|
||
// FETCH CATALOG
|
||
// =================================================
|
||
|
||
async function fetchCatalog() {
|
||
const symbol = str("symbol");
|
||
const timeframe = str("timeframe");
|
||
|
||
const res = await fetch(`/api/v2/calibration/strategies/catalog?symbol=${encodeURIComponent(symbol)}&timeframe=${encodeURIComponent(timeframe)}`);
|
||
if (!res.ok) throw new Error(`catalog failed: ${res.status}`);
|
||
const data = await res.json();
|
||
|
||
STRATEGY_CATALOG = data.strategies || [];
|
||
console.log("[catalog] strategies:", STRATEGY_CATALOG.map(s => s.strategy_id));
|
||
}
|
||
|
||
// =================================================
|
||
// UI RENDERING
|
||
// =================================================
|
||
|
||
function initSlots() {
|
||
strategySlots = [
|
||
{ strategy_id: null, parameters: {} }
|
||
];
|
||
}
|
||
|
||
function rerenderStrategySlots() {
|
||
|
||
const container = document.getElementById("strategies_container");
|
||
container.innerHTML = "";
|
||
|
||
const currentStrategies = strategySlots
|
||
.filter(s => s.strategy_id !== null);
|
||
|
||
strategySlots = [];
|
||
|
||
currentStrategies.forEach((slotData, index) => {
|
||
|
||
strategySlots.push({
|
||
strategy_id: slotData.strategy_id,
|
||
parameters: slotData.parameters || {}
|
||
});
|
||
|
||
renderStrategySlot(index);
|
||
});
|
||
|
||
// always one empty slot at end (if room)
|
||
if (strategySlots.length < MAX_STRATEGIES) {
|
||
strategySlots.push({ strategy_id: null, parameters: {} });
|
||
renderStrategySlot(strategySlots.length - 1);
|
||
}
|
||
|
||
updateCombinationCounter();
|
||
}
|
||
|
||
function renderStrategySlot(index) {
|
||
|
||
const container = document.getElementById("strategies_container");
|
||
|
||
const slot = document.createElement("div");
|
||
slot.className = "card p-3";
|
||
slot.id = `strategy_slot_${index}`;
|
||
|
||
slot.innerHTML = `
|
||
<div class="mb-3">
|
||
<label class="form-label">Strategy ${index + 1}</label>
|
||
<select class="form-select" id="strategy_select_${index}">
|
||
<option value="">-- Select --</option>
|
||
${STRATEGY_CATALOG.map(s => `<option value="${s.strategy_id}">${s.name}</option>`).join("")}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="row g-3" id="strategy_params_${index}"></div>
|
||
|
||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||
<small class="text-muted">Combinations: <span id="strategy_combo_${index}">0</span></small>
|
||
<button class="btn btn-outline-danger btn-sm" id="remove_strategy_${index}" type="button">
|
||
Remove
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
container.appendChild(slot);
|
||
|
||
const select = document.getElementById(`strategy_select_${index}`);
|
||
const removeBtn = document.getElementById(`remove_strategy_${index}`);
|
||
|
||
// set initial value
|
||
select.value = strategySlots[index].strategy_id || "";
|
||
|
||
select.addEventListener("change", (e) => {
|
||
onStrategySelected(index, e.target.value);
|
||
});
|
||
|
||
removeBtn.addEventListener("click", () => {
|
||
removeStrategySlot(index);
|
||
});
|
||
|
||
// if already selected, render params
|
||
if (strategySlots[index].strategy_id) {
|
||
renderParametersOnly(index, strategySlots[index].strategy_id);
|
||
}
|
||
}
|
||
|
||
function onStrategySelected(index, strategyId) {
|
||
|
||
if (!strategyId) {
|
||
removeStrategySlot(index);
|
||
return;
|
||
}
|
||
|
||
strategySlots[index].strategy_id = strategyId;
|
||
|
||
renderParametersOnly(index, strategyId);
|
||
|
||
// Si es el último slot activo, añadir nuevo vacío
|
||
if (index === strategySlots.length - 1 &&
|
||
strategySlots.length < MAX_STRATEGIES) {
|
||
|
||
strategySlots.push({ strategy_id: null, parameters: {} });
|
||
renderStrategySlot(strategySlots.length - 1);
|
||
}
|
||
|
||
updateCombinationCounter();
|
||
}
|
||
|
||
function renderParametersOnly(index, strategyId) {
|
||
|
||
const paramsContainer = document.getElementById(`strategy_params_${index}`);
|
||
paramsContainer.innerHTML = "";
|
||
|
||
if (!strategyId) return;
|
||
|
||
const strategyMeta = STRATEGY_CATALOG.find(
|
||
s => s.strategy_id === strategyId
|
||
);
|
||
|
||
if (!strategyMeta || !strategyMeta.parameters_meta) return;
|
||
|
||
strategyMeta.parameters_meta.forEach(meta => {
|
||
|
||
const col = document.createElement("div");
|
||
col.className = "col-md-4";
|
||
|
||
let inputHtml = "";
|
||
const paramName = meta.name;
|
||
|
||
// INT / FLOAT
|
||
if (meta.type === "int" || meta.type === "float") {
|
||
|
||
inputHtml = `
|
||
<input type="number"
|
||
class="form-control param-input"
|
||
id="${paramName}_value_${index}"
|
||
step="${meta.type === "int" ? "1" : "any"}"
|
||
${meta.min !== null ? `min="${meta.min}"` : ""}
|
||
${meta.max !== null ? `max="${meta.max}"` : ""}
|
||
value="${meta.default_value ?? ""}"
|
||
/>
|
||
`;
|
||
}
|
||
|
||
// ENUM
|
||
else if (meta.type === "enum") {
|
||
|
||
const options = (meta.choices || []).map(choice => `
|
||
<option value="${choice}" ${choice === meta.default_value ? "selected" : ""}>
|
||
${choice}
|
||
</option>
|
||
`).join("");
|
||
|
||
inputHtml = `
|
||
<select class="form-select param-input"
|
||
id="${paramName}_value_${index}">
|
||
${options}
|
||
</select>
|
||
`;
|
||
}
|
||
|
||
// BOOL
|
||
else if (meta.type === "bool") {
|
||
|
||
inputHtml = `
|
||
<select class="form-select param-input"
|
||
id="${paramName}_value_${index}">
|
||
<option value="true" ${meta.default_value === true ? "selected" : ""}>True</option>
|
||
<option value="false" ${meta.default_value === false ? "selected" : ""}>False</option>
|
||
</select>
|
||
`;
|
||
}
|
||
|
||
col.innerHTML = `
|
||
<label class="form-label fw-semibold">${paramName}</label>
|
||
${inputHtml}
|
||
`;
|
||
|
||
paramsContainer.appendChild(col);
|
||
});
|
||
|
||
validateParameterInputs();
|
||
}
|
||
|
||
function validateParameterInputs() {
|
||
|
||
let valid = true;
|
||
|
||
// Limpiar estados previos
|
||
document.querySelectorAll(".param-input").forEach(input => {
|
||
input.classList.remove("is-invalid");
|
||
});
|
||
|
||
strategySlots.forEach((slot, index) => {
|
||
|
||
if (!slot.strategy_id) return;
|
||
|
||
const strategyMeta = STRATEGY_CATALOG.find(
|
||
s => s.strategy_id === slot.strategy_id
|
||
);
|
||
|
||
if (!strategyMeta || !strategyMeta.parameters_meta) return;
|
||
|
||
strategyMeta.parameters_meta.forEach(meta => {
|
||
|
||
const el = document.getElementById(`${meta.name}_value_${index}`);
|
||
if (!el) return;
|
||
|
||
let raw = el.value;
|
||
let value = raw;
|
||
|
||
// 🔹 INT
|
||
if (meta.type === "int") {
|
||
value = parseInt(raw);
|
||
if (isNaN(value)) valid = false;
|
||
}
|
||
|
||
// 🔹 FLOAT
|
||
else if (meta.type === "float") {
|
||
value = parseFloat(raw);
|
||
if (isNaN(value)) valid = false;
|
||
}
|
||
|
||
// 🔹 BOOL
|
||
else if (meta.type === "bool") {
|
||
if (raw !== "true" && raw !== "false") valid = false;
|
||
}
|
||
|
||
// 🔹 ENUM
|
||
else if (meta.type === "enum") {
|
||
if (!meta.choices || !meta.choices.includes(raw)) {
|
||
valid = false;
|
||
}
|
||
}
|
||
|
||
// 🔹 Rango (solo números)
|
||
if ((meta.type === "int" || meta.type === "float") && !isNaN(value)) {
|
||
|
||
if (meta.min !== null && meta.min !== undefined && value < meta.min) {
|
||
valid = false;
|
||
}
|
||
|
||
if (meta.max !== null && meta.max !== undefined && value > meta.max) {
|
||
valid = false;
|
||
}
|
||
}
|
||
|
||
if (!valid) {
|
||
el.classList.add("is-invalid");
|
||
}
|
||
|
||
});
|
||
|
||
});
|
||
|
||
updateCombinationCounter();
|
||
|
||
return valid;
|
||
}
|
||
|
||
function updateCombinationCounter() {
|
||
|
||
let hasAnyStrategy = false;
|
||
|
||
strategySlots.forEach((slot, index) => {
|
||
|
||
if (!slot.strategy_id) return;
|
||
|
||
hasAnyStrategy = true;
|
||
|
||
const perStrategyEl = document.getElementById(`strategy_combo_${index}`);
|
||
if (perStrategyEl) {
|
||
perStrategyEl.textContent = "1";
|
||
}
|
||
});
|
||
|
||
const globalTotal = hasAnyStrategy ? 1 : 0;
|
||
|
||
const globalEl = document.getElementById("combination_counter");
|
||
if (globalEl) globalEl.textContent = globalTotal;
|
||
|
||
applyCombinationWarnings(globalTotal);
|
||
updateTimeEstimate(globalTotal);
|
||
|
||
return globalTotal;
|
||
}
|
||
|
||
function applyCombinationWarnings(total) {
|
||
|
||
const maxComb = parseInt(
|
||
document.getElementById("opt_max_combinations")?.value || 0
|
||
);
|
||
|
||
const counter = document.getElementById("combination_counter");
|
||
if (!counter) return;
|
||
|
||
counter.classList.remove("text-warning", "text-danger");
|
||
|
||
if (total > 10000) {
|
||
counter.classList.add("text-danger");
|
||
} else if (maxComb && total > maxComb) {
|
||
counter.classList.add("text-warning");
|
||
}
|
||
}
|
||
|
||
function updateTimeEstimate(totalComb) {
|
||
|
||
const trainDays = parseInt(
|
||
document.getElementById("wf_train_days")?.value || 0
|
||
);
|
||
|
||
const testDays = parseInt(
|
||
document.getElementById("wf_test_days")?.value || 0
|
||
);
|
||
|
||
const approxWindows = Math.max(
|
||
Math.floor(365 / testDays),
|
||
1
|
||
);
|
||
|
||
const operations = totalComb * approxWindows;
|
||
|
||
// 0.003s por combinación (estimación conservadora)
|
||
const seconds = operations * 0.003;
|
||
|
||
let label;
|
||
|
||
if (seconds < 60) {
|
||
label = `~ ${seconds.toFixed(1)} sec`;
|
||
} else if (seconds < 3600) {
|
||
label = `~ ${(seconds / 60).toFixed(1)} min`;
|
||
} else {
|
||
label = `~ ${(seconds / 3600).toFixed(1)} h`;
|
||
}
|
||
|
||
const el = document.getElementById("wf_time_estimate");
|
||
if (el) el.textContent = label;
|
||
}
|
||
|
||
function removeStrategySlot(index) {
|
||
|
||
strategySlots.splice(index, 1);
|
||
|
||
rerenderStrategySlots();
|
||
}
|
||
|
||
// =================================================
|
||
// PAYLOAD
|
||
// =================================================
|
||
|
||
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 wf_min_trades_test = num("wf_min_trades_test")
|
||
|
||
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,
|
||
min_trades_test: wf_min_trades_test,
|
||
},
|
||
commission: num("commission") ?? 0.001,
|
||
slippage: num("slippage") ?? 0.0005,
|
||
};
|
||
}
|
||
|
||
function collectSelectedStrategies() {
|
||
|
||
const strategies = [];
|
||
|
||
console.log("strategySlots:", strategySlots);
|
||
|
||
strategySlots.forEach((slot, index) => {
|
||
|
||
if (!slot.strategy_id) return;
|
||
|
||
const strategyMeta = STRATEGY_CATALOG.find(
|
||
s => s.strategy_id === slot.strategy_id
|
||
);
|
||
|
||
if (!strategyMeta) return;
|
||
|
||
const parameters = {};
|
||
|
||
strategyMeta.parameters_meta.forEach(meta => {
|
||
|
||
const el = document.getElementById(`${meta.name}_value_${index}`);
|
||
if (!el) return;
|
||
|
||
let value = el.value;
|
||
|
||
if (meta.type === "int") {
|
||
value = parseInt(value);
|
||
}
|
||
else if (meta.type === "float") {
|
||
value = parseFloat(value);
|
||
}
|
||
else if (meta.type === "bool") {
|
||
value = value === "true";
|
||
}
|
||
|
||
parameters[meta.name] = value;
|
||
});
|
||
|
||
|
||
strategies.push({
|
||
strategy_id: slot.strategy_id,
|
||
parameters: parameters
|
||
});
|
||
|
||
});
|
||
|
||
console.log("Collected strategies:", strategies);
|
||
|
||
return strategies;
|
||
}
|
||
|
||
async function fetchAvailableStrategies() {
|
||
const res = await fetch("/api/v2/calibration/strategies/catalog");
|
||
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 ?? "";
|
||
}
|
||
|
||
function loadFromStep2() {
|
||
|
||
document.getElementById("risk_fraction").value =
|
||
localStorage.getItem("calibration.risk_fraction") ?? "";
|
||
|
||
document.getElementById("max_position_fraction").value =
|
||
localStorage.getItem("calibration.max_position_fraction") ?? "";
|
||
|
||
document.getElementById("stop_type").value =
|
||
localStorage.getItem("calibration.stop_type") ?? "fixed";
|
||
|
||
document.getElementById("stop_fraction").value =
|
||
localStorage.getItem("calibration.stop_fraction") ?? "";
|
||
|
||
document.getElementById("atr_period").value =
|
||
localStorage.getItem("calibration.atr_period") ?? "";
|
||
|
||
document.getElementById("atr_multiplier").value =
|
||
localStorage.getItem("calibration.atr_multiplier") ?? "";
|
||
|
||
document.getElementById("max_drawdown_pct").value =
|
||
localStorage.getItem("calibration.max_drawdown_pct") ?? "";
|
||
|
||
document.getElementById("daily_loss_limit_pct").value =
|
||
localStorage.getItem("calibration.daily_loss_limit_pct") ?? "";
|
||
|
||
document.getElementById("max_consecutive_losses").value =
|
||
localStorage.getItem("calibration.max_consecutive_losses") ?? "";
|
||
|
||
document.getElementById("cooldown_bars").value =
|
||
localStorage.getItem("calibration.cooldown_bars") ?? "";
|
||
|
||
// Forzar actualización de UI según stop heredado
|
||
setTimeout(() => {
|
||
updateStopUI();
|
||
}, 0);
|
||
|
||
console.log("[calibration_strategies] Parameters loaded from Step 2 ✅");
|
||
}
|
||
|
||
function updateStopUI() {
|
||
|
||
const type = document.getElementById("stop_type").value;
|
||
|
||
const stopFraction = document.getElementById("stop_fraction_group");
|
||
const atrPeriod = document.getElementById("atr_group");
|
||
const atrMultiplier = document.getElementById("atr_multiplier_group");
|
||
|
||
if (type === "fixed" || type === "trailing") {
|
||
stopFraction.classList.remove("d-none");
|
||
atrPeriod.classList.add("d-none");
|
||
atrMultiplier.classList.add("d-none");
|
||
}
|
||
|
||
if (type === "atr") {
|
||
stopFraction.classList.add("d-none");
|
||
atrPeriod.classList.remove("d-none");
|
||
atrMultiplier.classList.remove("d-none");
|
||
}
|
||
}
|
||
|
||
async function loadStrategyCatalog() {
|
||
|
||
const res = await fetch("/api/v2/calibration/strategies/catalog");
|
||
const data = await res.json();
|
||
|
||
STRATEGY_CATALOG = data.strategies;
|
||
}
|
||
|
||
function addStrategySlot() {
|
||
|
||
// Si ya hay un slot vacío al final, no crear otro
|
||
if (strategySlots.length > 0 &&
|
||
strategySlots[strategySlots.length - 1].strategy_id === null) {
|
||
return;
|
||
}
|
||
|
||
if (strategySlots.length >= MAX_STRATEGIES) return;
|
||
|
||
const index = strategySlots.length;
|
||
|
||
strategySlots.push({
|
||
strategy_id: null,
|
||
parameters: {}
|
||
});
|
||
|
||
renderStrategySlot(index);
|
||
}
|
||
|
||
// =================================================
|
||
// 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);
|
||
}
|
||
|
||
|
||
// =================================================
|
||
// DIFFERENT RENDER PLOTS
|
||
// =================================================
|
||
|
||
function renderEquityAndReturns(strategyId, s, data) {
|
||
const equity = s.window_equity || [];
|
||
const ret = s.window_returns_pct || [];
|
||
const trd = s.window_trades || [];
|
||
|
||
const regimeWindows = s.window_regimes || s.diagnostics?.regimes?.by_window || [];
|
||
|
||
function regimeBgColor(regime) {
|
||
switch (regime) {
|
||
case "bull_strong":
|
||
return "rgba(0,114,178,0.20)";
|
||
case "bull_moderate":
|
||
return "rgba(0,114,178,0.10)";
|
||
case "bear_strong":
|
||
return "rgba(230,159,0,0.20)";
|
||
case "bear_moderate":
|
||
return "rgba(230,159,0,0.10)";
|
||
default:
|
||
return "rgba(150,150,150,0.12)";
|
||
}
|
||
}
|
||
|
||
function buildRegimeBackgroundShapes(regimeWindows, topYRef = "paper") {
|
||
if (!Array.isArray(regimeWindows) || regimeWindows.length === 0) return [];
|
||
|
||
const shapes = [];
|
||
|
||
regimeWindows.forEach((w, idx) => {
|
||
const x0 = idx - 0.5;
|
||
const x1 = idx + 0.5;
|
||
|
||
shapes.push({
|
||
type: "rect",
|
||
xref: "x",
|
||
yref: topYRef,
|
||
x0,
|
||
x1,
|
||
y0: 0.60, // mismo inicio que domain del gráfico superior
|
||
y1: 1.00, // mismo final que domain del gráfico superior
|
||
fillcolor: regimeBgColor(w.regime_detail || w.regime),
|
||
line: { width: 0 },
|
||
layer: "below"
|
||
});
|
||
});
|
||
|
||
return shapes;
|
||
}
|
||
|
||
// X común (windows)
|
||
const n = Math.max(equity.length, ret.length, trd.length);
|
||
const x = Array.from({ length: n }, (_, i) => i);
|
||
|
||
// ---- Escalado para alinear visualmente el 0 (returns) con el 0 (trades) ----
|
||
const retMax = Math.max(0, ...ret);
|
||
const retMin = Math.min(0, ...ret);
|
||
|
||
const minTrades = data.config?.wf?.min_trades_test ?? 10;
|
||
|
||
const trdMaxRaw = Math.max(0, ...trd);
|
||
const trdMax = Math.max(trdMaxRaw, minTrades);
|
||
|
||
const retPosSpan = Math.max(1e-9, retMax);
|
||
const retNegSpan = Math.abs(retMin);
|
||
|
||
const trdNegSpan = (retNegSpan / retPosSpan) * trdMax;
|
||
|
||
const y1Range = [retMin, retMax];
|
||
const y2Range = [-trdNegSpan, trdMax];
|
||
|
||
// ---- Trazas ----
|
||
const equityTrace = {
|
||
x,
|
||
y: equity,
|
||
type: "scatter",
|
||
mode: "lines",
|
||
name: "Equity",
|
||
xaxis: "x",
|
||
yaxis: "y"
|
||
};
|
||
|
||
const returnsTrace = {
|
||
x,
|
||
y: ret,
|
||
type: "bar",
|
||
name: "Return %",
|
||
marker: { color: "#3b82f6" },
|
||
xaxis: "x2",
|
||
yaxis: "y2",
|
||
offsetgroup: "returns",
|
||
alignmentgroup: "bottom"
|
||
};
|
||
|
||
const tradesTrace = {
|
||
x,
|
||
y: trd,
|
||
type: "bar",
|
||
name: "Trades",
|
||
marker: { color: "#f59e0b" },
|
||
xaxis: "x2",
|
||
yaxis: "y3",
|
||
offsetgroup: "trades",
|
||
alignmentgroup: "bottom"
|
||
};
|
||
|
||
const regimeShapesTop = buildRegimeBackgroundShapes(regimeWindows);
|
||
|
||
// ---- Layout ----
|
||
const layout = {
|
||
grid: { rows: 2, columns: 1, pattern: "independent" },
|
||
|
||
// IMPORTANTE: share X de verdad (pan/zoom sincronizado)
|
||
xaxis: { matches: "x2", showgrid: true },
|
||
xaxis2: { matches: "x", showgrid: true },
|
||
|
||
// Dominios (más altura para ver mejor)
|
||
// Ajusta estos números a gusto:
|
||
yaxis: {
|
||
domain: [0.60, 1.00],
|
||
title: "Equity",
|
||
showgrid: true,
|
||
rangemode: "tozero"
|
||
},
|
||
xaxis: {
|
||
domain: [0.00, 1.00],
|
||
anchor: "y"
|
||
},
|
||
|
||
yaxis2: {
|
||
domain: [0.00, 0.25],
|
||
title: "Return %",
|
||
range: y1Range,
|
||
zeroline: true,
|
||
zerolinewidth: 2,
|
||
showgrid: true
|
||
},
|
||
xaxis2: {
|
||
domain: [0.00, 1.00],
|
||
anchor: "y2",
|
||
title: "Windows"
|
||
},
|
||
|
||
yaxis3: {
|
||
title: "Trades",
|
||
overlaying: "y2",
|
||
side: "right",
|
||
range: y2Range,
|
||
zeroline: true,
|
||
zerolinewidth: 2,
|
||
showgrid: false
|
||
},
|
||
|
||
// Barras lado a lado
|
||
barmode: "group",
|
||
bargap: 0.2,
|
||
|
||
shapes: [
|
||
...regimeShapesTop,
|
||
{
|
||
type: "line",
|
||
x0: -0.5,
|
||
x1: n - 0.5,
|
||
y0: minTrades,
|
||
y1: minTrades,
|
||
xref: "x2",
|
||
yref: "y3",
|
||
line: { color: "red", width: 2, dash: "dash" }
|
||
}
|
||
],
|
||
|
||
legend: { orientation: "h" },
|
||
margin: { t: 50, r: 70, l: 70, b: 50 }
|
||
};
|
||
|
||
const topDomain = layout.yaxis.domain; // [start,end]
|
||
const botDomain = layout.yaxis2.domain; // [start,end]
|
||
|
||
layout.annotations = [
|
||
{
|
||
text: `Equity — ${strategyId}`,
|
||
x: 0.5, xref: "paper",
|
||
y: topDomain[1] + 0.05, yref: "paper",
|
||
showarrow: false,
|
||
font: { size: 16 }
|
||
},
|
||
{
|
||
text: `Returns & Trades — ${strategyId}`,
|
||
x: 0.5, xref: "paper",
|
||
y: botDomain[1] + 0.05, yref: "paper",
|
||
showarrow: false,
|
||
font: { size: 16 }
|
||
}
|
||
];
|
||
|
||
Plotly.newPlot("plot_strategy", [equityTrace, returnsTrace, tradesTrace], layout, {
|
||
displayModeBar: true,
|
||
responsive: true
|
||
});
|
||
|
||
const gd = document.getElementById("plot_strategy");
|
||
|
||
// Limpia listeners anteriores (si re-renderizas)
|
||
gd.removeAllListeners?.("plotly_relayout");
|
||
|
||
// Flag para evitar bucles cuando hacemos relayout desde el listener
|
||
gd.__syncing = false;
|
||
|
||
function clamp01(v) {
|
||
return Math.max(0, Math.min(1, v));
|
||
}
|
||
|
||
function zeroFrac(min, max) {
|
||
const span = max - min;
|
||
if (!isFinite(span) || span <= 1e-12) return 0;
|
||
return clamp01((0 - min) / span);
|
||
}
|
||
|
||
// Dado un max fijo y una fracción f (posición del 0), calcula el min
|
||
function minFromMaxAndFrac(max, f) {
|
||
const denom = Math.max(1e-9, (1 - f));
|
||
return -(f / denom) * max;
|
||
}
|
||
|
||
gd.on("plotly_relayout", (ev) => {
|
||
if (gd.__syncing) return;
|
||
|
||
const update = {};
|
||
|
||
// ===== 0) DETECTAR AUTOSCALE / RESET (prioridad máxima) =====
|
||
// Plotly puede indicar autoscale de varias maneras.
|
||
const autoscaleTriggered =
|
||
ev["yaxis2.autorange"] === true ||
|
||
ev["yaxis3.autorange"] === true ||
|
||
ev["yaxis.autorange"] === true ||
|
||
ev["xaxis.autorange"] === true ||
|
||
ev["xaxis2.autorange"] === true ||
|
||
ev["autosize"] === true ||
|
||
ev["resetScale2d"] === true ||
|
||
ev["xaxis.range[0]"] === undefined && ev["xaxis.range[1]"] === undefined && ev["yaxis.range[0]"] === undefined && ev["yaxis.range[1]"] === undefined
|
||
? false
|
||
: false;
|
||
|
||
// Si el usuario pulsa Autoscale/Reset, queremos SIEMPRE:
|
||
// - Reforzar yaxis2/yaxis3 con tus rangos calculados (0 alineado)
|
||
// - Volver a dibujar la línea minTrades
|
||
// - (Opcional) dejar X en autorange en ambos
|
||
if (
|
||
ev["yaxis2.autorange"] === true ||
|
||
ev["yaxis3.autorange"] === true ||
|
||
ev["yaxis.autorange"] === true ||
|
||
ev["xaxis.autorange"] === true ||
|
||
ev["xaxis2.autorange"] === true ||
|
||
ev["resetScale2d"] === true
|
||
) {
|
||
// update["yaxis2.autorange"] = false;
|
||
// update["yaxis3.autorange"] = false;
|
||
update["yaxis2.range"] = y1Range;
|
||
update["yaxis3.range"] = y2Range;
|
||
|
||
// Asegura que la línea de Min Trades se vea SIEMPRE
|
||
update["shapes[0].type"] = "line";
|
||
update["shapes[0].xref"] = "x2";
|
||
update["shapes[0].yref"] = "y3";
|
||
update["shapes[0].x0"] = -0.5;
|
||
update["shapes[0].x1"] = n - 0.5;
|
||
update["shapes[0].y0"] = minTrades;
|
||
update["shapes[0].y1"] = minTrades;
|
||
update["shapes[0].line.color"] = "red";
|
||
update["shapes[0].line.width"] = 2;
|
||
update["shapes[0].line.dash"] = "dash";
|
||
|
||
// Mantén X sincronizado también tras autoscale
|
||
// if (ev["xaxis.autorange"] === true || ev["xaxis2.autorange"] === true || ev["resetScale2d"] === true) {
|
||
// update["xaxis.autorange"] = true;
|
||
// update["xaxis2.autorange"] = true;
|
||
// }
|
||
|
||
gd.__syncing = true;
|
||
Plotly.relayout(gd, update).finally(() => {
|
||
gd.__syncing = false;
|
||
});
|
||
return; // IMPORTANTÍSIMO: no seguimos con el resto de sincronizaciones
|
||
}
|
||
|
||
// ===== 1) Sync X arriba/abajo (pan/zoom normal) =====
|
||
if (ev["xaxis2.range[0]"] !== undefined && ev["xaxis2.range[1]"] !== undefined) {
|
||
update["xaxis.range"] = [ev["xaxis2.range[0]"], ev["xaxis2.range[1]"]];
|
||
update["xaxis.autorange"] = false;
|
||
}
|
||
|
||
if (ev["xaxis.range[0]"] !== undefined && ev["xaxis.range[1]"] !== undefined) {
|
||
update["xaxis2.range"] = [ev["xaxis.range[0]"], ev["xaxis.range[1]"]];
|
||
update["xaxis2.autorange"] = false;
|
||
}
|
||
|
||
// ===== 2) Mantener 0 alineado cuando pan/zoom en Y2 o Y3 =====
|
||
const y2Changed = ev["yaxis2.range[0]"] !== undefined && ev["yaxis2.range[1]"] !== undefined;
|
||
const y3Changed = ev["yaxis3.range[0]"] !== undefined && ev["yaxis3.range[1]"] !== undefined;
|
||
|
||
const full = gd._fullLayout || {};
|
||
const curY2 = full.yaxis2?.range || y1Range;
|
||
const curY3 = full.yaxis3?.range || y2Range;
|
||
|
||
if (y2Changed && !y3Changed) {
|
||
const newY2 = [ev["yaxis2.range[0]"], ev["yaxis2.range[1]"]];
|
||
const f = zeroFrac(newY2[0], newY2[1]);
|
||
|
||
const y3MaxKeep = (curY3 && curY3.length === 2) ? curY3[1] : y2Range[1];
|
||
const y3Min = minFromMaxAndFrac(y3MaxKeep, f);
|
||
|
||
update["yaxis2.autorange"] = false;
|
||
update["yaxis3.autorange"] = false;
|
||
update["yaxis2.range"] = newY2;
|
||
update["yaxis3.range"] = [y3Min, y3MaxKeep];
|
||
}
|
||
|
||
if (y3Changed && !y2Changed) {
|
||
const newY3 = [ev["yaxis3.range[0]"], ev["yaxis3.range[1]"]];
|
||
const f = zeroFrac(newY3[0], newY3[1]);
|
||
|
||
const y2MaxKeep = (curY2 && curY2.length === 2) ? curY2[1] : y1Range[1];
|
||
const y2Min = minFromMaxAndFrac(y2MaxKeep, f);
|
||
|
||
update["yaxis2.autorange"] = false;
|
||
update["yaxis3.autorange"] = false;
|
||
update["yaxis3.range"] = newY3;
|
||
update["yaxis2.range"] = [y2Min, y2MaxKeep];
|
||
}
|
||
|
||
// Si ambos cambian (zoom box), prioriza el zeroFrac de y2
|
||
if (y2Changed && y3Changed) {
|
||
const newY2 = [ev["yaxis2.range[0]"], ev["yaxis2.range[1]"]];
|
||
const f = zeroFrac(newY2[0], newY2[1]);
|
||
|
||
const newY3 = [ev["yaxis3.range[0]"], ev["yaxis3.range[1]"]];
|
||
const y3MaxKeep = newY3[1];
|
||
const y3Min = minFromMaxAndFrac(y3MaxKeep, f);
|
||
|
||
update["yaxis2.autorange"] = false;
|
||
update["yaxis3.autorange"] = false;
|
||
update["yaxis2.range"] = newY2;
|
||
update["yaxis3.range"] = [y3Min, y3MaxKeep];
|
||
}
|
||
|
||
// ===== 3) Re-asegurar la línea minTrades (por si Plotly la toca en relayouts) =====
|
||
// (No molesta y evita que desaparezca en algunos reset)
|
||
update["shapes[0].y0"] = minTrades;
|
||
update["shapes[0].y1"] = minTrades;
|
||
|
||
if (Object.keys(update).length === 0) return;
|
||
|
||
gd.__syncing = true;
|
||
Plotly.relayout(gd, update).finally(() => {
|
||
gd.__syncing = false;
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderRollingSharpe(strategyId, s, data) {
|
||
const roll = s.diagnostics?.rolling?.rolling_sharpe_like || [];
|
||
const x = roll.map((_, i) => i + 1);
|
||
|
||
const k = s.diagnostics?.rolling?.rolling_window ?? "?";
|
||
|
||
Plotly.newPlot(
|
||
"plot_strategy",
|
||
[
|
||
{
|
||
x,
|
||
y: roll,
|
||
type: "scatter",
|
||
mode: "lines+markers",
|
||
name: `Rolling Sharpe-like (k=${k})`
|
||
}
|
||
],
|
||
{
|
||
margin: { t: 60, r: 40, l: 60, b: 50 },
|
||
title: { text: `Rolling Sharpe-like — ${strategyId}`, x: 0.5 }, // ✅ centrado + dinámico
|
||
xaxis: { title: "Window", showgrid: true, zeroline: false },
|
||
yaxis: { title: "Sharpe-like", showgrid: true, zeroline: true, zerolinewidth: 2 },
|
||
legend: { orientation: "h" }
|
||
},
|
||
{ responsive: true, displayModeBar: true }
|
||
);
|
||
}
|
||
|
||
function renderOOSReturnsDistribution(strategyId, s, data) {
|
||
const edges = s.diagnostics?.distribution?.hist_bin_edges || [];
|
||
const counts = s.diagnostics?.distribution?.hist_counts || [];
|
||
|
||
const centers = edges.map((_, i) => (edges[i] + edges[i + 1]) / 2);
|
||
|
||
Plotly.newPlot("plot_strategy", [{
|
||
x: centers,
|
||
y: counts,
|
||
type: "bar",
|
||
name: "OOS Return% (bins)"
|
||
}], {
|
||
margin: { t: 40 },
|
||
title: { text: `OOS Returns Distribution — ${strategyId}`, x: 0.5 }, // ✅ centrado + dinámico
|
||
xaxis: { title: "Return % (window)" },
|
||
yaxis: { title: "Count" }
|
||
});
|
||
}
|
||
|
||
function renderDrawdownEvolution(strategyId, s, data) {
|
||
const dd = s.diagnostics?.drawdown?.drawdown_pct || [];
|
||
const x = dd.map((_, i) => i);
|
||
|
||
Plotly.newPlot("plot_strategy", [{
|
||
x,
|
||
y: dd,
|
||
type: "scatter",
|
||
mode: "lines",
|
||
name: "Drawdown %"
|
||
}], {
|
||
margin: { t: 40 },
|
||
title: { text: `Drawdown Evolution — ${strategyId}`, x: 0.5 }, // ✅ centrado + dinámico
|
||
xaxis: { title: "Point (initial + windows)" },
|
||
yaxis: { title: "Drawdown %", zeroline: true }
|
||
});
|
||
}
|
||
|
||
function renderTradeDensity(strategyId, s, data) {
|
||
const tpw = s.diagnostics?.trades?.trades_per_window || [];
|
||
const tpd = s.diagnostics?.trades?.trades_per_day || [];
|
||
const x = tpw.map((_, i) => i + 1);
|
||
|
||
const tpwMax = Math.max(0, ...tpw);
|
||
const tpdMax = Math.max(0, ...tpd);
|
||
|
||
Plotly.newPlot("plot_strategy", [
|
||
{
|
||
x,
|
||
y: tpw,
|
||
type: "bar",
|
||
name: "Trades / window",
|
||
yaxis: "y"
|
||
},
|
||
{
|
||
x,
|
||
y: tpd,
|
||
type: "scatter",
|
||
mode: "lines+markers",
|
||
name: "Trades / day",
|
||
yaxis: "y2"
|
||
}
|
||
], {
|
||
margin: { t: 40 },
|
||
title: { text: `Trade Density — ${strategyId}`, x: 0.5 },
|
||
barmode: "group",
|
||
xaxis: { title: "Window" },
|
||
|
||
yaxis: {
|
||
title: "Trades / window",
|
||
range: [0, tpwMax],
|
||
zeroline: true,
|
||
zerolinewidth: 2
|
||
},
|
||
|
||
yaxis2: {
|
||
title: "Trades / day",
|
||
overlaying: "y",
|
||
side: "right",
|
||
range: [0, tpdMax],
|
||
zeroline: true,
|
||
zerolinewidth: 2
|
||
}
|
||
});
|
||
}
|
||
|
||
// =================================================
|
||
// 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 || ""}
|
||
${Array.isArray(r.warnings) && r.warnings.length ? `<div class="mt-1"><small>${r.warnings.map(escapeHtml).join(" · ")}</small></div>` : ""}
|
||
</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 = "";
|
||
|
||
// ✅ usar results, no series (para que aparezcan también warning/fail)
|
||
const ids = (data.results || []).map(r => r.strategy_id);
|
||
|
||
ids.forEach((sid) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = sid;
|
||
opt.textContent = sid;
|
||
sel.appendChild(opt);
|
||
});
|
||
|
||
sel.onchange = () => {
|
||
const sid = sel.value;
|
||
selectStrategy(sid, data);
|
||
};
|
||
|
||
if (ids.length > 0) {
|
||
sel.value = selectedStrategyId || ids[0];
|
||
}
|
||
}
|
||
|
||
function selectStrategy(strategyId, data) {
|
||
if (!strategyId || !data) return;
|
||
|
||
// Actualiza selectedStrategyId
|
||
selectedStrategyId = strategyId;
|
||
|
||
const row = (data.results || []).find(r => r.strategy_id === strategyId);
|
||
|
||
// 1) Alerts por status (siempre explícito)
|
||
if (row?.status === "warning") {
|
||
showPlotAlert("warning", `WARNING — ${strategyId}`, row.message || "Strategy warning.", row.warnings);
|
||
} else if (row?.status === "fail") {
|
||
showPlotAlert("danger", `FAIL — ${strategyId}`, row.message || "Strategy failed.", row.warnings);
|
||
} else {
|
||
clearPlotAlert();
|
||
}
|
||
|
||
// 2) Si el backend indica que NO hay serie, no intentamos renderizar
|
||
if (row && row.series_available === false) {
|
||
showPlotAlert(
|
||
row.status === "fail" ? "danger" : "warning",
|
||
`${(row.status || "warning").toUpperCase()} — ${strategyId}`,
|
||
row.series_error || row.message || "No chart series available for this strategy.",
|
||
row.warnings
|
||
);
|
||
clearPlots();
|
||
highlightSelectedRow(strategyId);
|
||
return;
|
||
}
|
||
|
||
// 3) Verificar si los datos de la estrategia están disponibles
|
||
const strategyData = data?.series?.strategies?.[selectedStrategyId];
|
||
|
||
if (!strategyData) {
|
||
showPlotAlert(
|
||
"warning",
|
||
`No data available — ${strategyId}`,
|
||
"Strategy data not available for rendering.",
|
||
[]
|
||
);
|
||
clearPlots();
|
||
highlightSelectedRow(strategyId);
|
||
return;
|
||
}
|
||
|
||
// 4) Mantén el gráfico previamente seleccionado en el dropdown
|
||
const chartType = document.getElementById("plot_strategy_select").value;
|
||
renderChart(chartType, selectedStrategyId, strategyData, data); // Renderiza el gráfico correctamente
|
||
renderRegimeSummary(selectedStrategyId, strategyData, data);
|
||
|
||
highlightSelectedRow(strategyId);
|
||
|
||
// scroll automático al gráfico
|
||
document.getElementById("plot_strategy")?.scrollIntoView({
|
||
behavior: "smooth",
|
||
block: "start"
|
||
});
|
||
}
|
||
|
||
function renderValidateResponse(data) {
|
||
|
||
lastValidationResult = 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.results && data.results.length > 0) {
|
||
if (!selectedStrategyId) {
|
||
selectedStrategyId = data.results[0].strategy_id;
|
||
}
|
||
selectStrategy(selectedStrategyId, data);
|
||
}
|
||
|
||
// -------------------------------
|
||
// 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>
|
||
<th>Best Regime</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>`;
|
||
|
||
for (const r of data.results) {
|
||
|
||
let bestRegime = "-";
|
||
|
||
const strat = data?.series?.strategies?.[r.strategy_id];
|
||
const perf = strat?.diagnostics?.regimes?.performance;
|
||
|
||
if (perf) {
|
||
const entries = ["bull", "sideways", "bear"]
|
||
.map(k => ({
|
||
regime: k,
|
||
val: perf?.[k]?.mean_return_pct ?? -Infinity
|
||
}));
|
||
|
||
entries.sort((a,b)=>b.val-a.val);
|
||
|
||
if (entries[0].val > -Infinity) {
|
||
bestRegime = entries[0].regime;
|
||
}
|
||
}
|
||
|
||
html += `
|
||
<tr>
|
||
<td class="strategy-row"
|
||
data-strategy="${r.strategy_id}"
|
||
style="cursor:pointer; font-weight:600;">
|
||
${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>
|
||
<td>${bestRegime}</td>
|
||
</tr>
|
||
`;
|
||
|
||
// 🔸 Mostrar warnings debajo de la fila si existen
|
||
if (r.warnings && r.warnings.length > 0) {
|
||
|
||
html += `
|
||
<tr class="table-warning">
|
||
<td colspan="6">
|
||
<ul class="mb-0">
|
||
${r.warnings.map(w => `<li>${w}</li>`).join("")}
|
||
</ul>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
}
|
||
|
||
html += "</tbody></table>";
|
||
wrap.innerHTML = html;
|
||
|
||
document.querySelectorAll(".strategy-row").forEach(el => {
|
||
el.addEventListener("click", function () {
|
||
|
||
console.log("Clicked:", selectedStrategyId);
|
||
|
||
selectedStrategyId = this.dataset.strategy;
|
||
selectStrategy(selectedStrategyId, lastValidationResult);
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderChart(chartType, strategyId, s, data) {
|
||
switch (chartType) {
|
||
case "equity":
|
||
renderEquityAndReturns(strategyId, s, data);
|
||
break;
|
||
case "rolling_sharpe":
|
||
renderRollingSharpe(strategyId, s, data);
|
||
break;
|
||
case "hist_oos_returns":
|
||
renderOOSReturnsDistribution(strategyId, s, data);
|
||
break;
|
||
case "drawdown":
|
||
renderDrawdownEvolution(strategyId, s, data);
|
||
break;
|
||
case "trade_density":
|
||
renderTradeDensity(strategyId, s, data);
|
||
break;
|
||
default:
|
||
renderEquityAndReturns(strategyId, s, data);
|
||
break;
|
||
}
|
||
}
|
||
|
||
function highlightSelectedRow(strategyId) {
|
||
|
||
document.querySelectorAll(".strategy-row").forEach(el => {
|
||
el.style.backgroundColor = "";
|
||
});
|
||
|
||
const active = document.querySelector(
|
||
`.strategy-row[data-strategy="${strategyId}"]`
|
||
);
|
||
|
||
if (active) {
|
||
active.style.backgroundColor = "#e6f0ff";
|
||
}
|
||
}
|
||
|
||
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;
|
||
};
|
||
|
||
if (!validateParameterInputs()) {
|
||
alert("Please fix parameter errors before running WF.");
|
||
return;
|
||
}
|
||
|
||
|
||
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");
|
||
});
|
||
}
|
||
|
||
function applyInheritedLock() {
|
||
const locked = document.getElementById("lock_inherited").checked;
|
||
const fields = document.querySelectorAll(".inherited-field");
|
||
|
||
fields.forEach(f => {
|
||
f.disabled = locked;
|
||
if (locked) {
|
||
f.classList.add("bg-light");
|
||
} else {
|
||
f.classList.remove("bg-light");
|
||
}
|
||
});
|
||
}
|
||
|
||
async function init() {
|
||
await loadStrategyCatalog();
|
||
addStrategySlot();
|
||
|
||
loadContextFromLocalStorage();
|
||
loadFromStep2();
|
||
applyInheritedLock();
|
||
|
||
document.getElementById("stop_type")
|
||
.addEventListener("change", updateStopUI);
|
||
|
||
wireButtons();
|
||
|
||
document.getElementById("plot_strategy_select").addEventListener("change", function() {
|
||
const chartType = this.value;
|
||
const strategyData = lastValidationResult.series.strategies[selectedStrategyId];
|
||
|
||
// Verifica que selectedStrategyId tenga el valor correcto
|
||
console.log("selectedStrategyId:", selectedStrategyId);
|
||
console.log("Strategy Data:", strategyData);
|
||
|
||
renderChart(chartType, selectedStrategyId, strategyData, lastValidationResult);
|
||
});
|
||
|
||
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);
|
||
}
|
||
|
||
// =================================================
|
||
// MARKET REGIME
|
||
// =================================================
|
||
|
||
function fmtPct(v, digits = 2) {
|
||
const n = Number(v ?? 0);
|
||
return `${n.toFixed(digits)}%`;
|
||
}
|
||
|
||
function fmtNum(v, digits = 2) {
|
||
const n = Number(v ?? 0);
|
||
return n.toFixed(digits);
|
||
}
|
||
|
||
function regimeBadgeClass(regime) {
|
||
switch (regime) {
|
||
case "bull":
|
||
case "bull_moderate":
|
||
case "bull_strong":
|
||
return "bg-blue-lt text-blue";
|
||
case "bear":
|
||
case "bear_moderate":
|
||
case "bear_strong":
|
||
return "bg-orange-lt text-orange";
|
||
default:
|
||
return "bg-secondary-lt text-secondary";
|
||
}
|
||
}
|
||
|
||
function regimeLabel(regime) {
|
||
switch (regime) {
|
||
case "bull_strong": return "Bull strong";
|
||
case "bull_moderate": return "Bull moderate";
|
||
case "bear_strong": return "Bear strong";
|
||
case "bear_moderate": return "Bear moderate";
|
||
case "bull": return "Bull";
|
||
case "bear": return "Bear";
|
||
default: return "Sideways";
|
||
}
|
||
}
|
||
|
||
function ensureRegimeContainer() {
|
||
let el = document.getElementById("regime_analysis_wrap");
|
||
if (el) return el;
|
||
|
||
const anchor = document.getElementById("plot_strategy");
|
||
if (!anchor || !anchor.parentElement) return null;
|
||
|
||
el = document.createElement("div");
|
||
el.id = "regime_analysis_wrap";
|
||
el.className = "mt-4";
|
||
|
||
anchor.parentElement.appendChild(el);
|
||
return el;
|
||
}
|
||
|
||
function clearRegimeSummary() {
|
||
const el = document.getElementById("regime_analysis_wrap");
|
||
if (el) el.innerHTML = "";
|
||
}
|
||
|
||
function getBestRegime(perf) {
|
||
const candidates = ["bull", "sideways", "bear"]
|
||
.map((k) => ({
|
||
regime: k,
|
||
n_windows: Number(perf?.[k]?.n_windows ?? 0),
|
||
mean_return_pct: Number(perf?.[k]?.mean_return_pct ?? -Infinity),
|
||
}))
|
||
.filter((x) => x.n_windows > 0);
|
||
|
||
if (!candidates.length) return null;
|
||
|
||
candidates.sort((a, b) => b.mean_return_pct - a.mean_return_pct);
|
||
return candidates[0].regime;
|
||
}
|
||
|
||
function renderRegimeSummary(strategyId, s, data) {
|
||
const wrap = ensureRegimeContainer();
|
||
if (!wrap) return;
|
||
|
||
const regimesDiag = s?.diagnostics?.regimes || {};
|
||
const perf = regimesDiag.performance?.group || {};
|
||
const perfDetail = regimesDiag.performance?.detail || {};
|
||
const byWindow = s?.window_regimes || regimesDiag.by_window || [];
|
||
const cfg = regimesDiag.config || data?.regimes?.config || {};
|
||
|
||
const bestRegime = getBestRegime(perf);
|
||
|
||
const detailOrderTop = ["bull_moderate", "bear_moderate"];
|
||
const detailOrderBottom = ["bull_strong", "sideways", "bear_strong"];
|
||
|
||
function renderDetailCard(regime, extraClass = "") {
|
||
const p = perfDetail?.[regime] || {};
|
||
return `
|
||
<div class="${extraClass}">
|
||
<div class="card h-100">
|
||
<div class="card-body">
|
||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||
<h4 class="card-title mb-0">${regimeLabel(regime)}</h4>
|
||
<span class="badge ${regimeBadgeClass(regime)}">${regimeLabel(regime).toUpperCase()}</span>
|
||
</div>
|
||
|
||
<div class="row g-2 small">
|
||
<div class="col-6">
|
||
<div class="text-secondary">Windows</div>
|
||
<div class="fw-semibold">${Number(p.n_windows ?? 0)}</div>
|
||
</div>
|
||
<div class="col-6">
|
||
<div class="text-secondary">Mean return</div>
|
||
<div class="fw-semibold">${fmtPct(p.mean_return_pct)}</div>
|
||
</div>
|
||
<div class="col-6">
|
||
<div class="text-secondary">Positive rate</div>
|
||
<div class="fw-semibold">${fmtPct((Number(p.positive_window_rate ?? 0) * 100.0))}</div>
|
||
</div>
|
||
<div class="col-6">
|
||
<div class="text-secondary">Avg trades</div>
|
||
<div class="fw-semibold">${fmtNum(p.avg_trades)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const topCards = detailOrderTop
|
||
.map((regime) => renderDetailCard(regime, "col-md-6"))
|
||
.join("");
|
||
|
||
const bottomCards = detailOrderBottom
|
||
.map((regime) => renderDetailCard(regime, "col-md-4"))
|
||
.join("");
|
||
|
||
const rows = byWindow.map((w) => `
|
||
<tr>
|
||
<td>${Number(w.window ?? 0)}</td>
|
||
<td>
|
||
<span class="badge ${regimeBadgeClass(w.regime_detail || w.regime)}">
|
||
${regimeLabel(w.regime_detail || w.regime)}
|
||
</span>
|
||
</td>
|
||
<td>${fmtPct(Number(w.bull_strong_pct ?? 0) * 100.0)}</td>
|
||
<td>${fmtPct(Number(w.bull_moderate_pct ?? 0) * 100.0)}</td>
|
||
<td>${fmtPct(Number(w.sideways_detail_pct ?? 0) * 100.0)}</td>
|
||
<td>${fmtPct(Number(w.bear_moderate_pct ?? 0) * 100.0)}</td>
|
||
<td>${fmtPct(Number(w.bear_strong_pct ?? 0) * 100.0)}</td>
|
||
</tr>
|
||
`).join("");
|
||
|
||
wrap.innerHTML = `
|
||
<div class="card">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<h3 class="card-title mb-0">Regime Analysis — ${strategyId}</h3>
|
||
<div class="text-secondary small mt-1">
|
||
EMA: ${(cfg.ema_periods || []).join(" / ")}
|
||
· Bull persistence: ${cfg.bull_persistence_bars ?? "-"}
|
||
· Sideways persistence: ${cfg.sideways_persistence_bars ?? "-"}
|
||
· Bear persistence: ${cfg.bear_persistence_bars ?? "-"}
|
||
· Bull ≥ ${cfg.bull_threshold ?? "-"}
|
||
· Bear ≤ ${cfg.bear_threshold ?? "-"}
|
||
</div>
|
||
</div>
|
||
${
|
||
bestRegime
|
||
? `<span class="badge ${regimeBadgeClass(bestRegime)}">
|
||
Best regime: ${regimeLabel(bestRegime)}
|
||
</span>`
|
||
: `<span class="badge bg-secondary-lt text-secondary">Best regime: —</span>`
|
||
}
|
||
</div>
|
||
|
||
<div class="card-body">
|
||
<div class="row justify-content-center g-3 mb-3">
|
||
<div class="col-12 col-xl-10">
|
||
<div class="row g-3">
|
||
${topCards}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row justify-content-center g-3 mb-4">
|
||
<div class="col-12">
|
||
<div class="row g-3">
|
||
${bottomCards}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-responsive">
|
||
<table class="table table-sm table-vcenter">
|
||
<thead>
|
||
<tr>
|
||
<th>Window</th>
|
||
<th>Majority regime</th>
|
||
<th>Bull strong %</th>
|
||
<th>Bull mod %</th>
|
||
<th>Sideways %</th>
|
||
<th>Bear mod %</th>
|
||
<th>Bear strong %</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${rows || `<tr><td colspan="7" class="text-secondary">No regime data available.</td></tr>`}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// =================================================
|
||
// PLOT ALERTS (Tabler) + SAFE PLOT CLEAR
|
||
// =================================================
|
||
|
||
function ensurePlotAlertContainer() {
|
||
// Lo colocamos antes del primer plot si existe
|
||
let el = document.getElementById("plot_alert");
|
||
if (el) return el;
|
||
|
||
const anchor = document.getElementById("plot_strategy");
|
||
if (!anchor || !anchor.parentElement) return null;
|
||
|
||
el = document.createElement("div");
|
||
el.id = "plot_alert";
|
||
el.className = "mb-3";
|
||
anchor.parentElement.insertBefore(el, anchor);
|
||
return el;
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return String(str ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function showPlotAlert(type, title, message, warnings) {
|
||
const el = ensurePlotAlertContainer();
|
||
if (!el) return;
|
||
|
||
const warnHtml = Array.isArray(warnings) && warnings.length
|
||
? `<ul class="mb-0">${warnings.map(w => `<li>${escapeHtml(w)}</li>`).join("")}</ul>`
|
||
: "";
|
||
|
||
el.innerHTML = `
|
||
<div class="alert alert-${type}" role="alert">
|
||
<div>
|
||
<h4 class="alert-title">${escapeHtml(title)}</h4>
|
||
<div class="text-secondary">${escapeHtml(message || "")}</div>
|
||
${warnHtml}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function clearPlotAlert() {
|
||
const el = document.getElementById("plot_alert");
|
||
if (el) el.innerHTML = "";
|
||
}
|
||
|
||
function clearPlots() {
|
||
const eq = document.getElementById("plot_strategy");
|
||
if (eq) eq.innerHTML = "";
|
||
|
||
clearRegimeSummary();
|
||
}
|
||
|
||
document.getElementById("lock_inherited")
|
||
.addEventListener("change", applyInheritedLock);
|
||
|
||
init();
|