mirror of
https://github.com/xavierchuan/FX-ML-Trading-Engine.git
synced 2026-08-13 00:58:05 +00:00
Add files via upload
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"""Paper trading adapter that simulates fills with configurable latency/slippage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from .adapter import CancelAck, ExecutionAdapter, OrderAck, OrderParams, PositionState
|
||||
from .metrics_logger import log_event
|
||||
from .order_store import OrderStore
|
||||
|
||||
|
||||
class PaperAdapter(ExecutionAdapter):
|
||||
def __init__(
|
||||
self,
|
||||
latency_ms: float = 50.0,
|
||||
slippage_pips: float = 0.1,
|
||||
order_store: OrderStore | None = None,
|
||||
starting_equity: float = 100000.0,
|
||||
equity_log_path: str = "results/execution/paper_equity.csv",
|
||||
):
|
||||
self.latency_ms = latency_ms
|
||||
self.slippage_pips = slippage_pips
|
||||
self.order_store = order_store or OrderStore("results/execution/paper_orders.log")
|
||||
self.positions: Dict[str, PositionState] = {}
|
||||
self.last_price: Dict[str, float] = {}
|
||||
self.cash = starting_equity
|
||||
self.equity_path = Path(equity_log_path)
|
||||
self.equity_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not self.equity_path.exists():
|
||||
self.equity_path.write_text("ts,equity\n", encoding="utf-8")
|
||||
self._order_seq = 0
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._order_seq += 1
|
||||
return f"PAPER-{self._order_seq}"
|
||||
|
||||
def submit(self, order: OrderParams) -> OrderAck:
|
||||
start = datetime.utcnow()
|
||||
time.sleep(self.latency_ms / 1000.0)
|
||||
order_id = self._next_id()
|
||||
fill_price = self._fill_price(order)
|
||||
self._apply_fill(order, fill_price)
|
||||
self.order_store.append(order_id, order)
|
||||
end = datetime.utcnow()
|
||||
log_event("submit", order_id, order.symbol, "accepted", start, end)
|
||||
self._record_equity(end)
|
||||
return OrderAck(order_id=order_id, status="accepted", timestamp=end)
|
||||
|
||||
def cancel(self, order_id: str) -> CancelAck:
|
||||
start = datetime.utcnow()
|
||||
time.sleep(self.latency_ms / 2000.0)
|
||||
end = datetime.utcnow()
|
||||
log_event("cancel", order_id, "", "cancelled", start, end)
|
||||
return CancelAck(order_id=order_id, status="cancelled", timestamp=end)
|
||||
|
||||
def sync_positions(self) -> dict[str, PositionState]:
|
||||
return self.positions
|
||||
|
||||
def heartbeat(self) -> bool:
|
||||
return True
|
||||
|
||||
def _pip_value(self, symbol: str) -> float:
|
||||
return 0.01 if symbol.endswith("JPY") else 0.0001
|
||||
|
||||
def _fill_price(self, order: OrderParams) -> float:
|
||||
base_price = order.price or self.last_price.get(order.symbol, 1.0)
|
||||
slip = self.slippage_pips * self._pip_value(order.symbol)
|
||||
direction = 1 if order.side.lower() == "buy" else -1
|
||||
return base_price + direction * slip * random.choice([1, -1])
|
||||
|
||||
def _apply_fill(self, order: OrderParams, price: float) -> None:
|
||||
qty = order.quantity if order.side.lower() == "buy" else -order.quantity
|
||||
pos = self.positions.get(order.symbol)
|
||||
if pos is None:
|
||||
pos = PositionState(symbol=order.symbol, quantity=0.0, avg_price=price, unrealized_pnl=0.0)
|
||||
total_qty = pos.quantity + qty
|
||||
if total_qty == 0:
|
||||
realized = (price - pos.avg_price) * (-qty) # closing position
|
||||
self.cash += realized
|
||||
self.positions.pop(order.symbol, None)
|
||||
else:
|
||||
if pos.quantity == 0 or (pos.quantity > 0 and qty > 0) or (pos.quantity < 0 and qty < 0):
|
||||
avg = ((pos.quantity * pos.avg_price) + (qty * price)) / total_qty
|
||||
pos = PositionState(symbol=order.symbol, quantity=total_qty, avg_price=avg, unrealized_pnl=0.0)
|
||||
self.positions[order.symbol] = pos
|
||||
else:
|
||||
realized = (pos.avg_price - price) * qty * -1
|
||||
self.cash += realized
|
||||
pos = PositionState(symbol=order.symbol, quantity=total_qty, avg_price=pos.avg_price, unrealized_pnl=0.0)
|
||||
if total_qty == 0:
|
||||
self.positions.pop(order.symbol, None)
|
||||
else:
|
||||
self.positions[order.symbol] = pos
|
||||
notional = price * order.quantity
|
||||
if qty > 0:
|
||||
self.cash -= notional
|
||||
else:
|
||||
self.cash += notional
|
||||
self.last_price[order.symbol] = price
|
||||
|
||||
def _record_equity(self, timestamp: datetime) -> None:
|
||||
equity = self.cash
|
||||
for symbol, pos in self.positions.items():
|
||||
mark = self.last_price.get(symbol, pos.avg_price)
|
||||
equity += pos.quantity * mark
|
||||
with self.equity_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(f"{timestamp.isoformat()},{equity}\n")
|
||||
Reference in New Issue
Block a user