Team Ai
Apppublic

javitechjkd/backtestingv2

sourceHugging Faceupdated 9mo agoView on Hugging Face
0likes
components.py780 linesDownload Raw Back to ui
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