feat(calibration-step3): complete Strategy Promotion workflow and prepare Step 4 entry

Step 3 is now functionally complete and ready to hand off selected strategies
to the optimization stage (Step 4).

Main additions and improvements:

Strategy Promotion
- Added promotion engine results visualization.
- Promotion statuses supported: promote, review, review_diversity, reject.
- Diversity filter included in promotion results.

Manual Step 4 selection
- Added "Step 4" checkbox column in Promotion table.
- Default selection rules:
  - promote → selected automatically
  - review / review_diversity / reject → unselected by default
- User can manually override any selection.
- Selection source tracked (auto, manual_on, manual_off).

Step 4 selection artifact
- Implemented Step 4 selection builder.
- Generates JSON artifact with selected strategies and parameters.
- Includes promotion metadata (status, score, diversity info).
- Preview shown directly in UI.

Dataset metadata integration
- Added backend endpoint:
  GET /api/v2/calibration/strategies/data-info
- Returns dataset metadata:
  start_date, end_date, n_rows, n_days.
- UI now displays dataset information:
  Dataset: start → end | rows | days | WF windows.

Walk-forward estimation improvements
- WF windows calculated using real dataset size.
- Time estimation updated with calibrated formula:
  ~1.5 seconds per window per strategy.
- Time estimate now robust even without date inputs.

UI improvements
- Dataset info line added to calibration page.
- Promotion table extended with Step 4 controls.
- Step 4 selection summary and JSON preview added.

Architecture
- Step 3 now outputs a clean Step 4 selection artifact.
- Promotion acts as recommendation, not final decision.
- Human override supported.

This commit closes the functional implementation of Step 3
and prepares the pipeline for Step 4 (strategy parameter optimization).
This commit is contained in:
dam
2026-03-09 10:54:07 +01:00
parent f3de09067e
commit 81416630a7
3 changed files with 390 additions and 45 deletions

View File

