Team Ai
Apppublic

diegobeyl/backtesting

sourceHugging Faceupdated 9mo agoView on Hugging Face
2likes
data_loader.py1057 linesDownload Raw Back to core
1"""2Cargador de datos multi-fuente3Soporta: Yahoo Finance4"""5 6import pandas as pd7import time8import os9from typing import Optional, List, Dict10from datetime import datetime, timedelta11import streamlit as st12 13import yfinance as yf14import requests15 16 17def _fix_multiindex_columns(df: pd.DataFrame) -> pd.DataFrame:18    """19    Arregla columnas MultiIndex de yfinance20 21    yf.download() a veces devuelve columnas como tuplas (MultiIndex)22    incluso para un solo símbolo. Esta función lo normaliza.23    """24    if df.empty:25        return df26 27    # Si las columnas son tuplas (MultiIndex), quitar el segundo nivel28    if isinstance(df.columns[0], tuple):29        df.columns = df.columns.droplevel(1)30 31    # Capitalizar nombres de columnas para estandarizar32    df.columns = [col.capitalize() if isinstance(col, str) else col for col in df.columns]33 34    return df35 36 37# Fuentes de datos disponibles38DATA_SOURCES = {39    'yahoo': 'Yahoo Finance',40}41 42# Mapeo de símbolos por fuente43SYMBOL_MAPPING = {}44 45# Mapeo de timeframes por fuente46TIMEFRAME_MAPPING = {47    'yahoo': {48        '1m': '1m', '5m': '5m', '15m': '15m', '30m': '30m',49        '1h': '1h', '4h': '4h', '1d': '1d', '1wk': '1wk', '1mo': '1mo'50    },51}52 53 54# Símbolos disponibles organizados por categoría55AVAILABLE_SYMBOLS: Dict[str, Dict[str, str]] = {56    '🇺🇸 USA - Large Cap (Dow Jones, S&P 500, Nasdaq €30M+ mensual)': {57        'AAPL': 'Apple',58        'MSFT': 'Microsoft',59        'GOOGL': 'Alphabet Google',60        'AMZN': 'Amazon',61        'META': 'Meta',62        'NVDA': 'NVIDIA',63        'TSLA': 'Tesla',64        'JNJ': 'Johnson & Johnson',65        'V': 'Visa',66        'WMT': 'Walmart',67        'JPM': 'JPMorgan',68        'MCD': 'McDonald\'s',69        'INTC': 'Intel',70        'GOOG': 'Alphabet',71        'NFLX': 'Netflix',72        'AMD': 'AMD',73        'PYPL': 'PayPal',74        'AVGO': 'Broadcom',75        'ADBE': 'Adobe',76        'CRM': 'Salesforce',77        'ACN': 'Accenture',78        'CSCO': 'Cisco',79        'INTC': 'Intel',80        'QCOM': 'Qualcomm',81        'TXN': 'Texas Instruments',82        'MU': 'Micron',83        'LRCX': 'Lam Research',84        'AMAT': 'Applied Materials',85        'KLAC': 'KLA',86        'MRVL': 'Marvell',87        'ASML': 'ASML Holdings',88        'NXPI': 'NXP',89        'SANM': 'Sanmina',90        'SMCI': 'Super Micro',91        'TSM': 'TSMC',92        'VLO': 'Valero',93        'WMB': 'Williams',94        'OKE': 'ONEOK',95        'KMI': 'Kinder Morgan',96        'SPY': 'S&P 500 ETF',97        'QQQ': 'Nasdaq 100 ETF',98        'DIA': 'Dow Jones ETF',99        'IWM': 'Russell 2000 ETF',100        'EEM': 'Emerging Markets ETF',101        'EFA': 'Int\'l Developed ETF',102        'AGG': 'Bond ETF',103        'BND': 'Total Bond ETF',104        'BAC': 'Bank of America',105        'WFC': 'Wells Fargo',106        'C': 'Citigroup',107        'USB': 'US Bancorp',108        'PNC': 'PNC Financial',109        'TFC': 'Truist',110        'COF': 'Capital One',111        'GS': 'Goldman Sachs',112        'MS': 'Morgan Stanley',113        'BX': 'Blackstone',114        'KKR': 'KKR',115        'APO': 'Apollo',116        'SCHW': 'Schwab',117        'BLK': 'BlackRock',118        'SPLG': 'SPDR Portfolio',119        'XLV': 'Healthcare ETF',120        'XLK': 'Tech ETF',121        'XLY': 'Consumer ETF',122        'XLE': 'Energy ETF',123        'XLRE': 'Real Estate ETF',124        'XLP': 'Staples ETF',125        'XLI': 'Industrial ETF',126        'CRH': 'CRH',127        'NRG': 'NRG Energy',128        'EXC': 'Exelon',129        'SO': 'Southern Company',130        'DUK': 'Duke Energy',131        'AEP': 'American Electric Power',132        'XEL': 'Xcel Energy',133        'CMS': 'CMS Energy',134        'DTE': 'DTE Energy',135        'AWK': 'American Water',136        'NEE': 'NextEra',137        'LIN': 'Linde',138        'APD': 'Air Products',139        'ECL': 'Ecolab',140        'HLT': 'Hilton',141        'IBM': 'IBM',142        'MT': 'Mittal Steel',143        'CLF': 'Cleveland-Cliffs',144        'NRG': 'NRG Energy',145        'EXC': 'Exelon',146        'SO': 'Southern Company',147        'DUK': 'Duke Energy',148        'AEP': 'American Electric Power',149        'XEL': 'Xcel Energy',150        'CMS': 'CMS Energy',151        'DTE': 'DTE Energy',152        'AWK': 'American Water',153        'NEE': 'NextEra Energy',154        'LIN': 'Linde',155        'APD': 'Air Products',156        'ECL': 'Ecolab',157        'HLT': 'Hilton Hotels',158        'MAR': 'Marriott',159        'RCL': 'Royal Caribbean',160        'LUV': 'Southwest Airlines',161        'UAL': 'United Airlines',162        'DAL': 'Delta Air Lines',163        'ALK': 'Alaska Air Group',164        'RBRK': 'Rubrik',165        'ASTS': 'AST SpaceMobile',166        'OKLO': 'Oklo Inc.',167        'NNE': 'Nano Nuclear Energy Inc.',168        'SOUN': 'Soundtrack',169        'SOFI': 'SoFi Technologies',170        'UPST': 'Upstart',171        'DKNG': 'DraftKings',172        'PENN': 'Penn Entertainment',173        'MSGS': 'MSG Sports',174        'MSGS': 'MSG Entertainment',175        'NCLH': 'Norwegian Cruise Line',176        'CCL': 'Carnival Corporation',177        'F': 'Ford',178        'GM': 'General Motors',179        'TM': 'Toyota',180        'HMC': 'Honda',181        'VWAGY': 'Volkswagen',182        'NIO': 'NIO',183        'LI': 'Li Auto',184        'XPEV': 'XPeng',185        'BBBY': 'Bed Bath Beyond',186        'PTON': 'Peloton',187        'LULU': 'Lululemon',188        'GIII': 'G-III Apparel',189        'AEO': 'American Eagle',190        'KSS': 'Kohl\'s',191        'M': 'Macy\'s',192        'DDS': 'Dillard\'s',193        'DECK': 'Deckers Outdoor',194        'CROX': 'Crocs',195        'DNOW': 'DistributionNOW',196        'DXCM': 'Dexcom',197        'INMD': 'Inmode',198        'INTU': 'Intuit',199        'QUAD': 'Quadratec',200        'QUBT': 'Quantum Computing',201        'RIOT': 'Riot Blockchain',202        'MARA': 'Marathon Digital',203        'CLSK': 'CleanSpark',204        'HUT': 'Hut 8 Mining',205        'COIN': 'Coinbase',206        'MSTR': 'Microstrategy',207        'AFRM': 'Affirm',208        'OPEN': 'Opendoor',209        'RBLX': 'Roblox',210        'U': 'Unity Software',211        'TTD': 'Trade Desk',212        'ESTC': 'Elastic',213        'DDOG': 'Datadog',214        'OKTA': 'Okta',215        'NET': 'Cloudflare',216        'FIVN': 'Five9',217        'ZM': 'Zoom',218        'CRWD': 'CrowdStrike',219        'CHKP': 'Check Point',220        'CVLT': 'Cavalier Therapeutics',221        'VEEV': 'Veeva Systems',222        'NOW': 'ServiceNow',223        'ORCL': 'Oracle',224        'SAP': 'SAP SE',225        'ADSK': 'Autodesk',226        'SYNA': 'Synaptics',227        'MANH': 'Manhattan Associates',228        'WDAY': 'Workday',229        'SNOW': 'Snowflake',230        'DBX': 'Dropbox',231        'GDDY': 'GoDaddy',232        'MNST': 'Monster Beverage',233        'KO': 'Coca-Cola',234        'PEP': 'PepsiCo',235        'KMB': 'Kimberly-Clark',236        'CL': 'Colgate-Palmolive',237        'PG': 'Procter & Gamble',238        'EL': 'Estée Lauder',239        'UL': 'Unilever',240        'CLX': 'Clorox',241        'HSII': 'Heidrick & Struggles',242        'MO': 'Altria',243        'PM': 'Philip Morris',244        'BTI': 'British American Tobacco',245        'SMPL': 'Sample Holdings',246        'CRSR': 'Corsair',247        'RIOT': 'Riot Blockchain',248        'TLRY': 'Tilray',249        'SNDL': 'Sundial Growers',250        'ACB': 'Aurora Cannabis',251        'CGC': 'Canopy Growth',252        'CURLF': 'Curaleaf',253        'TCNNF': 'Tetra Bio-Pharma',254        'HITI': 'High Tide',255        'SNDL': 'Sundial Growers',256        'VECO': 'Veeco Instruments',257        'WING': 'Wingman Inc',258        'YEXT': 'Yext',259        'ZETA': 'Zeta Global',260        'ZION': 'Zion Bancorp',261    },262    '🇪🇸 España': {263        'SAN.MC': 'Banco Santander',264        'BBVA.MC': 'BBVA',265        'IBE.MC': 'Iberdrola',266        'ELE.MC': 'Endesa',267        'TEF.MC': 'Telefónica',268        'ITX.MC': 'Inditex',269        'ACS.MC': 'ACS',270        'REP.MC': 'Repsol',271        'AENA.MC': 'AENA',272        'MAP.MC': 'Mapfre',273        'AMS.MC': 'Amstrad',274        'ANA.MC': 'Acciona',275        'ANE.MC': 'Acciona Energía',276        'CLNX.MC': 'Cellnex',277        'COL.MC': 'Colombier',278        'DIA.MC': 'Distribuidora Internacional de Alimentación',279        'ENG.MC': 'Enagás',280        'FER.MC': 'Ferrovial',281        'GRF.MC': 'Grifols',282        'IAG.MC': 'International Airlines Group',283        'IDR.MC': 'Indra Sistemas',284        'MEL.MC': 'Meliá Hotels',285        'SLR.MC': 'Solaria',286        'VIS.MC': 'Vistaprint',287        'CABK.MC': 'CaixaBank',288        'SCYR.MC': 'Scyres',289        'UNI.MC': 'Unipago',290    },291    '🇩🇪 Alemania - DAX': {292        'SAP.DE': 'SAP',293        'SIE.DE': 'Siemens',294        'ALV.DE': 'Allianz',295        'MUV2.DE': 'Munich Re',296        'LIN.DE': 'Linde',297        'IFX.DE': 'Infineon',298        'DB1.DE': 'Deutsche Börse',299        'RWE.DE': 'RWE',300        'VOW3.DE': 'Volkswagen',301        'BMW.DE': 'BMW',302        'BAYN.DE': 'Bayer (Non-Voting)',303        'MRK.DE': 'Merck KGaA',304        'HEI.DE': 'Heidelberg Cement',305        'FRE.DE': 'Fresenius',306        'FME.DE': 'Fresenius Medical Care',307        'HNR1.DE': 'Henkel',308        'ENR.DE': 'Eon SE',309        'EXS1.DE': 'Exel Industries',310        'PUM.DE': 'Puma SE',311        'TKA.DE': 'ThyssenKrupp',312        'LHA.DE': 'Deutsche Lufthansa',313        'VNA.DE': 'Vonovia SE',314        'ZAL.DE': 'Zalando SE',315        'EOAN.DE': 'E.ON SE',316        'BMW.DE': 'BMW Group',317        'BAS.DE': 'BASF',318        'CBK.DE': 'Commerzbank',319        'DTE.DE': 'Deutsche Telekom',320        'EVD.DE': 'Evotec SE',321        'HYQ.DE': 'Hyundai Engineering',322    },323    '🇩🇪 Alemania - SDAX (Mid-cap, €30M+ mensual)': {324        'NEM.DE': 'Nemetschek SE',325        'MUV2.DE': 'Munich Re',326        'EOAN.DE': 'E.ON SE',327        'ENR.DE': 'Encavis AG',328        'HEI.DE': 'Heidelberg Materials',329        'VNA.DE': 'Vonovia SE',330        'LEG.DE': 'LEG Immobilien SE',331        'PUM.DE': 'Puma SE',332        'QIA.DE': 'Qiagen NV',333        'SDF.DE': 'Südzucker AG',334    },335    '🇩🇪 Alemania - TecDAX (Tech €30M+ mensual)': {336        'IFX.DE': 'Infineon Technologies',337        'EVD.DE': 'Evotec SE',338        'VBK.DE': 'Villeroy & Boch',339    },340    '🇩🇪 Alemania - SETRA (Small-cap €30M+ mensual)': {341        'ADS.DE': 'Adidas AG',342        'BAS.DE': 'BASF SE',343        'BMW.DE': 'BMW AG',344        'DBK.DE': 'Deutsche Bank AG',345        'DTE.DE': 'Deutsche Telekom AG',346        'FRE.DE': 'Fresenius SE & Co. KGaA',347        'FME.DE': 'Fresenius Medical Care AG & Co. KGaA',348        'IFX.DE': 'Infineon Technologies AG',349        'LIN.DE': 'Linde plc',350        'RWE.DE': 'RWE AG',351        'SIE.DE': 'Siemens AG',352        'VOW3.DE': 'Volkswagen AG',353        'VNA.DE': 'Vonovia SE',354        'ZAL.DE': 'Zalando SE',355        'PUM.DE': 'Puma SE',356        'HEI.DE': 'Heidelberg Materials',357        'NEM.DE': 'Nemetschek SE',358        'SDF.DE': 'Südzucker AG',359        'CON.DE': 'Continental AG',360    },361    '🇫🇷 Francia - CAC 40': {362        'MC.PA': 'LVMH Moët Hennessy',363        'OR.PA': 'L\'Oréal',364        'TTE.PA': 'TotalEnergies',365        'SAN.PA': 'Sanofi',366        'BNP.PA': 'BNP Paribas',367        'RMS.PA': 'Hermès International',368        'DSY.PA': 'Dassault Systèmes',369        'AIR.PA': 'Airbus',370        'SAF.PA': 'Safran',371        'KER.PA': 'Kering',372        'LR.PA': 'Legrand',373        'SU.PA': 'Schneider Electric',374        'ENGI.PA': 'ENGIE',375        'CA.PA': 'Carrefour',376        'CAP.PA': 'Capgemini',377        'VIE.PA': 'Veolia',378        'VIV.PA': 'Vivendi',379        'SGO.PA': 'Saint-Gobain',380        'RI.PA': 'Pernod Ricard',381        'HO.PA': 'Thales',382        'PUB.PA': 'Publicis Groupe',383        'NOKIA.PA': 'Nokia',384        'ML.PA': 'Michelin',385        'NOKIA.PA': 'Nokia OYJ',386        'FR.PA': 'Accor',387        'GLE.PA': 'Société Générale',388        'ACA.PA': 'Crédit Agricole',389        'NOKIA.PA': 'Nokia',390    },391    '🇬🇧 UK': {392        'SHEL.L': 'Shell',393        'BP.L': 'BP',394        'HSBA.L': 'HSBC',395        'GSK.L': 'GSK',396        'AZN.L': 'AstraZeneca',397        'RIO.L': 'Rio Tinto',398        'ULVR.L': 'Unilever',399        'LLOY.L': 'Lloyds Banking Group',400        'VOD.L': 'Vodafone',401        'TSCO.L': 'Tesco',402        'BARC.L': 'Barclays',403        'PRU.L': 'Prudential',404    },405    '🇨🇦 Canadá': {406        'TD.TO': 'Toronto-Dominion Bank',407        'RY.TO': 'Royal Bank of Canada',408        'BNS.TO': 'Bank of Nova Scotia',409        'BCE.TO': 'BCE Inc',410        'ENB.TO': 'Enbridge',411        'SU.TO': 'Suncor Energy',412        'CNQ.TO': 'Canadian Natural Resources',413        'SHOP.TO': 'Shopify',414        'BMO.TO': 'Bank of Montreal',415    },416    '🇦🇺 Australia': {417        'CBA.AX': 'Commonwealth Bank',418        'ANZ.AX': 'ANZ Group',419        'NAB.AX': 'National Australia Bank',420        'WBC.AX': 'Westpac Banking',421        'WES.AX': 'Wesfarmers',422        'CSL.AX': 'CSL Ltd',423        'BHP.AX': 'BHP Group',424        'TLS.AX': 'Telstra',425        'WOW.AX': 'Woolworths Group',426    },427    '🇨🇳 China - ADR': {428        'BABA': 'Alibaba',429        'TCEHY': 'Tencent',430        'BIDU': 'Baidu',431        'JD': 'JD.com',432        'NTES': 'NetEase',433        'XPEV': 'XPeng',434        'NIO': 'NIO',435        'LI': 'Li Auto',436        'PDD': 'Pinduoduo',437        'BILI': 'Bilibili',438        'IQ': 'iQIYI',439    },440    '₿ Criptomonedas': {441        'BTC-USD': 'Bitcoin',442        'ETH-USD': 'Ethereum',443        'BNB-USD': 'Binance Coin',444        'SOL-USD': 'Solana',445        'XRP-USD': 'Ripple',446        'ADA-USD': 'Cardano',447        'DOGE-USD': 'Dogecoin',448        'MATIC-USD': 'Polygon',449        'DOT-USD': 'Polkadot',450        'AVAX-USD': 'Avalanche',451        'LINK-USD': 'Chainlink',452        'UNI-USD': 'Uniswap',453        'LTC-USD': 'Litecoin',454        'ATOM-USD': 'Cosmos',455        'SHIB-USD': 'Shiba Inu',456    },457    '🥇 Metales Preciosos': {458        'GLD': 'Oro (ETF)',459        'SLV': 'Plata (ETF)',460        'GC=F': 'Oro Futuros',461        'SI=F': 'Plata Futuros',462        'PALL': 'Paladio (ETF)',463        'PPLT': 'Platino (ETF)',464    },465}466 467 468def get_all_symbols() -> List[str]:469    """Retorna lista plana de todos los símbolos disponibles"""470    all_symbols = []471    for category_symbols in AVAILABLE_SYMBOLS.values():472        all_symbols.extend(category_symbols.keys())473    return all_symbols474 475 476def get_available_sources_for_symbol(symbol: str) -> List[str]:477    """Retorna las fuentes disponibles para un símbolo específico"""478    return ['yahoo']  # Solo Yahoo Finance disponible479 480 481# Timeframes disponibles482AVAILABLE_TIMEFRAMES = {483    '1m': '1 Minuto',484    '2m': '2 Minutos',485    '5m': '5 Minutos',486    '15m': '15 Minutos',487    '30m': '30 Minutos',488    '1h': '1 Hora',489    '4h': '4 Horas',490    '1d': 'Diario',491    '1wk': 'Semanal',492    '1mo': 'Mensual',493}494 495 496class DataLoader:497    """498    Cargador de datos históricos499    Soporta: Yahoo Finance500    """501    502    def __init__(self):503        self._cache = {}504    505    @staticmethod506    def download(507        symbol: str,508        interval: str = '1d',509        start_date: str = None,510        end_date: str = None,511        **kwargs512    ) -> pd.DataFrame:513        """514        Descarga datos históricos515        Usa Twelve Data + Yahoo Finance para historial >730 días516 517        Args:518            symbol: Símbolo del activo (ej: 'BTC-USD', 'AAPL')519            interval: Intervalo de las velas520            start_date: Fecha de inicio (YYYY-MM-DD)521            end_date: Fecha de fin (YYYY-MM-DD)522            api_key: API key de Twelve Data (opcional)523 524        Returns:525            DataFrame con columnas OHLCV526        """527        # Verificar si necesitamos usar Twelve Data528        api_key = kwargs.get('api_key')529        use_twelvedata = False530 531        if api_key and interval in ['1h', '4h', '1d']:532            # Calcular días del rango533            if start_date and end_date:534                try:535                    start = datetime.strptime(start_date, '%Y-%m-%d')536                    end = datetime.strptime(end_date, '%Y-%m-%d')537                    days_diff = (end - start).days538 539                    # Si excede o iguala 730 días (límite de Yahoo para 1h/4h), usar Twelve Data540                    if days_diff >= 730:541                        use_twelvedata = True542                        # st.info(f"📊 Período: {days_diff} días. Usando Twelve Data + Yahoo Finance...")543                except:544                    pass545 546        if use_twelvedata:547            # Para 4h, Twelve Data descarga en 1h y luego resampleamos548            if interval == '4h':549                return DataLoader._download_and_resample_4h_combined(550                    symbol, start_date, end_date, api_key551                )552            else:553                return DataLoader._download_combined_twelvedata_yahoo(554                    symbol, interval, start_date, end_date, api_key555                )556        else:557            return DataLoader._download_yahoo(symbol, interval, start_date, end_date)558    559    @staticmethod560    def _download_yahoo(561        symbol: str,562        interval: str,563        start_date: str,564        end_date: str565    ) -> pd.DataFrame:566        """Descarga datos de Yahoo Finance usando yf.download()"""567        try:568            # Debug: mostrar parámetros de descarga569            # st.info(f"🔍 Debug YF: symbol={symbol}, interval={interval}, start={start_date}, end={end_date}")570 571            # Manejar intervalos especiales que requieren resample572            if interval == '4h':573                df = DataLoader._download_and_resample_4h_yahoo(symbol, start_date, end_date)574            elif interval == '1mo':575                df = DataLoader._download_and_resample_1mo_yahoo(symbol, start_date, end_date)576            else:577                # Usar yf.download() en lugar de Ticker().history()578                df = yf.download(579                    symbol,580                    start=start_date,581                    end=end_date,582                    interval=interval,583                    progress=False,584                    auto_adjust=True,585                    threads=False586                )587 588            if df.empty:589                # Calcular días del período para dar mejor información590                try:591                    start = datetime.strptime(start_date, '%Y-%m-%d')592                    end = datetime.strptime(end_date, '%Y-%m-%d')593                    days_diff = (end - start).days594                except:595                    days_diff = "desconocido"596 597                # Mostrar información de debug598                if interval in ['1m', '2m', '5m', '15m', '30m', '1h']:599                    st.warning(f"""600                    ⚠️ Yahoo Finance no devolvió datos para {symbol} ({interval}).601 602                    **Posibles causas:**603                    - Para timeframes intradiarios (1m-1h), Yahoo limita a ~730 días de historial604                    - El símbolo {symbol} puede no tener datos para este período ({days_diff} días)605                    - Prueba reducir el período o usar timeframe diario (1d)606                    """)607                else:608                    st.warning(f"⚠️ Yahoo Finance no devolvió datos para {symbol} ({interval}, {days_diff} días)")609                return pd.DataFrame()610 611            # Arreglar columnas MultiIndex si es necesario612            df = _fix_multiindex_columns(df)613 614            # Renombrar columnas a minúsculas615            df = df.rename(columns={616                'Open': 'open', 'High': 'high', 'Low': 'low',617                'Close': 'close', 'Volume': 'volume'618            })619 620            # Mantener solo columnas OHLCV621            cols_to_keep = ['open', 'high', 'low', 'close', 'volume']622            df = df[[col for col in cols_to_keep if col in df.columns]]623 624            # Remover timezone info para evitar problemas en Plotly625            if hasattr(df.index, 'tz') and df.index.tz is not None:626                df.index = df.index.tz_localize(None)627 628            return df629 630        except Exception as e:631            st.error(f"Error Yahoo Finance ({symbol}): {str(e)}")632            import traceback633            st.code(traceback.format_exc(), language="python")634            return pd.DataFrame()635 636    @staticmethod637    def _download_twelvedata_paginated(638        symbol: str,639        interval: str,640        start_date: str,641        end_date: str,642        api_key: str643    ) -> pd.DataFrame:644        """645        Descarga datos de Twelve Data con paginación646        Permite descargar más de 5000 velas (hasta 10 años)647        """648        try:649            # Mapeo de símbolos Yahoo Finance -> Twelve Data650            symbol_mapping = {651                'GC=F': 'XAU/USD',      # Oro Futuros652                'SI=F': 'XAG/USD',      # Plata Futuros653                'CL=F': 'WTI/USD',      # Petróleo WTI654                'NG=F': 'NG/USD',       # Gas Natural655                'PL=F': 'XPT/USD',      # Platino Futuros656                'HG=F': 'XCU/USD',      # Cobre Futuros657            }658 659            # Convertir símbolo si es necesario660            clean_symbol = symbol.replace("$", "").upper()661 662            if clean_symbol in symbol_mapping:663                clean_symbol = symbol_mapping[clean_symbol]664                # st.info(f"🔄 Mapeando {symbol} → {clean_symbol} para Twelve Data")665            # Para criptomonedas: BTC-USD -> BTC/USD (Twelve Data usa slash)666            elif '-USD' in clean_symbol or '-USDT' in clean_symbol:667                clean_symbol = clean_symbol.replace('-', '/')668 669            td_interval = {'1h': '1h', '4h': '4h', '1d': '1day'}.get(interval, '1h')670 671            # Calcular cuántas páginas necesitamos672            start = datetime.strptime(start_date, '%Y-%m-%d')673            end = datetime.strptime(end_date, '%Y-%m-%d')674            days_diff = (end - start).days675 676            # Estimar velas necesarias (asumiendo 24h para 1h, 6 para 4h)677            velas_por_dia = {'1h': 24, '4h': 6, '1d': 1}.get(interval, 24)678            velas_estimadas = days_diff * velas_por_dia679            num_pages = max(1, min(20, (velas_estimadas // 5000) + 1))  # Max 20 páginas680 681            all_data = []682            current_end = datetime.strptime(end_date, '%Y-%m-%d')683 684            progress_bar = st.progress(0)685            progress_text = st.empty()686 687            for page in range(num_pages):688                # Actualizar barra de progreso689                progress_pct = int((page / num_pages) * 100)690                progress_bar.progress(progress_pct)691                progress_text.text(f"📥 Descargando datos históricos... {progress_pct}%")692 693                url = "https://api.twelvedata.com/time_series"694                params = {695                    "symbol": clean_symbol,696                    "interval": td_interval,697                    "outputsize": 5000,698                    "end_date": current_end.strftime("%Y-%m-%d %H:%M:%S"),699                    "apikey": api_key700                }701 702                response = requests.get(url, params=params)703                data = response.json()704 705                if "values" not in data:706                    if page == 0:  # Error en primera página707                        error_msg = data.get('message', str(data))708                        st.warning(f"⚠️ Twelve Data: {error_msg}")709                    break710 711                df_page = pd.DataFrame(data["values"])712 713                # Debug: mostrar columnas en primera página714                # if page == 0:715                #     st.info(f"🔍 Columnas recibidas: {list(df_page.columns)}")716 717                # Normalizar columnas inmediatamente718                df_page["datetime"] = pd.to_datetime(df_page["datetime"])719 720                # Seleccionar solo columnas OHLCV721                # Si no hay volume (común en crypto de Twelve Data), crear con 0722                if 'volume' not in df_page.columns:723                    df_page['volume'] = 0724                required_cols = ['datetime', 'open', 'high', 'low', 'close', 'volume']725                df_page = df_page[required_cols]726 727                # Verificar si llegamos al start_date728                fecha_min = df_page["datetime"].min()729                if fecha_min <= start:730                    # Filtrar solo lo que necesitamos y terminar731                    df_page = df_page[df_page["datetime"] >= start]732                    all_data.append(df_page)733                    break734 735                all_data.append(df_page)736                current_end = fecha_min737 738                # Rate limit: 8 requests/min = 7.5 segundos entre requests739                if page < num_pages - 1:740                    time.sleep(8)741 742            # Completar barra al 100% y limpiar743            progress_bar.progress(100)744            progress_text.text("✅ Descarga completada")745            time.sleep(0.5)746            progress_bar.empty()747            progress_text.empty()748 749            if not all_data:750                return pd.DataFrame()751 752            # Combinar todas las páginas (ya normalizadas)753            # st.info(f"🔧 Combinando {len(all_data)} páginas...")754            final_df = pd.concat(all_data, ignore_index=True)755            final_df = final_df.drop_duplicates(subset=["datetime"])756            final_df = final_df.sort_values("datetime")757            final_df.set_index("datetime", inplace=True)758 759            # Convertir a float (las columnas ya están seleccionadas)760            final_df = final_df.astype(float)761 762            # st.success(f"✅ Twelve Data: {len(final_df)} velas ({final_df.index.min().date()} → {final_df.index.max().date()})")763 764            return final_df765 766        except Exception as e:767            st.error(f"Error en descarga paginada: {e}")768            return pd.DataFrame()769 770    @staticmethod771    def _download_combined_twelvedata_yahoo(772        symbol: str,773        interval: str,774        start_date: str,775        end_date: str,776        api_key: str = None777    ) -> pd.DataFrame:778        """779        Combina datos de Twelve Data (antiguos) + Yahoo Finance (recientes)780        Para obtener historial completo más allá de 730 días781        """782        if not api_key:783            st.warning("No se proporcionó API key de Twelve Data. Usando solo Yahoo Finance.")784            return DataLoader._download_yahoo(symbol, interval, start_date, end_date)785 786        try:787            # Mapeo de símbolos Yahoo Finance -> Twelve Data788            symbol_mapping = {789                'GC=F': 'XAU/USD',      # Oro Futuros790                'SI=F': 'XAG/USD',      # Plata Futuros791                'CL=F': 'WTI/USD',      # Petróleo WTI792                'NG=F': 'NG/USD',       # Gas Natural793                'PL=F': 'XPT/USD',      # Platino Futuros794                'HG=F': 'XCU/USD',      # Cobre Futuros795            }796 797            # Convertir símbolo al formato de Twelve Data798            clean_symbol = symbol.replace("$", "").upper()799 800            if clean_symbol in symbol_mapping:801                clean_symbol = symbol_mapping[clean_symbol]802                # st.info(f"🔄 Mapeando {symbol} → {clean_symbol} para Twelve Data")803            # Para criptomonedas: BTC-USD -> BTC/USD (Twelve Data usa slash)804            elif '-USD' in clean_symbol or '-USDT' in clean_symbol:805                clean_symbol = clean_symbol.replace('-', '/')806 807            # Cache directory808            cache_dir = os.path.join(os.path.expanduser('~'), '.backtesting_cache')809            os.makedirs(cache_dir, exist_ok=True)810            # Para cache, usar símbolo original (sin slash) para evitar problemas de path811            safe_symbol = symbol.replace("$", "").replace("-", "_").upper()812            cache_file = os.path.join(cache_dir, f'{safe_symbol}_td_{interval}_{start_date}_to_{end_date}.csv')813 814            # ===== 1. TWELVE DATA (datos antiguos con cache y paginación) =====815            if os.path.exists(cache_file):816                # st.info(f"📂 Cargando {clean_symbol} desde cache...")817                td_data = pd.read_csv(cache_file, index_col=0, parse_dates=True)818            else:819                # Usar descarga paginada para obtener todos los datos820                td_data = DataLoader._download_twelvedata_paginated(821                    symbol=clean_symbol,822                    interval=interval,823                    start_date=start_date,824                    end_date=end_date,825                    api_key=api_key826                )827 828                if td_data.empty:829                    st.warning("Fallback a solo Yahoo Finance...")830                    return DataLoader._download_yahoo(symbol, interval, start_date, end_date)831 832                # Guardar cache833                td_data.to_csv(cache_file)834                # st.success(f"💾 Cache guardado ({len(td_data)} velas)")835 836            # ===== 2. YAHOO FINANCE (datos recientes, últimos 729 días) =====837            # st.info("📥 Descargando datos recientes de Yahoo Finance...")838 839            fecha_fin = datetime.now()840            fecha_inicio = fecha_fin - timedelta(days=729)841 842            yf_data = yf.download(843                symbol,844                start=fecha_inicio,845                end=fecha_fin,846                interval=interval,847                progress=False848            )849 850            if yf_data.empty:851                st.warning("⚠️ Yahoo Finance no devolvió datos, usando solo Twelve Data")852                return td_data853 854            # Arreglar columnas MultiIndex si es necesario855            yf_data = _fix_multiindex_columns(yf_data)856 857            # Renombrar columnas a minúsculas858            yf_data = yf_data.rename(columns={859                'Open': 'open', 'High': 'high', 'Low': 'low',860                'Close': 'close', 'Volume': 'volume'861            })862 863            # Remover timezone864            if hasattr(yf_data.index, 'tz') and yf_data.index.tz is not None:865                yf_data.index = yf_data.index.tz_localize(None)866 867            # ===== 3. COMBINAR sin duplicados =====868            td_ultima = td_data.index.max()869            yf_nuevos = yf_data[yf_data.index > td_ultima]870 871            df = pd.concat([td_data, yf_nuevos])872            df = df[~df.index.duplicated(keep='last')].sort_index()873 874            # Filtrar por rango de fechas solicitado875            if start_date:876                df = df[df.index >= start_date]877            if end_date:878                df = df[df.index <= end_date]879 880            # st.success(f"✅ Datos combinados: {len(df)} velas (📅 {df.index.min()} → {df.index.max()})")881            # st.info(f"   🔹 Twelve Data: {len(td_data)} velas | 🔹 Yahoo: {len(yf_nuevos)} velas nuevas")882 883            return df884 885        except Exception as e:886            st.error(f"Error combinando datos: {e}")887            st.warning("Fallback a solo Yahoo Finance...")888            return DataLoader._download_yahoo(symbol, interval, start_date, end_date)889 890    @staticmethod891    def _download_and_resample_4h_combined(892        symbol: str,893        start_date: str,894        end_date: str,895        api_key: str896    ) -> pd.DataFrame:897        """898        Descarga datos de 1h usando Twelve Data + Yahoo Finance y los convierte a 4h899        Para períodos >730 días900        """901        try:902            # Descargar datos de 1h usando Twelve Data + Yahoo903            df_1h = DataLoader._download_combined_twelvedata_yahoo(904                symbol=symbol,905                interval='1h',906                start_date=start_date,907                end_date=end_date,908                api_key=api_key909            )910 911            if df_1h.empty:912                return pd.DataFrame()913 914            # st.info("🔄 Resampleando datos de 1h a 4h...")915 916            # Resamplear a 4h917            df_4h = df_1h.resample('4h').agg({918                'open': 'first',919                'high': 'max',920                'low': 'min',921                'close': 'last',922                'volume': 'sum'923            }).dropna()924 925            # Remover timezone info926            if hasattr(df_4h.index, 'tz') and df_4h.index.tz is not None:927                df_4h.index = df_4h.index.tz_localize(None)928 929            # st.success(f"✅ Resample completado: {len(df_4h)} velas de 4h")930 931            return df_4h932 933        except Exception as e:934            st.error(f"Error resampleando 1h→4h con Twelve Data: {e}")935            return pd.DataFrame()936 937    @staticmethod938    def _download_and_resample_4h_yahoo(symbol: str, start_date: str, end_date: str) -> pd.DataFrame:939        """Descarga datos de 1h y los convierte a 4h"""940        try:941            # Usar yf.download() en lugar de Ticker().history()942            df = yf.download(943                symbol,944                start=start_date,945                end=end_date,946                interval='1h',947                progress=False948            )949 950            if df.empty:951                # Calcular días del período para dar mejor información952                try:953                    start = datetime.strptime(start_date, '%Y-%m-%d')954                    end = datetime.strptime(end_date, '%Y-%m-%d')955                    days_diff = (end - start).days956                except:957                    days_diff = "desconocido"958 959                st.warning(f"""960                ⚠️ Yahoo Finance no devolvió datos de 1h para {symbol} (necesarios para 4h).961 962                **Posibles causas:**963                - Para 4h, se descargan datos de 1h y Yahoo limita a ~730 días964                - Período solicitado: {days_diff} días965                - Prueba reducir el período o usar timeframe diario (1d)966                """)967                return pd.DataFrame()968 969            # Arreglar columnas MultiIndex si es necesario970            df = _fix_multiindex_columns(df)971 972            # Renombrar columnas a minúsculas973            df = df.rename(columns={974                'Open': 'open', 'High': 'high', 'Low': 'low',975                'Close': 'close', 'Volume': 'volume'976            })977 978            cols_to_keep = ['open', 'high', 'low', 'close', 'volume']979            df = df[[col for col in cols_to_keep if col in df.columns]]980 981            # Resamplear a 4h982            df_4h = df.resample('4h').agg({983                'open': 'first',984                'high': 'max',985                'low': 'min',986                'close': 'last',987                'volume': 'sum'988            }).dropna()989 990            # Remover timezone info991            if hasattr(df_4h.index, 'tz') and df_4h.index.tz is not None:992                df_4h.index = df_4h.index.tz_localize(None)993 994            return df_4h995 996        except Exception as e:997            st.error(f"Error resampleando 4h: {e}")998            return pd.DataFrame()999    1000    @staticmethod1001    def _download_and_resample_1mo_yahoo(symbol: str, start_date: str, end_date: str) -> pd.DataFrame:1002        """Descarga datos de 1d y los convierte a 1mo (Month End)"""1003        try:1004            # Usar yf.download() en lugar de Ticker().history()1005            df = yf.download(1006                symbol,1007                start=start_date,1008                end=end_date,1009                interval='1d',1010                progress=False1011            )1012 1013            if df.empty:1014                return pd.DataFrame()1015 1016            # Arreglar columnas MultiIndex si es necesario1017            df = _fix_multiindex_columns(df)1018 1019            # Renombrar columnas a minúsculas1020            df = df.rename(columns={1021                'Open': 'open', 'High': 'high', 'Low': 'low',1022                'Close': 'close', 'Volume': 'volume'1023            })1024 1025            cols_to_keep = ['open', 'high', 'low', 'close', 'volume']1026            df = df[[col for col in cols_to_keep if col in df.columns]]1027 1028            # Resamplear a Month End (ME)1029            df_monthly = df.resample('ME').agg({1030                'open': 'first',1031                'high': 'max',1032                'low': 'min',1033                'close': 'last',1034                'volume': 'sum'1035            }).dropna()1036 1037            # Remover timezone info1038            if hasattr(df_monthly.index, 'tz') and df_monthly.index.tz is not None:1039                df_monthly.index = df_monthly.index.tz_localize(None)1040 1041            return df_monthly1042 1043        except Exception as e:1044            st.error(f"Error resampleando 1mo: {e}")1045            return pd.DataFrame()1046    1047    1048    @staticmethod1049    def get_available_sources() -> Dict[str, str]:1050        """Retorna fuentes disponibles"""1051        return DATA_SOURCES1052    1053    @staticmethod1054    def is_crypto(symbol: str) -> bool:1055        """Verifica si el símbolo es criptomoneda"""1056        return False  # Ya no tenemos criptomonedas en AVAILABLE_SYMBOLS1057