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

118 lines
4.4 KiB
Python

from typing import Optional
from datetime import datetime
from loguru import logger
from oandapyV20 import API
import oandapyV20.endpoints.orders as orders
import csv
from pathlib import Path
from .base import ExecutionHandler, OrderEvent, FillEvent
class OANDAExecutionHandler(ExecutionHandler):
"""实盘环境下的 OANDA 执行实现。"""
def __init__(self, account_id: str, access_token: str, environment: str = "practice", fills_path: Optional[str] = None):
super().__init__()
self.client = API(access_token=access_token, environment=environment)
self.account_id = account_id
default_dir = Path("results/execution/live") if environment == "live" else Path("results/execution/paper")
default_dir.mkdir(parents=True, exist_ok=True)
self._fills_csv = Path(fills_path) if fills_path else default_dir / "fills.csv"
if not self._fills_csv.exists():
self._init_csv()
def _init_csv(self) -> None:
with self._fills_csv.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(
f,
fieldnames=["order_id", "ts", "symbol", "pnl", "adapter_latency_ms", "direction", "price", "quantity"],
)
writer.writeheader()
async def execute_order(self, order: OrderEvent) -> None:
# 将 OrderEvent 转换为 OANDA 下单请求
instrument = order.instrument.replace("_", "_")
units = int(order.quantity) if order.direction.upper() in ("BUY", "LONG") else -int(order.quantity)
order_body = {
"instrument": instrument,
"units": str(units),
"timeInForce": "FOK",
"positionFill": "DEFAULT",
}
if order.price:
order_body["type"] = "LIMIT"
order_body["price"] = f"{order.price:.5f}"
order_body["timeInForce"] = "GTC"
else:
order_body["type"] = "MARKET"
payload = {"order": order_body}
req = orders.OrderCreate(accountID=self.account_id, data=payload)
try:
resp = self.client.request(req)
except Exception as exc:
logger.error(f"[OANDA] Order submission failed for {instrument}: {exc}")
order.status = "REJECTED"
await self._notify_order(order)
return
order.status = "SUBMITTED"
await self._notify_order(order)
fill_txn = resp.get("orderFillTransaction")
if not fill_txn:
logger.warning(f"[OANDA] Order accepted but no fill: {resp}")
return
try:
price = float(fill_txn["price"])
filled_units = abs(float(fill_txn["units"]))
commission = float(fill_txn.get("commission", 0))
ts = datetime.fromisoformat(fill_txn["time"].replace("Z", "+00:00"))
side = "BUY" if float(fill_txn["units"]) > 0 else "SELL"
except Exception as exc:
logger.error(f"[OANDA] Unable to parse fill transaction: {fill_txn} ({exc})")
return
fill = FillEvent(
instrument=order.instrument,
direction=side,
quantity=filled_units,
price=price,
timestamp=ts,
commission=abs(commission),
order_id=order.order_id
)
order.status = "FILLED"
await self._notify_order(order)
await self._notify_fill(fill)
self.fills.append(fill)
self.update_position(fill)
self._append_fill_csv(fill)
def _append_fill_csv(self, fill: FillEvent) -> None:
record = {
"order_id": fill.order_id or "",
"ts": fill.timestamp.isoformat(),
"symbol": fill.instrument,
"pnl": 0.0,
"adapter_latency_ms": None,
"direction": fill.direction,
"price": fill.price,
"quantity": fill.quantity,
}
with self._fills_csv.open("a", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=record.keys())
writer.writerow(record)
async def cancel_order(self, order_id: str) -> bool:
# OANDA 取消需要调用 OrderCancel 或交易 API;简单实现为更新状态
if order_id in self.orders:
order = self.orders[order_id]
order.status = "CANCELLED"
await self._notify_order(order)
return True
return False