Files
stonks-oracle/tests/test_paper_trading.py
T
Celes Renata c85c0068a2 fix: clean up utcnow deprecation warnings, fix 12 failing tests, add CI/CD pipeline manifests
- 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
2026-04-18 03:59:28 +00:00

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