"""Module d'arbitrage inter-CEX (Phase 4) — DRY-RUN. Scanne en continu les écarts de prix d'une même paire entre plusieurs exchanges centralisés (via ccxt) et journalise les opportunités nettes (après frais). AUCUNE exécution réelle : on se contente de détecter et journaliser. L'interface `ArbStrategy` est abstraite pour brancher plus tard l'arbitrage triangulaire ou DEX sans réécrire le scanner. Usage : python cex_arb.py --once python cex_arb.py # boucle continue """ from __future__ import annotations import argparse import os import time from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime, timezone from typing import Optional import ccxt # Frais taker par défaut (fraction). Ajuster selon ton palier réel sur chaque exchange. DEFAULT_TAKER_FEE = { "binance": 0.001, "kraken": 0.0026, "coinbase": 0.006, "bybit": 0.001, } EXCHANGES = os.environ.get("ARB_EXCHANGES", "binance,kraken").split(",") PAIRS = os.environ.get("ARB_PAIRS", "BTC/USDT,ETH/USDT").split(",") MIN_NET_SPREAD_PCT = float(os.environ.get("ARB_MIN_SPREAD_PCT", "0.2")) # seuil net % INTERVAL_S = int(os.environ.get("ARB_INTERVAL_S", "30")) def _log(msg: str) -> None: ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") print(f"[arb {ts}] {msg}", flush=True) @dataclass class ArbOpportunity: pair: str buy_exchange: str sell_exchange: str buy_price: float sell_price: float gross_spread_pct: float net_spread_pct: float # après frais taker des deux côtés class ArbStrategy(ABC): """Interface d'une stratégie d'arbitrage (extensible : triangulaire, DEX…).""" @abstractmethod def scan(self) -> list[ArbOpportunity]: # pragma: no cover - interface ... class CrossExchangeArb(ArbStrategy): """Arbitrage 'spatial' : même paire, deux exchanges, acheter bas / vendre haut.""" def __init__(self, exchange_names: list[str], pairs: list[str]) -> None: self.pairs = pairs self.clients: dict[str, ccxt.Exchange] = {} for name in exchange_names: name = name.strip() try: self.clients[name] = getattr(ccxt, name)({"enableRateLimit": True}) except Exception as exc: # noqa: BLE001 _log(f"exchange '{name}' indisponible: {exc}") def _fee(self, exchange: str) -> float: return DEFAULT_TAKER_FEE.get(exchange, 0.001) def _ticker(self, exchange: str, pair: str) -> Optional[dict]: client = self.clients.get(exchange) if client is None: return None try: return client.fetch_ticker(pair) except Exception: # noqa: BLE001 — paire absente / erreur réseau : on ignore return None def scan(self) -> list[ArbOpportunity]: opportunities: list[ArbOpportunity] = [] names = list(self.clients.keys()) for pair in self.pairs: # Récupère le prix (last) sur chaque exchange. prices: dict[str, float] = {} for ex in names: t = self._ticker(ex, pair) if t and t.get("last"): prices[ex] = float(t["last"]) if len(prices) < 2: continue # Compare toutes les paires d'exchanges dans les deux sens. for buy_ex, buy_price in prices.items(): for sell_ex, sell_price in prices.items(): if buy_ex == sell_ex or sell_price <= buy_price: continue gross = (sell_price - buy_price) / buy_price * 100 # Coût aller-retour : frais à l'achat + frais à la vente. net = ( sell_price * (1 - self._fee(sell_ex)) - buy_price * (1 + self._fee(buy_ex)) ) / buy_price * 100 if net >= MIN_NET_SPREAD_PCT: opportunities.append( ArbOpportunity( pair=pair, buy_exchange=buy_ex, sell_exchange=sell_ex, buy_price=round(buy_price, 6), sell_price=round(sell_price, 6), gross_spread_pct=round(gross, 3), net_spread_pct=round(net, 3), ) ) return opportunities def run_cycle(strategy: ArbStrategy) -> int: opportunities = strategy.scan() if not opportunities: _log("aucune opportunité au-dessus du seuil.") return 0 for opp in opportunities: _log( f"💡 {opp.pair} : acheter {opp.buy_exchange} @ {opp.buy_price} → " f"vendre {opp.sell_exchange} @ {opp.sell_price} | " f"net {opp.net_spread_pct:.3f}% (brut {opp.gross_spread_pct:.3f}%) [DRY-RUN]" ) return len(opportunities) def main() -> None: parser = argparse.ArgumentParser(description="MidasBot — arbitrage CEX (dry-run)") parser.add_argument("--once", action="store_true", help="un seul scan puis sortie") args = parser.parse_args() pairs = [p.strip() for p in PAIRS if p.strip()] strategy = CrossExchangeArb(EXCHANGES, pairs) _log( f"Scanner inter-CEX : {list(strategy.clients.keys())} | paires {pairs} | " f"seuil net {MIN_NET_SPREAD_PCT}% | DRY-RUN" ) if args.once: run_cycle(strategy) return while True: try: run_cycle(strategy) except Exception as exc: # noqa: BLE001 _log(f"erreur: {exc}") time.sleep(INTERVAL_S) if __name__ == "__main__": main()