860 lines
28 KiB
Python
860 lines
28 KiB
Python
"""Tests for Paper Broker implementation.
|
|
|
|
Issue #26: [EXEC-25] Paper broker - simulation mode
|
|
"""
|
|
|
|
from decimal import Decimal
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import MagicMock, patch
|
|
import pytest
|
|
|
|
from tradingagents.execution import (
|
|
PaperBroker,
|
|
OrderRequest,
|
|
OrderSide,
|
|
OrderType,
|
|
OrderStatus,
|
|
TimeInForce,
|
|
AssetClass,
|
|
PositionSide,
|
|
ConnectionError,
|
|
OrderError,
|
|
InvalidOrderError,
|
|
InsufficientFundsError,
|
|
)
|
|
|
|
|
|
class TestPaperBrokerInit:
|
|
"""Test PaperBroker initialization."""
|
|
|
|
def test_default_initialization(self):
|
|
"""Test default broker initialization."""
|
|
broker = PaperBroker()
|
|
assert broker.name == "Paper"
|
|
assert broker.initial_cash == Decimal("100000")
|
|
assert broker.cash == Decimal("100000")
|
|
assert broker.is_paper_trading is True
|
|
assert broker.is_connected is False
|
|
|
|
def test_custom_initial_cash(self):
|
|
"""Test initialization with custom initial cash."""
|
|
broker = PaperBroker(initial_cash=Decimal("50000"))
|
|
assert broker.initial_cash == Decimal("50000")
|
|
assert broker.cash == Decimal("50000")
|
|
|
|
def test_custom_slippage(self):
|
|
"""Test initialization with custom slippage."""
|
|
broker = PaperBroker(slippage_percent=Decimal("0.1"))
|
|
assert broker._slippage_percent == Decimal("0.1")
|
|
|
|
def test_custom_fill_probability(self):
|
|
"""Test initialization with custom fill probability."""
|
|
broker = PaperBroker(fill_probability=0.5)
|
|
assert broker._fill_probability == 0.5
|
|
|
|
def test_market_closed_initialization(self):
|
|
"""Test initialization with market closed."""
|
|
broker = PaperBroker(market_open=False)
|
|
assert broker._market_open is False
|
|
|
|
def test_supported_asset_classes(self):
|
|
"""Test supported asset classes include all types."""
|
|
broker = PaperBroker()
|
|
assert AssetClass.EQUITY in broker.supported_asset_classes
|
|
assert AssetClass.ETF in broker.supported_asset_classes
|
|
assert AssetClass.CRYPTO in broker.supported_asset_classes
|
|
assert AssetClass.FUTURE in broker.supported_asset_classes
|
|
assert AssetClass.OPTION in broker.supported_asset_classes
|
|
assert AssetClass.FOREX in broker.supported_asset_classes
|
|
|
|
def test_custom_price_provider(self):
|
|
"""Test initialization with custom price provider."""
|
|
def price_provider(symbol: str) -> Decimal:
|
|
return Decimal("123.45")
|
|
|
|
broker = PaperBroker(price_provider=price_provider)
|
|
assert broker.get_simulated_price("ANY") == Decimal("123.45")
|
|
|
|
|
|
class TestPaperBrokerConnection:
|
|
"""Test PaperBroker connection methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_succeeds(self):
|
|
"""Test connect always succeeds."""
|
|
broker = PaperBroker()
|
|
result = await broker.connect()
|
|
assert result is True
|
|
assert broker.is_connected is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect(self):
|
|
"""Test disconnect."""
|
|
broker = PaperBroker()
|
|
await broker.connect()
|
|
await broker.disconnect()
|
|
assert broker.is_connected is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_connects(self):
|
|
"""Test multiple connects work."""
|
|
broker = PaperBroker()
|
|
await broker.connect()
|
|
await broker.connect()
|
|
assert broker.is_connected is True
|
|
|
|
|
|
class TestPaperBrokerMarketStatus:
|
|
"""Test PaperBroker market status."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_market_open_by_default(self):
|
|
"""Test market is open by default."""
|
|
broker = PaperBroker()
|
|
await broker.connect()
|
|
assert await broker.is_market_open() is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_market_closed(self):
|
|
"""Test market closed simulation."""
|
|
broker = PaperBroker(market_open=False)
|
|
await broker.connect()
|
|
assert await broker.is_market_open() is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_market_open(self):
|
|
"""Test changing market open status."""
|
|
broker = PaperBroker(market_open=False)
|
|
broker.set_market_open(True)
|
|
await broker.connect()
|
|
assert await broker.is_market_open() is True
|
|
|
|
|
|
class TestPaperBrokerPrices:
|
|
"""Test PaperBroker price simulation."""
|
|
|
|
def test_set_and_get_price(self):
|
|
"""Test setting and getting prices."""
|
|
broker = PaperBroker()
|
|
broker.set_price("TEST", Decimal("99.99"))
|
|
assert broker.get_simulated_price("TEST") == Decimal("99.99")
|
|
|
|
def test_default_prices(self):
|
|
"""Test default prices for common symbols."""
|
|
broker = PaperBroker()
|
|
assert broker.get_simulated_price("AAPL") == Decimal("175.00")
|
|
assert broker.get_simulated_price("MSFT") == Decimal("380.00")
|
|
assert broker.get_simulated_price("SPY") == Decimal("470.00")
|
|
|
|
def test_crypto_default_prices(self):
|
|
"""Test default crypto prices."""
|
|
broker = PaperBroker()
|
|
assert broker.get_simulated_price("BTCUSD") == Decimal("45000.00")
|
|
assert broker.get_simulated_price("ETHUSD") == Decimal("2500.00")
|
|
|
|
def test_futures_default_prices(self):
|
|
"""Test default futures prices."""
|
|
broker = PaperBroker()
|
|
assert broker.get_simulated_price("ES") == Decimal("4700.00")
|
|
assert broker.get_simulated_price("NQ") == Decimal("16500.00")
|
|
|
|
def test_unknown_symbol_generates_price(self):
|
|
"""Test unknown symbols generate random prices."""
|
|
broker = PaperBroker()
|
|
price = broker.get_simulated_price("UNKNOWN")
|
|
# Should be around 100 +/- 10
|
|
assert Decimal("80") < price < Decimal("120")
|
|
|
|
|
|
class TestPaperBrokerAccount:
|
|
"""Test PaperBroker account methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_account_requires_connection(self):
|
|
"""Test get_account requires connection."""
|
|
broker = PaperBroker()
|
|
with pytest.raises(ConnectionError, match="Not connected"):
|
|
await broker.get_account()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_account_basic(self):
|
|
"""Test basic account information."""
|
|
broker = PaperBroker(initial_cash=Decimal("50000"))
|
|
await broker.connect()
|
|
|
|
account = await broker.get_account()
|
|
assert account.account_type == "paper"
|
|
assert account.status == "active"
|
|
assert account.cash == Decimal("50000")
|
|
assert account.portfolio_value == Decimal("50000")
|
|
assert account.buying_power == Decimal("50000")
|
|
assert account.account_id.startswith("PAPER-")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_account_with_positions(self):
|
|
"""Test account includes position values."""
|
|
broker = PaperBroker(initial_cash=Decimal("100000"))
|
|
broker.set_price("AAPL", Decimal("150"))
|
|
await broker.connect()
|
|
|
|
# Buy some shares
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
account = await broker.get_account()
|
|
# Cash reduced by purchase
|
|
assert account.cash < Decimal("100000")
|
|
# Portfolio includes position
|
|
assert account.portfolio_value > account.cash
|
|
|
|
|
|
class TestPaperBrokerOrders:
|
|
"""Test PaperBroker order methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_market_buy_order(self):
|
|
"""Test submitting market buy order."""
|
|
broker = PaperBroker(initial_cash=Decimal("100000"))
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
order = await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
assert order.symbol == "AAPL"
|
|
assert order.side == OrderSide.BUY
|
|
assert order.quantity == Decimal("10")
|
|
assert order.status == OrderStatus.FILLED
|
|
assert order.filled_quantity == Decimal("10")
|
|
assert order.broker_order_id.startswith("PAPER-")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_market_sell_order(self):
|
|
"""Test submitting market sell order."""
|
|
broker = PaperBroker(initial_cash=Decimal("100000"))
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
# Buy first
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
# Then sell
|
|
order = await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.SELL, Decimal("5"))
|
|
)
|
|
|
|
assert order.status == OrderStatus.FILLED
|
|
assert order.filled_quantity == Decimal("5")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_limit_order_fills(self):
|
|
"""Test limit order that should fill."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
# Limit above market price - should fill
|
|
order = await broker.submit_order(
|
|
OrderRequest.limit("AAPL", OrderSide.BUY, Decimal("10"), Decimal("110"))
|
|
)
|
|
|
|
assert order.status == OrderStatus.FILLED
|
|
assert order.filled_avg_price == Decimal("110")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_submit_limit_order_no_fill(self):
|
|
"""Test limit order that shouldn't fill."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
# Limit below market price - shouldn't fill
|
|
order = await broker.submit_order(
|
|
OrderRequest.limit("AAPL", OrderSide.BUY, Decimal("10"), Decimal("90"))
|
|
)
|
|
|
|
assert order.status == OrderStatus.NEW
|
|
assert order.filled_quantity == Decimal("0")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_order_requires_connection(self):
|
|
"""Test order submission requires connection."""
|
|
broker = PaperBroker()
|
|
with pytest.raises(ConnectionError, match="Not connected"):
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_order_quantity(self):
|
|
"""Test invalid order quantity."""
|
|
broker = PaperBroker()
|
|
await broker.connect()
|
|
|
|
with pytest.raises(InvalidOrderError, match="quantity must be positive"):
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("-10"))
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insufficient_funds(self):
|
|
"""Test insufficient funds error."""
|
|
broker = PaperBroker(initial_cash=Decimal("100"))
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
with pytest.raises(InsufficientFundsError, match="Insufficient funds"):
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slippage_on_buy(self):
|
|
"""Test slippage applied to buy orders."""
|
|
broker = PaperBroker(slippage_percent=Decimal("1.0"))
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
order = await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("1"))
|
|
)
|
|
|
|
# 1% slippage on $100 = $101
|
|
assert order.filled_avg_price == Decimal("101.00")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slippage_on_sell(self):
|
|
"""Test slippage applied to sell orders."""
|
|
broker = PaperBroker(slippage_percent=Decimal("1.0"))
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
# Buy first
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
# Sell with slippage
|
|
order = await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.SELL, Decimal("5"))
|
|
)
|
|
|
|
# 1% slippage on $100 = $99
|
|
assert order.filled_avg_price == Decimal("99.00")
|
|
|
|
|
|
class TestPaperBrokerFillProbability:
|
|
"""Test PaperBroker fill probability."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_zero_fill_probability(self):
|
|
"""Test orders don't fill with 0% probability."""
|
|
broker = PaperBroker(fill_probability=0.0)
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
order = await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
assert order.status == OrderStatus.NEW
|
|
assert order.filled_quantity == Decimal("0")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_fill_probability(self):
|
|
"""Test orders always fill with 100% probability."""
|
|
broker = PaperBroker(fill_probability=1.0)
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
order = await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
assert order.status == OrderStatus.FILLED
|
|
|
|
|
|
class TestPaperBrokerCancelOrder:
|
|
"""Test PaperBroker cancel order."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_unfilled_order(self):
|
|
"""Test cancelling unfilled order."""
|
|
broker = PaperBroker(fill_probability=0.0)
|
|
await broker.connect()
|
|
|
|
order = await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
cancelled = await broker.cancel_order(order.broker_order_id)
|
|
assert cancelled.status == OrderStatus.CANCELLED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_filled_order_fails(self):
|
|
"""Test cannot cancel filled order."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("10"))
|
|
await broker.connect()
|
|
|
|
order = await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
with pytest.raises(OrderError, match="Cannot cancel filled order"):
|
|
await broker.cancel_order(order.broker_order_id)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_nonexistent_order(self):
|
|
"""Test cancelling nonexistent order."""
|
|
broker = PaperBroker()
|
|
await broker.connect()
|
|
|
|
with pytest.raises(OrderError, match="not found"):
|
|
await broker.cancel_order("INVALID-123")
|
|
|
|
|
|
class TestPaperBrokerReplaceOrder:
|
|
"""Test PaperBroker replace order."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_replace_order(self):
|
|
"""Test replacing an order."""
|
|
broker = PaperBroker(fill_probability=0.0)
|
|
await broker.connect()
|
|
|
|
order = await broker.submit_order(
|
|
OrderRequest.limit("AAPL", OrderSide.BUY, Decimal("10"), Decimal("100"))
|
|
)
|
|
|
|
# Replace with new quantity
|
|
new_order = await broker.replace_order(
|
|
order.broker_order_id,
|
|
quantity=Decimal("20"),
|
|
)
|
|
|
|
assert new_order.quantity == Decimal("20")
|
|
assert new_order.broker_order_id != order.broker_order_id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_replace_order_marks_old_replaced(self):
|
|
"""Test old order marked as replaced."""
|
|
broker = PaperBroker(fill_probability=0.0)
|
|
await broker.connect()
|
|
|
|
order = await broker.submit_order(
|
|
OrderRequest.limit("AAPL", OrderSide.BUY, Decimal("10"), Decimal("100"))
|
|
)
|
|
old_id = order.broker_order_id
|
|
|
|
await broker.replace_order(old_id, quantity=Decimal("20"))
|
|
|
|
old_order = await broker.get_order(old_id)
|
|
assert old_order.status == OrderStatus.REPLACED
|
|
|
|
|
|
class TestPaperBrokerGetOrders:
|
|
"""Test PaperBroker get orders."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_order(self):
|
|
"""Test getting single order."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("10"))
|
|
await broker.connect()
|
|
|
|
order = await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
retrieved = await broker.get_order(order.broker_order_id)
|
|
assert retrieved.broker_order_id == order.broker_order_id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_order_not_found(self):
|
|
"""Test getting nonexistent order."""
|
|
broker = PaperBroker()
|
|
await broker.connect()
|
|
|
|
with pytest.raises(OrderError, match="not found"):
|
|
await broker.get_order("INVALID-123")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_orders_all(self):
|
|
"""Test getting all orders."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("10"))
|
|
await broker.connect()
|
|
|
|
await broker.submit_order(OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10")))
|
|
await broker.submit_order(OrderRequest.market("AAPL", OrderSide.BUY, Decimal("20")))
|
|
|
|
orders = await broker.get_orders()
|
|
assert len(orders) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_orders_filter_by_status(self):
|
|
"""Test filtering orders by status."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("10"))
|
|
await broker.connect()
|
|
|
|
# Create filled order
|
|
await broker.submit_order(OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10")))
|
|
|
|
# Create unfilled order
|
|
broker._fill_probability = 0.0
|
|
await broker.submit_order(OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10")))
|
|
|
|
filled_orders = await broker.get_orders(status=OrderStatus.FILLED)
|
|
assert len(filled_orders) == 1
|
|
assert filled_orders[0].status == OrderStatus.FILLED
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_orders_filter_by_symbols(self):
|
|
"""Test filtering orders by symbols."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("10"))
|
|
broker.set_price("MSFT", Decimal("10"))
|
|
await broker.connect()
|
|
|
|
await broker.submit_order(OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10")))
|
|
await broker.submit_order(OrderRequest.market("MSFT", OrderSide.BUY, Decimal("10")))
|
|
|
|
aapl_orders = await broker.get_orders(symbols=["AAPL"])
|
|
assert len(aapl_orders) == 1
|
|
assert aapl_orders[0].symbol == "AAPL"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_orders_with_limit(self):
|
|
"""Test getting limited number of orders."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("10"))
|
|
await broker.connect()
|
|
|
|
for _ in range(5):
|
|
await broker.submit_order(OrderRequest.market("AAPL", OrderSide.BUY, Decimal("1")))
|
|
|
|
orders = await broker.get_orders(limit=3)
|
|
assert len(orders) == 3
|
|
|
|
|
|
class TestPaperBrokerPositions:
|
|
"""Test PaperBroker position methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_positions_empty(self):
|
|
"""Test getting positions when empty."""
|
|
broker = PaperBroker()
|
|
await broker.connect()
|
|
|
|
positions = await broker.get_positions()
|
|
assert positions == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_positions_after_buy(self):
|
|
"""Test position created after buy."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
positions = await broker.get_positions()
|
|
assert len(positions) == 1
|
|
assert positions[0].symbol == "AAPL"
|
|
assert positions[0].quantity == Decimal("10")
|
|
assert positions[0].side == PositionSide.LONG
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_position_single(self):
|
|
"""Test getting single position."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
position = await broker.get_position("AAPL")
|
|
assert position is not None
|
|
assert position.symbol == "AAPL"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_position_not_found(self):
|
|
"""Test getting nonexistent position."""
|
|
broker = PaperBroker()
|
|
await broker.connect()
|
|
|
|
position = await broker.get_position("AAPL")
|
|
assert position is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_position_pnl_calculation(self):
|
|
"""Test position P&L calculation."""
|
|
broker = PaperBroker(slippage_percent=Decimal("0"))
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
# Buy at 100
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
# Price goes up
|
|
broker.set_price("AAPL", Decimal("110"))
|
|
|
|
position = await broker.get_position("AAPL")
|
|
assert position.unrealized_pnl == Decimal("100") # 10 shares * $10 gain
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_position_closed_on_sell(self):
|
|
"""Test position closed when fully sold."""
|
|
broker = PaperBroker(slippage_percent=Decimal("0"))
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
# Buy
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
# Sell all
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.SELL, Decimal("10"))
|
|
)
|
|
|
|
position = await broker.get_position("AAPL")
|
|
assert position is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_position_partial_sell(self):
|
|
"""Test position reduced on partial sell."""
|
|
broker = PaperBroker(slippage_percent=Decimal("0"))
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
# Buy
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
# Partial sell
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.SELL, Decimal("3"))
|
|
)
|
|
|
|
position = await broker.get_position("AAPL")
|
|
assert position.quantity == Decimal("7")
|
|
|
|
|
|
class TestPaperBrokerQuotes:
|
|
"""Test PaperBroker quote methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_quote(self):
|
|
"""Test getting quote."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
quote = await broker.get_quote("AAPL")
|
|
assert quote.symbol == "AAPL"
|
|
assert quote.last_price == Decimal("100")
|
|
assert quote.bid_price is not None
|
|
assert quote.ask_price is not None
|
|
assert quote.bid_price < quote.ask_price
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quote_spread(self):
|
|
"""Test quote has bid/ask spread."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
quote = await broker.get_quote("AAPL")
|
|
spread = quote.ask_price - quote.bid_price
|
|
assert spread > Decimal("0")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quote_requires_connection(self):
|
|
"""Test quote requires connection."""
|
|
broker = PaperBroker()
|
|
with pytest.raises(ConnectionError):
|
|
await broker.get_quote("AAPL")
|
|
|
|
|
|
class TestPaperBrokerAssets:
|
|
"""Test PaperBroker asset methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_asset_equity(self):
|
|
"""Test getting equity asset info."""
|
|
broker = PaperBroker()
|
|
await broker.connect()
|
|
|
|
asset = await broker.get_asset("AAPL")
|
|
assert asset.symbol == "AAPL"
|
|
assert asset.asset_class == AssetClass.EQUITY
|
|
assert asset.tradable is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_asset_crypto(self):
|
|
"""Test getting crypto asset info."""
|
|
broker = PaperBroker()
|
|
await broker.connect()
|
|
|
|
asset = await broker.get_asset("BTCUSD")
|
|
assert asset.asset_class == AssetClass.CRYPTO
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_asset_etf(self):
|
|
"""Test getting ETF asset info."""
|
|
broker = PaperBroker()
|
|
await broker.connect()
|
|
|
|
asset = await broker.get_asset("SPY")
|
|
assert asset.asset_class == AssetClass.ETF
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_asset_future(self):
|
|
"""Test getting future asset info."""
|
|
broker = PaperBroker()
|
|
await broker.connect()
|
|
|
|
asset = await broker.get_asset("ES")
|
|
assert asset.asset_class == AssetClass.FUTURE
|
|
|
|
|
|
class TestPaperBrokerReset:
|
|
"""Test PaperBroker reset functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reset_clears_positions(self):
|
|
"""Test reset clears all positions."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("10"))
|
|
await broker.connect()
|
|
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
broker.reset()
|
|
|
|
positions = await broker.get_positions()
|
|
assert positions == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reset_clears_orders(self):
|
|
"""Test reset clears all orders."""
|
|
broker = PaperBroker()
|
|
broker.set_price("AAPL", Decimal("10"))
|
|
await broker.connect()
|
|
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
broker.reset()
|
|
|
|
orders = await broker.get_orders()
|
|
assert orders == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reset_restores_cash(self):
|
|
"""Test reset restores initial cash."""
|
|
broker = PaperBroker(initial_cash=Decimal("100000"))
|
|
broker.set_price("AAPL", Decimal("1000"))
|
|
await broker.connect()
|
|
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
assert broker.cash < Decimal("100000")
|
|
|
|
broker.reset()
|
|
|
|
assert broker.cash == Decimal("100000")
|
|
|
|
|
|
class TestPaperBrokerCashManagement:
|
|
"""Test PaperBroker cash management."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_buy_reduces_cash(self):
|
|
"""Test buying reduces cash."""
|
|
broker = PaperBroker(
|
|
initial_cash=Decimal("100000"),
|
|
slippage_percent=Decimal("0"),
|
|
)
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
# 10 shares at $100 = $1000
|
|
assert broker.cash == Decimal("99000")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sell_increases_cash(self):
|
|
"""Test selling increases cash."""
|
|
broker = PaperBroker(
|
|
initial_cash=Decimal("100000"),
|
|
slippage_percent=Decimal("0"),
|
|
)
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.connect()
|
|
|
|
# Buy first
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
# Then sell at higher price
|
|
broker.set_price("AAPL", Decimal("110"))
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.SELL, Decimal("10"))
|
|
)
|
|
|
|
# Should have initial + profit
|
|
# Buy: -$1000 (100*10), Sell: +$1100 (110*10) = +$100 profit
|
|
assert broker.cash == Decimal("100100")
|
|
|
|
|
|
class TestPaperBrokerAveragePriceCalculation:
|
|
"""Test average price calculation for positions."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_average_price_multiple_buys(self):
|
|
"""Test average price calculation with multiple buys."""
|
|
broker = PaperBroker(slippage_percent=Decimal("0"))
|
|
await broker.connect()
|
|
|
|
# Buy 10 at $100
|
|
broker.set_price("AAPL", Decimal("100"))
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
# Buy 10 more at $120
|
|
broker.set_price("AAPL", Decimal("120"))
|
|
await broker.submit_order(
|
|
OrderRequest.market("AAPL", OrderSide.BUY, Decimal("10"))
|
|
)
|
|
|
|
position = await broker.get_position("AAPL")
|
|
# Average: (10*100 + 10*120) / 20 = $110
|
|
assert position.avg_entry_price == Decimal("110")
|
|
assert position.quantity == Decimal("20")
|