""" server_G.py — Cerebro G — LiquidityStrategist v4.0 (APEX KMeans+LLM Auditor) ============================================================================= v4.0 — Remodelación según Plan Maestro APEX-ASYMMETRIC SWARM (Fase 3): ARQUITECTURA LLM+MATH (filosofía de Cerebro A): FASE MATH (<5ms) — Localización de zonas institucionales: · Volume Profile completo: POC, VAL, VAH (70% del volumen). · K-Means Clustering (numpy puro): agrupa los picos de volumen histórico en K=3 clusters para detectar los Nodos de Alto Volumen (HVN) EXACTOS. Sin aproximaciones: el centroide de cada cluster = HVN real del mercado. · Fibonacci Pivots: niveles F236, F382, F500, F618, F786. · Breakout Score: qué tan cerca está el precio de cruzar un HVN. · Señal clara (POC_HOT, POC_BOUNCE, ABOVE_VAH, BELOW_VAL): retorno inmediato sin LLM (<5ms) → [G/MATH-CLEAR]. FASE LLM (Auditor de Rupturas) — SOLO en zona ambigua: · El LLM actúa ÚNICAMENTE como auditor de breakouts. · Recibe: {"hvn_dist_pct": X, "price_vs_poc": Y, "vol_trend": "up|down"} · Pregunta: ¿Es este breakout de HVN genuino o una trampa institucional? · Output estricto: {"decision": "BUY|WAIT", "type": "scalp|long", "conf": 0.XX} · Si LLM falla → Math da la decisión conservadora (WAIT). ASIMETRÍA por RANK (heredada de v3.1, mejorada): RANK 1: Math + LLM si ambiguo RANK 2+: 100% Math (LLM PROHIBIDA) → [G/MATH-ONLY] ENDPOINTS: POST /batch → lote asimétrico (endpoint principal) POST /analyze_liquidity → análisis individual GET /health GET / TELEMETRÍA: [G/MATH-CLEAR] sym: decision conf | Xms [G/LLM] sym: decision conf | Xms (auditor de ruptura) [G/MATH-ONLY] sym: decision conf | Xms (rank 2+) [G/BATCH] N activos | LLM=X Clear=X Math=X | Xms """ import os, json, re, time, threading, math from fastapi import FastAPI, Request from fastapi.responses import JSONResponse import httpx # BitNet v6.0 try: import numpy as np _NP_OK = True except ImportError: _NP_OK = False app = FastAPI(title="Cerebro G — LiquidityStrategist v4.0 KMeans+LLM") # ── Configuración ────────────────────────────────────────────────────────────── MODEL_PATH = os.environ.get("MODEL_PATH", "/models/ggml-model-i2_s.gguf") N_CTX = int(os.environ.get("N_CTX", "128")) N_THREADS = int(os.environ.get("N_THREADS", "2")) N_BATCH = int(os.environ.get("N_BATCH", "64")) # ── BitNet Infrastructure v6.0 ─────────────────────────────────────────────── BITNET_PORT = int(os.environ.get("BITNET_PORT", "8080")) BITNET_HOST = os.environ.get("BITNET_HOST", "127.0.0.1") BITNET_BASE = f"http://{BITNET_HOST}:{BITNET_PORT}" BITNET_TIMEOUT = float(os.environ.get("BITNET_TIMEOUT", "30.0")) LLM_MAX_TOKENS = int(os.environ.get("LLM_MAX_TOKENS", "48")) _bitnet_client = None def _get_bitnet_client(): global _bitnet_client if _bitnet_client is None or _bitnet_client.is_closed: _bitnet_client = httpx.AsyncClient( base_url=BITNET_BASE, timeout=httpx.Timeout(BITNET_TIMEOUT), limits=httpx.Limits(max_connections=4, max_keepalive_connections=2), ) return _bitnet_client async def _bitnet_infer(prompt_text: str, max_tokens: int = 48) -> dict: client = _get_bitnet_client() payload = {"prompt": prompt_text, "n_predict": max_tokens, "temperature": 0.05, "top_p": 0.95, "top_k": 5, "repeat_penalty": 1.0, "stream": False, "stop": ["<|eot_id|>", "<|end_of_text|>"]} try: resp = await client.post("/completion", json=payload) resp.raise_for_status() return {"raw": resp.json().get("content", "").strip(), "ok": True} except httpx.TimeoutException: return {"raw": "", "ok": False, "error": "TIMEOUT"} except Exception as e: return {"raw": "", "ok": False, "error": str(e)[:80]} VP_MIN_BARS = int(float(os.environ.get("VP_MIN_BARS", "5"))) KILL_ZONE_PCT = float(os.environ.get("KILL_ZONE_PCT", "0.020")) HOT_ZONE_PCT = float(os.environ.get("HOT_ZONE_PCT", "0.005")) CONF_VP_STRONG = float(os.environ.get("CONF_VP_STRONG", "0.72")) CONF_VP_WEAK = float(os.environ.get("CONF_VP_WEAK", "0.28")) CACHE_TTL = float(os.environ.get("CACHE_TTL", "90.0")) AMBIG_CONF_LOW = float(os.environ.get("AMBIG_CONF_LOW", "0.48")) AMBIG_CONF_HIGH = float(os.environ.get("AMBIG_CONF_HIGH", "0.58")) KMEANS_K = int(os.environ.get("KMEANS_K", "3")) # clusters HVN # ── BitNet v6.0: modelo servido por llama-server externo (ik_llama.cpp) ────── # llm = Llama(...) eliminado — inferencias via HTTP a BITNET_BASE print(f"[G] ✅ LiquidityStrategist v4.0 KMeans+BitNet — numpy={'✅' if _NP_OK else '⚠️'}") print(f"[G] BitNet server: {BITNET_BASE} | n_ctx={N_CTX}") # ── Cache ────────────────────────────────────────────────────────────────────── _G_CACHE: dict = {} _G_LOCK = threading.Lock() def _cache_get(sym: str): with _G_LOCK: e = _G_CACHE.get(sym) if e and (time.time() - e["ts"]) < CACHE_TTL: age = int(time.time() - e["ts"]) return {**e["result"], "_cached": True, "_cache_age_s": age} return None def _cache_set(sym: str, result: dict): with _G_LOCK: _G_CACHE[sym] = {"result": result, "ts": time.time()} # ══════════════════════════════════════════════════════════════════════════════ # HELPER: extracción robusta de barras desde cualquier payload # ══════════════════════════════════════════════════════════════════════════════ def _extract_bars(payload: dict) -> list: bars = payload.get("bars") or payload.get("b") or [] return bars if isinstance(bars, list) else [] # ══════════════════════════════════════════════════════════════════════════════ # K-MEANS CLUSTERING (numpy puro — sin sklearn) # Detecta Nodos de Alto Volumen (HVN) exactos desde el Volume Profile # ══════════════════════════════════════════════════════════════════════════════ def _kmeans_hvn(price_levels: list, vol_levels: list, k: int = 3, max_iter: int = 20) -> list: """ K-Means simplificado para detectar HVN (High Volume Nodes). Entrada: price_levels: lista de precios de cada nivel del VP vol_levels: volumen correspondiente a cada nivel k: número de clusters (default 3 = bajo/medio/alto volumen) Retorna: lista de k HVNs (precios de los centroides con mayor volumen acumulado) Algoritmo: 1. Inicializar centroides con K-Means++ (distribución uniforme en el rango) 2. Iterar: asignar cada nivel al centroide más cercano 3. Recalcular centroides como promedio ponderado por volumen 4. Seleccionar los k centroides con mayor volumen promedio del cluster """ if not price_levels or len(price_levels) < k: return price_levels[:k] if price_levels else [] if _NP_OK: prices = np.array(price_levels, dtype=float) vols = np.array(vol_levels, dtype=float) # Inicialización: centroides uniformemente distribuidos en el rango p_min, p_max = float(prices.min()), float(prices.max()) if p_min == p_max: return [p_min] * k centroids = np.linspace(p_min, p_max, k) for _ in range(max_iter): # Asignación: cada precio al centroide más cercano dists = np.abs(prices[:, None] - centroids[None, :]) labels = np.argmin(dists, axis=1) # Actualización: centroide = media ponderada por volumen del cluster new_centroids = np.zeros(k) for j in range(k): mask = labels == j if mask.sum() > 0: w = vols[mask] new_centroids[j] = float(np.average(prices[mask], weights=w)) else: new_centroids[j] = centroids[j] if np.allclose(centroids, new_centroids, rtol=1e-4): break centroids = new_centroids # Seleccionar los HVNs: centroides con mayor volumen promedio del cluster cluster_vols = [] labels = np.argmin(np.abs(prices[:, None] - centroids[None, :]), axis=1) for j in range(k): mask = labels == j avg_vol = float(vols[mask].mean()) if mask.sum() > 0 else 0.0 cluster_vols.append((avg_vol, float(centroids[j]))) # Ordenar por volumen descendente y retornar precios cluster_vols.sort(key=lambda x: x[0], reverse=True) return [round(p, 6) for _, p in cluster_vols] else: # Fallback puro-Python: Top-K por volumen sin clustering real paired = sorted(zip(vol_levels, price_levels), reverse=True) return [round(p, 6) for _, p in paired[:k]] # ══════════════════════════════════════════════════════════════════════════════ # VOLUME PROFILE + FIBONACCI + HVN K-MEANS (FASE MATH COMPLETA) # ══════════════════════════════════════════════════════════════════════════════ def _compute_vp_kmeans(bars: list, px: float) -> dict: """ Volume Profile completo + K-Means HVN + Fibonacci en <5ms. Nuevo en v4.0: hvn_levels: lista de 3 HVNs exactos por K-Means (precios) hvn_nearest: HVN más cercano al precio actual hvn_dist_pct: distancia % del precio al HVN más cercano breakout_score: qué tan cerca está el precio de cruzar un HVN (0-1) vol_trend: tendencia del volumen (last 3 vs prev 3 barras) """ t0 = time.perf_counter() closes, volumes = [], [] for b in bars: c = b.get("c") or b.get("close") or 0.0 v = b.get("v") or b.get("volume") or 0.0 if c > 0: closes.append(float(c)) volumes.append(float(v) or 1.0) def math_ms(): return (time.perf_counter() - t0) * 1000 if len(closes) < VP_MIN_BARS or px <= 0: return { "poc": px, "val": px, "vah": px, "poc_dist_pct": 0.0, "zone": "degenerate", "conf_math": CONF_VP_WEAK, "is_ambiguous": False, "fib_levels": {}, "dec_math": "WAIT", "hvn_levels": [], "hvn_nearest": px, "hvn_dist_pct": 0.0, "breakout_score": 0.0, "vol_trend": "flat", "logic": f"Sin datos VP ({len(closes)} barras < {VP_MIN_BARS}). Degradado.", "_math_ms": round(math_ms(), 2), } price_min, price_max = min(closes), max(closes) # ── Fibonacci ───────────────────────────────────────────────────────────── fib_range = price_max - price_min fib_levels = {} if fib_range > 0: for ratio, name in [(0.236, "F236"), (0.382, "F382"), (0.500, "F500"), (0.618, "F618"), (0.786, "F786")]: fib_levels[name] = round(price_max - fib_range * ratio, 6) if price_max == price_min: poc = val = vah = price_min conf_math = CONF_VP_WEAK zone = "degenerate" logic = f"VAL=VAH=${poc:.4f}. Sin rango detectable." poc_dist_pct = 0.0 vp_prices = [price_min] * len(closes) vp_vols = volumes else: n_levels = min(10, len(closes)) step = (price_max - price_min) / n_levels levels = [] for i in range(n_levels): ll = price_min + i * step lh = ll + step lv = sum(v for c, v in zip(closes, volumes) if ll <= c < lh) mid = (ll + lh) / 2 levels.append({"low": ll, "high": lh, "mid": mid, "vol": lv}) poc_level = max(levels, key=lambda x: x["vol"]) poc = poc_level["mid"] total_vol = sum(lv["vol"] for lv in levels) or 1.0 sorted_levels = sorted(levels, key=lambda x: x["vol"], reverse=True) cum_vol, val, vah = 0.0, poc, poc for lv in sorted_levels: cum_vol += lv["vol"] val = min(val, lv["low"]) vah = max(vah, lv["high"]) if cum_vol >= total_vol * 0.70: break poc_dist_pct = abs(px - poc) / poc if poc > 0 else 0.0 vp_prices = [lv["mid"] for lv in levels] vp_vols = [lv["vol"] for lv in levels] if poc_dist_pct <= HOT_ZONE_PCT: zone, conf_math = "POC_HOT", CONF_VP_STRONG logic = f"Precio en POC. Magneto {poc_dist_pct*100:.2f}% del POC ${poc:.4f}." elif poc_dist_pct <= KILL_ZONE_PCT: zone, conf_math = "POC_BOUNCE", 0.60 logic = f"Precio en Kill Zone. POC ${poc:.4f} dist={poc_dist_pct*100:.2f}%." elif px > vah: zone, conf_math = "ABOVE_VAH", 0.62 logic = f"Precio ENCIMA del VAH ${vah:.4f}. Zona de extensión." elif px < val: zone, conf_math = "BELOW_VAL", CONF_VP_WEAK logic = f"Precio DEBAJO del VAL ${val:.4f}. Zona de riesgo." else: zone, conf_math = "VALUE_AREA", 0.50 logic = f"Precio dentro del Value Area [{val:.4f}–{vah:.4f}]." # ── K-Means HVN ─────────────────────────────────────────────────────────── hvn_levels = _kmeans_hvn(vp_prices, vp_vols, k=KMEANS_K) hvn_nearest = min(hvn_levels, key=lambda h: abs(px - h)) if hvn_levels else px hvn_dist_pct = abs(px - hvn_nearest) / hvn_nearest if hvn_nearest > 0 else 0.0 # Breakout Score: 1.0 si está cruzando el HVN ahora, 0.0 si está lejos breakout_score = max(0.0, 1.0 - hvn_dist_pct / max(KILL_ZONE_PCT, 0.001)) breakout_score = round(min(1.0, breakout_score), 3) # Tendencia del volumen: last 3 vs prev 3 barras vol_trend = "flat" if len(volumes) >= 6: v_recent = sum(volumes[-3:]) / 3 v_prev = sum(volumes[-6:-3]) / 3 if v_recent > v_prev * 1.2: vol_trend = "up" elif v_recent < v_prev * 0.8: vol_trend = "down" # Decisión matemática base if zone in ("POC_HOT", "POC_BOUNCE", "ABOVE_VAH"): dec_math = "BUY" elif zone == "BELOW_VAL": dec_math = "WAIT" else: dec_math = "WAIT" # VALUE_AREA ambigua → LLM decide # Ambigüedad: VALUE_AREA + conf_math entre AMBIG_CONF_LOW y AMBIG_CONF_HIGH is_ambiguous = (zone == "VALUE_AREA" and AMBIG_CONF_LOW <= conf_math <= AMBIG_CONF_HIGH) return { "poc": round(poc, 6), "val": round(val, 6), "vah": round(vah, 6), "poc_dist_pct": round(poc_dist_pct, 5), "zone": zone, "conf_math": round(conf_math, 4), "is_ambiguous": is_ambiguous, "fib_levels": fib_levels, "dec_math": dec_math, # KMeans HVN (nuevos v4.0) "hvn_levels": hvn_levels, "hvn_nearest": round(hvn_nearest, 6), "hvn_dist_pct": round(hvn_dist_pct, 5), "breakout_score": breakout_score, "vol_trend": vol_trend, "logic": logic, "_math_ms": round(math_ms(), 2), } # ══════════════════════════════════════════════════════════════════════════════ # TIPO DE TRADE (heredado de v3.1) # ══════════════════════════════════════════════════════════════════════════════ def _classify_trade_type(zone: str, conf: float, cronos: float, px: float, poc: float, vah: float, val: float) -> str: poc_dist = abs(px - poc) / poc if poc > 0 else 0.0 if poc_dist <= HOT_ZONE_PCT and cronos >= 0.70: return "scalp" if zone == "ABOVE_VAH" and conf >= 0.65: return "long" if zone in ("POC_BOUNCE", "POC_HOT") and conf >= 0.65: return "scalp" return "scalp" # ══════════════════════════════════════════════════════════════════════════════ # FASE LLM — Auditor de Rupturas (solo rank=1 en zona ambigua) # ══════════════════════════════════════════════════════════════════════════════ async def _llm_breakout_audit(math_data: dict, sym: str, px: float, cronos: float, a_data: dict, b_data: dict) -> dict: """ El LLM actúa como Auditor de Rupturas — NO calcula nada. Recibe el panel de control destilado por MATH: {"hvn_dist_pct": X, "price_vs_poc": Y, "vol_trend": "up|down", "breakout_score": Z, "zone": "VALUE_AREA", "cronos": X} Pregunta: ¿Es este breakout de HVN genuino o una trampa institucional? Output: {"decision": "BUY|WAIT", "type": "scalp|long", "conf": 0.XX} """ t0_llm = time.perf_counter() hvn_dist = math_data.get("hvn_dist_pct", 0.0) bk_score = math_data.get("breakout_score", 0.0) vol_trend = math_data.get("vol_trend", "flat")[0] # "u"/"d"/"f" zone = math_data.get("zone", "VALUE_AREA")[:3] poc_d = math_data.get("poc_dist_pct", 0.0) g_sent = a_data.get("bias", "neutral")[0] if a_data else "n" b_trend = b_data.get("trend", "side")[0] if b_data else "s" llm_prompt = ( "<|im_start|>system\n" 'Breakout auditor. SOLO JSON: {"decision":"BUY|WAIT","type":"scalp|long","conf":0.XX}.\n' "true=breakout real | false=trampa. Sin pensar.\n" "<|im_end|>\n" "<|im_start|>user\n" f'{{"s":"{sym[:8]}","cr":{cronos:.2f},"hvn":{hvn_dist:.3f},' f'"bk":{bk_score:.2f},"vt":"{vol_trend}","z":"{zone}",' f'"poc":{poc_d:.3f},"a":"{g_sent}","b":"{b_trend}"}}\n' "<|im_end|>\n" "<|im_start|>assistant\n{" ) try: result = await _bitnet_infer(llm_prompt) raw_out_text = result.get("raw", "") llm_ms = (time.perf_counter() - t0_llm) * 1000 raw = "{" + raw_out_text m = re.search(r'\{[^{}]*"decision"\s*:\s*"(BUY|WAIT)"[^{}]*\}', raw, re.IGNORECASE) if m: parsed = json.loads(m.group()) dec = parsed.get("decision", "WAIT").upper() ttype = parsed.get("type", "scalp").lower() conf = float(parsed.get("conf", 0.55)) if dec not in ("BUY", "WAIT"): dec = "WAIT" if ttype not in ("scalp", "long"): ttype = "scalp" conf = round(max(0.10, min(0.95, conf)), 4) print(f"[G/LLM] {sym}: {dec} {ttype} conf={conf:.2f} | {llm_ms:.1f}ms") return {"decision": dec, "type": ttype, "confidence": conf, "_llm_ms": round(llm_ms, 1), "_source": "llm"} # Parseo parcial dec = "BUY" if "BUY" in raw.upper() else "WAIT" print(f"[G/LLM] {sym}: partial={dec} | {llm_ms:.1f}ms") return {"decision": dec, "type": "scalp", "confidence": 0.52, "_llm_ms": round(llm_ms, 1), "_source": "llm_partial"} except Exception as e: llm_ms = (time.perf_counter() - t0_llm) * 1000 print(f"[G/LLM-ERR] {sym}: {type(e).__name__}: {str(e)[:50]} | {llm_ms:.1f}ms → WAIT") return {"decision": "WAIT", "type": "scalp", "confidence": 0.45, "_llm_ms": round(llm_ms, 1), "_source": "llm_fallback"} # ══════════════════════════════════════════════════════════════════════════════ # ANÁLISIS INDIVIDUAL (combina MATH + LLM si ambiguo) # ══════════════════════════════════════════════════════════════════════════════ async def _analyze_single(sym: str, px: float, bars: list, cronos: float, a_data: dict, b_data: dict, rank: int = 1) -> dict: """ Pipeline completo para un solo activo. rank=1: MATH + LLM si ambiguo → [G/MATH-CLEAR] o [G/LLM] rank>1: solo MATH → [G/MATH-ONLY] """ t0 = time.perf_counter() # ── FASE MATH ───────────────────────────────────────────────────────────── math_data = _compute_vp_kmeans(bars, px) math_ms = math_data["_math_ms"] zone = math_data["zone"] conf_math = math_data["conf_math"] dec_math = math_data["dec_math"] is_ambiguous = math_data["is_ambiguous"] poc = math_data["poc"] vah = math_data["vah"] val = math_data["val"] logic = math_data["logic"] trade_type = _classify_trade_type(zone, conf_math, cronos, px, poc, vah, val) # ── Señal clara → sin LLM ───────────────────────────────────────────────── if zone != "VALUE_AREA" or not is_ambiguous or rank > 1: label = "MATH-CLEAR" if rank == 1 else "MATH-ONLY" total_ms = (time.perf_counter() - t0) * 1000 print(f"[G/{label}] {sym}: {dec_math} {trade_type} conf={conf_math:.2f} " f"zone={zone} | {total_ms:.1f}ms") return { "decision": dec_math, "type": trade_type, "confidence": conf_math, "poc": poc, "vah": vah, "val": val, "logic": logic, "zone": zone, "hvn_levels": math_data["hvn_levels"], "hvn_nearest": math_data["hvn_nearest"], "breakout_score": math_data["breakout_score"], "fib_levels": math_data["fib_levels"], "cerebro": "G", "_math_ms": round(math_ms, 2), "_llm_ms": 0.0, "_total_ms": round(total_ms, 1), } # ── Zona ambigua (VALUE_AREA) + rank=1 → LLM Auditor ──────────────────── llm_result = await _llm_breakout_audit(math_data, sym, px, cronos, a_data, b_data) llm_ms = llm_result.get("_llm_ms", 0.0) decision = llm_result.get("decision", dec_math) trade_type = llm_result.get("type", trade_type) confidence = llm_result.get("confidence", conf_math) total_ms = (time.perf_counter() - t0) * 1000 return { "decision": decision, "type": trade_type, "confidence": confidence, "poc": poc, "vah": vah, "val": val, "logic": logic + f" | LLM-audit: {decision}", "zone": zone, "hvn_levels": math_data["hvn_levels"], "hvn_nearest": math_data["hvn_nearest"], "breakout_score": math_data["breakout_score"], "fib_levels": math_data["fib_levels"], "cerebro": "G", "_math_ms": round(math_ms, 2), "_llm_ms": round(llm_ms, 1), "_total_ms": round(total_ms, 1), } # ── Endpoints FastAPI ───────────────────────────────────────────────────────── @app.get("/") async def root(): return { "status": "online", "cerebro": "G", "version": "4.0-APEX-KMeans+LLM", "endpoints": ["/batch", "/analyze_liquidity", "/health"], } @app.get("/health") async def health(): return { "status": "online", "cerebro": "G", "version": "4.0-APEX-KMeans+LLM", "model": "BitNet-b1.58-2B-4T-i2_s (ik_llama.cpp)", "bitnet_server": BITNET_BASE, "numpy_ok": _NP_OK, "kmeans_k": KMEANS_K, "features": { "math": "VP + K-Means HVN + Fibonacci (<5ms)", "llm": "Auditor de Rupturas (solo rank=1 zona ambigua)", "async": "rank=1 LLM|CLEAR + rank2+ MATH-ONLY", }, } @app.post("/analyze_liquidity") async def analyze_liquidity(request: Request): try: payload = await request.json() except Exception: return JSONResponse({"error": "JSON inválido"}, status_code=400) sym = str(payload.get("sym", "?")) px = float(payload.get("px", 0)) cronos = float(payload.get("cronos_score", 0.5)) rank = int(payload.get("rank", 1)) bars = _extract_bars(payload) a_data = payload.get("A", {}) b_data = payload.get("B", {}) cached = _cache_get(sym) if cached: return JSONResponse(cached) result = await _analyze_single(sym, px, bars, cronos, a_data, b_data, rank) _cache_set(sym, result) return JSONResponse(result) @app.post("/batch") async def batch(request: Request): """ Procesamiento asimétrico en lote: Activo rank=1 → Math + LLM si ambiguo Activos rank>1 → 100% Math (LLM prohibida) """ try: payload = await request.json() except Exception: return JSONResponse({"error": "JSON inválido"}, status_code=400) assets = payload.get("assets", []) if not assets: return JSONResponse({"error": "assets vacío"}, status_code=400) t0_batch = time.perf_counter() results = {} llm_count, clear_count, math_count = 0, 0, 0 for asset in assets: sym = str(asset.get("sym", "?")) px = float(asset.get("px", 0)) cronos = float(asset.get("cronos_score", 0.5)) rank = int(asset.get("rank", asset.get("_rank", 0)) + 1) # 1-based bars = _extract_bars(asset) a_data = asset.get("A", {}) b_data = asset.get("B", {}) cached = _cache_get(sym) if cached: results[sym] = cached math_count += 1 continue result = await _analyze_single(sym, px, bars, cronos, a_data, b_data, rank) _cache_set(sym, result) results[sym] = result src = result.get("_source", "math") if "llm" in str(src): llm_count += 1 elif result.get("_llm_ms", 0) == 0.0 and rank == 1: clear_count += 1 else: math_count += 1 batch_ms = (time.perf_counter() - t0_batch) * 1000 print(f"[G/BATCH] {len(assets)} activos | LLM={llm_count} Clear={clear_count} " f"Math={math_count} | {batch_ms:.0f}ms") return JSONResponse({ "results": results, "llm_count": llm_count, "math_count": math_count, "cache_count": 0, "latency_ms": round(batch_ms, 1), }) @app.post("/slot_lock") async def slot_lock(request: Request): """Endpoint de notificación de slot (heredado — no altera la lógica).""" try: body = await request.json() except Exception: return JSONResponse({"ok": True}) sym = body.get("sym", "?") print(f"[G/SLOT-LOCK] {sym}: trade abierto — monitoreando") return JSONResponse({"ok": True, "sym": sym})