Files
FX-ML-Trading-Engine/QuantTrader/execution/oanda_adapter.py
T
2025-11-14 22:56:44 +00:00

135 lines
5.6 KiB
Python

"""OANDA REST adapter (mock/stub for Phase 3)."""
from __future__ import annotations
import os
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
import requests
from .adapter import CancelAck, ExecutionAdapter, OrderAck, OrderParams, PositionState
from .config_loader import load_oanda_config
from .metrics_logger import MetricsLogger, log_event
from .order_store import OrderStore
class OandaAdapter(ExecutionAdapter):
BASE_URL = "https://api-fxpractice.oanda.com/v3"
def __init__(
self,
account_id: Optional[str] = None,
token: Optional[str] = None,
order_store: Optional[OrderStore] = None,
base_url: Optional[str] = None,
timeout_ms: int = 10000,
retry_backoff: float = 1.0,
max_retries: int = 3,
metrics_path: Optional[str] = None,
error_log: str = "results/execution/errors.log",
) -> None:
self.account_id = account_id or os.environ.get("OANDA_ACCOUNT_ID", "")
self.token = token or os.environ.get("OANDA_TOKEN", "")
self.base_url = base_url or self.BASE_URL
self.timeout_ms = timeout_ms
self.retry_backoff = retry_backoff
self.max_retries = max_retries
self.metrics_logger = MetricsLogger(metrics_path)
self.error_log = Path(error_log)
self.session = requests.Session()
self.session.headers.update({"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"})
self.order_store = order_store or OrderStore()
def _endpoint(self, path: str) -> str:
return f"{self.base_url}{path}"
@classmethod
def from_config(cls, path: Optional[str] = None, order_store: Optional[OrderStore] = None) -> "OandaAdapter":
cfg = load_oanda_config(path)
return cls(order_store=order_store, **cfg)
def _log_error(self, context: str, message: str) -> None:
self.error_log.parent.mkdir(parents=True, exist_ok=True)
with self.error_log.open("a", encoding="utf-8") as fh:
fh.write(f"[{datetime.utcnow().isoformat()}] {context}: {message}\n")
def _request(self, method: str, url: str, **kwargs):
backoff = self.retry_backoff
for attempt in range(self.max_retries):
try:
resp = self.session.request(method, url, timeout=self.timeout_ms / 1000.0, **kwargs)
except requests.RequestException as exc:
self._log_error("network", str(exc))
time.sleep(backoff)
backoff *= 2
continue
if resp.status_code >= 500:
self._log_error("server_error", resp.text)
time.sleep(backoff)
backoff *= 2
continue
if resp.status_code >= 400:
self._log_error("client_error", resp.text)
resp.raise_for_status()
resp.raise_for_status()
return resp.json()
raise RuntimeError(f"Failed {method} {url} after {self.max_retries} retries")
def submit(self, order: OrderParams) -> OrderAck:
start = datetime.utcnow()
payload = {
"order": {
"instrument": order.symbol,
"units": int(order.quantity if order.side.lower() == "buy" else -order.quantity),
"type": "MARKET" if order.price is None else "LIMIT",
"timeInForce": order.tif.upper(),
}
}
if order.price is not None:
payload["order"]["price"] = f"{order.price:.5f}"
if order.client_order_id:
payload["order"]["clientExtensions"] = {"clientOrderID": order.client_order_id}
url = self._endpoint(f"/accounts/{self.account_id}/orders")
data = self._request("POST", url, json=payload)
order_id = data.get("orderCreateTransaction", {}).get("id", "")
if order_id:
self.order_store.append(order_id, order)
end = datetime.utcnow()
log_event("submit", order_id or "", order.symbol, "accepted", start, end)
ts = data.get("time")
timestamp = datetime.fromisoformat(ts.replace("Z", "+00:00")) if ts else datetime.utcnow()
return OrderAck(order_id=order_id, status="accepted", timestamp=timestamp)
def cancel(self, order_id: str) -> CancelAck:
start = datetime.utcnow()
url = self._endpoint(f"/accounts/{self.account_id}/orders/{order_id}/cancel")
data = self._request("PUT", url)
end = datetime.utcnow()
log_event("cancel", order_id, "", "cancelled", start, end)
ts = data.get("time")
timestamp = datetime.fromisoformat(ts.replace("Z", "+00:00")) if ts else datetime.utcnow()
return CancelAck(order_id=order_id, status="cancelled", timestamp=timestamp)
def sync_positions(self) -> Dict[str, PositionState]:
url = self._endpoint(f"/accounts/{self.account_id}/positions")
data = self._request("GET", url)
positions = {}
for pos in data.get("positions", []):
symbol = pos["instrument"]
net = float(pos["netUnrealizedPL"])
qty = float(pos.get("long", {}).get("units", 0)) + float(pos.get("short", {}).get("units", 0))
avg_price = float(pos.get("avgPrice", 0))
positions[symbol] = PositionState(symbol=symbol, quantity=qty, avg_price=avg_price, unrealized_pnl=net)
return positions
def heartbeat(self) -> bool:
url = self._endpoint("/accounts")
try:
self._request("GET", url)
return True
except Exception:
return False