@@ -5,8 +5,9 @@ import re
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Dict from typing import Dict
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request, Query
from fastapi.responses import JSONResponse, HTMLResponse from fastapi.responses import JSONResponse, HTMLResponse
from src.data.storage import StorageManager from src.data.storage import StorageManager
@@ -76,6 +77,64 @@ def strategy_catalog():
return {"strategies": enriched} return {"strategies": enriched}
@router.get("/status/{job_id}")
def get_status(job_id: str):
return WF_JOBS.get(job_id, {"status": "unknown"})
@router.get("/data-info")
def get_dataset_info(
symbol: str = Query(...),
timeframe: str = Query(...),
storage: StorageManager = Depends(get_storage),
):
df = storage.load_ohlcv(symbol=symbol, timeframe=timeframe)
if df is None or df.empty:
return {
"symbol": symbol,
"timeframe": timeframe,
"start_date": None,
"end_date": None,
"n_rows": 0,
"n_days": 0,
}
# soporta tanto índice datetime como columna timestamp/datetime
if hasattr(df.index, "min") and str(getattr(df.index, "dtype", "")).startswith("datetime"):
start_ts = df.index.min()
end_ts = df.index.max()
elif "timestamp" in df.columns:
ts = pd.to_datetime(df["timestamp"], errors="coerce")
start_ts = ts.min()
end_ts = ts.max()
elif "datetime" in df.columns:
ts = pd.to_datetime(df["datetime"], errors="coerce")
start_ts = ts.min()
end_ts = ts.max()
else:
start_ts = None
end_ts = None
if start_ts is not None and end_ts is not None and pd.notna(start_ts) and pd.notna(end_ts):
n_days = max(1, int((end_ts - start_ts).total_seconds() / 86400))
start_date = pd.Timestamp(start_ts).isoformat()
end_date = pd.Timestamp(end_ts).isoformat()
else:
n_days = 0
start_date = None
end_date = None
return {
"symbol": symbol,
"timeframe": timeframe,
"start_date": start_date,
"end_date": end_date,
"n_rows": int(len(df)),
"n_days": int(n_days),
}
@router.post("/inspect", response_model=CalibrationStrategiesInspectResponse) @router.post("/inspect", response_model=CalibrationStrategiesInspectResponse)
def inspect_strategies( def inspect_strategies(
payload: CalibrationStrategiesInspectRequest, payload: CalibrationStrategiesInspectRequest,
@@ -258,8 +317,3 @@ def run_strategies_async(
thread.start() thread.start()
return {"job_id": job_id} return {"job_id": job_id}
@router.get("/status/{job_id}")
def get_status(job_id: str):
return WF_JOBS.get(job_id, {"status": "unknown"})

View File

@@ -6,6 +6,9 @@ let STRATEGY_CATALOG = [];
let strategySlots = []; let strategySlots = [];
let selectedStrategyId = null; let selectedStrategyId = null;
let lastValidationResult = null; let lastValidationResult = null;
let DATASET_INFO = null;
let PROMOTION_RESULTS = [];
let STEP4_SELECTION = [];
const MAX_STRATEGIES = 10; const MAX_STRATEGIES = 10;
// ================================================= // =================================================
@@ -111,6 +114,52 @@ function sleep(ms) {
} }
function resolveDateRange() {
// 1) Prioridad: configuración heredada de steps anteriores
const cfg =
window.currentConfig ||
window.calibrationConfig ||
lastValidationResult?.config ||
null;
const candidates = [
cfg?.date_from,
cfg?.from_date,
cfg?.start_date,
cfg?.data?.date_from,
cfg?.data?.from_date,
cfg?.data?.start_date,
];
const candidatesTo = [
cfg?.date_to,
cfg?.to_date,
cfg?.end_date,
cfg?.data?.date_to,
cfg?.data?.to_date,
cfg?.data?.end_date,
];
const inheritedFrom = candidates.find(v => !!v) || null;
const inheritedTo = candidatesTo.find(v => !!v) || null;
if (inheritedFrom && inheritedTo) {
return { dateFrom: inheritedFrom, dateTo: inheritedTo };
}
// 2) Fallback: DOM, por si existen en algún layout
const domFrom = str("date_from");
const domTo = str("date_to");
if (domFrom && domTo) {
return { dateFrom: domFrom, dateTo: domTo };
}
// 3) Sin rango explícito
return { dateFrom: null, dateTo: null };
}
// ================================================= // =================================================
// FETCH CATALOG // FETCH CATALOG
// ================================================= // =================================================
@@ -454,6 +503,7 @@ function updateTimeEstimate(globalTotal) {
const el = const el =
document.getElementById("wf_time_estimate") || document.getElementById("wf_time_estimate") ||
document.getElementById("time_estimate"); document.getElementById("time_estimate");
if (!el) return; if (!el) return;
if (!globalTotal || globalTotal <= 0) { if (!globalTotal || globalTotal <= 0) {
@@ -463,31 +513,28 @@ function updateTimeEstimate(globalTotal) {
const trainDays = num("wf_train_days") || 180; const trainDays = num("wf_train_days") || 180;
const testDays = num("wf_test_days") || 30; const testDays = num("wf_test_days") || 30;
const stepDays = num("wf_step_days") || 30; const stepDays = num("wf_step_days") || testDays;
const dateFrom = str("date_from"); // usar metadata real del dataset si ya se ha cargado
const dateTo = str("date_to"); let totalDays = Number(DATASET_INFO?.n_days || 0);
let totalDays = 0; if (!Number.isFinite(totalDays) || totalDays <= 0) {
if (dateFrom && dateTo) { // fallback razonable, pero ya no es el camino principal
const fromTs = new Date(dateFrom).getTime(); totalDays = 3650;
const toTs = new Date(dateTo).getTime();
if (Number.isFinite(fromTs) && Number.isFinite(toTs) && toTs > fromTs) {
totalDays = Math.max(1, Math.ceil((toTs - fromTs) / 86400000));
}
} }
let estimatedWindows = 1;
const minSpan = trainDays + testDays; const minSpan = trainDays + testDays;
let estimatedWindows = 1;
if (totalDays > minSpan && stepDays > 0) { if (totalDays > minSpan) {
estimatedWindows = Math.max( estimatedWindows = Math.max(
1, 1,
Math.floor((totalDays - minSpan) / stepDays) + 1 Math.floor((totalDays - minSpan) / stepDays) + 1
); );
} }
const timeframe = str("timeframe") || "1h";
const barsPerDayMap = { const barsPerDayMap = {
"1m": 1440, "1m": 1440,
"3m": 480, "3m": 480,
@@ -503,24 +550,20 @@ function updateTimeEstimate(globalTotal) {
"1d": 1 "1d": 1
}; };
const timeframe = str("timeframe") || "1h";
const barsPerDay = barsPerDayMap[timeframe] || 24; const barsPerDay = barsPerDayMap[timeframe] || 24;
const estimatedBars = Math.max(1, totalDays * barsPerDay); const estimatedBars = Math.max(1, totalDays * barsPerDay);
// Heurística V1: const overheadSeconds = 3;
// - overhead fijo de arranque const perWindowPerStrategySeconds = 0.25;
// - coste por estrategia const dataSeconds = estimatedBars * globalTotal * 0.00005;
// - coste por ventana
// - pequeño coste por volumen de datos
const baseSeconds = 1.5;
const perStrategySeconds = 0.9 * globalTotal;
const perWindowSeconds = 0.35 * estimatedWindows * globalTotal;
const dataSeconds = estimatedBars * globalTotal * 0.00003;
const totalSeconds = Math.max( const totalSeconds = Math.max(
1, 1,
Math.round(baseSeconds + perStrategySeconds + perWindowSeconds + dataSeconds) Math.round(
overheadSeconds +
(estimatedWindows * globalTotal * perWindowPerStrategySeconds) +
dataSeconds
)
); );
if (totalSeconds < 60) { if (totalSeconds < 60) {
@@ -542,6 +585,27 @@ function updateTimeEstimate(globalTotal) {
} }
function estimateWFWindows() {
if (!DATASET_INFO) return 0;
const trainDays = num("wf_train_days") || 180;
const testDays = num("wf_test_days") || 30;
const stepDays = num("wf_step_days") || testDays;
const totalDays = Number(DATASET_INFO.n_days || 0);
const minSpan = trainDays + testDays;
if (totalDays <= minSpan) return 1;
const windows =
Math.floor((totalDays - minSpan) / stepDays) + 1;
return Math.max(1, windows);
}
function removeStrategySlot(index) { function removeStrategySlot(index) {
strategySlots.splice(index, 1); strategySlots.splice(index, 1);
@@ -550,6 +614,32 @@ function removeStrategySlot(index) {
} }
function renderDatasetInfo() {
const el = document.getElementById("dataset_info");
if (!el) return;
if (!DATASET_INFO) {
el.textContent = "";
return;
}
const start = DATASET_INFO.start_date
? new Date(DATASET_INFO.start_date).toISOString().slice(0, 10)
: "?";
const end = DATASET_INFO.end_date
? new Date(DATASET_INFO.end_date).toISOString().slice(0, 10)
: "?";
const rows = Number(DATASET_INFO.n_rows || 0).toLocaleString();
const days = Number(DATASET_INFO.n_days || 0).toLocaleString();
const windows = estimateWFWindows().toLocaleString();
el.textContent = `Dataset: ${start}${end} | ${rows} rows | ${days} days | ${windows} WF windows`;
}
// ================================================= // =================================================
// PAYLOAD // PAYLOAD
// ================================================= // =================================================
@@ -675,6 +765,30 @@ async function fetchAvailableStrategies() {
} }
async function fetchDatasetInfo() {
const symbol = str("symbol");
const timeframe = str("timeframe");
if (!symbol || !timeframe) {
DATASET_INFO = null;
return null;
}
const qs = new URLSearchParams({ symbol, timeframe });
const res = await fetch(`/api/v2/calibration/strategies/data-info?${qs.toString()}`);
if (!res.ok) {
DATASET_INFO = null;
return null;
}
const data = await res.json();
DATASET_INFO = data;
renderDatasetInfo();
return data;
}
function loadFromStep2() { function loadFromStep2() {
document.getElementById("risk_fraction").value = document.getElementById("risk_fraction").value =
@@ -1768,8 +1882,10 @@ function wireButtons() {
renderStrategiesList(strategies); renderStrategiesList(strategies);
}); });
document.getElementById("load_step2_btn")?.addEventListener("click", () => { document.getElementById("load_step2_btn")?.addEventListener("click", async () => {
loadContextFromLocalStorage(); loadContextFromLocalStorage();
await fetchDatasetInfo();
updateCombinationCounter();
}); });
document.getElementById("close_pdf_btn")?.addEventListener("click", () => { document.getElementById("close_pdf_btn")?.addEventListener("click", () => {
@@ -1804,24 +1920,44 @@ async function init() {
loadFromStep2(); loadFromStep2();
applyInheritedLock(); applyInheritedLock();
await fetchDatasetInfo();
document.getElementById("stop_type") document.getElementById("stop_type")
.addEventListener("change", updateStopUI); .addEventListener("change", updateStopUI);
wireButtons(); wireButtons();
[ [
"date_from",
"date_to",
"timeframe", "timeframe",
"wf_train_days", "wf_train_days",
"wf_test_days", "wf_test_days",
"wf_step_days" "wf_step_days"
].forEach(id => { ].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) { if (!el) return;
el.addEventListener("change", updateCombinationCounter);
el.addEventListener("input", updateCombinationCounter); if (id === "timeframe") {
el.addEventListener("change", async () => {
await fetchDatasetInfo();
updateCombinationCounter();
renderDatasetInfo();
});
el.addEventListener("input", async () => {
await fetchDatasetInfo();
updateCombinationCounter();
renderDatasetInfo();
});
} else {
el.addEventListener("change", () => {
updateCombinationCounter();
renderDatasetInfo();
});
el.addEventListener("input", () => {
updateCombinationCounter();
});
} }
}); });
wirePromotionUI(); wirePromotionUI();
document.getElementById("plot_strategy_select").addEventListener("change", function() { document.getElementById("plot_strategy_select").addEventListener("change", function() {
@@ -2076,6 +2212,81 @@ function renderRegimeSummary(strategyId, s, data) {
// ================================================= // =================================================
function defaultStep4SelectionForStatus(status) {
return status === "promote";
}
function normalizePromotionResults(results) {
return (results || []).map(r => ({
...r,
selected_for_step4: defaultStep4SelectionForStatus(r.status),
selection_source: defaultStep4SelectionForStatus(r.status) ? "auto" : "manual_off"
}));
}
function getStrategyConfigFromSlots(strategyId) {
const slot = (strategySlots || []).find(s => s.strategy_id === strategyId);
if (!slot) return null;
return {
strategy_id: slot.strategy_id,
parameters: { ...(slot.parameters || {}) }
};
}
function collectStep4Selection() {
return (PROMOTION_RESULTS || [])
.filter(r => r.selected_for_step4)
.map(r => {
const cfg = getStrategyConfigFromSlots(r.strategy_id);
return {
strategy_id: r.strategy_id,
promotion_status: r.status,
promotion_score: r.score,
selection_source: r.selection_source,
diversity_blocked_by: r.diversity_blocked_by ?? null,
diversity_correlation: r.diversity_correlation ?? null,
parameters: cfg?.parameters || {}
};
});
}
function updateStep4SelectionSummary() {
const el = document.getElementById("step4SelectionSummary");
if (!el) return;
const selected = (PROMOTION_RESULTS || []).filter(r => r.selected_for_step4).length;
const total = (PROMOTION_RESULTS || []).length;
el.textContent = `Selected for Step 4: ${selected} / ${total}`;
}
function buildStep4SelectionArtifact() {
STEP4_SELECTION = collectStep4Selection();
const previewEl = document.getElementById("step4SelectionPreview");
if (!previewEl) return;
const artifact = {
created_at: new Date().toISOString(),
symbol: str("symbol"),
timeframe: str("timeframe"),
selected_strategies: STEP4_SELECTION
};
previewEl.textContent = JSON.stringify(artifact, null, 2);
previewEl.classList.remove("d-none");
updateStep4SelectionSummary();
}
async function runPromotion() { async function runPromotion() {
if (!Array.isArray(window.lastStrategiesResult) || window.lastStrategiesResult.length === 0) { if (!Array.isArray(window.lastStrategiesResult) || window.lastStrategiesResult.length === 0) {
@@ -2105,7 +2316,10 @@ async function runPromotion() {
} }
const data = await res.json(); const data = await res.json();
renderPromotionResults(data.results || []);
PROMOTION_RESULTS = normalizePromotionResults(data.results || []);
renderPromotionResults(PROMOTION_RESULTS);
updateStep4SelectionSummary();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -2119,7 +2333,7 @@ function renderPromotionResults(results) {
const table = document.querySelector("#promotionTable tbody"); const table = document.querySelector("#promotionTable tbody");
table.innerHTML = ""; table.innerHTML = "";
results.forEach(r => { results.forEach((r, idx) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
@@ -2128,13 +2342,39 @@ function renderPromotionResults(results) {
? "—" ? "—"
: Number(r.diversity_correlation).toFixed(4); : Number(r.diversity_correlation).toFixed(4);
const checked = r.selected_for_step4 ? "checked" : "";
let statusBadge = "bg-secondary-lt text-secondary";
if (r.status === "promote") statusBadge = "bg-success-lt text-success";
else if (r.status === "review") statusBadge = "bg-warning-lt text-warning";
else if (r.status === "review_diversity") statusBadge = "bg-orange-lt text-orange";
else if (r.status === "reject") statusBadge = "bg-danger-lt text-danger";
let sourceBadge = "bg-secondary-lt text-secondary";
let sourceLabel = r.selection_source || "manual_off";
if (sourceLabel === "auto") sourceBadge = "bg-success-lt text-success";
else if (sourceLabel === "manual_on") sourceBadge = "bg-blue-lt text-blue";
else if (sourceLabel === "manual_off") sourceBadge = "bg-secondary-lt text-secondary";
tr.innerHTML = ` tr.innerHTML = `
<td>${r.rank}</td> <td>${r.rank}</td>
<td>${r.strategy_id}</td> <td>${r.strategy_id}</td>
<td>${r.score}</td> <td>${r.score}</td>
<td>${r.status}</td> <td><span class="badge ${statusBadge}">${r.status}</span></td>
<td>${blockedBy}</td> <td>${blockedBy}</td>
<td>${corr}</td> <td>${corr}</td>
<td>
<label class="form-check m-0">
<input
class="form-check-input promotion-step4-checkbox"
type="checkbox"
data-index="${idx}"
${checked}
>
</label>
</td>
<td><span class="badge ${sourceBadge}">${sourceLabel}</span></td>
`; `;
table.appendChild(tr); table.appendChild(tr);
@@ -2144,16 +2384,49 @@ function renderPromotionResults(results) {
const review = results.filter(r => r.status === "review").length; const review = results.filter(r => r.status === "review").length;
const reviewDiversity = results.filter(r => r.status === "review_diversity").length; const reviewDiversity = results.filter(r => r.status === "review_diversity").length;
const reject = results.filter(r => r.status === "reject").length; const reject = results.filter(r => r.status === "reject").length;
const selected = results.filter(r => r.selected_for_step4).length;
document.getElementById("promotionSummary").innerHTML = document.getElementById("promotionSummary").innerHTML =
`Promoted: ${promoted} | Review: ${review} | Review diversity: ${reviewDiversity} | Rejected: ${reject}`; `Promoted: ${promoted} | Review: ${review} | Review diversity: ${reviewDiversity} | Rejected: ${reject} | Step 4 selected: ${selected}`;
updateStep4SelectionSummary();
}
function handlePromotionStep4Toggle(event) {
const target = event.target;
if (!target || !target.classList.contains("promotion-step4-checkbox")) return;
const index = Number(target.dataset.index);
if (!Number.isInteger(index) || !PROMOTION_RESULTS[index]) return;
const row = PROMOTION_RESULTS[index];
row.selected_for_step4 = !!target.checked;
if (row.selected_for_step4) {
row.selection_source = row.status === "promote" ? "auto" : "manual_on";
} else {
row.selection_source = "manual_off";
}
renderPromotionResults(PROMOTION_RESULTS);
} }
function wirePromotionUI() { function wirePromotionUI() {
const btn = document.getElementById("btnRunPromotion"); const btnRun = document.getElementById("btnRunPromotion");
if (btn) { if (btnRun) {
btn.addEventListener("click", runPromotion); btnRun.addEventListener("click", runPromotion);
}
const table = document.querySelector("#promotionTable");
if (table) {
table.addEventListener("change", handlePromotionStep4Toggle);
}
const btnBuild = document.getElementById("btnBuildStep4Selection");
if (btnBuild) {
btnBuild.addEventListener("click", buildStep4SelectionArtifact);
} }
} }

View File

@@ -24,7 +24,7 @@
<div class="flex-grow-1 text-center"> <div class="flex-grow-1 text-center">
<h2 class="mb-0">Calibración · Paso 3 · Strategies</h2> <h2 class="mb-0">Calibración · Paso 3 · Strategies</h2>
<div class="text-secondary">Optimización + Walk Forward (OOS)</div> <div class="text-secondary">Strategy Selector</div>
</div> </div>
<!-- Forward arrow (disabled until OK) --> <!-- Forward arrow (disabled until OK) -->
@@ -245,6 +245,11 @@
<span id="wf_time_estimate">~ 0 sec</span> <span id="wf_time_estimate">~ 0 sec</span>
</small> </small>
</div> </div>
<div class="mt-2 text-end">
<small class="text-muted">
<span id="dataset_info"></span>
</small>
</div>
<div class="mt-3 text-secondary"> <div class="mt-3 text-secondary">
Cada estrategia utiliza parámetros fijos (validación OOS sin grid). Cada estrategia utiliza parámetros fijos (validación OOS sin grid).
</div> </div>
@@ -306,6 +311,17 @@
<div id="promotionSummary" class="mt-3"></div> <div id="promotionSummary" class="mt-3"></div>
<div class="mt-2 d-flex gap-2 flex-wrap">
<button id="btnBuildStep4Selection" class="btn btn-success btn-sm">
Build Step 4 Selection
</button>
</div>
<div id="step4SelectionSummary" class="mt-2 text-secondary small"></div>
<pre id="step4SelectionPreview" class="mt-2 p-2 bg-light border rounded d-none"
style="max-height: 280px; overflow:auto;"></pre>
<table class="table mt-3" id="promotionTable"> <table class="table mt-3" id="promotionTable">
<thead> <thead>
<tr> <tr>
@@ -315,6 +331,8 @@
<th>Status</th> <th>Status</th>
<th>Blocked by</th> <th>Blocked by</th>
<th>Corr</th> <th>Corr</th>
<th>Step 4</th>
<th>Source</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>