Files
2025-11-14 22:56:44 +00:00

112 lines
4.6 KiB
Python

"""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")