688 lines
21 KiB
Python
688 lines
21 KiB
Python
"""Paper Broker implementation for simulation trading.
|
|
|
|
Issue #26: [EXEC-25] Paper broker - simulation mode
|
|
|
|
This module provides a simulated broker for paper trading, backtesting,
|
|
and testing without real API connections. It simulates order execution,
|
|
position tracking, and account management.
|
|
|
|
Features:
|
|
- Simulated order execution with configurable fill behavior
|
|
- Position tracking with P&L calculations
|
|
- Account balance management
|
|
- Slippage simulation
|
|
- Market data simulation
|
|
- No external dependencies required
|
|
|
|
Example:
|
|
>>> from tradingagents.execution import PaperBroker, OrderRequest, OrderSide
|
|
>>>
|
|
>>> broker = PaperBroker(
|
|
... initial_cash=100000,
|
|
... slippage_percent=0.05,
|
|
... )
|
|
>>>
|
|
>>> await broker.connect()
|
|
>>> order = await broker.submit_order(
|
|
... OrderRequest.market("AAPL", OrderSide.BUY, 100)
|
|
... )
|
|
>>> print(f"Order filled at {order.avg_fill_price}")
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import random
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal
|
|
from typing import Any, Dict, List, Optional, Callable
|
|
|
|
from .broker_base import (
|
|
AccountInfo,
|
|
AssetClass,
|
|
AssetInfo,
|
|
AuthenticationError,
|
|
BrokerBase,
|
|
BrokerError,
|
|
ConnectionError,
|
|
InsufficientFundsError,
|
|
InvalidOrderError,
|
|
Order,
|
|
OrderError,
|
|
OrderRequest,
|
|
OrderSide,
|
|
OrderStatus,
|
|
OrderType,
|
|
Position,
|
|
PositionError,
|
|
PositionSide,
|
|
Quote,
|
|
RateLimitError,
|
|
TimeInForce,
|
|
)
|
|
|
|
|
|
class PaperBroker(BrokerBase):
|
|
"""Simulated paper trading broker.
|
|
|
|
Provides a fully simulated trading environment for testing,
|
|
backtesting, and paper trading without any real API connections.
|
|
|
|
Attributes:
|
|
initial_cash: Starting cash balance
|
|
slippage_percent: Slippage applied to fills (0.05 = 0.05%)
|
|
fill_probability: Probability of order fills (0.0-1.0)
|
|
market_open: Whether market is simulated as open
|
|
|
|
Example:
|
|
>>> broker = PaperBroker(initial_cash=100000)
|
|
>>> await broker.connect()
|
|
>>> order = await broker.submit_order(
|
|
... OrderRequest.market("AAPL", OrderSide.BUY, 10)
|
|
... )
|
|
>>> positions = await broker.get_positions()
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
initial_cash: Decimal = Decimal("100000"),
|
|
slippage_percent: Decimal = Decimal("0.05"),
|
|
fill_probability: float = 1.0,
|
|
market_open: bool = True,
|
|
price_provider: Optional[Callable[[str], Decimal]] = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
"""Initialize paper broker.
|
|
|
|
Args:
|
|
initial_cash: Starting cash balance (default: 100,000)
|
|
slippage_percent: Slippage as percentage (default: 0.05%)
|
|
fill_probability: Probability of fills (default: 1.0 = 100%)
|
|
market_open: Whether to simulate market as open
|
|
price_provider: Optional function to get prices for symbols
|
|
**kwargs: Additional arguments passed to BrokerBase.
|
|
"""
|
|
super().__init__(
|
|
name="Paper",
|
|
supported_asset_classes=[
|
|
AssetClass.EQUITY,
|
|
AssetClass.ETF,
|
|
AssetClass.CRYPTO,
|
|
AssetClass.FUTURE,
|
|
AssetClass.OPTION,
|
|
AssetClass.FOREX,
|
|
],
|
|
paper_trading=True,
|
|
**kwargs,
|
|
)
|
|
|
|
self._initial_cash = Decimal(str(initial_cash))
|
|
self._cash = self._initial_cash
|
|
self._slippage_percent = Decimal(str(slippage_percent))
|
|
self._fill_probability = fill_probability
|
|
self._market_open = market_open
|
|
self._price_provider = price_provider
|
|
|
|
# Internal state
|
|
self._orders: Dict[str, Order] = {}
|
|
self._positions: Dict[str, Position] = {}
|
|
self._order_counter = 0
|
|
|
|
# Simulated price cache
|
|
self._prices: Dict[str, Decimal] = {}
|
|
|
|
# Default prices for common symbols
|
|
self._default_prices = {
|
|
"AAPL": Decimal("175.00"),
|
|
"MSFT": Decimal("380.00"),
|
|
"GOOGL": Decimal("140.00"),
|
|
"AMZN": Decimal("155.00"),
|
|
"NVDA": Decimal("480.00"),
|
|
"META": Decimal("360.00"),
|
|
"TSLA": Decimal("250.00"),
|
|
"SPY": Decimal("470.00"),
|
|
"QQQ": Decimal("400.00"),
|
|
"IWM": Decimal("200.00"),
|
|
"BTCUSD": Decimal("45000.00"),
|
|
"ETHUSD": Decimal("2500.00"),
|
|
"ES": Decimal("4700.00"),
|
|
"NQ": Decimal("16500.00"),
|
|
}
|
|
|
|
@property
|
|
def cash(self) -> Decimal:
|
|
"""Get current cash balance."""
|
|
return self._cash
|
|
|
|
@property
|
|
def initial_cash(self) -> Decimal:
|
|
"""Get initial cash balance."""
|
|
return self._initial_cash
|
|
|
|
def set_price(self, symbol: str, price: Decimal) -> None:
|
|
"""Set simulated price for a symbol.
|
|
|
|
Args:
|
|
symbol: Symbol to set price for
|
|
price: Price to set
|
|
"""
|
|
self._prices[symbol] = Decimal(str(price))
|
|
|
|
def get_simulated_price(self, symbol: str) -> Decimal:
|
|
"""Get simulated price for a symbol.
|
|
|
|
Args:
|
|
symbol: Symbol to get price for
|
|
|
|
Returns:
|
|
Simulated price
|
|
"""
|
|
# Check custom price provider first
|
|
if self._price_provider:
|
|
return self._price_provider(symbol)
|
|
|
|
# Check cached prices
|
|
if symbol in self._prices:
|
|
return self._prices[symbol]
|
|
|
|
# Check default prices
|
|
if symbol in self._default_prices:
|
|
return self._default_prices[symbol]
|
|
|
|
# Generate random price for unknown symbols
|
|
return Decimal("100.00") + Decimal(str(random.uniform(-10, 10)))
|
|
|
|
def _require_connection(self) -> None:
|
|
"""Require broker to be connected."""
|
|
if not self.is_connected:
|
|
raise ConnectionError("Not connected to Paper broker. Call connect() first.")
|
|
|
|
async def connect(self) -> bool:
|
|
"""Connect to paper broker (always succeeds).
|
|
|
|
Returns:
|
|
True always
|
|
"""
|
|
self._connected = True
|
|
return True
|
|
|
|
async def disconnect(self) -> None:
|
|
"""Disconnect from paper broker."""
|
|
self._connected = False
|
|
|
|
async def is_market_open(self) -> bool:
|
|
"""Check if simulated market is open.
|
|
|
|
Returns:
|
|
The configured market_open value
|
|
"""
|
|
return self._market_open
|
|
|
|
def set_market_open(self, is_open: bool) -> None:
|
|
"""Set market open status.
|
|
|
|
Args:
|
|
is_open: Whether market should be open
|
|
"""
|
|
self._market_open = is_open
|
|
|
|
async def get_account(self) -> AccountInfo:
|
|
"""Get simulated account information.
|
|
|
|
Returns:
|
|
AccountInfo with current simulated account state.
|
|
"""
|
|
self._require_connection()
|
|
|
|
# Calculate portfolio value
|
|
portfolio_value = self._cash
|
|
for position in self._positions.values():
|
|
portfolio_value += position.market_value
|
|
|
|
return AccountInfo(
|
|
account_id="PAPER-" + str(uuid.uuid4())[:8].upper(),
|
|
account_type="paper",
|
|
status="active",
|
|
cash=self._cash,
|
|
portfolio_value=portfolio_value,
|
|
buying_power=self._cash, # Simplified: no margin
|
|
equity=portfolio_value,
|
|
)
|
|
|
|
def _generate_order_id(self) -> str:
|
|
"""Generate unique order ID."""
|
|
self._order_counter += 1
|
|
return f"PAPER-{self._order_counter}-{uuid.uuid4().hex[:8]}"
|
|
|
|
def _calculate_fill_price(
|
|
self,
|
|
symbol: str,
|
|
side: OrderSide,
|
|
order_type: OrderType,
|
|
limit_price: Optional[Decimal] = None,
|
|
) -> Optional[Decimal]:
|
|
"""Calculate fill price with slippage.
|
|
|
|
Args:
|
|
symbol: Symbol being traded
|
|
side: Order side
|
|
order_type: Order type
|
|
limit_price: Limit price if applicable
|
|
|
|
Returns:
|
|
Fill price or None if order shouldn't fill
|
|
"""
|
|
base_price = self.get_simulated_price(symbol)
|
|
|
|
if order_type == OrderType.LIMIT:
|
|
if limit_price is None:
|
|
return None
|
|
# For limit orders, check if price is favorable
|
|
if side == OrderSide.BUY:
|
|
if base_price > limit_price:
|
|
return None # Market price above limit
|
|
return limit_price
|
|
else:
|
|
if base_price < limit_price:
|
|
return None # Market price below limit
|
|
return limit_price
|
|
|
|
# Apply slippage for market orders
|
|
slippage_factor = self._slippage_percent / Decimal("100")
|
|
if side == OrderSide.BUY:
|
|
# Slippage increases price for buys
|
|
fill_price = base_price * (Decimal("1") + slippage_factor)
|
|
else:
|
|
# Slippage decreases price for sells
|
|
fill_price = base_price * (Decimal("1") - slippage_factor)
|
|
|
|
return fill_price.quantize(Decimal("0.01"))
|
|
|
|
def _should_fill(self) -> bool:
|
|
"""Determine if order should fill based on fill probability."""
|
|
return random.random() < self._fill_probability
|
|
|
|
def _update_position(
|
|
self,
|
|
symbol: str,
|
|
side: OrderSide,
|
|
quantity: Decimal,
|
|
fill_price: Decimal,
|
|
) -> None:
|
|
"""Update position after fill.
|
|
|
|
Args:
|
|
symbol: Symbol traded
|
|
side: Order side
|
|
quantity: Quantity filled
|
|
fill_price: Fill price
|
|
"""
|
|
if symbol in self._positions:
|
|
position = self._positions[symbol]
|
|
|
|
if side == OrderSide.BUY:
|
|
# Add to position
|
|
new_quantity = position.quantity + quantity
|
|
total_cost = (position.avg_entry_price * position.quantity) + (fill_price * quantity)
|
|
new_avg_price = total_cost / new_quantity if new_quantity > 0 else fill_price
|
|
position.quantity = new_quantity
|
|
position.avg_entry_price = new_avg_price
|
|
else:
|
|
# Reduce position
|
|
position.quantity -= quantity
|
|
if position.quantity <= 0:
|
|
del self._positions[symbol]
|
|
return
|
|
|
|
# Update market value and P&L
|
|
current_price = self.get_simulated_price(symbol)
|
|
position.current_price = current_price
|
|
position.market_value = position.quantity * current_price
|
|
position.cost_basis = position.quantity * position.avg_entry_price
|
|
position.unrealized_pnl = position.market_value - position.cost_basis
|
|
if position.cost_basis > 0:
|
|
position.unrealized_pnl_percent = (
|
|
position.unrealized_pnl / position.cost_basis * Decimal("100")
|
|
)
|
|
else:
|
|
if side == OrderSide.BUY:
|
|
# Create new long position
|
|
current_price = self.get_simulated_price(symbol)
|
|
self._positions[symbol] = Position(
|
|
symbol=symbol,
|
|
quantity=quantity,
|
|
side=PositionSide.LONG,
|
|
avg_entry_price=fill_price,
|
|
current_price=current_price,
|
|
market_value=quantity * current_price,
|
|
cost_basis=quantity * fill_price,
|
|
unrealized_pnl=quantity * (current_price - fill_price),
|
|
unrealized_pnl_percent=(
|
|
(current_price - fill_price) / fill_price * Decimal("100")
|
|
if fill_price > 0 else Decimal("0")
|
|
),
|
|
)
|
|
# For sells without existing position, we'd need short selling logic
|
|
# For simplicity, ignore sells without positions
|
|
|
|
async def submit_order(self, request: OrderRequest) -> Order:
|
|
"""Submit a simulated order.
|
|
|
|
Args:
|
|
request: Order request details.
|
|
|
|
Returns:
|
|
Order with execution details.
|
|
|
|
Raises:
|
|
InvalidOrderError: If order parameters are invalid.
|
|
InsufficientFundsError: If insufficient funds.
|
|
"""
|
|
self._require_connection()
|
|
|
|
# Validate order
|
|
if request.quantity <= 0:
|
|
raise InvalidOrderError("Order quantity must be positive")
|
|
|
|
# Generate order ID
|
|
order_id = self._generate_order_id()
|
|
|
|
# Calculate fill price
|
|
fill_price = self._calculate_fill_price(
|
|
request.symbol,
|
|
request.side,
|
|
request.order_type,
|
|
request.limit_price,
|
|
)
|
|
|
|
# Determine if order should fill
|
|
should_fill = self._should_fill() and fill_price is not None
|
|
|
|
if should_fill:
|
|
# Check funds for buys
|
|
if request.side == OrderSide.BUY:
|
|
required_funds = request.quantity * fill_price
|
|
if required_funds > self._cash:
|
|
raise InsufficientFundsError(
|
|
f"Insufficient funds: need ${required_funds}, have ${self._cash}"
|
|
)
|
|
# Deduct cash
|
|
self._cash -= required_funds
|
|
|
|
# For sells, add cash back
|
|
else:
|
|
proceeds = request.quantity * fill_price
|
|
self._cash += proceeds
|
|
|
|
# Update position
|
|
self._update_position(
|
|
request.symbol,
|
|
request.side,
|
|
request.quantity,
|
|
fill_price,
|
|
)
|
|
|
|
status = OrderStatus.FILLED
|
|
filled_qty = request.quantity
|
|
avg_fill = fill_price
|
|
filled_at = datetime.now(timezone.utc)
|
|
else:
|
|
status = OrderStatus.NEW
|
|
filled_qty = Decimal("0")
|
|
avg_fill = None
|
|
filled_at = None
|
|
|
|
# Create order
|
|
order = Order(
|
|
broker_order_id=order_id,
|
|
client_order_id=request.client_order_id or "",
|
|
symbol=request.symbol,
|
|
side=request.side,
|
|
quantity=request.quantity,
|
|
order_type=request.order_type,
|
|
status=status,
|
|
limit_price=request.limit_price,
|
|
stop_price=request.stop_price,
|
|
time_in_force=request.time_in_force,
|
|
filled_quantity=filled_qty,
|
|
filled_avg_price=avg_fill,
|
|
created_at=datetime.now(timezone.utc),
|
|
filled_at=filled_at,
|
|
)
|
|
|
|
self._orders[order_id] = order
|
|
return order
|
|
|
|
async def cancel_order(self, order_id: str) -> Order:
|
|
"""Cancel a simulated order.
|
|
|
|
Args:
|
|
order_id: Order ID to cancel.
|
|
|
|
Returns:
|
|
Cancelled order.
|
|
|
|
Raises:
|
|
OrderError: If order not found or cannot be cancelled.
|
|
"""
|
|
self._require_connection()
|
|
|
|
if order_id not in self._orders:
|
|
raise OrderError(f"Order {order_id} not found")
|
|
|
|
order = self._orders[order_id]
|
|
|
|
# Can only cancel unfilled orders
|
|
if order.status == OrderStatus.FILLED:
|
|
raise OrderError("Cannot cancel filled order")
|
|
|
|
order.status = OrderStatus.CANCELLED
|
|
order.cancelled_at = datetime.now(timezone.utc)
|
|
|
|
return order
|
|
|
|
async def replace_order(
|
|
self,
|
|
order_id: str,
|
|
quantity: Optional[Decimal] = None,
|
|
limit_price: Optional[Decimal] = None,
|
|
stop_price: Optional[Decimal] = None,
|
|
time_in_force: Optional[TimeInForce] = None,
|
|
) -> Order:
|
|
"""Replace a simulated order.
|
|
|
|
Creates a new order with updated parameters.
|
|
"""
|
|
self._require_connection()
|
|
|
|
if order_id not in self._orders:
|
|
raise OrderError(f"Order {order_id} not found")
|
|
|
|
old_order = self._orders[order_id]
|
|
|
|
# Cancel old order
|
|
if old_order.status != OrderStatus.FILLED:
|
|
old_order.status = OrderStatus.REPLACED
|
|
|
|
# Create new order request
|
|
request = OrderRequest(
|
|
symbol=old_order.symbol,
|
|
side=old_order.side,
|
|
quantity=quantity or old_order.quantity,
|
|
order_type=old_order.order_type,
|
|
time_in_force=time_in_force or old_order.time_in_force,
|
|
limit_price=limit_price or old_order.limit_price,
|
|
stop_price=stop_price or old_order.stop_price,
|
|
)
|
|
|
|
return await self.submit_order(request)
|
|
|
|
async def get_order(self, order_id: str) -> Order:
|
|
"""Get order by ID.
|
|
|
|
Args:
|
|
order_id: Order ID.
|
|
|
|
Returns:
|
|
Order details.
|
|
|
|
Raises:
|
|
OrderError: If order not found.
|
|
"""
|
|
self._require_connection()
|
|
|
|
if order_id not in self._orders:
|
|
raise OrderError(f"Order {order_id} not found")
|
|
|
|
return self._orders[order_id]
|
|
|
|
async def get_orders(
|
|
self,
|
|
status: Optional[OrderStatus] = None,
|
|
limit: int = 100,
|
|
symbols: Optional[List[str]] = None,
|
|
) -> List[Order]:
|
|
"""Get orders with optional filters.
|
|
|
|
Args:
|
|
status: Filter by status.
|
|
limit: Maximum number to return.
|
|
symbols: Filter by symbols.
|
|
|
|
Returns:
|
|
List of matching orders.
|
|
"""
|
|
self._require_connection()
|
|
|
|
orders = list(self._orders.values())
|
|
|
|
# Apply filters
|
|
if status:
|
|
orders = [o for o in orders if o.status == status]
|
|
if symbols:
|
|
orders = [o for o in orders if o.symbol in symbols]
|
|
|
|
# Sort by creation time, most recent first
|
|
orders.sort(key=lambda o: o.created_at or datetime.min, reverse=True)
|
|
|
|
return orders[:limit]
|
|
|
|
async def get_positions(self) -> List[Position]:
|
|
"""Get all positions.
|
|
|
|
Returns:
|
|
List of current positions.
|
|
"""
|
|
self._require_connection()
|
|
|
|
# Update current prices
|
|
for symbol, position in self._positions.items():
|
|
current_price = self.get_simulated_price(symbol)
|
|
position.current_price = current_price
|
|
position.market_value = position.quantity * current_price
|
|
position.unrealized_pnl = position.market_value - position.cost_basis
|
|
if position.cost_basis > 0:
|
|
position.unrealized_pnl_percent = (
|
|
position.unrealized_pnl / position.cost_basis * Decimal("100")
|
|
)
|
|
|
|
return list(self._positions.values())
|
|
|
|
async def get_position(self, symbol: str) -> Optional[Position]:
|
|
"""Get position for a specific symbol.
|
|
|
|
Args:
|
|
symbol: Symbol to get position for.
|
|
|
|
Returns:
|
|
Position if exists, None otherwise.
|
|
"""
|
|
self._require_connection()
|
|
|
|
if symbol in self._positions:
|
|
position = self._positions[symbol]
|
|
# Update current price
|
|
current_price = self.get_simulated_price(symbol)
|
|
position.current_price = current_price
|
|
position.market_value = position.quantity * current_price
|
|
position.unrealized_pnl = position.market_value - position.cost_basis
|
|
return position
|
|
|
|
return None
|
|
|
|
async def get_quote(self, symbol: str) -> Quote:
|
|
"""Get simulated quote.
|
|
|
|
Args:
|
|
symbol: Symbol to get quote for.
|
|
|
|
Returns:
|
|
Simulated quote data.
|
|
"""
|
|
self._require_connection()
|
|
|
|
base_price = self.get_simulated_price(symbol)
|
|
|
|
# Simulate bid/ask spread
|
|
spread = base_price * Decimal("0.001") # 0.1% spread
|
|
bid = base_price - spread / 2
|
|
ask = base_price + spread / 2
|
|
|
|
return Quote(
|
|
symbol=symbol,
|
|
bid_price=bid.quantize(Decimal("0.01")),
|
|
ask_price=ask.quantize(Decimal("0.01")),
|
|
last_price=base_price,
|
|
bid_size=random.randint(100, 1000),
|
|
ask_size=random.randint(100, 1000),
|
|
volume=random.randint(100000, 10000000),
|
|
timestamp=datetime.now(timezone.utc),
|
|
)
|
|
|
|
async def get_asset(self, symbol: str) -> AssetInfo:
|
|
"""Get simulated asset information.
|
|
|
|
Args:
|
|
symbol: Symbol to get info for.
|
|
|
|
Returns:
|
|
Simulated asset information.
|
|
"""
|
|
self._require_connection()
|
|
|
|
# Determine asset class based on symbol patterns
|
|
if symbol.endswith("USD"):
|
|
asset_class = AssetClass.CRYPTO
|
|
elif symbol in ["ES", "NQ", "CL", "GC"]:
|
|
asset_class = AssetClass.FUTURE
|
|
elif symbol in ["SPY", "QQQ", "IWM", "VTI"]:
|
|
asset_class = AssetClass.ETF
|
|
else:
|
|
asset_class = AssetClass.EQUITY
|
|
|
|
return AssetInfo(
|
|
symbol=symbol,
|
|
name=f"{symbol} (Paper)",
|
|
asset_class=asset_class,
|
|
exchange="PAPER",
|
|
tradable=True,
|
|
shortable=True,
|
|
marginable=True,
|
|
fractionable=False,
|
|
)
|
|
|
|
def reset(self) -> None:
|
|
"""Reset broker to initial state.
|
|
|
|
Clears all positions and orders, resets cash to initial amount.
|
|
"""
|
|
self._cash = self._initial_cash
|
|
self._orders.clear()
|
|
self._positions.clear()
|
|
self._order_counter = 0
|
|
|
|
|
|
# Export
|
|
__all__ = ["PaperBroker"]
|