javitechjkd/backtestingv2
0
1"""2Componentes de UI reutilizables3"""4 5import streamlit as st6import pandas as pd7from typing import Dict, Any, List, Optional8from datetime import datetime, timedelta9import sys10from pathlib import Path11 12# Add parent directory for imports13sys.path.insert(0, str(Path(__file__).parent.parent.parent))14 15from backtesting_app.core.backtester import BacktestResult, Trade16 17 18def format_currency(value: float) -> str:19 """Formatea valor como moneda"""20 if abs(value) >= 1e6:21 return f"${value/1e6:,.2f}M"22 elif abs(value) >= 1e3:23 return f"${value/1e3:,.2f}K"24 else:25 return f"${value:,.2f}"26 27 28def format_percent(value: float) -> str:29 """Formatea valor como porcentaje"""30 return f"{value:+.2f}%"31 32 33def convert_direction_filter(direction_str: str) -> Optional[str]:34 """35 Convierte la selección de dirección a formato para el backtester36 37 Args:38 direction_str: Valor de la selección ('LONG y SHORT', 'Solo LONG', 'Solo SHORT')39 40 Returns:41 Dirección filtrada ('LONG', 'SHORT', None para ambas)42 """43 if direction_str == 'Solo LONG':44 return 'LONG'45 elif direction_str == 'Solo SHORT':46 return 'SHORT'47 else: # 'LONG y SHORT'48 return None49 50 51def render_sidebar_params(algorithm_name: str, timeframe: str) -> Dict[str, Any]:52 """53 Renderiza los parámetros del algoritmo en el sidebar54 55 Args:56 algorithm_name: Nombre del algoritmo ('FAST' o 'SLOW')57 timeframe: Timeframe seleccionado58 59 Returns:60 Diccionario con los parámetros configurados61 """62 params = {}63 64 st.sidebar.markdown("---")65 st.sidebar.subheader(f"⚙️ Parámetros {algorithm_name}")66 67 if algorithm_name == 'FAST':68 # Parámetros del algoritmo FAST (AriasCampos 5.2)69 params['lookbehind'] = st.sidebar.number_input(70 "Lookbehind",71 min_value=0,72 max_value=20,73 value=0,74 help="Barras hacia atrás para buscar máximos/mínimos (0 = solo vela actual)"75 )76 77 params['stop_resets_support'] = st.sidebar.checkbox(78 "Stop resets support",79 value=True,80 help="Si el stop resetea los niveles de soporte/resistencia"81 )82 83 params['break_margin'] = st.sidebar.number_input(84 "Break Margin (%)",85 min_value=0.0,86 max_value=5.0,87 value=0.0,88 step=0.1,89 help="Margen adicional para confirmar ruptura (0 = usar margen por timeframe)"90 )91 92 # Márgenes por timeframe (solo mostrar el relevante)93 st.sidebar.markdown("##### Márgenes de Soporte/Resistencia")94 95 if timeframe == '4h':96 params['margin_4h'] = st.sidebar.number_input(97 "Margen 4H (%)",98 min_value=0.0,99 max_value=10.0,100 value=0.3,101 step=0.05102 )103 elif timeframe == '1d':104 params['margin_1d'] = st.sidebar.number_input(105 "Margen 1D (%)",106 min_value=0.0,107 max_value=10.0,108 value=0.7,109 step=0.05110 )111 elif timeframe == '1wk':112 params['margin_1wk'] = st.sidebar.number_input(113 "Margen 1W (%)",114 min_value=0.0,115 max_value=10.0,116 value=1.4,117 step=0.05118 )119 elif timeframe == '1mo':120 params['margin_1mo'] = st.sidebar.number_input(121 "Margen 1M (%)",122 min_value=0.0,123 max_value=10.0,124 value=3.0,125 step=0.1126 )127 else:128 params['margin_1h'] = st.sidebar.number_input(129 "Margen 1H (%)",130 min_value=0.0,131 max_value=10.0,132 value=0.15,133 step=0.05134 )135 136 elif algorithm_name == 'SLOW':137 # Parámetros del algoritmo SLOW138 params['lookbehind'] = st.sidebar.number_input(139 "Lookbehind",140 min_value=0,141 max_value=20,142 value=0,143 help="Barras hacia atrás para buscar máximos/mínimos (0 = solo vela actual)"144 )145 146 params['sensibilidad'] = st.sidebar.number_input(147 "Sensibilidad",148 min_value=1,149 max_value=10,150 value=2,151 help="Número de rupturas consecutivas necesarias para cambiar tendencia"152 )153 154 params['offset'] = st.sidebar.number_input(155 "Offset",156 min_value=0,157 max_value=10,158 value=1,159 help="Desplazamiento para buscar máximos/mínimos"160 )161 162 # Márgenes por tipo163 st.sidebar.markdown("##### Márgenes Long")164 165 if timeframe == '4h':166 params['margin_long_support'] = st.sidebar.number_input(167 "Soporte Long 4H (%)", 0.0, 10.0, 0.0, 0.05168 )169 params['margin_long_resistance'] = st.sidebar.number_input(170 "Resistencia Long 4H (%)", 0.0, 10.0, 0.3, 0.05171 )172 elif timeframe == '1d':173 params['margin_long_support'] = st.sidebar.number_input(174 "Soporte Long 1D (%)", 0.0, 10.0, 0.0, 0.05175 )176 params['margin_long_resistance'] = st.sidebar.number_input(177 "Resistencia Long 1D (%)", 0.0, 10.0, 0.7, 0.05178 )179 elif timeframe == '1wk':180 params['margin_long_support'] = st.sidebar.number_input(181 "Soporte Long 1W (%)", 0.0, 10.0, 0.0, 0.05182 )183 params['margin_long_resistance'] = st.sidebar.number_input(184 "Resistencia Long 1W (%)", 0.0, 10.0, 1.4, 0.05185 )186 elif timeframe == '1mo':187 params['margin_long_support'] = st.sidebar.number_input(188 "Soporte Long 1M (%)", 0.0, 10.0, 0.0, 0.1189 )190 params['margin_long_resistance'] = st.sidebar.number_input(191 "Resistencia Long 1M (%)", 0.0, 10.0, 3.0, 0.1192 )193 else:194 params['margin_long_support'] = st.sidebar.number_input(195 "Soporte Long 1H (%)", 0.0, 10.0, 0.0, 0.05196 )197 params['margin_long_resistance'] = st.sidebar.number_input(198 "Resistencia Long 1H (%)", 0.0, 10.0, 0.15, 0.05199 )200 201 st.sidebar.markdown("##### Márgenes Short")202 203 if timeframe == '4h':204 params['margin_short_support'] = st.sidebar.number_input(205 "Soporte Short 4H (%)", 0.0, 10.0, 0.3, 0.05206 )207 params['margin_short_resistance'] = st.sidebar.number_input(208 "Resistencia Short 4H (%)", 0.0, 10.0, 0.0, 0.05209 )210 elif timeframe == '1d':211 params['margin_short_support'] = st.sidebar.number_input(212 "Soporte Short 1D (%)", 0.0, 10.0, 0.7, 0.05213 )214 params['margin_short_resistance'] = st.sidebar.number_input(215 "Resistencia Short 1D (%)", 0.0, 10.0, 0.0, 0.05216 )217 elif timeframe == '1wk':218 params['margin_short_support'] = st.sidebar.number_input(219 "Soporte Short 1W (%)", 0.0, 10.0, 1.4, 0.05220 )221 params['margin_short_resistance'] = st.sidebar.number_input(222 "Resistencia Short 1W (%)", 0.0, 10.0, 0.0, 0.05223 )224 elif timeframe == '1mo':225 params['margin_short_support'] = st.sidebar.number_input(226 "Soporte Short 1M (%)", 0.0, 10.0, 3.0, 0.1227 )228 params['margin_short_resistance'] = st.sidebar.number_input(229 "Resistencia Short 1M (%)", 0.0, 10.0, 0.0, 0.1230 )231 else:232 params['margin_short_support'] = st.sidebar.number_input(233 "Soporte Short 1H (%)", 0.0, 10.0, 0.15, 0.05234 )235 params['margin_short_resistance'] = st.sidebar.number_input(236 "Resistencia Short 1H (%)", 0.0, 10.0, 0.0, 0.05237 )238 239 # Parámetros de trading comunes240 st.sidebar.markdown("---")241 st.sidebar.subheader("🎯 Direcciones")242 243 params['trade_direction'] = st.sidebar.selectbox(244 "Tipo de operaciones",245 options=['LONG y SHORT', 'Solo LONG', 'Solo SHORT'],246 index=0,247 help="Selecciona qué tipo de operaciones ejecutar"248 )249 250 st.sidebar.markdown("---")251 st.sidebar.subheader("💰 Gestión de Capital")252 253 params['initial_capital'] = st.sidebar.number_input(254 "Capital Inicial ($)",255 min_value=100,256 max_value=10000000,257 value=10000,258 step=1000259 )260 261 # Modo de posicionamiento262 position_sizing_mode = st.sidebar.selectbox(263 "Modo de Posición",264 options=['risk', 'fixed'],265 format_func=lambda x: "Riesgo Fijo (% a perder)" if x == 'risk' else "Posición Fija (% de capital)",266 index=0, # Siempre Riesgo Fijo por defecto (según solicitud del usuario)267 help="Riesgo Fijo: Calcula posición basado en distancia al stop. Posición Fija: Usa % fijo del capital por trade."268 )269 params['position_sizing'] = position_sizing_mode270 271 if position_sizing_mode == 'risk':272 params['risk_percent'] = st.sidebar.slider(273 "Riesgo por Trade (%)",274 min_value=0.1,275 max_value=10.0,276 value=1.0,277 step=0.1,278 help="% del capital que estás dispuesto a perder si salta el stop"279 )280 else:281 params['fixed_position_pct'] = st.sidebar.slider(282 "Posición Fija (% capital)",283 min_value=5.0,284 max_value=95.0,285 value=50.0,286 step=5.0,287 help="% del capital a usar por trade (recomendado 50% para timeframes grandes)"288 )289 290 params['commission'] = st.sidebar.number_input(291 "Comisión (%)",292 min_value=0.0,293 max_value=1.0,294 value=0.1,295 step=0.01296 )297 298 return params299 300 301def render_metrics_dashboard(result: BacktestResult):302 """303 Renderiza el dashboard de métricas principales304 """305 # Fila 1: Métricas principales306 col1, col2, col3, col4 = st.columns(4)307 308 with col1:309 color = "🟢" if result.total_return_percent >= 0 else "🔴"310 st.metric(311 f"{color} Retorno Total",312 format_currency(result.total_return),313 format_percent(result.total_return_percent)314 )315 316 with col2:317 st.metric(318 "📊 Win Rate",319 f"{result.win_rate:.1f}%",320 f"{result.winning_trades}W / {result.losing_trades}L"321 )322 323 with col3:324 pf_color = "🟢" if result.profit_factor >= 1 else "🔴"325 st.metric(326 f"{pf_color} Profit Factor",327 f"{result.profit_factor:.2f}",328 "Favorable" if result.profit_factor >= 1.5 else "Normal"329 )330 331 with col4:332 st.metric(333 "📉 Max Drawdown",334 f"{result.max_drawdown_percent:.1f}%",335 format_currency(result.max_drawdown)336 )337 338 # Fila 2: Métricas secundarias339 col5, col6, col7, col8 = st.columns(4)340 341 with col5:342 st.metric(343 "🎯 Total Trades",344 str(result.total_trades),345 f"Avg bars: {result.avg_bars_held:.1f}"346 )347 348 with col6:349 st.metric(350 "📈 Mejor Trade",351 format_percent(result.best_trade),352 ""353 )354 355 with col7:356 st.metric(357 "📉 Peor Trade",358 format_percent(result.worst_trade),359 ""360 )361 362 with col8:363 st.metric(364 "⚡ Sharpe Ratio",365 f"{result.sharpe_ratio:.2f}",366 "Bueno" if result.sharpe_ratio >= 1 else "Bajo"367 )368 369 # Fila 3: Promedios370 col9, col10 = st.columns(2)371 372 with col9:373 st.metric(374 "💰 Ganancia Promedio",375 format_currency(result.avg_win),376 ""377 )378 379 with col10:380 st.metric(381 "💸 Pérdida Promedio",382 format_currency(-result.avg_loss),383 ""384 )385 386 387def render_trades_table(trades: List[Trade], max_rows: int = 50):388 """389 Renderiza tabla de trades390 """391 if not trades:392 st.info("No hay trades para mostrar")393 return394 395 # Convertir trades a DataFrame396 data = []397 for t in trades[:max_rows]:398 # Calcular distancia al stop y tamaño de posición399 sl_distance = abs(t.entry_price - t.initial_sl)400 sl_distance_pct = (sl_distance / t.entry_price) * 100401 position_value = t.position_size * t.entry_price402 leverage = position_value / t.capital_at_entry if t.capital_at_entry > 0 else 0403 404 # Verificar relación entre entrada y stop405 if t.direction == 'LONG':406 sl_check = "✅ SL < Entry" if t.initial_sl < t.entry_price else "⚠️ SL >= Entry"407 else: # SHORT408 sl_check = "✅ SL > Entry" if t.initial_sl > t.entry_price else "⚠️ SL <= Entry"409 410 # Calcular stop loss final (último update o inicial)411 final_sl = t.sl_updates[-1] if t.sl_updates else t.initial_sl412 sl_movement = ((final_sl - t.initial_sl) / t.initial_sl * 100) if t.initial_sl != 0 else 0413 414 # Debug info415 debug_checks = getattr(t, '_debug_checks', 0)416 debug_none = getattr(t, '_debug_none_count', 0)417 418 data.append({419 'Fecha Entrada': t.entry_date.strftime('%Y-%m-%d %H:%M') if t.entry_date else '',420 'Fecha Salida': t.exit_date.strftime('%Y-%m-%d %H:%M') if t.exit_date else '',421 'Dirección': t.direction,422 'Entrada': f"${t.entry_price:,.2f}",423 'Stop Inicial': f"${t.initial_sl:,.2f}",424 'Stop Final': f"${final_sl:,.2f}",425 'SL Movimiento': f"{sl_movement:+.1f}%",426 'Check SL': sl_check,427 'Dist. SL %': f"{sl_distance_pct:.2f}%",428 'Tamaño (unid)': f"{t.position_size:,.1f}",429 'Valor Pos': f"${position_value:,.0f}",430 'Apalancam.': f"{leverage:.2f}x",431 'Salida': f"${t.exit_price:,.2f}" if t.exit_price else '',432 'P&L': f"${t.pnl:,.2f}",433 'P&L %': f"{t.pnl_percent:+.2f}%",434 'Barras': t.bars_held,435 'Razón Salida': t.exit_reason,436 'SL Updates': len(t.sl_updates),437 'Debug Checks': debug_checks,438 'Debug None': debug_none439 })440 441 df = pd.DataFrame(data)442 443 # Mostrar DataFrame con estilo444 if not df.empty and 'P&L' in df.columns and 'P&L %' in df.columns:445 try:446 # Estilo condicional447 def highlight_pnl(val):448 try:449 if isinstance(val, str) and val.startswith('$'):450 num = float(val.replace('$', '').replace(',', ''))451 return 'color: #26a69a' if num >= 0 else 'color: #ef5350'452 elif isinstance(val, str) and '%' in val:453 num = float(val.replace('%', '').replace('+', ''))454 return 'color: #26a69a' if num >= 0 else 'color: #ef5350'455 except:456 pass457 return ''458 459 styled_df = df.style.map(highlight_pnl, subset=['P&L', 'P&L %'])460 st.dataframe(styled_df, use_container_width=True, hide_index=True)461 except (AttributeError, KeyError) as e:462 # Fallback sin estilos si hay problemas463 st.dataframe(df, use_container_width=True, hide_index=True)464 else:465 st.dataframe(df, use_container_width=True, hide_index=True)466 467 if len(trades) > max_rows:468 st.caption(f"Mostrando {max_rows} de {len(trades)} trades")469 470 471def render_symbol_selector(472 available_symbols: Dict[str, Dict[str, str]],473 key: str = "symbol"474) -> str:475 """476 Renderiza selector de símbolos por categoría477 """478 # Selector de categoría479 categories = list(available_symbols.keys())480 # Por defecto: Crypto (donde está BTCUSD)481 default_category_idx = categories.index('Crypto') if 'Crypto' in categories else 0482 selected_category = st.selectbox(483 "Categoría",484 categories,485 index=default_category_idx,486 key=f"{key}_category"487 )488 489 # Selector de símbolo dentro de la categoría490 symbols_in_category = available_symbols[selected_category]491 symbol_options = list(symbols_in_category.keys())492 symbol_names = [f"{s} - {symbols_in_category[s]}" for s in symbol_options]493 494 # Por defecto: BTC-USD si existe en la categoría495 default_symbol_idx = 0496 if 'BTC-USD' in symbol_options:497 default_symbol_idx = symbol_options.index('BTC-USD')498 499 selected_idx = st.selectbox(500 "Activo",501 range(len(symbol_options)),502 format_func=lambda x: symbol_names[x],503 index=default_symbol_idx,504 key=f"{key}_symbol"505 )506 507 return symbol_options[selected_idx]508 509 510def render_multi_symbol_selector(511 available_symbols: Dict[str, Dict[str, str]],512 key: str = "multi_symbol"513) -> List[str]:514 """515 Renderiza selector múltiple de símbolos516 """517 # Aplanar todos los símbolos518 all_symbols = {}519 for category, symbols in available_symbols.items():520 for symbol, name in symbols.items():521 all_symbols[symbol] = f"[{category}] {symbol} - {name}"522 523 # Multiselect524 selected = st.multiselect(525 "Seleccionar Activos",526 options=list(all_symbols.keys()),527 format_func=lambda x: all_symbols[x],528 default=['BTC-USD', 'ETH-USD'] if 'BTC-USD' in all_symbols else list(all_symbols.keys())[:2],529 key=key530 )531 532 return selected533 534 535def render_timeframe_selector(key: str = "timeframe") -> str:536 """537 Renderiza selector de timeframe538 """539 timeframes = {540 '1h': '1 Hora',541 '4h': '4 Horas',542 '1d': '1 Día',543 '1wk': '1 Semana',544 '1mo': '1 Mes'545 }546 547 selected = st.selectbox(548 "Timeframe",549 options=list(timeframes.keys()),550 format_func=lambda x: timeframes[x],551 index=4, # 1mo por defecto (últimas velas mensuales)552 key=key553 )554 555 return selected556 557 558def render_date_range_selector(key: str = "date_range"):559 """560 Renderiza selector de rango de fechas profesional con:561 - Períodos predefinidos (rápidos)562 - Selector de mes específico563 - Rango personalizado (calendario)564 """565 st.subheader("📅 Seleccionar Período")566 567 default_end = datetime.now()568 default_start_date = (default_end - timedelta(days=365)).date()569 default_end_date = default_end.date()570 571 # Mapas de períodos rápidos572 period_map = {573 "1m": timedelta(days=30),574 "3m": timedelta(days=90),575 "6m": timedelta(days=180),576 "1y": timedelta(days=365),577 "2y": timedelta(days=730),578 "5y": timedelta(days=1825),579 "10y": timedelta(days=3650),580 "all": timedelta(days=10000),581 }582 583 # Valores por defecto para retornar584 start_date = datetime.combine(default_start_date, datetime.min.time())585 end_date = datetime.combine(default_end_date, datetime.min.time())586 587 # Pestañas para diferentes modos588 tab1, tab2, tab3 = st.tabs(["⚡ Rápido", "📆 Mes Específico", "📋 Rango Personalizado"])589 590 with tab1:591 st.markdown("##### 🎯 Períodos Predefinidos")592 st.write("Elige un período rápidamente con un solo click:")593 594 # Grid de 2x4 para los botones595 col1, col2, col3, col4 = st.columns(4)596 597 with col1:598 if st.button("📊\nÚltimo Mes", key=f"{key}_1m", use_container_width=True):599 st.session_state[f"{key}_mode"] = "quick"600 st.session_state[f"{key}_quick"] = "1m"601 602 with col2:603 if st.button("📈\n3 Meses", key=f"{key}_3m", use_container_width=True):604 st.session_state[f"{key}_mode"] = "quick"605 st.session_state[f"{key}_quick"] = "3m"606 607 with col3:608 if st.button("📊\n6 Meses", key=f"{key}_6m", use_container_width=True):609 st.session_state[f"{key}_mode"] = "quick"610 st.session_state[f"{key}_quick"] = "6m"611 612 with col4:613 if st.button("📈\n1 Año", key=f"{key}_1y", use_container_width=True):614 st.session_state[f"{key}_mode"] = "quick"615 st.session_state[f"{key}_quick"] = "1y"616 617 col5, col6, col7, col8 = st.columns(4)618 619 with col5:620 if st.button("📊\n2 Años", key=f"{key}_2y", use_container_width=True):621 st.session_state[f"{key}_mode"] = "quick"622 st.session_state[f"{key}_quick"] = "2y"623 624 with col6:625 if st.button("📈\n5 Años", key=f"{key}_5y", use_container_width=True):626 st.session_state[f"{key}_mode"] = "quick"627 st.session_state[f"{key}_quick"] = "5y"628 629 with col7:630 if st.button("📊\n10 Años", key=f"{key}_10y", use_container_width=True):631 st.session_state[f"{key}_mode"] = "quick"632 st.session_state[f"{key}_quick"] = "10y"633 634 with col8:635 if st.button("📈\nDesde Inicio", key=f"{key}_all", use_container_width=True):636 st.session_state[f"{key}_mode"] = "quick"637 st.session_state[f"{key}_quick"] = "all"638 639 # Procesar choice640 if f"{key}_quick" in st.session_state:641 period_map = {642 "1m": timedelta(days=30),643 "3m": timedelta(days=90),644 "6m": timedelta(days=180),645 "1y": timedelta(days=365),646 "2y": timedelta(days=730),647 "5y": timedelta(days=1825),648 "10y": timedelta(days=3650),649 "all": timedelta(days=10000),650 }651 period = period_map.get(st.session_state[f"{key}_quick"], timedelta(days=365))652 start_date = default_end - period653 end_date = default_end654 655 st.success(f"✅ Período: {start_date.strftime('%Y-%m-%d')} → {end_date.strftime('%Y-%m-%d')} ({(end_date - start_date).days} días)")656 657 with tab2:658 st.markdown("##### 📆 Selecciona Año y Mes")659 st.write("Elige un mes completo de forma precisa:")660 661 col1, col2, col3 = st.columns([1, 1, 1.2])662 663 with col1:664 year = st.selectbox(665 "Año",666 options=list(range(2020, datetime.now().year + 1))[::-1],667 index=0,668 key=f"{key}_year"669 )670 671 with col2:672 months_es = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",673 "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"]674 month = st.selectbox(675 "Mes",676 options=list(range(1, 13)),677 format_func=lambda x: months_es[x-1],678 index=datetime.now().month - 1,679 key=f"{key}_month"680 )681 682 # Calcular primer y último día del mes683 start_date = datetime(year, month, 1)684 if month == 12:685 end_date = datetime(year + 1, 1, 1) - timedelta(days=1)686 else:687 end_date = datetime(year, month + 1, 1) - timedelta(days=1)688 689 # Información visual mejorada690 with col3:691 st.empty() # Espacio para alineación692 693 st.divider()694 695 # Mostrar el resultado en cards visuales696 col_a, col_b, col_c = st.columns(3)697 698 with col_a:699 st.metric("📅 Mes", f"{months_es[month-1]} {year}", delta=None, label_visibility="visible")700 701 with col_b:702 st.metric("📊 Días", f"{(end_date - start_date).days + 1} días", delta=None, label_visibility="visible")703 704 with col_c:705 st.metric("📈 Rango", f"{(end_date - start_date).days + 1}d", delta=None, label_visibility="visible")706 707 st.caption(f"🔹 Desde: {start_date.strftime('%d %b %Y')} → Hasta: {end_date.strftime('%d %b %Y')}")708 709 with tab3:710 st.markdown("##### 🗓️ Rango Personalizado")711 st.write("Define un rango de fechas flexible con el calendario:")712 713 col1, col2 = st.columns(2)714 715 with col1:716 start_date_input = st.date_input(717 "📅 Fecha Inicio",718 value=default_start_date,719 key=f"{key}_start"720 )721 722 with col2:723 end_date_input = st.date_input(724 "📅 Fecha Fin",725 value=default_end_date,726 key=f"{key}_end"727 )728 729 st.divider()730 731 # Convertir a date si es necesario732 start_date_obj = start_date_input.date() if isinstance(start_date_input, datetime) else start_date_input733 end_date_obj = end_date_input.date() if isinstance(end_date_input, datetime) else end_date_input734 735 days = (end_date_obj - start_date_obj).days736 years = days / 365.25737 738 # Mostrar error si las fechas están invertidas739 if days < 0:740 st.error("❌ La fecha de inicio debe ser anterior a la de fin")741 elif days == 0:742 st.warning("⚠️ Las fechas son iguales. Selecciona al menos 1 día.")743 744 # Mostrar métricas siempre745 col_x, col_y, col_z = st.columns(3)746 747 with col_x:748 st.metric("📊 Días", f"{max(0, days)}", delta=None, label_visibility="visible")749 750 with col_y:751 st.metric("📈 Años", f"{max(0, years):.2f}", delta=None, label_visibility="visible")752 753 with col_z:754 st.metric("🔹 Velas (1h)", f"{max(0, days * 24):,}", delta=None, label_visibility="visible")755 756 # Mensaje de éxito solo si es válido757 if days > 0:758 st.success(f"✅ Período válido: {days} días (~{years:.1f} años)")759 760 # Actualizar valores para retornar si es válido761 if days > 0:762 start_date = datetime.combine(start_date_obj, datetime.min.time())763 end_date = datetime.combine(end_date_obj, datetime.min.time())764 765 # Retornar al final (se actualiza según qué tab estés usando)766 # Prioridad: custom > month > quick > default767 mode = st.session_state.get(f"{key}_mode", "quick")768 769 if mode == "month" and f"{key}_month_range" in st.session_state:770 start_date, end_date = st.session_state[f"{key}_month_range"]771 elif mode == "custom" and f"{key}_custom_range" in st.session_state:772 start_date, end_date = st.session_state[f"{key}_custom_range"]773 elif mode == "quick" and f"{key}_quick" in st.session_state:774 period = period_map.get(st.session_state[f"{key}_quick"], timedelta(days=365))775 start_date = default_end - period776 end_date = default_end777 778 return start_date, end_date779 780 