phase 14-15: docker build validation and helm deployment
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user