1089 lines
41 KiB
Python
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()
|