""" GeminiStrategy.py Estrategia custom para Freqtrade que usa Google Gemini AI como motor de decisión de trading. Autor: GeminiTradingBot Version: 1.0.0 """ import json import logging import math import os import random import time import threading import requests from datetime import datetime, timezone from pathlib import Path from typing import Optional from google import genai from google.genai import types as genai_types import pandas as pd from pandas import DataFrame from freqtrade.strategy import DecimalParameter, IStrategy, IntParameter logger = logging.getLogger(__name__) # Configurar Gemini — rotación de múltiples APIs GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "AIzaSyCbpd0NdDac1bARkb2L5bofijR3ejigaHw") GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash-lite") MIN_CONFIDENCE = int(os.getenv("MIN_CONFIDENCE", "50")) RATE_LIMIT_SECONDS = 5.0 # Pool de APIs — SOLO gratuitas # Orden: 7 keys gratuitas (1000 RPD c/u) # Cuando las gratis se agoten, el bot queda en HOLD # A medianoche UTC se resetean las gratis y vuelve a empezar con ellas _K1 = "AIzaSyAoqNG6fqNGo0LdGG1pCnMx4XBdI4XFpi8" # gratuita _K2 = "AIzaSyBw667n2GHEdk1foXJJ1GRBJGmy5cJWoIM" # gratuita _K3 = "AIzaSyDwNH5wRWxTotsoTS2Hii3QDkirdXRquug" # gratuita _K4 = "AIzaSyDzMSfPhvjBn9bUiiIkGqKIOOdzKl7gUNI" # gratuita _K5 = "AIzaSyCbpd0NdDac1bARkb2L5bofijR3ejigaHw" # gratuita _K6 = "AIzaSyBxBeB2ny4paZ__9kV-hisewlUDHTZVRPU" # gratuita _K7 = "AIzaSyCZf8gaa41fZ_JLmhCacTp_-E6bqXCJKZw" # gratuita GEMINI_API_POOL = [ {"key": _K1, "model": GEMINI_MODEL, "daily_limit": 1000, "label": "Gratis-1"}, {"key": _K2, "model": GEMINI_MODEL, "daily_limit": 1000, "label": "Gratis-2"}, {"key": _K3, "model": GEMINI_MODEL, "daily_limit": 1000, "label": "Gratis-3"}, {"key": _K4, "model": GEMINI_MODEL, "daily_limit": 1000, "label": "Gratis-4"}, {"key": _K5, "model": GEMINI_MODEL, "daily_limit": 1000, "label": "Gratis-5"}, {"key": _K6, "model": GEMINI_MODEL, "daily_limit": 1000, "label": "Gratis-6"}, {"key": _K7, "model": GEMINI_MODEL, "daily_limit": 1000, "label": "Gratis-7"}, ] # Telegram directo (sin python-telegram-bot) TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "8298976121:AAEHcMysV_zX3msnkNu9CHGfLQn6_9FC0rw") TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "6729779078") # CryptoPanic — noticias crypto en tiempo real (tier gratuito, sin API key) CRYPTO_NEWS_CACHE: dict = {"data": [], "sentiment": 50, "ts": 0.0} CRYPTO_NEWS_TTL = 900 # refrescar cada 15 minutos FEAR_GREED_CACHE: dict = {"value": 50, "label": "Neutral", "ts": 0.0} FEAR_GREED_TTL = 3600 # refrescar cada hora # Reddit sentiment — menciones en r/cryptocurrency (gratis, sin auth) REDDIT_SENTIMENT_CACHE: dict = {} # {coin: {"score": 50, "mentions": 0, "ts": 0.0}} REDDIT_SENTIMENT_TTL = 1800 # refrescar cada 30 minutos # CoinGecko trending — coins trending socialmente (gratis) TRENDING_CACHE: dict = {"coins": [], "ts": 0.0} TRENDING_TTL = 3600 # refrescar cada hora def _fetch_fear_greed() -> tuple: """Obtiene el Fear & Greed Index actual. Caché 1 hora. Retorna (valor, label).""" global FEAR_GREED_CACHE now = time.time() if now - FEAR_GREED_CACHE["ts"] > FEAR_GREED_TTL: try: resp = requests.get("https://api.alternative.me/fng/?limit=1", timeout=5) if resp.status_code == 200: data = resp.json()["data"][0] FEAR_GREED_CACHE["value"] = int(data["value"]) FEAR_GREED_CACHE["label"] = data["value_classification"] FEAR_GREED_CACHE["ts"] = now except Exception: pass return FEAR_GREED_CACHE["value"], FEAR_GREED_CACHE["label"] def _fetch_crypto_news(coin: str) -> tuple: """Obtiene titulares + sentiment de noticias. Caché 15 min. Retorna (headlines_str, sentiment_score).""" global CRYPTO_NEWS_CACHE now = time.time() if now - CRYPTO_NEWS_CACHE["ts"] > CRYPTO_NEWS_TTL: try: coin_symbol = coin.split("/")[0].lower() url = f"https://cryptopanic.com/api/free/v1/posts/?auth_token=anonymous¤cies={coin_symbol}&kind=news&public=true" resp = requests.get(url, timeout=5) if resp.status_code == 200: items = resp.json().get("results", [])[:5] CRYPTO_NEWS_CACHE["data"] = [i.get("title", "") for i in items] # Calcular sentiment de votos bullish/bearish bullish = sum(i.get("votes", {}).get("positive", 0) for i in items) bearish = sum(i.get("votes", {}).get("negative", 0) for i in items) total_votes = bullish + bearish if total_votes > 0: CRYPTO_NEWS_CACHE["sentiment"] = int(bullish / total_votes * 100) else: CRYPTO_NEWS_CACHE["sentiment"] = 50 CRYPTO_NEWS_CACHE["ts"] = now except Exception: pass headlines = CRYPTO_NEWS_CACHE["data"] headline_str = " | ".join(f"• {h}" for h in headlines[:3]) if headlines else "Sin noticias recientes." return headline_str, CRYPTO_NEWS_CACHE["sentiment"] def _fetch_reddit_sentiment(coin: str) -> tuple: """Scraping Reddit r/cryptocurrency para sentiment. Caché 30 min. Retorna (score 0-100, menciones).""" global REDDIT_SENTIMENT_CACHE coin_symbol = coin.split("/")[0].upper() now = time.time() cached = REDDIT_SENTIMENT_CACHE.get(coin_symbol, {"score": 50, "mentions": 0, "ts": 0.0}) if now - cached["ts"] > REDDIT_SENTIMENT_TTL: try: url = f"https://www.reddit.com/r/cryptocurrency/search.json?q={coin_symbol}&sort=new&t=day&limit=15" headers = {"User-Agent": "GeminiTradingBot/1.0"} resp = requests.get(url, headers=headers, timeout=8) if resp.status_code == 200: posts = resp.json().get("data", {}).get("children", []) mentions = len(posts) if mentions > 0: # Score basado en upvote ratio y score de posts total_score = sum(p["data"].get("score", 0) for p in posts) avg_upvote = sum(p["data"].get("upvote_ratio", 0.5) for p in posts) / mentions # Normalizar: >0 score promedio = bullish, ratio > 0.6 = bullish sentiment = int(avg_upvote * 100) sentiment = max(10, min(90, sentiment)) # clamp 10-90 else: sentiment = 50 REDDIT_SENTIMENT_CACHE[coin_symbol] = {"score": sentiment, "mentions": mentions, "ts": now} else: REDDIT_SENTIMENT_CACHE[coin_symbol] = {"score": 50, "mentions": 0, "ts": now} except Exception: REDDIT_SENTIMENT_CACHE[coin_symbol] = {"score": cached.get("score", 50), "mentions": cached.get("mentions", 0), "ts": now} cached = REDDIT_SENTIMENT_CACHE.get(coin_symbol, {"score": 50, "mentions": 0}) return cached["score"], cached["mentions"] def _fetch_trending_coins() -> list: """Obtiene lista de coins trending en CoinGecko. Caché 1 hora. Retorna lista de símbolos.""" global TRENDING_CACHE now = time.time() if now - TRENDING_CACHE["ts"] > TRENDING_TTL: try: resp = requests.get("https://api.coingecko.com/api/v3/search/trending", timeout=8) if resp.status_code == 200: items = resp.json().get("coins", []) TRENDING_CACHE["coins"] = [i["item"]["symbol"].upper() for i in items[:10]] TRENDING_CACHE["ts"] = now except Exception: pass return TRENDING_CACHE["coins"] def _tg(msg: str) -> None: """Envía mensaje a Telegram en background, sin bloquear el bot.""" def _send(): try: url = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage" requests.post(url, json={"chat_id": TELEGRAM_CHAT_ID, "text": msg, "parse_mode": "HTML"}, timeout=5) except Exception: pass threading.Thread(target=_send, daemon=True).start() class GeminiStrategy(IStrategy): """ Estrategia de trading basada en Gemini AI. Usa indicadores técnicos (RSI, EMA20, EMA50, volumen) como contexto y llama a Gemini API cada vela para obtener la decisión de trading. Timeframe: 5m Exchange: KuCoin | 5 pares: BTC, ETH, XRP, SOL, BNB """ INTERFACE_VERSION = 3 timeframe = "5m" can_short = False max_open_trades = 5 stoploss = -0.015 minimal_roi = {"0": 0.04, "30": 0.025, "60": 0.02, "120": 0.015} trailing_stop = True trailing_stop_positive = 0.005 trailing_stop_positive_offset = 0.02 trailing_only_offset_is_reached = True use_custom_stoploss = True startup_candle_count: int = 210 rsi_buy_threshold = IntParameter(30, 60, default=55, space="buy") rsi_sell_threshold = IntParameter(65, 85, default=70, space="sell") ema_fast = IntParameter(10, 30, default=20, space="buy") ema_slow = IntParameter(30, 70, default=50, space="buy") def __init__(self, config: dict) -> None: super().__init__(config) self._last_gemini_call = 0.0 self._gemini_decisions = {} self._last_pair_refresh = 0.0 self._selected_pair = None # Tracking diario self._daily_trades = [] # lista de {pair, profit_usd, profit_pct, won} self._last_summary_date = None self._daily_loss_usd = 0.0 # pérdida acumulada hoy en USD self._daily_loss_date = None # fecha del último reset de pérdidas self._stoploss_cooldown: dict = {} # par -> timestamp del último stop-loss # Memoria de aprendizaje: últimos 50 resultados de trades self._trade_memory: list = [] # {pair, accion, confianza, rsi, profit_usd, won, razon} self._daily_trades_lock = threading.Lock() # Cache de datos 1h por par — instancia, no clase self._cache_1h: dict = {} self._cache_1h_ttl: float = 3600.0 self._cache_1h_lock = threading.Lock() # ── Q-Learning Tabular ────────────────────────────────────────────── self._qtable_path = Path(__file__).parent / "qtable.json" self._q_actions = ["HOLD", "BUY", "SELL", "CLOSE"] # índices 0-3 self._q_states = 100 # estados del mercado (tendencia×volatilidad×momentum) self._q_alpha = 0.1 # tasa de aprendizaje self._q_gamma = 0.9 # descuento de recompensa futura self._q_epsilon = 0.3 # exploración inicial (30% aleatorio) self._q_epsilon_min = 0.05 # exploración mínima (5%) self._q_epsilon_decay = 0.995 # decaimiento por episodio self._q_episodes = 0 # operaciones totales realizadas self._experience_replay: list = [] # buffer de hasta 500 experiencias self._q_table: list = self._load_qtable() self._q_lock = threading.Lock() # ── Régimen de Mercado ────────────────────────────────────────────── self._market_regime = "UNKNOWN" # estado actual del mercado self._regime_stake_mult = 1.0 # multiplicador de stake según régimen self._regime_sl_mult = 1.0 # multiplicador de stop-loss según régimen # ── Autopausa ─────────────────────────────────────────────────────── self._autopause_until: float = 0.0 # timestamp hasta el que está pausado self._autopause_reason: str = "" self._trades_since_check: int = 0 # contador para evaluar métricas cada N trades # Sistema de rotación de APIs self._api_index = 0 self._api_usage = {entry["label"]: {"count": 0, "date": datetime.now(timezone.utc).date()} for entry in GEMINI_API_POOL} self._api_lock = threading.Lock() self._init_gemini_client() # Arrancar scheduler de resumen diario en background threading.Thread(target=self._daily_summary_scheduler, daemon=True).start() # Thread que resetea contadores de API a medianoche UTC (cuando Google las recarga) threading.Thread(target=self._api_reset_scheduler, daemon=True).start() # Pre-cargar datos 1h de los pares fijos al arrancar # Los pares dinámicos (VolumePairList) se cargan cuando entran static_pairs = [ "BTC/USDT", "ETH/USDT", ] for p in static_pairs: threading.Thread(target=self._refresh_1h_background, args=(p,), daemon=True).start() def _api_reset_scheduler(self) -> None: """Resetea contadores de API a medianoche UTC cuando Google los recarga.""" from datetime import timedelta while True: now = datetime.now(timezone.utc) # Siguiente medianoche UTC midnight = (now + timedelta(days=1)).replace(hour=0, minute=0, second=30, microsecond=0) time.sleep((midnight - now).total_seconds()) # Resetear todos los contadores today = datetime.now(timezone.utc).date() with self._api_lock: for entry in GEMINI_API_POOL: self._api_usage[entry["label"]]["count"] = 0 self._api_usage[entry["label"]]["date"] = today self._api_index = 0 self._gemini_client = genai.Client(api_key=GEMINI_API_POOL[0]["key"]) logger.info("[RESET] Contadores de API reseteados a medianoche UTC (reset diario)") _tg( "APIS RECARGADAS\n" "Nueva cuota diaria disponible (medianoche UTC).\n" "El bot retoma el analisis con Gemini normalmente." ) def _init_gemini_client(self) -> None: """Inicializa el cliente Gemini con la API activa del pool.""" entry = GEMINI_API_POOL[self._api_index] self._gemini_client = genai.Client(api_key=entry["key"]) self._gemini_model_active = entry["model"] logger.info(f"[OK] Gemini activo: {entry['label']} | modelo: {entry['model']}") # ── Q-Learning ───────────────────────────────────────────────────────── def _load_qtable(self) -> list: """Carga la Q-table desde disco o crea una nueva con ceros.""" if self._qtable_path.exists(): try: data = json.loads(self._qtable_path.read_text()) self._q_epsilon = data.get("epsilon", 0.3) self._q_episodes = data.get("episodes", 0) logger.info(f"[QLEARN] Q-table cargada: {self._q_episodes} episodios, epsilon={self._q_epsilon:.3f}") return data["qtable"] except Exception as e: logger.warning(f"[QLEARN] Error cargando Q-table: {e}, creando nueva") return [[0.0] * 4 for _ in range(self._q_states)] def _save_qtable(self) -> None: """Guarda Q-table, epsilon y episodios en disco.""" try: with self._q_lock: data = {"qtable": self._q_table, "epsilon": self._q_epsilon, "episodes": self._q_episodes} self._qtable_path.write_text(json.dumps(data)) except Exception as e: logger.warning(f"[QLEARN] Error guardando Q-table: {e}") def _encode_state(self, dataframe: DataFrame) -> int: """Codifica el estado del mercado en un número 0-99 basado en tendencia, volatilidad y momentum.""" last = dataframe.iloc[-1] # Tendencia: 0-4 basado en EMA cruce y posición vs EMA200 if last.get("ema200_signal") == "ABOVE" and last.get("ema_signal") == "BULL": trend = 4 elif last.get("ema_signal") == "BULL": trend = 3 elif last.get("ema200_signal") == "ABOVE": trend = 2 elif last.get("ema_signal") == "BEAR": trend = 1 else: trend = 0 # Volatilidad: 0-3 basado en ATR normalizado atr_pct = last.get("atr_pct", 0.5) if atr_pct < 0.3: vol = 0 elif atr_pct < 0.6: vol = 1 elif atr_pct < 1.2: vol = 2 else: vol = 3 # Momentum: 0-4 basado en RSI rsi = last.get("rsi", 50) if rsi < 30: mom = 0 elif rsi < 45: mom = 1 elif rsi < 55: mom = 2 elif rsi < 70: mom = 3 else: mom = 4 # Combinar en estado 0-99: trend(5) × vol(4) × mom(5) = 100 state = (trend * 20) + (vol * 5) + mom return min(state, 99) def _q_get_action(self, state: int, force_exploit: bool = False) -> int: """Selecciona acción por epsilon-greedy. Retorna índice de acción (0=HOLD,1=BUY,2=SELL,3=CLOSE).""" with self._q_lock: if not force_exploit and random.random() < self._q_epsilon: return random.randint(0, 3) return int(max(range(4), key=lambda a: self._q_table[state][a])) def _q_update(self, state: int, action: int, reward: float, next_state: int) -> None: """Actualiza Q-table con la fórmula de Bellman.""" with self._q_lock: best_next = max(self._q_table[next_state]) old_q = self._q_table[state][action] self._q_table[state][action] = old_q + self._q_alpha * (reward + self._q_gamma * best_next - old_q) self._q_epsilon = max(self._q_epsilon_min, self._q_epsilon * self._q_epsilon_decay) self._q_episodes += 1 def _q_reward(self, profit_pct: float, stake: float) -> float: """Calcula recompensa: positiva si gana (suavizada por raíz), negativa si pierde (penalización 1.5×).""" if profit_pct > 0: reward = math.sqrt(abs(profit_pct) * 100) rr = abs(profit_pct) / 0.015 # ratio vs stop-loss if rr > 1.5: reward += 1.0 # bonus de calidad por buen RR else: reward = -math.sqrt(abs(profit_pct) * 100) * 1.5 return round(reward, 4) def _experience_replay_train(self) -> None: """Repasa 32 experiencias aleatorias del buffer para acelerar el aprendizaje.""" if len(self._experience_replay) < 32: return batch = random.sample(self._experience_replay, 32) for exp in batch: self._q_update(exp["state"], exp["action"], exp["reward"], exp["next_state"]) # ── Régimen de Mercado ──────────────────────────────────────────────── def _detect_regime(self, dataframe: DataFrame) -> str: """Clasifica el mercado en uno de 10 regímenes usando ADX real, ATR y EMAs.""" if len(dataframe) < 30: return "UNKNOWN" last = dataframe.iloc[-1] # Usar ADX real calculado en populate_indicators (mas preciso que ema_diff) adx_val = float(last.get("adx", 0)) if not pd.isna(last.get("adx", float('nan'))) else 0.0 atr_pct = last.get("atr_pct", 0.5) rsi = last.get("rsi", 50) ema_bull = last.get("ema_signal") == "BULL" ema200 = last.get("ema200_signal") == "ABOVE" # Determinar tendencia, volatilidad y régimen usando ADX real strong_trend = adx_val > 20 volatile = atr_pct > 1.0 very_volatile = atr_pct > 2.0 if very_volatile and not strong_trend: regime = "CAOS_VOLATIL" elif strong_trend and ema_bull and ema200 and volatile: regime = "TENDENCIA_ALCISTA_VOLATIL" elif strong_trend and ema_bull and ema200: regime = "TENDENCIA_ALCISTA_NORMAL" if atr_pct > 0.4 else "TENDENCIA_ALCISTA_CALMADA" elif strong_trend and not ema_bull and volatile: regime = "TENDENCIA_BAJISTA_VOLATIL" elif strong_trend and not ema_bull: regime = "TENDENCIA_BAJISTA_NORMAL" if atr_pct > 0.4 else "TENDENCIA_BAJISTA_CALMADA" elif not strong_trend and volatile: regime = "RANGO_VOLATIL" elif not strong_trend: regime = "RANGO_TRANQUILO" else: regime = "TRANSICION" # Ajustar multiplicadores de stake y stop segun regimen regime_params = { "TENDENCIA_ALCISTA_CALMADA": (1.0, 1.0), "TENDENCIA_ALCISTA_NORMAL": (1.0, 1.2), "TENDENCIA_ALCISTA_VOLATIL": (0.7, 1.8), "TENDENCIA_BAJISTA_CALMADA": (0.5, 1.0), "TENDENCIA_BAJISTA_NORMAL": (0.3, 1.5), "TENDENCIA_BAJISTA_VOLATIL": (0.0, 2.0), "RANGO_TRANQUILO": (0.8, 0.8), "RANGO_VOLATIL": (0.5, 1.2), "CAOS_VOLATIL": (0.0, 2.5), "TRANSICION": (0.7, 1.3), "UNKNOWN": (0.5, 1.0), } self._regime_stake_mult, self._regime_sl_mult = regime_params.get(regime, (0.5, 1.0)) if regime != self._market_regime: logger.info(f"[REGIME] Cambio de regimen: {self._market_regime} -> {regime} | stake_mult={self._regime_stake_mult} sl_mult={self._regime_sl_mult}") self._market_regime = regime return regime # ── Autopausa ───────────────────────────────────────────────────────── def _check_autopause(self) -> None: """Evalúa métricas y activa autopausa si el bot está rindiendo mal.""" recent = self._trade_memory[-20:] if len(self._trade_memory) >= 10 else [] if not recent: return wins = sum(1 for t in recent if t["won"]) losses = len(recent) - wins winrate = wins / len(recent) total_profit = sum(t["profit_usd"] for t in recent if t["won"]) total_loss = abs(sum(t["profit_usd"] for t in recent if not t["won"])) profit_factor = total_profit / total_loss if total_loss > 0 else 99.0 drawdown_pct = self._daily_loss_usd / max(self._daily_loss_usd + 50, 50) * 100 pause_duration = 0 reason = "" if drawdown_pct > 5.0: pause_duration = 7200 # 2 horas reason = f"Drawdown diario {drawdown_pct:.1f}% > 5%" elif winrate < 0.35 and len(recent) >= 10: pause_duration = 3600 # 1 hora reason = f"Winrate {winrate*100:.0f}% < 35% en últimos {len(recent)} trades" elif profit_factor < 0.7 and len(recent) >= 15: pause_duration = 1800 # 30 min reason = f"Profit factor {profit_factor:.2f} < 0.7" if pause_duration > 0: self._autopause_until = time.time() + pause_duration self._autopause_reason = reason mins = pause_duration // 60 logger.warning(f"[AUTOPAUSA] Bot pausado {mins}min | Razón: {reason}") _tg( f"AUTOPAUSA ACTIVADA\n" f"Razon: {reason}\n" f"WinRate: {winrate*100:.0f}% | PF: {profit_factor:.2f} | DD: {drawdown_pct:.1f}%\n" f"Bot pausado {mins} minutos. Se reanuda automaticamente." ) def _fetch_1h_trend(self, pair: str) -> tuple: """Devuelve tendencia 1h desde caché. El refresco ocurre en background.""" cached = self._cache_1h.get(pair) if cached: return cached["trend"], cached["rsi"] # Primera vez: lanzar fetch en background y retornar UNKNOWN threading.Thread(target=self._refresh_1h_background, args=(pair,), daemon=True).start() return "UNKNOWN", 50.0 def _refresh_1h_background(self, pair: str) -> None: """Refresca datos 1h en un thread daemon para no bloquear el hilo principal.""" now = time.time() with self._cache_1h_lock: cached = self._cache_1h.get(pair) if cached and now - cached["ts"] < self._cache_1h_ttl: return try: symbol = pair.replace("/", "-") if "-" not in pair else pair url = f"https://api.kucoin.com/api/v1/market/candles?type=1hour&symbol={symbol}&startAt={int(now)-7200*100}&endAt={int(now)}" resp = requests.get(url, timeout=8) if resp.status_code == 200: candles = resp.json().get("data", []) if len(candles) >= 50: closes = pd.Series([float(c[2]) for c in reversed(candles)]) ema50 = float(self._calc_ema(closes, 50).iloc[-1]) rsi_val = float(self._calc_rsi(closes, 14).iloc[-1]) trend = "BULL" if closes.iloc[-1] > ema50 else "BEAR" with self._cache_1h_lock: self._cache_1h[pair] = {"trend": trend, "rsi": rsi_val, "ts": now} except Exception as e: logger.debug(f"1h fetch fallido para {pair}: {e}") def _rotate_api(self) -> bool: """Rota a la siguiente API disponible. Retorna False si todas agotadas.""" today = datetime.now(timezone.utc).date() with self._api_lock: for _ in range(len(GEMINI_API_POOL)): self._api_index = (self._api_index + 1) % len(GEMINI_API_POOL) entry = GEMINI_API_POOL[self._api_index] label = entry["label"] usage = self._api_usage[label] # Reset conteo si es nuevo día if usage["date"] != today: usage["count"] = 0 usage["date"] = today if usage["count"] < entry["daily_limit"]: self._gemini_client = genai.Client(api_key=entry["key"]) self._gemini_model_active = entry["model"] logger.info(f"[ROTATE] Rotando a {label} ({usage['count']}/{entry['daily_limit']} usadas)") _tg(f"API rotada a {label}\nModelo: {entry['model']}\nUso: {usage['count']}/{entry['daily_limit']}") return True logger.warning("[WARN] Todas las APIs agotadas por hoy. Bot en modo HOLD hasta manana.") _tg("APIS GEMINI AGOTADAS\nEl bot esta en HOLD hasta la medianoche (reset diario).") return False def _get_active_api_entry(self) -> Optional[dict]: """Devuelve la entrada activa del pool, rotando si está agotada.""" today = datetime.now(timezone.utc).date() with self._api_lock: entry = GEMINI_API_POOL[self._api_index] label = entry["label"] usage = self._api_usage[label] if usage["date"] != today: usage["count"] = 0 usage["date"] = today if usage["count"] < entry["daily_limit"]: usage["count"] += 1 return entry needs_rotate = True # Lock liberado antes de llamar a _rotate_api para evitar deadlock if needs_rotate: if self._rotate_api(): return GEMINI_API_POOL[self._api_index] return None def _daily_summary_scheduler(self) -> None: """Envía resumen diario a las 11:59 PM cada día.""" from datetime import timedelta while True: now = datetime.now(timezone.utc) target = now.replace(hour=23, minute=59, second=0, microsecond=0) if now >= target: target = target + timedelta(days=1) sleep_secs = (target - now).total_seconds() time.sleep(sleep_secs) self._send_daily_summary() def _send_daily_summary(self) -> None: """Construye y envía el resumen del día por Telegram.""" today = datetime.now(timezone.utc).strftime("%d/%m/%Y") with self._daily_trades_lock: trades = self._daily_trades[:] self._daily_trades.clear() if not trades: _tg( f"Resumen del dia {today}\n" f"Sin operaciones hoy.\n" f"El bot siguio analizando 10 pares." ) return total_usd = sum(t["profit_usd"] for t in trades) ganadas = sum(1 for t in trades if t["won"]) perdidas = len(trades) - ganadas mejor = max(trades, key=lambda t: t["profit_usd"]) peor = min(trades, key=lambda t: t["profit_usd"]) winrate = (ganadas / len(trades)) * 100 signo_total = "+" if total_usd >= 0 else "" _tg( f"Resumen del dia {today}\n" f"Resultado total: {signo_total}${total_usd:.2f}\n" f"Operaciones: {len(trades)} ({ganadas} ganadoras {perdidas} perdedoras)\n" f"Winrate: {winrate:.0f}%\n" f"Mejor trade: {mejor['pair']} +${mejor['profit_usd']:.2f}\n" f"Peor trade: {peor['pair']} ${peor['profit_usd']:.2f}\n" f"El bot sigue operando" ) @staticmethod def _calc_rsi(series: pd.Series, period: int = 14) -> pd.Series: """RSI calculado con pandas puro, sin TA-Lib.""" delta = series.diff() gain = delta.clip(lower=0).ewm(com=period - 1, min_periods=period).mean() loss = (-delta.clip(upper=0)).ewm(com=period - 1, min_periods=period).mean() rs = gain / loss.replace(0, 1e-10) return 100 - (100 / (1 + rs)) @staticmethod def _calc_ema(series: pd.Series, period: int) -> pd.Series: """EMA calculada con pandas puro.""" return series.ewm(span=period, adjust=False).mean() def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Calcula indicadores técnicos que se pasan a Gemini como contexto.""" dataframe["rsi"] = self._calc_rsi(dataframe["close"], 14) dataframe["ema20"] = self._calc_ema(dataframe["close"], self.ema_fast.value) dataframe["ema50"] = self._calc_ema(dataframe["close"], self.ema_slow.value) dataframe["ema_signal"] = ( dataframe["ema20"] > dataframe["ema50"] ).map({True: "BULL", False: "BEAR"}) dataframe["volume_ratio"] = ( dataframe["volume"] / dataframe["volume"].rolling(20).mean() ) dataframe["price_vs_ema20"] = ( (dataframe["close"] - dataframe["ema20"]) / dataframe["ema20"] * 100 ) # MACD (12, 26, 9) ema12 = self._calc_ema(dataframe["close"], 12) ema26 = self._calc_ema(dataframe["close"], 26) dataframe["macd"] = ema12 - ema26 dataframe["macd_signal"] = self._calc_ema(dataframe["macd"], 9) dataframe["macd_hist"] = dataframe["macd"] - dataframe["macd_signal"] # Bollinger Bands (20, 2) bb_mid = dataframe["close"].rolling(20).mean() bb_std = dataframe["close"].rolling(20).std() dataframe["bb_upper"] = bb_mid + 2 * bb_std dataframe["bb_lower"] = bb_mid - 2 * bb_std dataframe["bb_mid"] = bb_mid dataframe["bb_pct"] = (dataframe["close"] - dataframe["bb_lower"]) / (dataframe["bb_upper"] - dataframe["bb_lower"]) * 100 # EMA200 — soporte/resistencia institucional dataframe["ema200"] = self._calc_ema(dataframe["close"], 200) dataframe["ema200_signal"] = ( dataframe["close"] > dataframe["ema200"] ).map({True: "ABOVE", False: "BELOW"}) # ATR(14) — volatilidad real para stop-loss dinámico high_low = dataframe["high"] - dataframe["low"] high_close = (dataframe["high"] - dataframe["close"].shift()).abs() low_close = (dataframe["low"] - dataframe["close"].shift()).abs() true_range = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1) dataframe["atr"] = true_range.ewm(span=14, adjust=False).mean() dataframe["atr_pct"] = dataframe["atr"] / dataframe["close"] * 100 # Divergencia RSI: precio sube pero RSI baja = señal de salida rsi_prev = dataframe["rsi"].shift(3) close_prev = dataframe["close"].shift(3) dataframe["rsi_divergence"] = ( (dataframe["close"] > close_prev) & (dataframe["rsi"] < rsi_prev - 5) ).map({True: "BEARISH_DIV", False: "OK"}) # Stochastic RSI (14,3,3) — momentum sobrecompra/sobreventa más sensible que RSI rsi_min = dataframe["rsi"].rolling(14).min() rsi_max = dataframe["rsi"].rolling(14).max() stoch_rsi_k = (dataframe["rsi"] - rsi_min) / (rsi_max - rsi_min + 1e-10) * 100 dataframe["stoch_rsi_k"] = stoch_rsi_k.rolling(3).mean() dataframe["stoch_rsi_d"] = dataframe["stoch_rsi_k"].rolling(3).mean() # OBV (On Balance Volume) — confirma si el volumen apoya la tendencia obv = (pd.Series( [0] + [v if c > p else (-v if c < p else 0) for c, p, v in zip(dataframe["close"][1:], dataframe["close"][:-1], dataframe["volume"][1:])], index=dataframe.index )).cumsum() dataframe["obv"] = obv dataframe["obv_signal"] = (obv > obv.rolling(10).mean()).map({True: "BULL", False: "BEAR"}) # Williams %R (14) — otro oscilador de sobrecompra/sobreventa highest_high = dataframe["high"].rolling(14).max() lowest_low = dataframe["low"].rolling(14).min() dataframe["williams_r"] = (highest_high - dataframe["close"]) / (highest_high - lowest_low + 1e-10) * -100 # CCI (20) — Commodity Channel Index, detecta reversiones tp = (dataframe["high"] + dataframe["low"] + dataframe["close"]) / 3 tp_ma = tp.rolling(20).mean() tp_std = tp.rolling(20).std() dataframe["cci"] = (tp - tp_ma) / (0.015 * tp_std + 1e-10) # Soporte y Resistencia multicapa: 20, 50 (swing) y 100 velas (institucional) dataframe["support_20"] = dataframe["low"].rolling(20).min() dataframe["resistance_20"] = dataframe["high"].rolling(20).max() dataframe["support_50"] = dataframe["low"].rolling(50).min() dataframe["resistance_50"] = dataframe["high"].rolling(50).max() dataframe["support_100"] = dataframe["low"].rolling(100).min() dataframe["resistance_100"] = dataframe["high"].rolling(100).max() # Usar el soporte/resistencia más cercano al precio actual dataframe["support"] = dataframe[["support_20", "support_50"]].max(axis=1) dataframe["resistance"] = dataframe[["resistance_20", "resistance_50"]].min(axis=1) dataframe["dist_support_pct"] = (dataframe["close"] - dataframe["support"]) / dataframe["close"] * 100 dataframe["dist_resistance_pct"] = (dataframe["resistance"] - dataframe["close"]) / dataframe["close"] * 100 # Niveles institucionales (100 velas) para referencia en el prompt dataframe["dist_support_100_pct"] = (dataframe["close"] - dataframe["support_100"]) / dataframe["close"] * 100 dataframe["dist_resistance_100_pct"] = (dataframe["resistance_100"] - dataframe["close"]) / dataframe["close"] * 100 # Número redondo más cercano (psicología del mercado) def nearest_round(price): if price <= 0: return 0.0 if price < 1.0: # Para precios < 1 usar multiplos de 0.1, 0.01 según magnitud magnitude = 10 ** (math.floor(math.log10(price))) else: magnitude = 10 ** (len(str(int(price))) - 1) rounded = round(price / magnitude) * magnitude dist_pct = abs(price - rounded) / price * 100 return round(dist_pct, 2) dataframe["dist_round_number_pct"] = dataframe["close"].apply(nearest_round) # Patrones de velas japonesas (últimas 3 velas) def detect_candle_pattern(df): patterns = [] for i in range(len(df)): if i < 2: patterns.append("NEUTRAL") continue o, h, l, c = df["open"].iloc[i], df["high"].iloc[i], df["low"].iloc[i], df["close"].iloc[i] po, ph, pl, pc = df["open"].iloc[i-1], df["high"].iloc[i-1], df["low"].iloc[i-1], df["close"].iloc[i-1] body = abs(c - o) prev_body = abs(pc - po) upper_wick = h - max(o, c) lower_wick = min(o, c) - l # Hammer (martillo) — reversión alcista if lower_wick > body * 2 and upper_wick < body * 0.5 and c > o: patterns.append("HAMMER") # Shooting star — reversión bajista elif upper_wick > body * 2 and lower_wick < body * 0.5 and c < o: patterns.append("SHOOTING_STAR") # Engulfing alcista elif c > o and pc < po and c > po and o < pc: patterns.append("BULL_ENGULF") # Engulfing bajista elif c < o and pc > po and c < po and o > pc: patterns.append("BEAR_ENGULF") # Doji (indecisión) elif body < (h - l) * 0.1: patterns.append("DOJI") else: patterns.append("NEUTRAL") return patterns dataframe["candle_pattern"] = detect_candle_pattern(dataframe) # Fibonacci retracement — niveles 38.2%, 50%, 61.8% de las últimas 50 velas high50 = dataframe["high"].rolling(50).max() low50 = dataframe["low"].rolling(50).min() fib_range = high50 - low50 dataframe["fib_382"] = high50 - fib_range * 0.382 dataframe["fib_500"] = high50 - fib_range * 0.500 dataframe["fib_618"] = high50 - fib_range * 0.618 # ¿Está el precio cerca de algún nivel Fibonacci? (<0.5%) def fib_zone(row): price = row["close"] for level, name in [(row["fib_382"], "FIB38"), (row["fib_500"], "FIB50"), (row["fib_618"], "FIB61")]: if abs(price - level) / price < 0.005: return name return "NONE" dataframe["fib_zone"] = dataframe.apply(fib_zone, axis=1) # ADX (14) — fuerza de la tendencia (no dirección) # +DI y -DI para el cálculo completo de ADX high_diff = dataframe["high"].diff() low_diff = dataframe["low"].diff() plus_dm = high_diff.where((high_diff > 0) & (high_diff > -low_diff), 0.0) minus_dm = (-low_diff).where((-low_diff > 0) & (-low_diff > high_diff), 0.0) atr14 = dataframe["atr"] plus_di = 100 * (plus_dm.ewm(span=14, adjust=False).mean() / atr14.replace(0, 1e-10)) minus_di = 100 * (minus_dm.ewm(span=14, adjust=False).mean() / atr14.replace(0, 1e-10)) dx = (abs(plus_di - minus_di) / (plus_di + minus_di + 1e-10)) * 100 dataframe["adx"] = dx.ewm(span=14, adjust=False).mean() dataframe["adx_trend"] = dataframe["adx"].apply( lambda x: "FUERTE" if x > 25 else "LATERAL" ) # MFI (14) — Money Flow Index: RSI ponderado por volumen typical_price = (dataframe["high"] + dataframe["low"] + dataframe["close"]) / 3 money_flow = typical_price * dataframe["volume"] tp_prev = typical_price.shift(1) pos_flow = money_flow.where(typical_price > tp_prev, 0.0).rolling(14).sum() neg_flow = money_flow.where(typical_price < tp_prev, 0.0).rolling(14).sum() mfi_ratio = pos_flow / neg_flow.replace(0, 1e-10) dataframe["mfi"] = 100 - (100 / (1 + mfi_ratio)) dataframe["mfi_signal"] = dataframe["mfi"].apply( lambda x: "SOBREVENTA" if x < 20 else ("SOBRECOMPRA" if x > 80 else "NEUTRO") ) dataframe["gemini_buy"] = 0 dataframe["gemini_sell"] = 0 dataframe["gemini_confidence"] = 0.0 return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Llama a Gemini para la última vela y guarda la decisión.""" pair = metadata["pair"] logger.info(f"[ENTRY] populate_entry_trend | {pair} | velas={len(dataframe)}") if len(dataframe) == 0: return dataframe # Autopausa: si el bot está en pausa, no abrir nuevos trades if time.time() < self._autopause_until: mins_left = (self._autopause_until - time.time()) / 60 logger.info(f"[AUTOPAUSA] Bot en pausa, faltan {mins_left:.0f} min | {self._autopause_reason}") dataframe.loc[dataframe["gemini_buy"] == 1, "enter_long"] = 1 return dataframe # Detectar régimen de mercado y actualizar multiplicadores regime = self._detect_regime(dataframe) # Régimen CAOS o BAJISTA_VOLATIL: no abrir nuevas posiciones if self._regime_stake_mult == 0.0: logger.info(f"[REGIME] {regime} — no se abren trades en este régimen") dataframe.loc[dataframe["gemini_buy"] == 1, "enter_long"] = 1 return dataframe # Prefiltro de ahorro: solo llamar Gemini si hay oportunidad técnica clara # Esto evita gastar tokens en velas neutras last = dataframe.iloc[-1] pass_filter = ( (last['rsi'] < 42 and last['stoch_rsi_k'] < 35) or (last['bb_pct'] < 25 and last['volume_ratio'] > 0.8) or (last['macd_hist'] > 0 and last['rsi'] < 52 and last['volume_ratio'] > 1.0) or (last['volume_ratio'] > 1.8 and last['rsi'] < 60) or (last['candle_pattern'] in ['HAMMER', 'BULL_ENGULF', 'MORNING_STAR'] and last['volume_ratio'] > 1.0) or (last['dist_support_pct'] < 1.0 and last['rsi'] < 55) ) if not pass_filter: dataframe.loc[dataframe["gemini_buy"] == 1, "enter_long"] = 1 return dataframe # Codificar estado para Q-Learning q_state = self._encode_state(dataframe) decision = self._get_gemini_decision(pair, dataframe) if decision and decision.get("accion") == "BUY": confidence = decision.get("confianza", 0) # Guardar el RSI actual en la decisión para la memoria de aprendizaje decision["rsi"] = float(dataframe["rsi"].iloc[-1]) decision["q_state"] = q_state if confidence >= MIN_CONFIDENCE: dataframe.loc[dataframe.index[-1], "gemini_buy"] = 1 dataframe.loc[dataframe.index[-1], "gemini_confidence"] = confidence logger.info( f"[BUY] GEMINI BUY | {pair} | " f"Confianza: {confidence}% | Régimen: {regime} | " f"Q-estado: {q_state} | Razon: {decision.get('razon', 'N/A')}" ) dataframe.loc[dataframe["gemini_buy"] == 1, "enter_long"] = 1 return dataframe def custom_stake_amount( self, current_time, current_rate, proposed_stake, min_stake, max_stake, leverage, entry_tag, side, **kwargs ) -> float: """Escala el capital según la confianza de Gemini. Incluye protección drawdown diario y cooldown tras stop-loss. """ pair = kwargs.get("pair", "") # Reset diario de pérdidas today = datetime.now(timezone.utc).date() if self._daily_loss_date != today: self._daily_loss_usd = 0.0 self._daily_loss_date = today # Protección drawdown diario: si se perdió >5% del balance, no operar más hoy try: balance = self.wallets.get_available_capital() except Exception: balance = 45.0 # fallback demo max_daily_loss = balance * 0.05 if self._daily_loss_usd >= max_daily_loss: logger.warning( f"[RISK] Drawdown diario ${self._daily_loss_usd:.2f} >= limite ${max_daily_loss:.2f}. " f"Sin nuevas entradas hasta mañana." ) _tg( f"PROTECCION ACTIVADA\n" f"Perdida del dia: ${self._daily_loss_usd:.2f}\n" f"Limite 5% alcanzado. Sin nuevas entradas hasta mañana." ) return 0.0 # Cooldown tras stop-loss: esperar 15 min antes de reentrar al mismo par cooldown_ts = self._stoploss_cooldown.get(pair, 0) mins_restantes = (900 - (time.time() - cooldown_ts)) / 60 if time.time() - cooldown_ts < 900: logger.info(f"[COOLDOWN] {pair} en cooldown, faltan {mins_restantes:.0f} min") return 0.0 # Capital por trade: 20% del balance disponible (max 5 trades = 100% exposición total) base_pct = 0.20 max_by_balance = min(max(balance * base_pct, min_stake), max_stake) cache_key = next((k for k in self._gemini_decisions if k.startswith(pair)), None) if cache_key: confidence = self._gemini_decisions[cache_key].get("confianza", 0) if confidence >= 75: # Alta confianza: usar 20% completo sin reducir stake = min(max_by_balance, max_stake) logger.info(f"[STAKE] Alto {stake:.2f}$ | {pair} | conf={confidence}%") return stake stake = min(max_by_balance, max_stake) # Ajustar stake según régimen de mercado stake = round(stake * self._regime_stake_mult, 2) stake = max(stake, min_stake) if stake > 0 else 0.0 logger.info(f"[STAKE] Normal {stake:.2f}$ ({base_pct*100:.0f}% balance) | {pair} | regimen={self._market_regime} mult={self._regime_stake_mult}") return stake def custom_stoploss( self, pair: str, trade, current_time, current_rate: float, current_profit: float, after_fill: bool, **kwargs ) -> float: """Stop loss dinámico basado en ATR × multiplicador según volatilidad y régimen.""" # Obtener ATR% real del par desde decisión cacheada (guardado en _get_gemini_decision) last_decision = next((v for k, v in self._gemini_decisions.items() if k.startswith(pair)), {}) atr_pct = last_decision.get("atr_pct", 1.0) # % de ATR real, default 1% regime = self._market_regime if "CALMADA" in regime or regime == "RANGO_TRANQUILO": multiplier = 1.2 # stop más ajustado en calma elif "VOLATIL" in regime or "CAOS" in regime: multiplier = 2.0 # stop más amplio en volatilidad elif "TRANSICION" in regime: multiplier = 1.5 else: multiplier = 1.5 # default neutro # Calcular stop distance: ATR% * multiplicador, con floor y ceiling stop_distance = max(0.008, min(0.030, (atr_pct / 100) * multiplier)) # Si ya estamos en profit > 2%, activar trailing más ajustado if current_profit > 0.02: return -0.005 # trailing muy ajustado una vez en ganancia return -stop_distance def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """Señal de salida basada en RSI sobrecomprado o decisión cacheada de Gemini.""" pair = metadata["pair"] cache_key = f"{pair}_{dataframe.index[-1]}" decision = self._gemini_decisions.get(cache_key) # Bug fix: limpiar decisiones viejas >2h para evitar fuga de memoria now_ts = time.time() cutoff = now_ts - 7200 stale = [k for k, v in self._gemini_decisions.items() if v.get("_ts", now_ts) < cutoff] for k in stale: del self._gemini_decisions[k] # Bug fix: RSI sobrecomprado SOLO en la última vela, no en todas las históricas last_rsi = float(dataframe["rsi"].iloc[-1]) if last_rsi > self.rsi_sell_threshold.value: dataframe.loc[dataframe.index[-1], "gemini_sell"] = 1 if decision and decision.get("accion") in ["SELL", "CLOSE"]: dataframe.loc[dataframe.index[-1], "gemini_sell"] = 1 logger.info( f"[SELL] GEMINI SELL | {pair} | " f"Razon: {decision.get('razon', 'N/A')}" ) dataframe.loc[dataframe["gemini_sell"] == 1, "exit_long"] = 1 return dataframe def _get_gemini_decision( self, pair: str, dataframe: DataFrame ) -> Optional[dict]: """ Llama a Gemini API con el contexto del mercado. Respeta rate limit: máx 1 llamada por vela por par. Fallback: retorna HOLD si hay cualquier error. """ if not self._gemini_client: return {"accion": "HOLD", "confianza": 0, "razon": "Gemini no configurado"} cache_key = f"{pair}_{dataframe.index[-1]}" if cache_key in self._gemini_decisions: return self._gemini_decisions[cache_key] elapsed = time.time() - self._last_gemini_call if elapsed < RATE_LIMIT_SECONDS: time.sleep(RATE_LIMIT_SECONDS - elapsed) try: last = dataframe.iloc[-1] # Detectar momentum: precio de las últimas 3 velas closes = dataframe['close'].tail(4).values momentum = "SUBIENDO" if closes[-1] > closes[-2] > closes[-3] else ("BAJANDO" if closes[-1] < closes[-2] < closes[-3] else "LATERAL") macd_trend = "ALCISTA" if last['macd_hist'] > 0 else "BAJISTA" bb_pos = "SOBRECOMPRA" if last['bb_pct'] > 80 else ("SOBREVENTA" if last['bb_pct'] < 20 else "MEDIO") news, news_sentiment = _fetch_crypto_news(pair) fg_value, fg_label = _fetch_fear_greed() trend_1h, rsi_1h = self._fetch_1h_trend(pair) reddit_score, reddit_mentions = _fetch_reddit_sentiment(pair) trending_coins = _fetch_trending_coins() coin_symbol = pair.split("/")[0] is_trending = "SI" if coin_symbol in trending_coins else "NO" # Sentiment compuesto: promedio de FG, noticias y reddit social_avg = int((news_sentiment + reddit_score) / 2) social_label = "BULLISH" if social_avg > 60 else ("BEARISH" if social_avg < 40 else "NEUTRAL") # Hora UTC — evitar operar entre 00:00-04:00 UTC (caída de madrugada) hora_utc = datetime.now(timezone.utc).hour hora_peligro = "SI" if 0 <= hora_utc < 4 else "NO" # Memoria activa: aprender de errores por par específico pair_trades = [t for t in self._trade_memory if t["pair"] == pair] losses = [t for t in pair_trades if not t["won"]] wins = [t for t in pair_trades if t["won"]] memory_ctx = "" if pair_trades: win_rate = len(wins) / len(pair_trades) * 100 memory_ctx = f" Historial {pair}: {len(wins)}W/{len(losses)}L ({win_rate:.0f}% WR)" if losses: last_loss = losses[-1] memory_ctx += f" UltimaLoss: RSI={last_loss.get('rsi', '?')} conf={last_loss.get('confianza', '?')}%" if len(losses) >= 2 and losses[-2].get('rsi'): memory_ctx += f" PATRON_PERDIDA: evitar RSI>{min(l.get('rsi',99) for l in losses[-3:] if l.get('rsi'))}" rsi_status = "SOBRECOMPRADO" if last['rsi'] > 70 else ("SOBREVENTA" if last['rsi'] < 30 else "OK") stoch_status = "SOBRECOMPRADO" if last['stoch_rsi_k'] > 80 else ("SOBREVENTA" if last['stoch_rsi_k'] < 20 else "OK") cci_status = "SOBRECOMPRADO" if last['cci'] > 100 else ("SOBREVENTA" if last['cci'] < -100 else "NEUTRO") wr_status = "SOBRECOMPRADO" if last['williams_r'] > -20 else ("SOBREVENTA" if last['williams_r'] < -80 else "NEUTRO") # Q-Learning hint: acción preferida según experiencia acumulada q_state_now = self._encode_state(dataframe) with self._q_lock: q_vals = self._q_table[q_state_now] q_best_action = self._q_actions[q_vals.index(max(q_vals))] q_confidence = max(q_vals) q_hint = f"Q-Learning recomienda {q_best_action} (valor={q_confidence:.2f}, episodios={self._q_episodes})" adx_status = "FUERTE" if last['adx'] > 40 else ("TENDENCIA" if last['adx'] > 25 else "LATERAL") mfi_status = last['mfi_signal'] prompt = f"""Eres un trader crypto AGRESIVO que busca oportunidades de compra. Par: {pair}. Responde UNICAMENTE JSON. IMPORTANTE: Tu objetivo es ENCONTRAR compras rentables. Solo di HOLD si NO hay ninguna oportunidad. Ignora Q-Learning si ves buena entrada. PRECIO: {last['close']:.4f} | Momento={momentum} | Vela={last['candle_pattern']} REGIMEN: {self._market_regime} TENDENCIA: 1H={trend_1h} RSI1H={rsi_1h:.0f} EMA50={last['ema_signal']} EMA200={last['ema200_signal']} FUERZA: ADX={last['adx']:.0f}[{adx_status}] MFI={last['mfi']:.0f}[{mfi_status}] MOMENTUM: RSI={last['rsi']:.0f}[{rsi_status}] StochRSI={last['stoch_rsi_k']:.0f}[{stoch_status}] MACD={macd_trend} OSCILADORES: CCI={last['cci']:.0f}[{cci_status}] WilliamsR={last['williams_r']:.0f}[{wr_status}] VOLUMEN: Vol={last['volume_ratio']:.1f}x OBV={last['obv_signal']} BB={last['bb_pct']:.0f}%[{bb_pos}] NIVELES_SWING: Soporte={last['dist_support_pct']:.1f}%abajo Resistencia={last['dist_resistance_pct']:.1f}%arriba FIBONACCI: {last['fib_zone']} | NumRedondo={last['dist_round_number_pct']:.1f}% RIESGO: ATR={last['atr_pct']:.2f}% DivRSI={last['rsi_divergence']} HoraBaja={hora_peligro} MERCADO: FG={fg_value}[{fg_label}] Noticias={news[:80]} SENTIMENT: Social={social_label}({social_avg}%) Reddit={reddit_score}%({reddit_mentions}posts) Trending={is_trending} NewsSent={news_sentiment}% IA_MEMORIA: {q_hint} (solo referencia, NO seguir ciegamente) {memory_ctx} COMPRAR si cumple 2+ de estas: - MACD ALCISTA + RSI<60 - RSI<40 (sobreventa = oportunidad) - StochRSI<30 + Vol>0.7x - BB<25% (precio bajo en banda) - Vela=HAMMER/BULL_ENGULF/DOJI + Vol>0.8x - Precio cerca soporte (<1.5%) - Sentiment BULLISH + Trending=SI - EMA50=ABOVE o EMA200=ABOVE - 1H=BULL + ADX>20 NUNCA COMPRAR solo si: Vol<0.3x | DivRSI=BEARISH_DIV | HoraBaja=SI | vela=SHOOTING_STAR/BEAR_ENGULF | REGIMEN=CAOS VENDER si: RSI>75 + MACD BAJISTA | DivRSI=BEARISH_DIV | StochRSI>85 + MFI>85 Confianza: 40-60=senal debil, 61-80=buena, 81-100=excelente JSON: {{"accion":"BUY","confianza":65,"razon":"max15palabras"}}""" api_entry = self._get_active_api_entry() if not api_entry: return {"accion": "HOLD", "confianza": 0, "razon": "Todas las APIs agotadas"} response = self._gemini_client.models.generate_content( model=api_entry["model"], contents=prompt, config=genai_types.GenerateContentConfig( temperature=0.1, max_output_tokens=120, thinking_config=genai_types.ThinkingConfig(thinking_budget=0), ), ) self._last_gemini_call = time.time() raw = response.text.strip() raw = raw.replace("```json", "").replace("```", "").strip() # Intentar parsear JSON, si falla intentar extraer con regex try: decision = json.loads(raw) except json.JSONDecodeError: import re accion_m = re.search(r'"accion"\s*:\s*"(BUY|SELL|HOLD|CLOSE)"', raw, re.IGNORECASE) conf_m = re.search(r'"confianza"\s*:\s*(\d+)', raw) razon_m = re.search(r'"razon"\s*:\s*"([^"]{1,80})', raw) if accion_m: decision = { "accion": accion_m.group(1).upper(), "confianza": int(conf_m.group(1)) if conf_m else 50, "razon": razon_m.group(1) if razon_m else "parsed", } else: raise if "accion" not in decision: raise ValueError("Respuesta inválida de Gemini") valid_actions = {"BUY", "SELL", "HOLD", "CLOSE"} if decision["accion"] not in valid_actions: decision["accion"] = "HOLD" decision["confianza"] = max(0, min(100, int(decision.get("confianza", 0)))) logger.info( f"[GEMINI] {pair} | " f"{decision['accion']} ({decision.get('confianza', 0)}%) | " f"{decision.get('razon', '')}" ) decision["_ts"] = time.time() decision["atr_pct"] = float(last.get("atr_pct", 1.0)) decision["adx"] = float(last.get("adx", 0)) decision["mfi"] = float(last.get("mfi", 50)) self._gemini_decisions[cache_key] = decision if len(self._gemini_decisions) > 100: oldest = list(self._gemini_decisions.keys())[0] del self._gemini_decisions[oldest] return decision except json.JSONDecodeError as e: logger.warning(f"[WARN] Gemini JSON invalido: {e} | Raw: {raw}") return {"accion": "HOLD", "confianza": 0, "razon": "JSON parse error"} except Exception as e: err_str = str(e) if "429" in err_str or "quota" in err_str.lower() or "exhausted" in err_str.lower() or "RESOURCE_EXHAUSTED" in err_str: # Marcar la API actual como agotada en el contador local # para que _rotate_api la salte y no siga intentando with self._api_lock: label = GEMINI_API_POOL[self._api_index]["label"] limit = GEMINI_API_POOL[self._api_index]["daily_limit"] self._api_usage[label]["count"] = limit logger.warning(f"[WARN] API {label} agotada en Google, marcando como usada y rotando...") if not self._rotate_api(): _tg( "APIS AGOTADAS\n" "Todas las APIs de Gemini llegaron al limite diario de Google.\n" "El bot seguira analizando pero en modo HOLD hasta medianoche UTC.\n" "Reset automatico a las 00:00 UTC." ) elif "503" in err_str or "UNAVAILABLE" in err_str or "ServiceUnavailable" in err_str: logger.warning("[WARN] Gemini 503, esperando 5s y rotando API...") time.sleep(5) self._rotate_api() else: logger.error(f"[ERROR] Llamada a Gemini: {e}") return {"accion": "HOLD", "confianza": 0, "razon": f"Error: {str(e)[:50]}"} def confirm_trade_entry( self, pair, order_type, amount, rate, time_in_force, current_time, entry_tag, side, **kwargs ) -> bool: """Última verificación antes de abrir trade — notifica por Telegram.""" total_cost = amount * rate confidence = 0 razon = "" for k, v in self._gemini_decisions.items(): if k.startswith(pair): confidence = v.get("confianza", 0) razon = v.get("razon", "")[:60] break nivel = "ALTA CONFIANZA" if confidence >= 80 else "Normal" moneda = pair.split("/")[0] last_dec_e = next((v for k, v in self._gemini_decisions.items() if k.startswith(pair)), {}) atr_e = last_dec_e.get("atr_pct", 1.0) reg = self._market_regime sl_m = 1.2 if ("CALMADA" in reg or reg == "RANGO_TRANQUILO") else (2.0 if ("VOLATIL" in reg or "CAOS" in reg) else 1.5) stop_pct = max(0.8, min(3.0, atr_e * sl_m)) ganancia_esperada = total_cost * 0.02 perdida_max = total_cost * (stop_pct / 100) rr = ganancia_esperada / perdida_max if perdida_max > 0 else 0 adx_e = last_dec_e.get("adx", 0) mfi_e = last_dec_e.get("mfi", 50) logger.info( f"[ENTRY_OK] {pair} | Precio: {rate:.6f} | Capital: ${total_cost:.2f} | ADX={adx_e:.0f} | Stop={stop_pct:.1f}%" ) _tg( f"COMPRANDO {moneda}\n" f"Par: {pair}\n" f"Precio entrada: {rate:.4f} USDT\n" f"Capital: ${total_cost:.2f}\n" f"Ganancia objetivo: +${ganancia_esperada:.2f} (+2%)\n" f"Stop loss: -${perdida_max:.2f} (-{stop_pct:.1f}% ATR)\n" f"Riesgo/Ganancia: 1:{rr:.1f}\n" f"Confianza Gemini: {confidence}% [{nivel}]\n" f"ADX: {adx_e:.0f} | MFI: {mfi_e:.0f}\n" f"Regimen: {self._market_regime}\n" f"Razon: {razon}" ) return True def confirm_trade_exit( self, pair, trade, order_type, amount, rate, time_in_force, exit_reason, current_time, **kwargs ) -> bool: """Log y notificación Telegram cuando se cierra un trade.""" profit_pct = trade.calc_profit_ratio(rate) * 100 ganó = profit_pct > 0 profit_usd = trade.stake_amount * (profit_pct / 100) resultado = "GANANCIA" if ganó else "PERDIDA" signo = "+" if ganó else "" moneda = pair.split("/")[0] logger.info( f"[{'WIN' if ganó else 'LOSS'}] Trade cerrado | {pair} | " f"P&L: {profit_pct:.2f}% | Razon: {exit_reason}" ) with self._daily_trades_lock: self._daily_trades.append({ "pair": pair, "profit_usd": profit_usd, "profit_pct": profit_pct, "won": ganó }) # Activar cooldown si fue stop-loss para evitar reentrar inmediatamente if exit_reason in ("stop_loss", "stoploss", "trailing_stop_loss") and not ganó: self._stoploss_cooldown[pair] = time.time() self._daily_loss_usd += abs(profit_usd) logger.info(f"[COOLDOWN] {pair} en cooldown 15min tras stop-loss. Perdida hoy: ${self._daily_loss_usd:.2f}") # Guardar en memoria de aprendizaje con RSI y contexto para detección de patrones last_decision = next((v for k, v in self._gemini_decisions.items() if k.startswith(pair)), {}) self._trade_memory.append({ "pair": pair, "accion": last_decision.get("accion", "BUY"), "confianza": last_decision.get("confianza", 0), "rsi": last_decision.get("rsi", None), "razon": last_decision.get("razon", ""), "exit_reason": exit_reason, "profit_usd": round(profit_usd, 3), "profit_pct": round(profit_pct, 2), "won": ganó, }) if len(self._trade_memory) > 50: self._trade_memory.pop(0) # ── Q-Learning: actualizar tabla con resultado del trade ── last_decision = next((v for k, v in self._gemini_decisions.items() if k.startswith(pair)), {}) q_state = last_decision.get("q_state", 50) q_action = 1 # BUY=1 (la acción que tomamos fue comprar) reward = self._q_reward(profit_pct / 100, trade.stake_amount) # Calcular next_state aproximado (estado actual sin dataframe — usamos estado actual del mercado) next_state = q_state # aproximación conservadora self._q_update(q_state, q_action, reward, next_state) # Guardar experiencia en buffer de replay exp = {"state": q_state, "action": q_action, "reward": reward, "next_state": next_state} self._experience_replay.append(exp) if len(self._experience_replay) > 500: self._experience_replay.pop(0) # Experience Replay: repasar 32 experiencias pasadas self._experience_replay_train() # Guardar Q-table en disco self._save_qtable() logger.info(f"[QLEARN] Actualizado | estado={q_state} | reward={reward:.4f} | epsilon={self._q_epsilon:.3f} | episodios={self._q_episodes}") # ── Autopausa: evaluar métricas cada 5 trades ── self._trades_since_check += 1 if self._trades_since_check >= 5: self._trades_since_check = 0 self._check_autopause() razones = { "roi": "Objetivo de ganancia alcanzado", "stop_loss": "Stop loss activado", "trailing_stop_loss": "Stop loss dinamico activado", "stoploss": "Stop loss activado", "force_sell": "Venta manual", "sell_signal": "Senal de venta de Gemini", "exit_signal": "Senal de salida de Gemini", } razon_es = razones.get(exit_reason, exit_reason) last_dec_x = next((v for k, v in self._gemini_decisions.items() if k.startswith(pair)), {}) adx_x = last_dec_x.get("adx", 0) mfi_x = last_dec_x.get("mfi", 50) emoji = "GANANCIA" if ganó else "PERDIDA" _tg( f"{emoji} - {moneda}\n" f"Par: {pair}\n" f"Resultado: {signo}{profit_pct:.2f}% ({signo}${abs(profit_usd):.2f})\n" f"Capital usado: ${trade.stake_amount:.2f}\n" f"Motivo: {razon_es}\n" f"ADX salida: {adx_x:.0f} | MFI salida: {mfi_x:.0f}\n" f"Regimen: {self._market_regime}" ) return True