c85c0068a2
- Replace all datetime.utcnow() with datetime.now(tz=timezone.utc) across 8 files - Fix 12 failing tests to match current implementation behavior - Fix pytest_plugins in non-top-level conftest (moved to root conftest.py) - Auto-fix 189 lint issues (import sorting, unused imports) - Add CI/CD pipeline infrastructure (ARC, ArgoCD, Kargo manifests) - Add values-beta.yaml and values-paper.yaml for staged deployments - Update GitHub Actions workflow to use self-hosted-gremlin runners - Add integration-test job to CI pipeline Result: 1596 passed, 0 failed, 0 warnings
338 lines
12 KiB
Python
338 lines
12 KiB
Python
"""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,
|
|
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
|