==================================================================== BETFAIR SCALPING BOT — DOCUMENTACIÓN COMPLETA Recuperada de git (commit f1ebb77, eliminado en limpieza Julio 2026) ==================================================================== Índice: 1. La idea 2. El flujo completo (paso a paso del motor) 3. Mercados y selecciones que usábamos 4. Señales / datos que consumíamos 5. Configuración y parámetros 6. La fórmula P&L (corregida) 7. Por qué NO funcionó (análisis honesto) 8. Qué llegamos a probar (evolución de la estrategia) 9. Bugs críticos encontrados y arreglados 10. Archivos recuperados (dónde están) 11. Cómo relanzarlo si queremos intentarlo de nuevo -------------------------------------------------------------------- 1. LA IDEA -------------------------------------------------------------------- Trading automatizado en Betfair Exchange con scalping: entrar y salir del mercado en segundos/minutos capturando movimientos de precio. Evolución en 2 fases: FASE A (original, matemáticamente PERDEDORA): Scalping clásico de 1 tick de spread: entrar BACK al mejor precio y cerrar LAY cuando el spread se cierre 1 tick. Se ejecutaba en mercados tranquilos esperando movimiento aleatorio. Resultado: tras ~12 ciclos, -0.50€ de spread + comisión, incluso en los trades "ganadores". La comisión del 5% se comía el spread. FASE B (rediseño, lo último que hicimos): "Momentum direccional con triple confirmación": NO scalpear el spread, sino entrar SOLO cuando hay movimiento real direccional (precio cayendo o subiendo con dinero de verdad fluyendo), y capturar ese movimiento. BACK-first cuando el precio baja, LAY-first cuando sube. -------------------------------------------------------------------- 2. EL FLUJO COMPLETO (código recuperado) -------------------------------------------------------------------- Motor: ScalpingEngine (thread daemon, bucle cada 2 segundos). run() ──> bucle infinito hasta stop_flag: 1. ¿kill_switch_triggered()? → para todo, persiste daily_pnl 2. _scan() → buscar entrada 3. _monitor_trades() → gestionar trades abiertos 4. _persist_daily_pnl() → guardar P&L diario en BD 5. espera 2.0 segundos _scan() — buscar entrada: 1. _can_enter(): saldo real Betfair (campo "balance", NO "available") >= stake; stake >= 2€ mínimo Betfair 2. list_market_book([market_id]) → order book completo 3. Si wait_for_inplay: NO entra hasta que isInplay=True 4. _momentum.update(book): calcula volume_ema, imbalance, back_slope, momentum_score 5. _momentum.entry_signal() → "BACK" | "LAY" | None (triple confirmación, ver sección 4) 6. Si señal None → no entra (descarta señales falsas) 7. Límite de 2 trades abiertos simultáneos 8. Loop por runners (todos los del mercado): - odds filtro 1.40–3.50 - liquidez mínima 50€ en el lado de entrada - spread máximo 5 tics - elegir el de menor spread (best_entry) 9. Crear TradeExecutor y place_entry(): - BACK → place_bet Lado=BACK a back_price - LAY → place_bet Lado=LAY a lay_price (LIMIT marketable, es decir, al mejor precio disponible) _monitor_trades() — gestionar cada trade abierto (cada 2s): Orden de comprobaciones por trade: 1. ENTRY MATCH: si el entry no se ha igualado: - >30s sin match → cancel_bet y eliminar trade - usar check_bets_matched: verifica sizeMatched > 0 (no basta EXECUTION_COMPLETE: incluye lapsed/cancelled) 2. STOP-LOSS (contra el precio del lado ENTRY, no del exit): - BACK-first: si current_entry_price <= entry*(1-stop%) → force_exit - LAY-first: si current_entry_price >= entry*(1+stop%) → force_exit (stop por fase: 4% pre, 5% early, 6% mid, 8% late) 3. TIMEOUT: age_seconds >= max_trade_time (60-90s según fase) → force_exit("timeout") 4. SALIDA POR SEÑAL (momentum_exit): should_exit_signal(direction): - slope revierte >= 0.3 ticks/s contra el trade - momentum_score < 0.20 (se agotó) - imbalance gira contra la entrada (>0.4) → force_exit("momentum_exit") 5. GREEN-UP RENTABLE: si close_price cruza entry_price a favor: - BACK-first: current_close_price (LAY) <= entry_price - LAY-first: current_close_price (BACK) >= entry_price → place_exit() (coloca LAY o BACK balanceado) 6. VERIFICAR EXIT MATCHED: - si se igualó → _finalize("exit_matched"), guardar en BD - si no y el precio se movió >= 1 tic → cancel_exit() (solo recoloca si cambió el precio, para no darle comisiones al mercado) force_exit(reason) — salir a toda costa: 1. abandon_count += 1; si llega a 3 → ABANDON: trade cerrado, exit_reason="abandoned", pnl = -entry_stake * comisión (pérdida real persistida) 2. Buscar mejor precio disponible del lado exit 3. Si green-up rentable (pnl >= 0.01 y stake >= 2€) → place_exit 4. Si no, probar precios 0.2% peores hasta encontrar close_stake >= 2€ mínimo (hasta 5 offsets) 5. Último recurso: salir con stake mínimo aceptando pérdida 6. Si nada funciona → retorna None (sin cerrar), reintenta en el próximo ciclo Kill switch (diario): daily_pnl (tabla bf_scalping_daily_pnl) <= -bankroll*0.05 → cierra todos los trades y detiene el motor. El P&L diario persiste en BD para sobrevivir reinicios. Salida por defecto: stop() cierra todos los trades con exit_reason="engine_stop". -------------------------------------------------------------------- 3. MERCADOS Y SELECCIONES QUE USÁBAMOS -------------------------------------------------------------------- - Mercado: SOLO "Match Odds" (o "Partido") del evento Betfair. resolve_event() busca en list_market_catalogue_by_event el primer mercado cuyo marketName contenga "Match Odds" o "Partido". - Selección: el PRIMER runner del mercado (runners[0]) — en Betfair el "home" / local. En _scan() podía saltar a otros runners del mismo mercado si eran los que tenían la señal (todos los runners con odds 1.40-3.50 y liquidez >= 50€). - Odds objetivo: 1.40 – 3.50 (favoritos y moderados; España a 2.36 era el caso típico documentado). - Tamaños: liquidez mínima 50€ al lado de entrada (antes 30€). - Spread máximo: 5 tics in-play (antes 10); 2 pre-match (legacy). - Tick size Betfair (función betfair_tick_size): <=2.0 → 0.01 <=3.0 → 0.02 <=4.0 → 0.05 <=6.0 → 0.1 <=10 → 0.2 <=20 → 0.5 <=30 → 1.0 <=50 → 2.0 <=100 → 5.0 >100 → 10.0 -------------------------------------------------------------------- 4. SEÑALES / DATOS QUE CONSUMÍAMOS -------------------------------------------------------------------- De list_market_book (cada 2s) para el primer runner: - totalMatched del mercado → volumen instantáneo €/s con EMA (alpha 0.3), ventana de historia 30 segundos. - Mejor BACK y mejor LAY (precio + tamaño) → imbalance: imbalance = (back_size - lay_size) / (back_size + lay_size) (+1 = toda la liquidez en BACK, -1 = toda en LAY) - Historial de precios (timestamp, back, lay) → slope en ticks/s: slope = (p1 - p0) / tick_size / dt (ventana 30s) MomentumSignal.entry_signal() → "BACK" | "LAY" | None Exige las 3 condiciones (TRIPLE CONFIRMACIÓN): 1. momentum_score >= 0.50 2. |order_book_imbalance| >= 0.40 3. |volume_ema| >= 100.0 €/s 4. |back_slope| >= 0.30 ticks/s momentum_score = 0.4*norm_vol + 0.3*norm_imb + 0.3*norm_slope norm_vol = min(|volume_ema|/500, 1) norm_imb = |imbalance| norm_slope = min(|back_slope|/1.0, 1) Dirección (con confirmación de coherencia): - slope < 0 (BACK price bajando) + imbalance > 0 (presión backed) → "BACK" (entrar BACK, cerrar LAY cuando siga bajando) - slope > 0 (BACK price subiendo) + imbalance < 0 (presión layed) → "LAY" (entrar LAY, cerrar BACK cuando siga subiendo) - Si slope e imbalance se contradicen → None (NO entra) should_exit_signal(direction) → True si: - BACK y back_slope > +0.3 (precio revirtiendo contra el trade) - LAY y back_slope < -0.3 - momentum_score < 0.20 - BACK y imbalance < -0.40 (giró a presión layed) - LAY y imbalance > +0.40 -------------------------------------------------------------------- 5. CONFIGURACIÓN Y PARÁMETROS -------------------------------------------------------------------- DEFAULT_SETTINGS: bankroll: 100.0 € stake_por_trade: 10.0 € (stake = min(stake, bankroll*0.05), nunca < 2€ mínimo Betfair) stop_loss_pct: 0.08 (por defecto, antes de fase) max_trade_time: 25 (por defecto, antes de fase) max_concurrent_trades: 2 Parámetros dinámicos por fase (MATCH_PHASES): pre_match (antes del KO): stop 4%, tiempo máx 60s early (0-15') : stop 5%, tiempo máx 75s mid (15-75') : stop 6%, tiempo máx 90s late (75-90'+) : stop 8%, tiempo máx 60s Constantes: COMMISSION_PCT = 0.05 (comisión Betfair) MIN_PROFIT = 0.01 € (no cierra si beneficio neto < 0.01) MIN_BETFAIR_STAKE = 2.0 € MAX_ABANDON_RETRIES = 3 EXIT_RETRY_ATTEMPTS = 3 MIN_PROFITABLE_LAY_TICKS = 3 (legacy, de la fase A) Endpoints que se llamaban (betfair_client): - list_market_catalogue_by_event(event_id, max_results=50) - list_market_book([market_id]) - place_bet(market_id, selection_id, side, stake, price) - cancel_bet(bet_id, market_id) - get_balance() → usar balance["balance"] - check_bets_matched([bet_ids]) → verifica sizeMatched > 0 -------------------------------------------------------------------- 6. LA FÓRMULA P&L (CORREGIDA — crítico) -------------------------------------------------------------------- ERROR ORIGINAL: la comisión estaba DENTRO del cálculo del stake de cierre: S_L = S_B * B / (L - c) ← INCORRECTO Esto obligaba a que el LAY de cierre fuera ~0.07 más bajo que el BACK de entrada para cerrar "rentable", multiplicando el spread necesario ~2x y mostrando P&L falsos: trades que parecían +0.01€ en realidad perdían -0.09€. FÓRMULA CORRECTA (la que quedó en el código): green-up balanceado: close_stake = entry_stake * entry_price / close_price BACK-first (entro BACK a B, cierro LAY a L): P&L = entry_stake * (entry_price/close_price - 1) * (1-c) → rentable cuando el precio BAJA (close < entry) LAY-first (entro LAY a L, cierro BACK a B): P&L = entry_stake * (1 - entry_price/close_price) * (1-c) → rentable cuando el precio SUBE (close > entry) Cierre no balanceado (real, cuando el exit no quedó balanceado): BACK-first: if_selection_wins = S_B*(B-1) - S_L*(L-1) if_selection_loses = -S_B + S_L net = min(wins, loses) → P&L = net*(1-c) si net>0 LAY-first: los dos casos invertidos. Abandon: pnl = -entry_stake * commission (pérdida real mínima). -------------------------------------------------------------------- 7. POR QUÉ NO FUNCIONÓ (análisis honesto) -------------------------------------------------------------------- a) FASE A (scalping 1-tick spread): PERDEDOR POR DISEÑO. - En Betfair el spread BACK/LAY es ~2 ticks de mínimo. - Para ganar 1 tick de spread hay que pagar 2 y encima la comisión del 5% se aplica al beneficio. - Entraba en mercados tranquilos SIN movimiento → cerraba por timeout/stale a pérdida (~-0.50€ por ~12 ciclos). - Con stake 10€ a odds ~2.3, cada ciclo perdedor costaba ~0.40-0.60€. Era ruleta, no edge. b) FASE B (momentum direccional): NO SE LLEGÓ A VALIDAR. - Se rediseñó la estrategia (triple confirmación) pero: + El umbral de volumen (100 €/s EMA) era muy exigente: en mercados pre-match / poco líquidos casi nunca se daba. + En in-play los partidos de equipos "moderados" rara vez producían movimientos sostenidos de >0.30 ticks/s con >100€/s de flujo real. + La "confirmación" slope+imbalance era frágil: el libro cambia más rápido que la ventana de 30s. + Cada ciclo de 2s con list_market_book real es lento: la latencia Betfair (~200-500ms) + polling 2s = señales muertas al ejecutarlas. - Resultado práctico: pocas entradas, muchas "sin señal", y las pocas entradas dependían de si el mercado se movía durante los 60-90s de vida del trade. No hubo muestra suficiente para probar si el edge existía. - Nunca se midió el edge real (backtest con datos históricos). Se intentó montar un sistema de paper trading (API-Football) pero para scalping de 90s eso no aplica: hace falta HISTORIAL DE ORDER BOOK, que no teníamos. c) COSTES OCULTOS: - Comisión 5% (no es 2%: cuenta sin tarifa reducida). - Los cancel/place repetidos generan "pace" y el exchange puede penalizar la cuenta. - Mínimos: 2€ por apuesta — con stake 10€ el margen de maniobra era enorme para el objetivo de 0.01-0.05€ por trade. d) VEREDICTO: El scalping de 1 tick era matemáticamente perdedor (se demostró con la fórmula). El momentum direccional era prometedor PERO nunca se pudo validar: sin datos históricos de order book, sin backtest, y con ejecución demasiado lenta (polling 2s) para capturar movimientos reales. -------------------------------------------------------------------- 8. QUÉ LLEGAMOS A PROBAR (evolución) -------------------------------------------------------------------- 1. Scalping clásico 1-tick spread (pre-match + in-play) → perdedor, eliminado. 2. Match Odds SOLO sobre el primer runner (España como caso real) 3. Parámetros dinámicos por fase del partido (pre/early/mid/late) con stop-loss y timeout ajustados. 4. Filtros de liquidez (30€ → 50€), odds (1.40-3.50), spread máximo 5 tics, wait_for_inplay. 5. Triple confirmación de momentum (slope + imbalance + volumen) con dirección BACK/LAY según coherencia de señales. 6. Salidas por señal (momentum_exit) en lugar de solo timeout. 7. Retry de salidas con precios progresivos (0.2% peor), abandon tras 3 intentos con pérdida real persistida en BD. 8. Kill switch diario (5% del bankroll) con persistencia en BD. 9. Modo simulado (SIM) para probar sin dinero real (stake virtual, saldo virtual, trades con prefijo SIM-). Nunca llegamos a usarlo de forma sistemática. 10. (RELACIONADO, mismo commit) Anomalías / book_scanner: scanner de errores de cuota en el libro (mispricing) y cashout_engine: motor de cash-out inteligente. También se eliminaron por rotos/sin uso real. -------------------------------------------------------------------- 9. BUGS CRÍTICOS ENCONTRADOS Y ARREGLADOS -------------------------------------------------------------------- 1. Fórmula P&L con comisión dentro del stake (CRÍTICO, Julio 2026) → corregida a S_L = S_B * B / L, comisión solo sobre neto. 2. Stop-loss comparaba lados distintos (BACK vs LAY) → nunca disparaba. Fix: refresh_price(side=entry_side). 3. Argumentos cruzados en cancel_bet: llamaba cancel_bet(market_id, bet_id) pero la firma era cancel_bet(bet_id, market_id=None). Las cancelaciones nunca funcionaban. Fix: invertir argumentos. 4. Entry no verificado antes de monitorizar: ponía exit orders sin confirmar el match del entry. Fix: entry_is_matched() con sizeMatched > 0, y cancelar tras 30s sin match. 5. check_bets_matched confundía matched con lapsed: EXECUTION_COMPLETE incluye lapsed/cancelled. Fix: verificar sizeMatched > 0 y filtrar por betIds. 6. Código duplicado en _scan() generaba 2 trades por ciclo. Fix: return explícito. 7. Dinero bloqueado sin saldo disponible: usaba bankroll virtual. Fix: get_balance() real, campo "balance" (no "available"). 8. Trade atascado (ERROR_IN_ORDER) por mercado suspendido → bucle infinito. Fix: exit retry 3 precios + abandon tras 3 intentos. 9. _stop sobreescribía threading.Thread._stop → TypeError en is_alive(). Fix: usar _stop_flag. 10. Test de Kelly desactualizado (esperaba cap 25%, Half Kelly lo cambió a 5%). Fix: assert 0.05. -------------------------------------------------------------------- 10. ARCHIVOS RECUPERADOS (están en /tmp, NO en el repo) -------------------------------------------------------------------- - /tmp/scalping_bot_recovered.py (1159 líneas) el bot completo - /tmp/test_scalping_math_recovered.py (517 líneas) tests matemáticos - /tmp/book_scanner_recovered.py (187 líneas) scanner anomalías - /tmp/cashout_engine_recovered.py (740 líneas) motor cash-out Modelos BD implicados: - bf_scalping_trades (ScalpingTrade): match_id, match_label, market_id, selection_id, selection_name, entry_side, entry_price, entry_stake, entry_time, exit_side, exit_price, exit_stake, exit_time, pnl, status, exit_reason, simulated, created_at - bf_scalping_daily_pnl (ScalpingDailyPnl): date, total_pnl, trade_count, win_count, loss_count, updated_at Los endpoints (start-match, stop, status, history, clear) y la página /scalping del frontend también se eliminaron. Se pueden recrear desde los archivos de /tmp. -------------------------------------------------------------------- 11. CÓMO RELANZARLO SI QUEREMOS INTENTARLO DE NUEVO -------------------------------------------------------------------- Requisitos previos ANTES de tocar dinero real: 1. DATOS: conseguir historial de order book (listMarketBook grabado durante días/semanas) para backtest real. Sin eso, cualquier estrategia de scalping es fe. 2. VALIDACIÓN: backtest del momentum direccional con esos datos: - ¿Cuántas veces se da la triple confirmación al día? - ¿El precio sigue moviéndose 0.3+ ticks a favor en los 60-90s siguientes más del 50% de las veces? - ¿El P&L esperado (descontando spread 2 tics + comisión 5%) es positivo? Con stake 2€ mínimo y odds ~2.3, cada trade necesita mover el precio ~0.02-0.03 a favor SOLO para cubrir el spread. El edge real debe demostrarse. 3. EJECUCIÓN: bajar el polling de 2s a streaming (Betfair MarketStreaming) o usar latencia menor; el polling de 2s mata las señales en vivo. 4. Comisión: confirmar tarifa real de la cuenta (5% es duro para scalping; con 2% cambia todo). 5. Probar primero en modo SIM (simulate=True) durante 1-2 semanas completas y comparar con el mercado. Estrategias alternativas más viables con lo que ya tenemos: - Value betting en pre-match (Dixon-Coles + EV+ filter + Half Kelly) — ya implementado y validable con paper trading. - Cash-out inteligente sobre trades ya abiertos (cashout_engine recuperado en /tmp). - Anomalías de cuota (book_scanner) solo si se demoniza que las desviaciones son explotables con la velocidad de ejecución que tenemos. ==================================================================== Fin del documento — BetFriend project ====================================================================