Files
ahad-quant/exchange_adapter.py
2026-06-25 14:00:20 +03:00

1089 lines
41 KiB
Python

"""
AHAD QUANT — Exchange Adapter Layer (Forex Edition)
Unified interface for trading Forex across multiple brokers.
Supported: OANDA, MetaTrader 5, Interactive Brokers (paper), Paper (built-in).
Usage:
from exchange_adapter import get_exchange
exchange = get_exchange("oanda")
exchange.connect()
balance = exchange.get_balance()
Pair format (internal): "EURUSD", "GBPJPY", etc.
Each adapter converts to its broker's native format internally.
"""
import logging
import os
import time
from abc import ABC, abstractmethod
import requests
logger = logging.getLogger(__name__)
DEFAULT_SLIPPAGE = 0.00003 # ~3 pips for Forex market orders
# ─── Optional SDK imports ─────────────────────────────────────────────────────
try:
import oandapyV20
import oandapyV20.endpoints.accounts as accounts_ep
import oandapyV20.endpoints.instruments as instruments_ep
import oandapyV20.endpoints.orders as orders_ep
import oandapyV20.endpoints.trades as trades_ep
import oandapyV20.endpoints.pricing as pricing_ep
import oandapyV20.endpoints.positions as positions_ep
from oandapyV20.contrib.requests import (
MarketOrderRequest, LimitOrderRequest, TakeProfitDetails, StopLossDetails
)
_HAS_OANDA = True
except ImportError:
_HAS_OANDA = False
try:
import MetaTrader5 as mt5
_HAS_MT5 = True
except ImportError:
_HAS_MT5 = False
try:
from ib_insync import IB, Forex as IBForex, MarketOrder, LimitOrder
_HAS_IB = True
except ImportError:
_HAS_IB = False
# ─── Abstract base class ──────────────────────────────────────────────────────
class ExchangeAdapter(ABC):
"""
Abstract interface that every broker adapter must implement.
All methods use a common data format so the trading bot does not need
to know which broker it is connected to.
Pair format: "EURUSD", "GBPJPY" (no separator, 6 chars).
"""
@abstractmethod
def connect(self) -> None:
"""Authenticate and establish the connection to the broker."""
...
@abstractmethod
def get_balance(self) -> float:
"""Return total account equity in account currency (USD/EUR)."""
...
@abstractmethod
def get_positions(self) -> list[dict]:
"""
Return open positions as a list of dicts:
[
{
"coin": "EURUSD", # pair name (coin alias for compat)
"size": 10000.0, # positive = long, negative = short (units)
"entry": 1.08520,
"side": "long",
"unrealized_pnl": 12.5,
}, ...
]
"""
...
@abstractmethod
def get_orderbook(self, coin: str) -> dict:
"""
Return best bid/ask for pair.
Returns {"bid": float, "ask": float, "mid": float}
"""
...
@abstractmethod
def get_candles(self, coin: str, interval: str = "1h",
limit: int = 200) -> list[dict]:
"""
Return recent OHLCV candles.
Each element: {"o": float, "h": float, "l": float, "c": float, "v": float}
"""
...
@abstractmethod
def place_limit_order(self, coin: str, side: str, size: float,
price: float) -> dict:
"""
Place a limit order.
coin : pair, e.g. "EURUSD"
side : "buy" or "sell"
size : units of base currency (e.g. 10000 = 0.1 lot EUR/USD)
price : limit price
Returns {"success": bool, "order_id": str|None, "detail": str}
"""
...
@abstractmethod
def place_market_order(self, coin: str, side: str, size: float) -> dict:
"""
Place a market order.
Returns {"success": bool, "order_id": str|None, "detail": str}
"""
...
@abstractmethod
def cancel_order(self, coin: str, oid: str) -> None:
"""Cancel a pending order by id."""
...
@abstractmethod
def close_position(self, coin: str) -> dict:
"""
Fully close the open position for pair.
Returns {"success": bool, "detail": str}
"""
...
@abstractmethod
def set_leverage(self, coin: str, leverage: int) -> None:
"""
Set leverage for pair.
Note: most Forex brokers use margin % not leverage multiplier.
This method is a no-op for brokers that don't expose leverage settings.
"""
...
@abstractmethod
def get_funding_rate(self, coin: str) -> float:
"""
Return the current swap/rollover rate for pair (as decimal per day).
Analogous to crypto funding rate.
"""
...
# ─── OANDA Adapter ────────────────────────────────────────────────────────────
class OANDAAdapter(ExchangeAdapter):
"""
Adapter for OANDA v20 REST API (practice or live).
Requires: pip install oandapyV20
"""
# OANDA granularity map
_GRAN = {
"1m": "M1", "5m": "M5", "15m": "M15", "30m": "M30",
"1h": "H1", "2h": "H2", "4h": "H4", "8h": "H8",
"1d": "D", "1w": "W",
}
def __init__(self) -> None:
self.api_key: str = os.getenv("OANDA_API_KEY", "")
self.account_id: str = os.getenv("OANDA_ACCOUNT_ID", "")
self.practice: bool = os.getenv("OANDA_PRACTICE", "true").lower() == "true"
self.client: "oandapyV20.API | None" = None
@staticmethod
def _instrument(pair: str) -> str:
"""EURUSD → EUR_USD"""
return f"{pair[:3]}_{pair[3:]}"
@staticmethod
def _pair(instrument: str) -> str:
"""EUR_USD → EURUSD"""
return instrument.replace("_", "")
def connect(self) -> None:
if not _HAS_OANDA:
raise ImportError(
"oandapyV20 is required for OANDA support.\n"
"Install with: pip install oandapyV20"
)
if not self.api_key or not self.account_id:
raise ValueError(
"OANDA_API_KEY and OANDA_ACCOUNT_ID env vars are required"
)
environment = "practice" if self.practice else "live"
self.client = oandapyV20.API(access_token=self.api_key,
environment=environment)
# Verify connection
r = accounts_ep.AccountSummary(self.account_id)
self.client.request(r)
logger.info("[OANDA] Connected — account: %s (%s)",
self.account_id, environment)
# -- account info --------------------------------------------------------
def get_balance(self) -> float:
try:
r = accounts_ep.AccountSummary(self.account_id)
self.client.request(r)
return float(r.response["account"]["NAV"])
except Exception:
logger.error("OANDAAdapter.get_balance failed", exc_info=True)
return 0.0
def get_positions(self) -> list[dict]:
try:
r = positions_ep.OpenPositions(self.account_id)
self.client.request(r)
result: list[dict] = []
for pos in r.response.get("positions", []):
long_units = float(pos["long"]["units"])
short_units = float(pos["short"]["units"])
if long_units == 0 and short_units == 0:
continue
if long_units != 0:
result.append({
"coin": self._pair(pos["instrument"]),
"size": long_units,
"entry": float(pos["long"].get("averagePrice", 0) or 0),
"side": "long",
"unrealized_pnl": float(pos["long"].get("unrealizedPL", 0) or 0),
})
if short_units != 0:
result.append({
"coin": self._pair(pos["instrument"]),
"size": short_units, # already negative
"entry": float(pos["short"].get("averagePrice", 0) or 0),
"side": "short",
"unrealized_pnl": float(pos["short"].get("unrealizedPL", 0) or 0),
})
return result
except Exception:
logger.error("OANDAAdapter.get_positions failed", exc_info=True)
return []
# -- market data ---------------------------------------------------------
def get_orderbook(self, coin: str) -> dict:
instrument = self._instrument(coin)
r = pricing_ep.PricingInfo(
self.account_id,
params={"instruments": instrument}
)
self.client.request(r)
price = r.response["prices"][0]
bid = float(price["bids"][0]["price"])
ask = float(price["asks"][0]["price"])
return {"bid": bid, "ask": ask, "mid": (bid + ask) / 2}
def get_candles(self, coin: str, interval: str = "1h",
limit: int = 200) -> list[dict]:
granularity = self._GRAN.get(interval, "H1")
instrument = self._instrument(coin)
params = {
"count": min(limit, 5000),
"granularity": granularity,
"price": "M", # midpoint
}
r = instruments_ep.InstrumentsCandles(instrument, params=params)
self.client.request(r)
candles = []
for c in r.response.get("candles", []):
if not c.get("complete", True):
continue
mid = c["mid"]
candles.append({
"o": float(mid["o"]),
"h": float(mid["h"]),
"l": float(mid["l"]),
"c": float(mid["c"]),
"v": float(c.get("volume", 0)),
})
return candles
def get_funding_rate(self, coin: str) -> float:
"""
OANDA does not expose swap rates via public API endpoint directly.
Returns 0.0 — swap rates are baked into position P&L overnight.
Override this with your broker's swap table if needed.
"""
return 0.0
# -- order execution -----------------------------------------------------
def place_market_order(self, coin: str, side: str, size: float) -> dict:
try:
units = int(size) if side == "buy" else -int(size)
instrument = self._instrument(coin)
data = MarketOrderRequest(
instrument=instrument,
units=units,
)
r = orders_ep.OrderCreate(self.account_id, data=data.data)
self.client.request(r)
resp = r.response
if "orderFillTransaction" in resp:
oid = resp["orderFillTransaction"].get("id", "")
return {"success": True, "order_id": str(oid), "detail": str(resp)}
return {"success": False, "order_id": None, "detail": str(resp)}
except Exception as e:
return {"success": False, "order_id": None, "detail": str(e)}
def place_limit_order(self, coin: str, side: str, size: float,
price: float) -> dict:
try:
units = int(size) if side == "buy" else -int(size)
instrument = self._instrument(coin)
data = LimitOrderRequest(
instrument=instrument,
units=units,
price=str(round(price, 5)),
)
r = orders_ep.OrderCreate(self.account_id, data=data.data)
self.client.request(r)
resp = r.response
if "orderCreateTransaction" in resp:
oid = resp["orderCreateTransaction"].get("id", "")
return {"success": True, "order_id": str(oid), "detail": str(resp)}
return {"success": False, "order_id": None, "detail": str(resp)}
except Exception as e:
return {"success": False, "order_id": None, "detail": str(e)}
def cancel_order(self, coin: str, oid: str) -> None:
r = orders_ep.OrderCancel(self.account_id, orderID=oid)
self.client.request(r)
def close_position(self, coin: str) -> dict:
try:
instrument = self._instrument(coin)
# Close all long and short units
data = {"longUnits": "ALL", "shortUnits": "ALL"}
r = positions_ep.PositionClose(self.account_id,
instrument=instrument,
data=data)
self.client.request(r)
return {"success": True, "detail": str(r.response)}
except Exception as e:
return {"success": False, "detail": str(e)}
def set_leverage(self, coin: str, leverage: int) -> None:
# OANDA uses margin requirements, not explicit leverage setting
logger.debug(
"[OANDA] Leverage is set by margin requirement on the instrument, "
"not configurable via API. Requested: %dx for %s", leverage, coin
)
# ─── MetaTrader 5 Adapter ─────────────────────────────────────────────────────
class MT5Adapter(ExchangeAdapter):
"""
Adapter for MetaTrader 5 via the official Python integration.
Requires: pip install MetaTrader5
Works on Windows only (MT5 runs natively on Windows).
"""
# MT5 timeframe map
_TF = {
"1m": 1, "5m": 5, "15m": 15, "30m": 30,
"1h": 16385, "4h": 16388, "1d": 16408,
}
def __init__(self) -> None:
self.login: int = int(os.getenv("MT5_LOGIN", "0"))
self.password: str = os.getenv("MT5_PASSWORD", "")
self.server: str = os.getenv("MT5_SERVER", "")
def connect(self) -> None:
if not _HAS_MT5:
raise ImportError(
"MetaTrader5 package is required.\n"
"Install with: pip install MetaTrader5\n"
"Note: MT5 works on Windows only."
)
if not mt5.initialize(login=self.login,
password=self.password,
server=self.server):
raise ConnectionError(
f"MT5 initialization failed: {mt5.last_error()}"
)
logger.info("[MT5] Connected — account: %s", self.login)
# -- account info --------------------------------------------------------
def get_balance(self) -> float:
try:
info = mt5.account_info()
return float(info.equity) if info else 0.0
except Exception:
logger.error("MT5Adapter.get_balance failed", exc_info=True)
return 0.0
def get_positions(self) -> list[dict]:
try:
positions = mt5.positions_get()
if positions is None:
return []
result = []
for p in positions:
# MT5 type: 0=BUY(long), 1=SELL(short)
side = "long" if p.type == 0 else "short"
signed_size = p.volume * 100000 if side == "long" else -p.volume * 100000
result.append({
"coin": p.symbol.replace("/", ""),
"size": signed_size,
"entry": float(p.price_open),
"side": side,
"unrealized_pnl": float(p.profit),
})
return result
except Exception:
logger.error("MT5Adapter.get_positions failed", exc_info=True)
return []
# -- market data ---------------------------------------------------------
def get_orderbook(self, coin: str) -> dict:
tick = mt5.symbol_info_tick(coin)
if tick is None:
return {"bid": 0.0, "ask": 0.0, "mid": 0.0}
return {
"bid": float(tick.bid),
"ask": float(tick.ask),
"mid": (float(tick.bid) + float(tick.ask)) / 2,
}
def get_candles(self, coin: str, interval: str = "1h",
limit: int = 200) -> list[dict]:
tf = self._TF.get(interval, 16385) # default H1
rates = mt5.copy_rates_from_pos(coin, tf, 0, limit)
if rates is None:
return []
return [
{
"o": float(r["open"]),
"h": float(r["high"]),
"l": float(r["low"]),
"c": float(r["close"]),
"v": float(r.get("tick_volume", 0)),
}
for r in rates
]
def get_funding_rate(self, coin: str) -> float:
"""
MT5 swap rates are per symbol, stored in symbol info.
Returns average of long/short swap normalized to per-day decimal.
"""
try:
info = mt5.symbol_info(coin)
if info is None:
return 0.0
# swap_long/short are in points per day
point = float(info.point)
avg_swap_points = (abs(float(info.swap_long)) +
abs(float(info.swap_short))) / 2
# Convert to approximate decimal return per day
tick = mt5.symbol_info_tick(coin)
if tick and tick.bid > 0:
return (avg_swap_points * point) / float(tick.bid)
except Exception:
pass
return 0.0
# -- order execution -----------------------------------------------------
def _order_send(self, request: dict) -> dict:
result = mt5.order_send(request)
if result is None:
return {"success": False, "order_id": None,
"detail": f"MT5 error: {mt5.last_error()}"}
if result.retcode == mt5.TRADE_RETCODE_DONE:
return {"success": True, "order_id": str(result.order),
"detail": f"retcode={result.retcode}"}
return {"success": False, "order_id": None,
"detail": f"retcode={result.retcode} comment={result.comment}"}
def place_market_order(self, coin: str, side: str, size: float) -> dict:
lots = size / 100000
action = mt5.ORDER_TYPE_BUY if side == "buy" else mt5.ORDER_TYPE_SELL
tick = mt5.symbol_info_tick(coin)
price = tick.ask if side == "buy" else tick.bid
request = {
"action": mt5.TRADE_ACTION_DEAL,
"symbol": coin,
"volume": round(lots, 2),
"type": action,
"price": price,
"deviation": 20, # max slippage in points
"magic": 20241001,
"comment": "AHAD QUANT",
"type_time": mt5.ORDER_TIME_GTC,
"type_filling": mt5.ORDER_FILLING_IOC,
}
return self._order_send(request)
def place_limit_order(self, coin: str, side: str, size: float,
price: float) -> dict:
lots = size / 100000
action = (mt5.ORDER_TYPE_BUY_LIMIT if side == "buy"
else mt5.ORDER_TYPE_SELL_LIMIT)
request = {
"action": mt5.TRADE_ACTION_PENDING,
"symbol": coin,
"volume": round(lots, 2),
"type": action,
"price": price,
"deviation": 20,
"magic": 20241001,
"comment": "AHAD QUANT",
"type_time": mt5.ORDER_TIME_GTC,
"type_filling": mt5.ORDER_FILLING_RETURN,
}
return self._order_send(request)
def cancel_order(self, coin: str, oid: str) -> None:
request = {
"action": mt5.TRADE_ACTION_REMOVE,
"order": int(oid),
}
mt5.order_send(request)
def close_position(self, coin: str) -> dict:
try:
positions = mt5.positions_get(symbol=coin)
if not positions:
return {"success": False, "detail": f"No open position for {coin}"}
for pos in positions:
if pos.type == mt5.ORDER_TYPE_BUY:
side = "sell"
tick = mt5.symbol_info_tick(coin)
price = tick.bid
else:
side = "buy"
tick = mt5.symbol_info_tick(coin)
price = tick.ask
request = {
"action": mt5.TRADE_ACTION_DEAL,
"symbol": coin,
"volume": pos.volume,
"type": mt5.ORDER_TYPE_SELL if side == "sell" else mt5.ORDER_TYPE_BUY,
"position": pos.ticket,
"price": price,
"deviation": 20,
"magic": 20241001,
"comment": "AHAD QUANT close",
"type_time": mt5.ORDER_TIME_GTC,
"type_filling": mt5.ORDER_FILLING_IOC,
}
mt5.order_send(request)
return {"success": True, "detail": f"Closed all positions for {coin}"}
except Exception as e:
return {"success": False, "detail": str(e)}
def set_leverage(self, coin: str, leverage: int) -> None:
# MT5 leverage is set at account level, not per-instrument
logger.debug(
"[MT5] Leverage is managed at account level. "
"Requested: %dx for %s", leverage, coin
)
# ─── ccxt Forex CFD Adapter (e.g. IG, FOREX.COM) ─────────────────────────────
class CCXTForexAdapter(ExchangeAdapter):
"""
Adapter for Forex CFD brokers supported by ccxt (IG, GAIN Capital, etc.).
Also works with crypto exchanges offering Forex pairs (Binance, Bybit CFDs).
Set CCXT_BROKER env var to the exchange id (e.g. "ig", "gainCapital").
"""
def __init__(self) -> None:
self.broker: str = os.getenv("CCXT_BROKER", "").lower()
self.api_key: str = os.getenv("CCXT_API_KEY", "")
self.api_secret: str = os.getenv("CCXT_API_SECRET", "")
self.passphrase: str = os.getenv("CCXT_PASSPHRASE", "")
self.sandbox: bool = os.getenv("CCXT_SANDBOX", "false").lower() == "true"
self.client = None
def _symbol(self, pair: str) -> str:
"""EURUSD → EUR/USD (ccxt format)."""
return f"{pair[:3]}/{pair[3:]}"
def connect(self) -> None:
try:
import ccxt
except ImportError:
raise ImportError(
"ccxt is required. Install with: pip install ccxt"
)
if not self.broker:
raise ValueError("CCXT_BROKER env var must be set (e.g. 'ig')")
cls = getattr(ccxt, self.broker, None)
if cls is None:
raise ValueError(f"Unknown ccxt broker: {self.broker}")
self.client = cls({
"apiKey": self.api_key,
"secret": self.api_secret,
"password": self.passphrase,
"enableRateLimit": True,
})
if self.sandbox:
self.client.set_sandbox_mode(True)
self.client.load_markets()
def get_balance(self) -> float:
try:
bal = self.client.fetch_balance()
return float(bal.get("total", {}).get("USD", 0)
or bal.get("total", {}).get("EUR", 0))
except Exception:
logger.error("CCXTForexAdapter.get_balance failed", exc_info=True)
return 0.0
def get_positions(self) -> list[dict]:
try:
raw = self.client.fetch_positions()
result = []
for p in raw:
size = float(p.get("contracts", 0) or 0)
if size == 0:
continue
side = p.get("side", "long")
sym = p.get("symbol", "").replace("/", "").replace(":", "")[:6]
result.append({
"coin": sym,
"size": size if side == "long" else -size,
"entry": float(p.get("entryPrice", 0) or 0),
"side": side,
"unrealized_pnl": float(p.get("unrealizedPnl", 0) or 0),
})
return result
except Exception:
logger.error("CCXTForexAdapter.get_positions failed", exc_info=True)
return []
def get_orderbook(self, coin: str) -> dict:
book = self.client.fetch_order_book(self._symbol(coin), limit=5)
bid = book["bids"][0][0]
ask = book["asks"][0][0]
return {"bid": bid, "ask": ask, "mid": (bid + ask) / 2}
def get_candles(self, coin: str, interval: str = "1h",
limit: int = 200) -> list[dict]:
ohlcv = self.client.fetch_ohlcv(self._symbol(coin), interval, limit=limit)
return [
{"o": c[1], "h": c[2], "l": c[3], "c": c[4], "v": c[5]}
for c in ohlcv
]
def get_funding_rate(self, coin: str) -> float:
return 0.0 # Forex CFDs use swap, not funding
def place_limit_order(self, coin: str, side: str, size: float,
price: float) -> dict:
try:
order = self.client.create_limit_order(
self._symbol(coin), side, size, price
)
return {"success": True, "order_id": order.get("id"),
"detail": str(order)}
except Exception as e:
return {"success": False, "order_id": None, "detail": str(e)}
def place_market_order(self, coin: str, side: str, size: float) -> dict:
try:
order = self.client.create_market_order(
self._symbol(coin), side, size
)
return {"success": True, "order_id": order.get("id"),
"detail": str(order)}
except Exception as e:
return {"success": False, "order_id": None, "detail": str(e)}
def cancel_order(self, coin: str, oid: str) -> None:
self.client.cancel_order(oid, self._symbol(coin))
def close_position(self, coin: str) -> dict:
try:
positions = self.get_positions()
for p in positions:
if p["coin"] == coin:
side = "sell" if p["side"] == "long" else "buy"
return self.place_market_order(coin, side, abs(p["size"]))
return {"success": False, "detail": f"No open position for {coin}"}
except Exception as e:
return {"success": False, "detail": str(e)}
def set_leverage(self, coin: str, leverage: int) -> None:
try:
self.client.set_leverage(leverage, self._symbol(coin))
except Exception:
logger.debug(
"CCXTForexAdapter.set_leverage not supported for %s", coin
)
# ─── Alpaca Markets Adapter ───────────────────────────────────────────────────
class AlpacaAdapter(ExchangeAdapter):
"""
Adapter for Alpaca Markets (paper + live).
Supporte les actions US et les crypto — Forex via Crypto (EURUSD, etc.).
Requires: pip install alpaca-py
Variables d'environnement :
ALPACA_API_KEY — clé API Alpaca
ALPACA_SECRET — secret Alpaca
ALPACA_PAPER — true (paper) | false (live)
"""
def __init__(self) -> None:
self.api_key: str = os.getenv("ALPACA_API_KEY", "")
self.secret: str = os.getenv("ALPACA_SECRET", "")
self.paper: bool = os.getenv("ALPACA_PAPER", "true").lower() == "true"
self._trading = None
self._data = None
def connect(self) -> None:
try:
from alpaca.trading.client import TradingClient
from alpaca.data.historical import CryptoHistoricalDataClient
except ImportError:
raise ImportError(
"alpaca-py est requis pour le broker Alpaca.\n"
"Installer avec : pip install alpaca-py"
)
if not self.api_key or not self.secret:
raise ValueError(
"ALPACA_API_KEY et ALPACA_SECRET sont requis dans .env"
)
self._trading = TradingClient(
api_key=self.api_key,
secret_key=self.secret,
paper=self.paper,
)
self._data = CryptoHistoricalDataClient(
api_key=self.api_key,
secret_key=self.secret,
)
account = self._trading.get_account()
env_label = "paper" if self.paper else "live"
logger.info("[Alpaca] Connecté — compte: %s (%s)", account.id, env_label)
def get_balance(self) -> float:
try:
account = self._trading.get_account()
return float(account.equity)
except Exception:
logger.error("AlpacaAdapter.get_balance failed", exc_info=True)
return 0.0
def get_positions(self) -> list[dict]:
try:
positions = self._trading.get_all_positions()
result = []
for p in positions:
size = float(p.qty)
side = "long" if p.side.value == "long" else "short"
result.append({
"coin": p.symbol.replace("/", "").replace("USD", "USD")[:6],
"size": size if side == "long" else -size,
"entry": float(p.avg_entry_price),
"side": side,
"unrealized_pnl": float(p.unrealized_pl),
})
return result
except Exception:
logger.error("AlpacaAdapter.get_positions failed", exc_info=True)
return []
def get_orderbook(self, coin: str) -> dict:
"""Alpaca ne fournit pas d'orderbook Forex — retourne latest quote."""
try:
from alpaca.data.requests import CryptoLatestQuoteRequest
req = CryptoLatestQuoteRequest(symbol_or_symbols=coin)
quote = self._data.get_crypto_latest_quote(req)
q = quote[coin]
bid = float(q.bid_price)
ask = float(q.ask_price)
return {"bid": bid, "ask": ask, "mid": (bid + ask) / 2}
except Exception:
return {"bid": 0.0, "ask": 0.0, "mid": 0.0}
def get_candles(self, coin: str, interval: str = "1h",
limit: int = 200) -> list[dict]:
try:
from alpaca.data.requests import CryptoBarsRequest
from alpaca.data.timeframe import TimeFrame, TimeFrameUnit
from datetime import datetime, timedelta
_tf_map = {
"1m": TimeFrame(1, TimeFrameUnit.Minute),
"5m": TimeFrame(5, TimeFrameUnit.Minute),
"15m": TimeFrame(15, TimeFrameUnit.Minute),
"1h": TimeFrame(1, TimeFrameUnit.Hour),
"4h": TimeFrame(4, TimeFrameUnit.Hour),
"1d": TimeFrame(1, TimeFrameUnit.Day),
}
tf = _tf_map.get(interval, TimeFrame(1, TimeFrameUnit.Hour))
end = datetime.utcnow()
# Estimer la plage pour obtenir `limit` bougies
hours_map = {"1m": 1/60, "5m": 5/60, "15m": 0.25, "1h": 1,
"4h": 4, "1d": 24}
hours_per_candle = hours_map.get(interval, 1)
start = end - timedelta(hours=hours_per_candle * limit * 1.5)
req = CryptoBarsRequest(
symbol_or_symbols=coin,
timeframe=tf,
start=start,
end=end,
)
bars = self._data.get_crypto_bars(req)
result = []
for bar in bars[coin]:
result.append({
"o": float(bar.open),
"h": float(bar.high),
"l": float(bar.low),
"c": float(bar.close),
"v": float(bar.volume),
})
return result[-limit:]
except Exception:
logger.error("AlpacaAdapter.get_candles failed", exc_info=True)
return []
def get_funding_rate(self, coin: str) -> float:
return 0.0
def place_market_order(self, coin: str, side: str, size: float) -> dict:
try:
from alpaca.trading.requests import MarketOrderRequest
from alpaca.trading.enums import OrderSide, TimeInForce
req = MarketOrderRequest(
symbol=coin,
qty=size,
side=OrderSide.BUY if side == "buy" else OrderSide.SELL,
time_in_force=TimeInForce.GTC,
)
order = self._trading.submit_order(req)
return {"success": True, "order_id": str(order.id), "detail": str(order)}
except Exception as e:
return {"success": False, "order_id": None, "detail": str(e)}
def place_limit_order(self, coin: str, side: str, size: float,
price: float) -> dict:
try:
from alpaca.trading.requests import LimitOrderRequest
from alpaca.trading.enums import OrderSide, TimeInForce
req = LimitOrderRequest(
symbol=coin,
qty=size,
side=OrderSide.BUY if side == "buy" else OrderSide.SELL,
time_in_force=TimeInForce.GTC,
limit_price=price,
)
order = self._trading.submit_order(req)
return {"success": True, "order_id": str(order.id), "detail": str(order)}
except Exception as e:
return {"success": False, "order_id": None, "detail": str(e)}
def cancel_order(self, coin: str, oid: str) -> None:
try:
import uuid
self._trading.cancel_order_by_id(uuid.UUID(oid))
except Exception:
logger.debug("[Alpaca] cancel_order failed for %s", oid)
def close_position(self, coin: str) -> dict:
try:
self._trading.close_position(coin)
return {"success": True, "detail": f"Closed {coin}"}
except Exception as e:
return {"success": False, "detail": str(e)}
def set_leverage(self, coin: str, leverage: int) -> None:
# Alpaca ne permet pas de changer le levier par instrument
logger.debug("[Alpaca] Leverage non configurable par instrument (%dx)", leverage)
# ─── PaperAdapter ─────────────────────────────────────────────────────────────
class PaperAdapter(ExchangeAdapter):
"""
Adaptateur paper trading pur — aucune dépendance externe requise.
Toutes les opérations sont des no-ops ou retournent des valeurs simulées.
Utilisé quand EXCHANGE=paper dans .env.
"""
def connect(self) -> None:
logger.info("[Paper] Paper adapter connecté — aucun broker réel")
def get_balance(self) -> float:
return 0.0 # géré par RiskManager / paper engine interne
def get_positions(self) -> list:
return []
def get_candles(self, coin: str, interval: str = "1h", limit: int = 200) -> list:
"""Fetch real candles via yfinance (gratuit, sans clé API)."""
try:
import yfinance as yf
# Forex : EURUSD → EURUSD=X
ticker = coin + "=X" if (len(coin) == 6 and coin.isalpha()) else coin.replace("USDT", "-USD")
period_map = {"1m": "7d", "5m": "60d", "15m": "60d",
"30m": "60d", "1h": "730d", "4h": "730d", "1d": "5y"}
yf_int_map = {"1h": "1h", "4h": "1h", "1d": "1d",
"1m": "1m", "5m": "5m", "15m": "15m", "30m": "30m"}
period = period_map.get(interval, "730d")
yf_int = yf_int_map.get(interval, "1h")
df = yf.download(ticker, period=period, interval=yf_int,
progress=False, auto_adjust=True)
if df is None or df.empty:
return []
# yfinance 1.4+ retourne des colonnes MultiIndex (col, ticker)
# On aplatit pour avoir des colonnes simples : Open, High, Low, Close, Volume
if isinstance(df.columns, __import__("pandas").MultiIndex):
df.columns = df.columns.get_level_values(0)
candles = []
for ts, row in df.iterrows():
try:
candles.append({
"t": int(ts.timestamp()),
"o": float(row["Open"]),
"h": float(row["High"]),
"l": float(row["Low"]),
"c": float(row["Close"]),
"v": float(row.get("Volume", 1.0) or 1.0),
})
except Exception:
continue
return candles[-limit:]
except Exception as e:
logger.warning(f"[PaperAdapter] yfinance get_candles({coin}) failed: {e}")
return []
def get_price(self, coin: str) -> float:
"""Fetch real-time price via yfinance."""
try:
candles = self.get_candles(coin, interval="1h", limit=2)
return candles[-1]["c"] if candles else 0.0
except Exception:
return 0.0
def get_orderbook(self, coin: str) -> dict:
"""Retourne un orderbook minimal avec mid price via yfinance."""
try:
candles = self.get_candles(coin, interval="1h", limit=2)
mid = candles[-1]["c"] if candles else 0.0
except Exception:
mid = 0.0
return {"bids": [], "asks": [], "mid": mid}
def place_market_order(self, coin: str, side: str, size: float) -> dict:
return {"success": True, "order_id": "paper", "detail": "paper mode"}
def place_limit_order(self, coin: str, side: str, size: float, price: float) -> dict:
return {"success": True, "order_id": "paper", "detail": "paper mode"}
def cancel_order(self, coin: str, oid: str) -> None:
pass
def close_position(self, coin: str) -> dict:
return {"success": True, "detail": "paper mode"}
def set_leverage(self, coin: str, leverage: int) -> None:
pass
def get_funding_rate(self, coin: str) -> float:
return 0.0
# ─── Utility helpers ──────────────────────────────────────────────────────────
def _parse_interval(interval: str) -> int:
"""Convert interval string like '1h', '15m', '1d' to seconds."""
units = {"m": 60, "h": 3600, "d": 86400}
unit = interval[-1]
value = int(interval[:-1])
return value * units.get(unit, 3600)
def _round_price(price: float, pair: str = "") -> float:
"""Round Forex price to appropriate precision."""
# JPY pairs use 3 decimal places; others use 5
if "JPY" in pair.upper():
return round(price, 3)
return round(price, 5)
def pip_value(pair: str, price: float) -> float:
"""
Return pip value per unit of base currency.
For USD-quoted pairs (EURUSD): 1 pip = 0.0001
For JPY pairs (USDJPY): 1 pip = 0.01
"""
if "JPY" in pair.upper():
return 0.01
return 0.0001
# ─── Factory ──────────────────────────────────────────────────────────────────
_ADAPTERS: dict[str, type[ExchangeAdapter]] = {
# ── Brokers directs ────────────────────────────────────────────────────────
"oanda": OANDAAdapter, # OANDA v20 REST API
"mt5": MT5Adapter, # MetaTrader 5 (Python SDK, Windows)
"metatrader5": MT5Adapter,
"alpaca": AlpacaAdapter, # Alpaca Markets (paper + live)
"ib": CCXTForexAdapter, # Interactive Brokers via ccxt (fallback)
# ── Via ccxt (100+ brokers) ────────────────────────────────────────────────
# Définir CCXT_BROKER dans .env avec le nom ccxt exact du broker
# Exemples : ig | gainCapital | okcoin | bitfinex | kraken | binance | bybit
"ccxt": CCXTForexAdapter,
# ── Aliases hérités ────────────────────────────────────────────────────────
"binance": CCXTForexAdapter,
"bybit": CCXTForexAdapter,
"bitget": CCXTForexAdapter,
"ig": CCXTForexAdapter,
"gaincapital": CCXTForexAdapter,
"fxcm": CCXTForexAdapter,
# ── Paper interne ──────────────────────────────────────────────────────────
# PAPER_MODE=true + EXCHANGE=paper → simulation complète sans broker
"paper": PaperAdapter, # Aucune dépendance externe — no-op complet
}
def get_exchange(name: str) -> ExchangeAdapter:
"""
Return a broker adapter instance by name.
Parameters
----------
name : str
Broker name (case-insensitive).
Supported : oanda | mt5 | alpaca | ccxt | ib | paper
+ aliases : metatrader5 | binance | bybit | bitget | ig | gaincapital | fxcm
Returns
-------
ExchangeAdapter
An unconnected adapter — call .connect() before use.
Notes
-----
Pour CCXT, définir aussi dans .env :
CCXT_BROKER=nom_du_broker (ex: "ig", "gainCapital", "kraken")
"""
key = name.strip().lower()
cls = _ADAPTERS.get(key)
if cls is None:
supported = ", ".join(sorted(set(_ADAPTERS.keys())))
raise ValueError(
f"Unknown broker '{name}'. Supported: {supported}"
)
return cls()