diegobeyl/backtesting
2
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 