from datetime import datetime, timezone from typing import Optional import pandas as pd from oandapyV20 import API from oandapyV20.endpoints import accounts, instruments, orders, trades, pricing from app.core.config import settings from app.core.exchange.base import ( AbstractExchange, AccountInfo, OpenTrade, OrderResult, ) # Granularités OANDA valides GRANULARITIES = { "M1", "M5", "M15", "M30", "H1", "H2", "H4", "H6", "H8", "H12", "D", "W", "M", } # Nombre max de candles par requête OANDA MAX_CANDLES_PER_REQUEST = 5000 def _parse_time(ts: str) -> datetime: """Parse RFC3339 timestamp OANDA → datetime UTC.""" return datetime.fromisoformat(ts.replace("Z", "+00:00")) class OandaExchange(AbstractExchange): def __init__(self) -> None: env = "practice" if settings.oanda_environment == "practice" else "live" self._client = API( access_token=settings.oanda_api_key, environment=env, ) self._account_id = settings.oanda_account_id async def get_candles( self, instrument: str, granularity: str = "H1", count: int = 200, from_time: Optional[datetime] = None, to_time: Optional[datetime] = None, ) -> pd.DataFrame: if granularity not in GRANULARITIES: raise ValueError(f"Granularité invalide: {granularity}. Valides: {GRANULARITIES}") params: dict = {"granularity": granularity, "price": "M"} if from_time and to_time: params["from"] = from_time.strftime("%Y-%m-%dT%H:%M:%SZ") params["to"] = to_time.strftime("%Y-%m-%dT%H:%M:%SZ") else: params["count"] = min(count, MAX_CANDLES_PER_REQUEST) r = instruments.InstrumentsCandles(instrument, params=params) self._client.request(r) candles = r.response.get("candles", []) rows = [] for c in candles: if c.get("complete", True): mid = c["mid"] rows.append({ "time": _parse_time(c["time"]), "open": float(mid["o"]), "high": float(mid["h"]), "low": float(mid["l"]), "close": float(mid["c"]), "volume": int(c.get("volume", 0)), }) df = pd.DataFrame(rows) if not df.empty: df.sort_values("time", inplace=True) df.reset_index(drop=True, inplace=True) return df async def place_order( self, instrument: str, units: float, stop_loss: float, take_profit: float, ) -> OrderResult: data = { "order": { "type": "MARKET", "instrument": instrument, "units": str(int(units)), "stopLossOnFill": {"price": f"{stop_loss:.5f}"}, "takeProfitOnFill": {"price": f"{take_profit:.5f}"}, "timeInForce": "FOK", } } r = orders.OrderCreate(self._account_id, data=data) self._client.request(r) resp = r.response fill = resp.get("orderFillTransaction") or resp.get("relatedTransactionIDs", {}) trade_id = resp.get("orderFillTransaction", {}).get("tradeOpened", {}).get("tradeID", "unknown") entry_price = float(resp.get("orderFillTransaction", {}).get("price", 0)) direction = "buy" if units > 0 else "sell" return OrderResult( trade_id=trade_id, instrument=instrument, direction=direction, units=abs(units), entry_price=entry_price, stop_loss=stop_loss, take_profit=take_profit, opened_at=datetime.now(timezone.utc), ) async def close_trade(self, trade_id: str) -> float: r = trades.TradeClose(self._account_id, tradeID=trade_id) self._client.request(r) pnl = float(r.response.get("orderFillTransaction", {}).get("pl", 0)) return pnl async def get_open_trades(self) -> list[OpenTrade]: r = trades.OpenTrades(self._account_id) self._client.request(r) result = [] for t in r.response.get("trades", []): units = float(t["currentUnits"]) direction = "buy" if units > 0 else "sell" sl = float(t.get("stopLoss", {}).get("price", 0)) if t.get("stopLoss") else 0.0 tp = float(t.get("takeProfit", {}).get("price", 0)) if t.get("takeProfit") else 0.0 result.append(OpenTrade( trade_id=t["id"], instrument=t["instrument"], direction=direction, units=abs(units), entry_price=float(t["price"]), stop_loss=sl, take_profit=tp, unrealized_pnl=float(t.get("unrealizedPL", 0)), opened_at=_parse_time(t["openTime"]), )) return result async def get_account_info(self) -> AccountInfo: r = accounts.AccountSummary(self._account_id) self._client.request(r) acc = r.response["account"] return AccountInfo( balance=float(acc["balance"]), nav=float(acc["NAV"]), unrealized_pnl=float(acc.get("unrealizedPL", 0)), currency=acc.get("currency", "USD"), ) async def get_price(self, instrument: str) -> float: r = pricing.PricingInfo( self._account_id, params={"instruments": instrument}, ) self._client.request(r) prices = r.response.get("prices", []) if not prices: raise ValueError(f"Aucun prix pour {instrument}") bid = float(prices[0]["bids"][0]["price"]) ask = float(prices[0]["asks"][0]["price"]) return (bid + ask) / 2