"""Tests for the paper trading adapter - local order simulation and state sync. Validates position tracking, order fills, idempotency, cash management, and the PaperAccount/PaperPosition data structures. """ import pytest from services.adapters.broker_adapter import ( OrderRequest, OrderResponse, OrderSide, OrderStatus, OrderType, PositionInfo, TradingMode, ) from services.adapters.paper_trading import ( PaperAccount, PaperPosition, PaperTradingAdapter, ) # --------------------------------------------------------------------------- # PaperPosition tests # --------------------------------------------------------------------------- class TestPaperPosition: def test_new_position_is_not_open(self): pos = PaperPosition(ticker="AAPL") assert not pos.is_open assert pos.quantity == 0.0 def test_buy_fill_opens_position(self): pos = PaperPosition(ticker="AAPL") pnl = pos.apply_fill(OrderSide.BUY, 10, 150.0) assert pos.is_open assert pos.quantity == 10 assert pos.avg_entry_price == 150.0 assert pnl == 0.0 def test_sell_fill_realizes_pnl(self): pos = PaperPosition(ticker="AAPL", quantity=10, avg_entry_price=150.0) pnl = pos.apply_fill(OrderSide.SELL, 5, 160.0) assert pos.quantity == 5 assert pnl == 50.0 # 5 shares * $10 gain assert pos.realized_pnl == 50.0 def test_sell_all_closes_position(self): pos = PaperPosition(ticker="AAPL", quantity=10, avg_entry_price=150.0) pos.apply_fill(OrderSide.SELL, 10, 140.0) assert not pos.is_open assert pos.quantity == 0 assert pos.avg_entry_price == 0.0 assert pos.realized_pnl == -100.0 # 10 * -$10 def test_buy_averages_up(self): pos = PaperPosition(ticker="AAPL", quantity=10, avg_entry_price=100.0) pos.apply_fill(OrderSide.BUY, 10, 200.0) assert pos.quantity == 20 assert pos.avg_entry_price == 150.0 # (1000 + 2000) / 20 def test_to_position_info(self): pos = PaperPosition(ticker="AAPL", quantity=10, avg_entry_price=150.0) info = pos.to_position_info(current_price=160.0) assert isinstance(info, PositionInfo) assert info.ticker == "AAPL" assert info.quantity == 10 assert info.unrealized_pnl == 100.0 # 10 * $10 assert info.market_value == 1600.0 def test_to_position_info_no_current_price(self): pos = PaperPosition(ticker="AAPL", quantity=10, avg_entry_price=150.0) info = pos.to_position_info() assert info.current_price == 150.0 assert info.unrealized_pnl == 0.0 # --------------------------------------------------------------------------- # PaperAccount tests # --------------------------------------------------------------------------- class TestPaperAccount: def test_default_account(self): acct = PaperAccount() assert acct.cash == 100_000.0 assert acct.portfolio_value == 100_000.0 assert acct.buying_power == 100_000.0 def test_custom_initial_cash(self): acct = PaperAccount(initial_cash=50_000.0) assert acct.cash == 50_000.0 def test_get_position_creates_new(self): acct = PaperAccount() pos = acct.get_position("AAPL") assert pos.ticker == "AAPL" assert pos.quantity == 0 def test_get_position_returns_existing(self): acct = PaperAccount() pos1 = acct.get_position("AAPL") pos1.quantity = 10 pos2 = acct.get_position("AAPL") assert pos2.quantity == 10 def test_portfolio_value_includes_positions(self): acct = PaperAccount(initial_cash=10_000.0) acct.cash = 5_000.0 pos = acct.get_position("AAPL") pos.quantity = 10 pos.avg_entry_price = 100.0 # portfolio = cash + position value = 5000 + 1000 = 6000 assert acct.portfolio_value == 6_000.0 def test_to_account_info(self): acct = PaperAccount(account_id="test-acct") info = acct.to_account_info() assert info.account_id == "test-acct" assert info.mode == TradingMode.PAPER assert info.cash == 100_000.0 # --------------------------------------------------------------------------- # PaperTradingAdapter tests # --------------------------------------------------------------------------- class TestPaperTradingAdapterBasics: def test_mode_is_paper(self): adapter = PaperTradingAdapter() assert adapter.mode == TradingMode.PAPER def test_source_type(self): adapter = PaperTradingAdapter() assert adapter.source_type() == "broker" def test_custom_initial_cash(self): adapter = PaperTradingAdapter(initial_cash=50_000.0) assert adapter.account.cash == 50_000.0 @pytest.mark.asyncio class TestPaperTradingSubmitOrder: async def test_buy_market_order_fills(self): adapter = PaperTradingAdapter(initial_cash=100_000.0) order = OrderRequest( ticker="AAPL", side=OrderSide.BUY, quantity=10, order_type=OrderType.LIMIT, limit_price=150.0, ) resp = await adapter.submit_order(order) assert resp.status == OrderStatus.FILLED assert resp.filled_quantity == 10 assert resp.filled_avg_price == 150.0 assert resp.ok # Cash should decrease assert adapter.account.cash < 100_000.0 async def test_sell_order_realizes_pnl(self): adapter = PaperTradingAdapter(initial_cash=100_000.0) # Buy first buy = OrderRequest(ticker="AAPL", side=OrderSide.BUY, quantity=10, order_type=OrderType.LIMIT, limit_price=150.0) await adapter.submit_order(buy) # Sell at higher price sell = OrderRequest(ticker="AAPL", side=OrderSide.SELL, quantity=10, order_type=OrderType.LIMIT, limit_price=160.0) resp = await adapter.submit_order(sell) assert resp.status == OrderStatus.FILLED assert resp.raw_response["realized_pnl"] == 100.0 # 10 * $10 async def test_insufficient_cash_rejects(self): adapter = PaperTradingAdapter(initial_cash=1_000.0) order = OrderRequest( ticker="AAPL", side=OrderSide.BUY, quantity=100, order_type=OrderType.LIMIT, limit_price=150.0, ) resp = await adapter.submit_order(order) assert resp.status == OrderStatus.REJECTED assert "Insufficient cash" in resp.error async def test_insufficient_shares_rejects(self): adapter = PaperTradingAdapter() order = OrderRequest( ticker="AAPL", side=OrderSide.SELL, quantity=10, order_type=OrderType.LIMIT, limit_price=150.0, ) resp = await adapter.submit_order(order) assert resp.status == OrderStatus.REJECTED assert "Insufficient shares" in resp.error async def test_idempotency_returns_same_response(self): adapter = PaperTradingAdapter(initial_cash=100_000.0) order = OrderRequest( ticker="AAPL", side=OrderSide.BUY, quantity=10, order_type=OrderType.LIMIT, limit_price=150.0, idempotency_key="test-key-1", ) resp1 = await adapter.submit_order(order) resp2 = await adapter.submit_order(order) assert resp1.broker_order_id == resp2.broker_order_id assert resp1.status == resp2.status # Cash should only be deducted once assert adapter.account.cash == pytest.approx(100_000.0 - 1500.0) async def test_order_events_recorded(self): adapter = PaperTradingAdapter(initial_cash=100_000.0) order = OrderRequest( ticker="AAPL", side=OrderSide.BUY, quantity=5, order_type=OrderType.LIMIT, limit_price=100.0, ) await adapter.submit_order(order) events = adapter.account.order_events event_types = [e["event_type"] for e in events] assert "submitted" in event_types assert "accepted" in event_types assert "fill" in event_types async def test_stop_order_fills_at_stop_price(self): adapter = PaperTradingAdapter(initial_cash=100_000.0) order = OrderRequest( ticker="AAPL", side=OrderSide.BUY, quantity=10, order_type=OrderType.STOP, stop_price=145.0, ) resp = await adapter.submit_order(order) assert resp.filled_avg_price == 145.0 @pytest.mark.asyncio class TestPaperTradingCancelAndStatus: async def test_cancel_filled_order_rejected(self): adapter = PaperTradingAdapter(initial_cash=100_000.0) order = OrderRequest( ticker="AAPL", side=OrderSide.BUY, quantity=5, order_type=OrderType.LIMIT, limit_price=100.0, ) resp = await adapter.submit_order(order) cancel_resp = await adapter.cancel_order(resp.broker_order_id) assert cancel_resp.status == OrderStatus.REJECTED assert "filled" in cancel_resp.error.lower() async def test_cancel_unknown_order(self): adapter = PaperTradingAdapter() resp = await adapter.cancel_order("nonexistent-id") assert resp.status == OrderStatus.REJECTED assert "not found" in resp.error async def test_get_order_status(self): adapter = PaperTradingAdapter(initial_cash=100_000.0) order = OrderRequest( ticker="AAPL", side=OrderSide.BUY, quantity=5, order_type=OrderType.LIMIT, limit_price=100.0, ) resp = await adapter.submit_order(order) status = await adapter.get_order_status(resp.broker_order_id) assert status.status == OrderStatus.FILLED async def test_get_unknown_order_status(self): adapter = PaperTradingAdapter() resp = await adapter.get_order_status("nonexistent") assert resp.status == OrderStatus.REJECTED @pytest.mark.asyncio class TestPaperTradingPositionsAndAccount: async def test_get_positions_empty(self): adapter = PaperTradingAdapter() positions = await adapter.get_positions() assert positions == [] async def test_get_positions_after_buy(self): adapter = PaperTradingAdapter(initial_cash=100_000.0) order = OrderRequest( ticker="AAPL", side=OrderSide.BUY, quantity=10, order_type=OrderType.LIMIT, limit_price=150.0, ) await adapter.submit_order(order) positions = await adapter.get_positions() assert len(positions) == 1 assert positions[0].ticker == "AAPL" assert positions[0].quantity == 10 async def test_get_account(self): adapter = PaperTradingAdapter(initial_cash=50_000.0, account_id="test") info = await adapter.get_account() assert info.account_id == "test" assert info.cash == 50_000.0 assert info.mode == TradingMode.PAPER @pytest.mark.asyncio class TestPaperTradingFetch: async def test_fetch_positions(self): adapter = PaperTradingAdapter(initial_cash=100_000.0) buy = OrderRequest( ticker="AAPL", side=OrderSide.BUY, quantity=5, order_type=OrderType.LIMIT, limit_price=100.0, ) await adapter.submit_order(buy) result = await adapter.fetch("AAPL", {"endpoint": "positions"}) assert result.ok assert len(result.items) == 1 assert result.metadata["provider"] == "paper" async def test_fetch_account(self): adapter = PaperTradingAdapter() result = await adapter.fetch("*", {"endpoint": "account"}) assert result.ok assert result.items[0]["mode"] == "paper" async def test_fetch_orders(self): adapter = PaperTradingAdapter(initial_cash=100_000.0) buy = OrderRequest( ticker="AAPL", side=OrderSide.BUY, quantity=5, order_type=OrderType.LIMIT, limit_price=100.0, ) await adapter.submit_order(buy) result = await adapter.fetch("AAPL", {"endpoint": "orders"}) assert len(result.items) == 1 async def test_fetch_empty_position(self): adapter = PaperTradingAdapter() result = await adapter.fetch("AAPL", {"endpoint": "positions"}) assert len(result.items) == 0