628 lines
23 KiB
Python
628 lines
23 KiB
Python
"""Paper trading simulation scenarios.
|
||
|
||
End-to-end scenarios that exercise the full recommendation-to-execution
|
||
pipeline through the paper trading adapter, risk engine, and position
|
||
tracking. Each scenario simulates a realistic trading session using
|
||
real logic from all service modules — no mocked business logic.
|
||
|
||
Covers:
|
||
- Single-symbol buy-and-sell round trips with P&L verification
|
||
- Multi-symbol portfolio construction and diversification
|
||
- Risk engine rejection scenarios (position limits, daily loss, lockouts)
|
||
- Idempotent order submission under replay conditions
|
||
- Insufficient funds and insufficient shares edge cases
|
||
- Recommendation-driven order flow (bullish → buy, bearish → sell)
|
||
- Portfolio drawdown halting via daily loss limits
|
||
- News-shock lockout preventing trades during high-impact events
|
||
|
||
Requirements: 7.1-7.4, 8.1-8.5
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
import pytest
|
||
|
||
from services.adapters.broker_adapter import (
|
||
OrderRequest,
|
||
OrderSide,
|
||
OrderStatus,
|
||
OrderType,
|
||
TradingMode,
|
||
)
|
||
from services.adapters.paper_trading import PaperTradingAdapter
|
||
from services.aggregation.worker import (
|
||
ImpactRow,
|
||
assemble_trend_with_evidence,
|
||
build_weighted_signals,
|
||
)
|
||
from services.recommendation.eligibility import evaluate_eligibility
|
||
from services.recommendation.worker import build_recommendation
|
||
from services.risk.engine import (
|
||
AccountRiskState,
|
||
DailyLossLimits,
|
||
NewsShockLockout,
|
||
PortfolioRiskConfig,
|
||
PositionLimits,
|
||
ProposedOrder,
|
||
RiskCheckResult,
|
||
SectorExposureLimits,
|
||
SymbolCooldown,
|
||
evaluate_order,
|
||
)
|
||
from services.shared.schemas import (
|
||
ActionType,
|
||
RecommendationMode,
|
||
)
|
||
|
||
NOW = datetime(2026, 4, 11, 14, 0, 0, tzinfo=timezone.utc)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _bullish_impacts(ticker: str, count: int = 3) -> list[ImpactRow]:
|
||
"""Generate bullish impact rows for aggregation."""
|
||
return [
|
||
ImpactRow(
|
||
document_id=f"doc-bull-{ticker}-{i}",
|
||
confidence=0.80 + i * 0.02,
|
||
novelty_score=0.6,
|
||
source_credibility=0.8,
|
||
sentiment="positive",
|
||
impact_score=0.70 + i * 0.03,
|
||
catalyst_type="earnings",
|
||
key_facts=[f"Strong Q{i+1} results for {ticker}"],
|
||
risks=[],
|
||
published_at=NOW - timedelta(hours=i + 1),
|
||
)
|
||
for i in range(count)
|
||
]
|
||
|
||
|
||
def _bearish_impacts(ticker: str, count: int = 3) -> list[ImpactRow]:
|
||
"""Generate bearish impact rows for aggregation."""
|
||
return [
|
||
ImpactRow(
|
||
document_id=f"doc-bear-{ticker}-{i}",
|
||
confidence=0.78 + i * 0.02,
|
||
novelty_score=0.55,
|
||
source_credibility=0.75,
|
||
sentiment="negative",
|
||
impact_score=0.65 + i * 0.03,
|
||
catalyst_type="legal",
|
||
key_facts=[f"Regulatory action against {ticker}"],
|
||
risks=[f"Potential fine for {ticker}"],
|
||
published_at=NOW - timedelta(hours=i + 1),
|
||
)
|
||
for i in range(count)
|
||
]
|
||
|
||
|
||
def _build_trend_and_recommendation(impacts, ticker, window="7d"):
|
||
"""Run aggregation + eligibility + recommendation for a set of impacts."""
|
||
signals = build_weighted_signals(impacts, NOW, window)
|
||
assembled = assemble_trend_with_evidence(
|
||
ticker, window, signals, impacts, reference_time=NOW,
|
||
)
|
||
summary = assembled.summary
|
||
eligibility = evaluate_eligibility(summary)
|
||
rec = build_recommendation(summary, eligibility, reference_time=NOW)
|
||
return summary, eligibility, rec
|
||
|
||
|
||
def _risk_state_from_adapter(adapter: PaperTradingAdapter) -> AccountRiskState:
|
||
"""Build an AccountRiskState snapshot from the paper adapter's in-memory state."""
|
||
acct = adapter.account
|
||
positions_by_symbol = {
|
||
t: p.quantity * p.avg_entry_price
|
||
for t, p in acct.positions.items()
|
||
if p.is_open
|
||
}
|
||
return AccountRiskState(
|
||
account_id=acct.account_id,
|
||
portfolio_value=acct.portfolio_value,
|
||
cash=acct.cash,
|
||
buying_power=acct.buying_power,
|
||
positions_by_symbol=positions_by_symbol,
|
||
open_position_count=sum(1 for p in acct.positions.values() if p.is_open),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Scenario 1: Single-symbol buy-sell round trip
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
class TestSingleSymbolRoundTrip:
|
||
"""Buy shares, sell at a profit, verify P&L and cash reconciliation."""
|
||
|
||
async def test_buy_hold_sell_profit(self):
|
||
adapter = PaperTradingAdapter(initial_cash=100_000.0)
|
||
|
||
# Generate bullish recommendation
|
||
impacts = _bullish_impacts("AAPL")
|
||
summary, eligibility, rec = _build_trend_and_recommendation(impacts, "AAPL")
|
||
assert rec.action == ActionType.BUY
|
||
|
||
# Execute buy
|
||
buy = OrderRequest(
|
||
ticker="AAPL", side=OrderSide.BUY, quantity=50,
|
||
order_type=OrderType.LIMIT, limit_price=180.0,
|
||
)
|
||
buy_resp = await adapter.submit_order(buy)
|
||
assert buy_resp.status == OrderStatus.FILLED
|
||
assert adapter.account.cash == pytest.approx(100_000.0 - 50 * 180.0)
|
||
|
||
# Verify position
|
||
positions = await adapter.get_positions()
|
||
assert len(positions) == 1
|
||
assert positions[0].ticker == "AAPL"
|
||
assert positions[0].quantity == 50
|
||
|
||
# Sell at higher price
|
||
sell = OrderRequest(
|
||
ticker="AAPL", side=OrderSide.SELL, quantity=50,
|
||
order_type=OrderType.LIMIT, limit_price=195.0,
|
||
)
|
||
sell_resp = await adapter.submit_order(sell)
|
||
assert sell_resp.status == OrderStatus.FILLED
|
||
assert sell_resp.raw_response["realized_pnl"] == pytest.approx(50 * 15.0)
|
||
|
||
# Cash should be back to initial + profit
|
||
expected_cash = 100_000.0 + 50 * 15.0
|
||
assert adapter.account.cash == pytest.approx(expected_cash)
|
||
|
||
# Position should be closed
|
||
positions = await adapter.get_positions()
|
||
assert len(positions) == 0
|
||
|
||
async def test_buy_hold_sell_loss(self):
|
||
adapter = PaperTradingAdapter(initial_cash=100_000.0)
|
||
|
||
buy = OrderRequest(
|
||
ticker="TSLA", side=OrderSide.BUY, quantity=20,
|
||
order_type=OrderType.LIMIT, limit_price=250.0,
|
||
)
|
||
await adapter.submit_order(buy)
|
||
|
||
sell = OrderRequest(
|
||
ticker="TSLA", side=OrderSide.SELL, quantity=20,
|
||
order_type=OrderType.LIMIT, limit_price=230.0,
|
||
)
|
||
sell_resp = await adapter.submit_order(sell)
|
||
assert sell_resp.raw_response["realized_pnl"] == pytest.approx(-400.0)
|
||
|
||
expected_cash = 100_000.0 - 400.0
|
||
assert adapter.account.cash == pytest.approx(expected_cash)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Scenario 2: Multi-symbol portfolio construction
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
class TestMultiSymbolPortfolio:
|
||
"""Build a diversified portfolio across multiple symbols."""
|
||
|
||
async def test_build_three_position_portfolio(self):
|
||
adapter = PaperTradingAdapter(initial_cash=100_000.0)
|
||
|
||
orders = [
|
||
("AAPL", 20, 180.0),
|
||
("MSFT", 15, 420.0),
|
||
("GOOGL", 10, 175.0),
|
||
]
|
||
total_cost = 0.0
|
||
for ticker, qty, price in orders:
|
||
req = OrderRequest(
|
||
ticker=ticker, side=OrderSide.BUY, quantity=qty,
|
||
order_type=OrderType.LIMIT, limit_price=price,
|
||
)
|
||
resp = await adapter.submit_order(req)
|
||
assert resp.status == OrderStatus.FILLED
|
||
total_cost += qty * price
|
||
|
||
assert adapter.account.cash == pytest.approx(100_000.0 - total_cost)
|
||
|
||
positions = await adapter.get_positions()
|
||
tickers = {p.ticker for p in positions}
|
||
assert tickers == {"AAPL", "MSFT", "GOOGL"}
|
||
|
||
# Portfolio value = cash + position value at entry
|
||
assert adapter.account.portfolio_value == pytest.approx(100_000.0)
|
||
|
||
async def test_partial_liquidation(self):
|
||
adapter = PaperTradingAdapter(initial_cash=50_000.0)
|
||
|
||
# Buy two positions
|
||
await adapter.submit_order(OrderRequest(
|
||
ticker="AAPL", side=OrderSide.BUY, quantity=30,
|
||
order_type=OrderType.LIMIT, limit_price=150.0,
|
||
))
|
||
await adapter.submit_order(OrderRequest(
|
||
ticker="MSFT", side=OrderSide.BUY, quantity=10,
|
||
order_type=OrderType.LIMIT, limit_price=400.0,
|
||
))
|
||
|
||
# Sell only AAPL
|
||
await adapter.submit_order(OrderRequest(
|
||
ticker="AAPL", side=OrderSide.SELL, quantity=30,
|
||
order_type=OrderType.LIMIT, limit_price=155.0,
|
||
))
|
||
|
||
positions = await adapter.get_positions()
|
||
assert len(positions) == 1
|
||
assert positions[0].ticker == "MSFT"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Scenario 3: Risk engine blocks unsafe orders
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestRiskEngineBlocking:
|
||
"""Verify risk engine prevents orders that violate configured limits."""
|
||
|
||
def test_position_size_limit_blocks_large_order(self):
|
||
config = PortfolioRiskConfig(
|
||
position_limits=PositionLimits(max_position_value=5_000.0),
|
||
)
|
||
state = AccountRiskState(
|
||
portfolio_value=100_000.0, cash=100_000.0,
|
||
)
|
||
order = ProposedOrder(
|
||
ticker="AAPL", sector="Technology",
|
||
estimated_value=10_000.0, quantity=50,
|
||
)
|
||
result = evaluate_order(order, config, state)
|
||
assert not result.passed
|
||
assert any(
|
||
c.check_name == "max_position_value" and c.result == RiskCheckResult.FAIL
|
||
for c in result.checks
|
||
)
|
||
|
||
def test_sector_concentration_blocks_overweight(self):
|
||
config = PortfolioRiskConfig(
|
||
sector_exposure=SectorExposureLimits(max_sector_pct=0.20),
|
||
)
|
||
state = AccountRiskState(
|
||
portfolio_value=100_000.0,
|
||
positions_by_sector={"Technology": 18_000.0},
|
||
)
|
||
order = ProposedOrder(
|
||
ticker="NVDA", sector="Technology",
|
||
estimated_value=5_000.0, quantity=20,
|
||
)
|
||
result = evaluate_order(order, config, state)
|
||
assert not result.passed
|
||
|
||
def test_daily_loss_halt_blocks_further_trading(self):
|
||
config = PortfolioRiskConfig(
|
||
daily_loss=DailyLossLimits(
|
||
max_daily_loss_pct=0.02,
|
||
max_daily_loss_value=2_000.0,
|
||
),
|
||
)
|
||
state = AccountRiskState(
|
||
portfolio_value=100_000.0,
|
||
daily_pnl=-2_500.0,
|
||
)
|
||
order = ProposedOrder(
|
||
ticker="AAPL", sector="Technology",
|
||
estimated_value=1_000.0, quantity=5,
|
||
)
|
||
result = evaluate_order(order, config, state)
|
||
assert not result.passed
|
||
loss_failures = [
|
||
c for c in result.checks
|
||
if c.check_name.startswith("daily_loss") and c.result == RiskCheckResult.FAIL
|
||
]
|
||
assert len(loss_failures) >= 1
|
||
|
||
def test_news_shock_lockout_blocks_trade(self):
|
||
lockout_expiry = NOW + timedelta(minutes=45)
|
||
config = PortfolioRiskConfig(
|
||
news_shock=NewsShockLockout(enabled=True, lockout_minutes=60),
|
||
)
|
||
state = AccountRiskState(
|
||
portfolio_value=100_000.0,
|
||
locked_symbols={"AAPL": lockout_expiry},
|
||
)
|
||
order = ProposedOrder(
|
||
ticker="AAPL", sector="Technology",
|
||
estimated_value=1_000.0, quantity=5,
|
||
)
|
||
result = evaluate_order(order, config, state, now=NOW)
|
||
assert not result.passed
|
||
assert any(
|
||
c.check_name == "news_shock_lockout" and c.result == RiskCheckResult.FAIL
|
||
for c in result.checks
|
||
)
|
||
|
||
def test_symbol_cooldown_blocks_rapid_retrade(self):
|
||
last_trade = NOW - timedelta(minutes=5)
|
||
config = PortfolioRiskConfig(
|
||
symbol_cooldown=SymbolCooldown(cooldown_minutes=15),
|
||
)
|
||
state = AccountRiskState(
|
||
portfolio_value=100_000.0,
|
||
last_trade_times={"AAPL": last_trade},
|
||
)
|
||
order = ProposedOrder(
|
||
ticker="AAPL", sector="Technology",
|
||
estimated_value=1_000.0, quantity=5,
|
||
)
|
||
result = evaluate_order(order, config, state, now=NOW)
|
||
assert not result.passed
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Scenario 4: Recommendation-driven order flow
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
class TestRecommendationDrivenOrders:
|
||
"""Simulate the full path: signals → recommendation → risk check → paper fill."""
|
||
|
||
async def test_bullish_recommendation_to_paper_buy(self):
|
||
adapter = PaperTradingAdapter(initial_cash=100_000.0)
|
||
impacts = _bullish_impacts("AAPL", count=4)
|
||
summary, eligibility, rec = _build_trend_and_recommendation(impacts, "AAPL")
|
||
|
||
assert rec.action == ActionType.BUY
|
||
assert rec.confidence > 0
|
||
|
||
# Risk check the proposed order
|
||
risk_state = _risk_state_from_adapter(adapter)
|
||
proposed = ProposedOrder(
|
||
ticker="AAPL", sector="Technology",
|
||
estimated_value=rec.position_sizing.portfolio_pct * risk_state.portfolio_value,
|
||
quantity=10,
|
||
confidence=rec.confidence,
|
||
recommendation_id=rec.recommendation_id,
|
||
)
|
||
risk_eval = evaluate_order(proposed, PortfolioRiskConfig(), risk_state)
|
||
assert risk_eval.passed
|
||
|
||
# Execute the paper order
|
||
order = OrderRequest(
|
||
ticker="AAPL", side=OrderSide.BUY, quantity=10,
|
||
order_type=OrderType.LIMIT, limit_price=180.0,
|
||
)
|
||
resp = await adapter.submit_order(order)
|
||
assert resp.status == OrderStatus.FILLED
|
||
|
||
async def test_bearish_recommendation_to_paper_sell(self):
|
||
adapter = PaperTradingAdapter(initial_cash=100_000.0)
|
||
|
||
# First buy a position to sell
|
||
await adapter.submit_order(OrderRequest(
|
||
ticker="TSLA", side=OrderSide.BUY, quantity=20,
|
||
order_type=OrderType.LIMIT, limit_price=250.0,
|
||
))
|
||
|
||
# Generate bearish recommendation
|
||
impacts = _bearish_impacts("TSLA", count=3)
|
||
summary, eligibility, rec = _build_trend_and_recommendation(impacts, "TSLA")
|
||
assert rec.action == ActionType.SELL
|
||
|
||
# Execute the sell
|
||
sell = OrderRequest(
|
||
ticker="TSLA", side=OrderSide.SELL, quantity=20,
|
||
order_type=OrderType.LIMIT, limit_price=240.0,
|
||
)
|
||
resp = await adapter.submit_order(sell)
|
||
assert resp.status == OrderStatus.FILLED
|
||
assert resp.raw_response["realized_pnl"] == pytest.approx(-200.0)
|
||
|
||
async def test_low_confidence_recommendation_is_informational(self):
|
||
"""Low-confidence signals should produce informational-only recommendations."""
|
||
impacts = [
|
||
ImpactRow(
|
||
document_id="doc-weak-1",
|
||
confidence=0.40,
|
||
novelty_score=0.3,
|
||
source_credibility=0.5,
|
||
sentiment="positive",
|
||
impact_score=0.3,
|
||
catalyst_type="other",
|
||
key_facts=["Minor update"],
|
||
risks=[],
|
||
published_at=NOW - timedelta(hours=1),
|
||
),
|
||
ImpactRow(
|
||
document_id="doc-weak-2",
|
||
confidence=0.35,
|
||
novelty_score=0.2,
|
||
source_credibility=0.4,
|
||
sentiment="positive",
|
||
impact_score=0.25,
|
||
catalyst_type="other",
|
||
key_facts=["Routine filing"],
|
||
risks=[],
|
||
published_at=NOW - timedelta(hours=3),
|
||
),
|
||
]
|
||
_, _, rec = _build_trend_and_recommendation(impacts, "XYZ")
|
||
assert rec.mode == RecommendationMode.INFORMATIONAL
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Scenario 5: Idempotent order submission
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
class TestIdempotentOrderSubmission:
|
||
"""Verify duplicate orders with the same idempotency key are not double-executed."""
|
||
|
||
async def test_duplicate_buy_only_fills_once(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="idem-buy-1",
|
||
)
|
||
|
||
resp1 = await adapter.submit_order(order)
|
||
resp2 = await adapter.submit_order(order)
|
||
|
||
assert resp1.broker_order_id == resp2.broker_order_id
|
||
# Cash deducted only once
|
||
assert adapter.account.cash == pytest.approx(100_000.0 - 1_500.0)
|
||
# Only one position entry
|
||
pos = adapter.account.get_position("AAPL")
|
||
assert pos.quantity == 10
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Scenario 6: Insufficient funds and shares
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
class TestInsufficientResources:
|
||
"""Verify the adapter rejects orders when resources are insufficient."""
|
||
|
||
async def test_buy_exceeding_cash_rejected(self):
|
||
adapter = PaperTradingAdapter(initial_cash=5_000.0)
|
||
order = OrderRequest(
|
||
ticker="AAPL", side=OrderSide.BUY, quantity=100,
|
||
order_type=OrderType.LIMIT, limit_price=180.0,
|
||
)
|
||
resp = await adapter.submit_order(order)
|
||
assert resp.status == OrderStatus.REJECTED
|
||
assert resp.error is not None and "Insufficient cash" in resp.error
|
||
|
||
async def test_sell_more_than_held_rejected(self):
|
||
adapter = PaperTradingAdapter(initial_cash=100_000.0)
|
||
await adapter.submit_order(OrderRequest(
|
||
ticker="AAPL", side=OrderSide.BUY, quantity=10,
|
||
order_type=OrderType.LIMIT, limit_price=150.0,
|
||
))
|
||
sell = OrderRequest(
|
||
ticker="AAPL", side=OrderSide.SELL, quantity=20,
|
||
order_type=OrderType.LIMIT, limit_price=155.0,
|
||
)
|
||
resp = await adapter.submit_order(sell)
|
||
assert resp.status == OrderStatus.REJECTED
|
||
assert resp.error is not None and "Insufficient shares" in resp.error
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Scenario 7: Portfolio drawdown halts trading
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestDrawdownHalt:
|
||
"""Simulate a losing session that triggers the daily loss circuit breaker."""
|
||
|
||
def test_cumulative_losses_trigger_halt(self):
|
||
"""After multiple losing trades, the risk engine should block new orders."""
|
||
config = PortfolioRiskConfig(
|
||
daily_loss=DailyLossLimits(
|
||
max_daily_loss_pct=0.03,
|
||
max_daily_loss_value=3_000.0,
|
||
max_daily_trades=50,
|
||
),
|
||
)
|
||
|
||
# Simulate state after several losing trades
|
||
state = AccountRiskState(
|
||
portfolio_value=97_000.0,
|
||
cash=47_000.0,
|
||
daily_pnl=-3_200.0,
|
||
daily_trade_count=8,
|
||
)
|
||
|
||
order = ProposedOrder(
|
||
ticker="NVDA", sector="Technology",
|
||
estimated_value=2_000.0, quantity=5,
|
||
)
|
||
result = evaluate_order(order, config, state)
|
||
assert not result.passed
|
||
# Both pct and value limits should be breached
|
||
failed_checks = {
|
||
c.check_name for c in result.checks if c.result == RiskCheckResult.FAIL
|
||
}
|
||
assert "daily_loss_value" in failed_checks
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Scenario 8: Full session simulation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
class TestFullTradingSession:
|
||
"""Simulate a realistic multi-trade session with mixed outcomes."""
|
||
|
||
async def test_morning_session_with_mixed_results(self):
|
||
adapter = PaperTradingAdapter(initial_cash=100_000.0)
|
||
initial_cash = 100_000.0
|
||
|
||
# Trade 1: Buy AAPL, sell at profit
|
||
await adapter.submit_order(OrderRequest(
|
||
ticker="AAPL", side=OrderSide.BUY, quantity=30,
|
||
order_type=OrderType.LIMIT, limit_price=180.0,
|
||
))
|
||
resp1 = await adapter.submit_order(OrderRequest(
|
||
ticker="AAPL", side=OrderSide.SELL, quantity=30,
|
||
order_type=OrderType.LIMIT, limit_price=185.0,
|
||
))
|
||
pnl_1 = resp1.raw_response["realized_pnl"]
|
||
assert pnl_1 == pytest.approx(150.0)
|
||
|
||
# Trade 2: Buy TSLA, sell at loss
|
||
await adapter.submit_order(OrderRequest(
|
||
ticker="TSLA", side=OrderSide.BUY, quantity=10,
|
||
order_type=OrderType.LIMIT, limit_price=250.0,
|
||
))
|
||
resp2 = await adapter.submit_order(OrderRequest(
|
||
ticker="TSLA", side=OrderSide.SELL, quantity=10,
|
||
order_type=OrderType.LIMIT, limit_price=242.0,
|
||
))
|
||
pnl_2 = resp2.raw_response["realized_pnl"]
|
||
assert pnl_2 == pytest.approx(-80.0)
|
||
|
||
# Trade 3: Buy MSFT, hold (don't sell)
|
||
await adapter.submit_order(OrderRequest(
|
||
ticker="MSFT", side=OrderSide.BUY, quantity=5,
|
||
order_type=OrderType.LIMIT, limit_price=420.0,
|
||
))
|
||
|
||
# Verify final state
|
||
positions = await adapter.get_positions()
|
||
assert len(positions) == 1
|
||
assert positions[0].ticker == "MSFT"
|
||
|
||
# Cash = initial + AAPL profit + TSLA loss - MSFT cost
|
||
expected_cash = initial_cash + 150.0 - 80.0 - (5 * 420.0)
|
||
assert adapter.account.cash == pytest.approx(expected_cash)
|
||
|
||
# Audit trail should have events for all trades
|
||
event_count = len(adapter.account.order_events)
|
||
# 5 orders × 3 events each (submitted, accepted, fill) = 15
|
||
# (rejected orders get fewer events, but all 5 here are fills)
|
||
assert event_count == 15
|
||
|
||
async def test_account_info_reflects_session(self):
|
||
adapter = PaperTradingAdapter(initial_cash=50_000.0, account_id="sim-session")
|
||
|
||
await adapter.submit_order(OrderRequest(
|
||
ticker="AAPL", side=OrderSide.BUY, quantity=10,
|
||
order_type=OrderType.LIMIT, limit_price=180.0,
|
||
))
|
||
|
||
acct = await adapter.get_account()
|
||
assert acct.account_id == "sim-session"
|
||
assert acct.mode == TradingMode.PAPER
|
||
assert acct.cash == pytest.approx(50_000.0 - 1_800.0)
|
||
assert acct.portfolio_value == pytest.approx(50_000.0)
|