diff --git a/src/web/api/v2/routers/calibration_strategies.py b/src/web/api/v2/routers/calibration_strategies.py index 5a97c0f..6eb26ab 100644 --- a/src/web/api/v2/routers/calibration_strategies.py +++ b/src/web/api/v2/routers/calibration_strategies.py @@ -5,8 +5,9 @@ import re import uuid from pathlib import Path 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 src.data.storage import StorageManager @@ -76,6 +77,64 @@ def strategy_catalog(): 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) def inspect_strategies( payload: CalibrationStrategiesInspectRequest, @@ -258,8 +317,3 @@ def run_strategies_async( thread.start() return {"job_id": job_id} - - -@router.get("/status/{job_id}") -def get_status(job_id: str): - return WF_JOBS.get(job_id, {"status": "unknown"}) diff --git a/src/web/ui/v2/static/js/pages/calibration_strategies.js b/src/web/ui/v2/static/js/pages/calibration_strategies.js index 842600d..533e8f7 100644 --- a/src/web/ui/v2/static/js/pages/calibration_strategies.js +++ b/src/web/ui/v2/static/js/pages/calibration_strategies.js @@ -6,6 +6,9 @@ let STRATEGY_CATALOG = []; let strategySlots = []; let selectedStrategyId = null; let lastValidationResult = null; +let DATASET_INFO = null; +let PROMOTION_RESULTS = []; +let STEP4_SELECTION = []; 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 // ================================================= @@ -454,6 +503,7 @@ function updateTimeEstimate(globalTotal) { const el = document.getElementById("wf_time_estimate") || document.getElementById("time_estimate"); + if (!el) return; if (!globalTotal || globalTotal <= 0) { @@ -463,31 +513,28 @@ function updateTimeEstimate(globalTotal) { const trainDays = num("wf_train_days") || 180; 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"); - const dateTo = str("date_to"); + // usar metadata real del dataset si ya se ha cargado + let totalDays = Number(DATASET_INFO?.n_days || 0); - let totalDays = 0; - if (dateFrom && dateTo) { - const fromTs = new Date(dateFrom).getTime(); - const toTs = new Date(dateTo).getTime(); - - if (Number.isFinite(fromTs) && Number.isFinite(toTs) && toTs > fromTs) { - totalDays = Math.max(1, Math.ceil((toTs - fromTs) / 86400000)); - } + if (!Number.isFinite(totalDays) || totalDays <= 0) { + // fallback razonable, pero ya no es el camino principal + totalDays = 3650; } - let estimatedWindows = 1; const minSpan = trainDays + testDays; + let estimatedWindows = 1; - if (totalDays > minSpan && stepDays > 0) { + if (totalDays > minSpan) { estimatedWindows = Math.max( 1, Math.floor((totalDays - minSpan) / stepDays) + 1 ); } + const timeframe = str("timeframe") || "1h"; + const barsPerDayMap = { "1m": 1440, "3m": 480, @@ -503,24 +550,20 @@ function updateTimeEstimate(globalTotal) { "1d": 1 }; - const timeframe = str("timeframe") || "1h"; const barsPerDay = barsPerDayMap[timeframe] || 24; - const estimatedBars = Math.max(1, totalDays * barsPerDay); - // Heurística V1: - // - overhead fijo de arranque - // - coste por estrategia - // - 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 overheadSeconds = 3; + const perWindowPerStrategySeconds = 0.25; + const dataSeconds = estimatedBars * globalTotal * 0.00005; const totalSeconds = Math.max( 1, - Math.round(baseSeconds + perStrategySeconds + perWindowSeconds + dataSeconds) + Math.round( + overheadSeconds + + (estimatedWindows * globalTotal * perWindowPerStrategySeconds) + + dataSeconds + ) ); 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) { 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 // ================================================= @@ -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() { document.getElementById("risk_fraction").value = @@ -1768,8 +1882,10 @@ function wireButtons() { renderStrategiesList(strategies); }); - document.getElementById("load_step2_btn")?.addEventListener("click", () => { + document.getElementById("load_step2_btn")?.addEventListener("click", async () => { loadContextFromLocalStorage(); + await fetchDatasetInfo(); + updateCombinationCounter(); }); document.getElementById("close_pdf_btn")?.addEventListener("click", () => { @@ -1804,24 +1920,44 @@ async function init() { loadFromStep2(); applyInheritedLock(); + await fetchDatasetInfo(); + document.getElementById("stop_type") .addEventListener("change", updateStopUI); wireButtons(); + [ - "date_from", - "date_to", "timeframe", "wf_train_days", "wf_test_days", "wf_step_days" ].forEach(id => { const el = document.getElementById(id); - if (el) { - el.addEventListener("change", updateCombinationCounter); - el.addEventListener("input", updateCombinationCounter); + if (!el) return; + + 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(); 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() { if (!Array.isArray(window.lastStrategiesResult) || window.lastStrategiesResult.length === 0) { @@ -2105,7 +2316,10 @@ async function runPromotion() { } const data = await res.json(); - renderPromotionResults(data.results || []); + + PROMOTION_RESULTS = normalizePromotionResults(data.results || []); + renderPromotionResults(PROMOTION_RESULTS); + updateStep4SelectionSummary(); } catch (err) { console.error(err); @@ -2119,7 +2333,7 @@ function renderPromotionResults(results) { const table = document.querySelector("#promotionTable tbody"); table.innerHTML = ""; - results.forEach(r => { + results.forEach((r, idx) => { const tr = document.createElement("tr"); @@ -2128,13 +2342,39 @@ function renderPromotionResults(results) { ? "—" : 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 = `
| Status | Blocked by | Corr | +Step 4 | +Source |
|---|