stock/tests/test_paper_trading.py
ZhangPeng 9aecdd036c Initial commit: OpenClaw Trading - AI多智能体量化交易系统
- 添加项目核心代码和配置
- 添加前端界面 (Next.js)
- 添加单元测试
- 更新 .gitignore 排除缓存和依赖
2026-02-27 03:47:40 +08:00

657 lines
21 KiB
Python

"""Tests for paper trading engine.
This module contains tests for PaperTradingEngine, Trade, FeeConfig,
SlippageConfig, PositionSummary, and AccountSummary.
"""
import asyncio
import pytest
from datetime import datetime, timedelta
from openclaw.exchange.paper_trading import (
PaperTradingEngine,
PaperTradingError,
PriceFeedError,
Trade,
FeeConfig,
SlippageConfig,
PositionSummary,
AccountSummary,
)
from openclaw.exchange.models import OrderSide, OrderStatus, OrderType
from openclaw.exchange.base import InsufficientFundsError, InvalidOrderError
class TestPaperTradingEngine:
"""Tests for PaperTradingEngine class."""
@pytest.fixture
def engine(self):
"""Fixture to create a PaperTradingEngine instance."""
return PaperTradingEngine(
name="test_engine",
initial_capital={"USDT": 10000.0, "BTC": 1.0},
)
def test_engine_initialization(self, engine):
"""Test PaperTradingEngine initialization."""
assert engine.name == "test_engine"
assert "USDT" in engine.balances
assert "BTC" in engine.balances
assert engine.balances["USDT"].free == 10000.0
assert engine.balances["BTC"].free == 1.0
def test_default_initial_capital(self):
"""Test default initial capital."""
engine = PaperTradingEngine()
assert "USDT" in engine.balances
assert engine.balances["USDT"].free == 10000.0
def test_custom_fee_config(self):
"""Test custom fee configuration."""
fee_config = FeeConfig(maker_fee=0.0005, taker_fee=0.0015)
engine = PaperTradingEngine(fee_config=fee_config)
assert engine.fee_config.maker_fee == 0.0005
assert engine.fee_config.taker_fee == 0.0015
def test_custom_slippage_config(self):
"""Test custom slippage configuration."""
slippage_config = SlippageConfig(enabled=True, base_slippage_pct=0.1)
engine = PaperTradingEngine(slippage_config=slippage_config)
assert engine.slippage_config.enabled is True
assert engine.slippage_config.base_slippage_pct == 0.1
@pytest.mark.asyncio
async def test_update_price(self, engine):
"""Test updating price for a symbol."""
await engine.update_price("BTC/USDT", 50000.0)
# Price should be cached
price = engine._get_price("BTC/USDT")
assert price == 50000.0
@pytest.mark.asyncio
async def test_place_market_buy_order(self, engine):
"""Test placing a market buy order."""
await engine.update_price("BTC/USDT", 50000.0)
order = await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
assert order.symbol == "BTC/USDT"
assert order.side == OrderSide.BUY
assert order.amount == 0.1
assert order.status == OrderStatus.FILLED
assert order.filled_amount == 0.1
assert order.order_type == OrderType.MARKET
@pytest.mark.asyncio
async def test_place_market_sell_order(self, engine):
"""Test placing a market sell order."""
await engine.update_price("BTC/USDT", 50000.0)
order = await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.SELL,
amount=0.5,
)
assert order.symbol == "BTC/USDT"
assert order.side == OrderSide.SELL
assert order.amount == 0.5
assert order.status == OrderStatus.FILLED
@pytest.mark.asyncio
async def test_market_buy_without_price_feed(self, engine):
"""Test that market buy fails without price."""
with pytest.raises(PriceFeedError):
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
@pytest.mark.asyncio
async def test_insufficient_funds_buy(self, engine):
"""Test buy order with insufficient funds."""
await engine.update_price("BTC/USDT", 50000.0)
with pytest.raises(InsufficientFundsError):
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=1.0, # Costs $50k + fees, only have $10k
)
@pytest.mark.asyncio
async def test_insufficient_funds_sell(self, engine):
"""Test sell order with insufficient funds."""
await engine.update_price("BTC/USDT", 50000.0)
with pytest.raises(InsufficientFundsError):
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.SELL,
amount=2.0, # Only have 1 BTC
)
@pytest.mark.asyncio
async def test_buy_updates_balances(self, engine):
"""Test that buy order updates balances correctly."""
await engine.update_price("BTC/USDT", 50000.0)
initial_usdt = engine.balances["USDT"].free
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
# USDT should decrease, BTC should increase
assert engine.balances["BTC"].free == 1.0 + 0.1
assert engine.balances["USDT"].free < initial_usdt
@pytest.mark.asyncio
async def test_sell_updates_balances(self, engine):
"""Test that sell order updates balances correctly."""
await engine.update_price("BTC/USDT", 50000.0)
initial_btc = engine.balances["BTC"].free
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.SELL,
amount=0.5,
)
# BTC should decrease, USDT should increase
assert engine.balances["BTC"].free == initial_btc - 0.5
assert engine.balances["USDT"].free > 10000.0
@pytest.mark.asyncio
async def test_place_limit_order(self, engine):
"""Test placing a limit order."""
await engine.update_price("BTC/USDT", 50000.0)
order = await engine.place_limit_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
price=45000.0,
)
assert order.symbol == "BTC/USDT"
assert order.side == OrderSide.BUY
assert order.amount == 0.1
assert order.price == 45000.0
assert order.status == OrderStatus.OPEN
assert order.filled_amount == 0.0
@pytest.mark.asyncio
async def test_limit_order_invalid_price(self, engine):
"""Test limit order with invalid price."""
with pytest.raises(InvalidOrderError):
await engine.place_limit_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
price=-100.0,
)
@pytest.mark.asyncio
async def test_limit_order_fills_when_price_reached(self, engine):
"""Test that limit order fills when price is reached."""
await engine.update_price("BTC/USDT", 50000.0)
# Place buy limit at $45k
order = await engine.place_limit_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
price=45000.0,
)
assert order.status == OrderStatus.OPEN
# Price drops to $44k (below limit)
await engine.update_price("BTC/USDT", 44000.0)
# Order should now be filled
filled_order = engine.get_order(order.order_id)
assert filled_order.status == OrderStatus.FILLED
@pytest.mark.asyncio
async def test_limit_sell_order_fills_when_price_reached(self, engine):
"""Test that sell limit order fills when price is reached."""
await engine.update_price("BTC/USDT", 50000.0)
# Place sell limit at $55k
order = await engine.place_limit_order(
symbol="BTC/USDT",
side=OrderSide.SELL,
amount=0.1,
price=55000.0,
)
assert order.status == OrderStatus.OPEN
# Price rises to $56k (above limit)
await engine.update_price("BTC/USDT", 56000.0)
# Order should now be filled
filled_order = engine.get_order(order.order_id)
assert filled_order.status == OrderStatus.FILLED
@pytest.mark.asyncio
async def test_cancel_order(self, engine):
"""Test cancelling a pending order."""
await engine.update_price("BTC/USDT", 50000.0)
order = await engine.place_limit_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
price=45000.0,
)
initial_usdt_locked = engine.balances["USDT"].locked
assert initial_usdt_locked > 0
cancelled = await engine.cancel_order(order.order_id)
assert cancelled is True
# Funds should be unlocked
assert engine.balances["USDT"].locked == 0.0
# Order status should be cancelled
updated_order = engine.get_order(order.order_id)
assert updated_order.status == OrderStatus.CANCELLED
def test_cancel_nonexistent_order(self, engine):
"""Test cancelling nonexistent order."""
cancelled = asyncio.run(engine.cancel_order("nonexistent"))
assert cancelled is False
@pytest.mark.asyncio
async def test_get_open_orders(self, engine):
"""Test getting open orders."""
await engine.update_price("BTC/USDT", 50000.0)
# Place limit orders (use smaller amounts to stay within balance)
await engine.place_limit_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.05,
price=45000.0,
)
await engine.place_limit_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.05,
price=44000.0,
)
open_orders = engine.get_open_orders()
assert len(open_orders) == 2
@pytest.mark.asyncio
async def test_get_open_orders_filtered(self, engine):
"""Test getting open orders filtered by symbol."""
await engine.update_price("BTC/USDT", 50000.0)
await engine.update_price("ETH/USDT", 3000.0)
await engine.place_limit_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
price=45000.0,
)
await engine.place_limit_order(
symbol="ETH/USDT",
side=OrderSide.BUY,
amount=1.0,
price=2800.0,
)
btc_orders = engine.get_open_orders("BTC/USDT")
assert len(btc_orders) == 1
assert btc_orders[0].symbol == "BTC/USDT"
@pytest.mark.asyncio
async def test_position_created_after_buy(self, engine):
"""Test that position is created after buy order."""
await engine.update_price("BTC/USDT", 50000.0)
# No positions initially
assert len(engine.get_positions()) == 0
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
# Should have position now
positions = engine.get_positions()
assert len(positions) == 1
position = engine.get_position("BTC/USDT")
assert position is not None
assert position.symbol == "BTC/USDT"
assert position.side == OrderSide.BUY
assert position.amount == 0.1
@pytest.mark.asyncio
async def test_position_updated_on_additional_buy(self, engine):
"""Test position averaging on additional buy."""
await engine.update_price("BTC/USDT", 50000.0)
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.05,
)
# Update price and buy more (use smaller amount to stay within balance)
await engine.update_price("BTC/USDT", 55000.0)
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.05,
)
position = engine.get_position("BTC/USDT")
assert position.amount == 0.1
# Average entry price should be between 50k and 55k
assert 50000.0 < position.entry_price < 55000.0
@pytest.mark.asyncio
async def test_realized_pnl_on_sell(self, engine):
"""Test realized PnL calculation on sell."""
await engine.update_price("BTC/USDT", 50000.0)
# Buy at $50k
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
# Sell at $55k (10% gain)
await engine.update_price("BTC/USDT", 55000.0)
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.SELL,
amount=0.1,
)
# Should have realized profit
assert engine.total_realized_pnl > 0
# Check trade record
assert len(engine.trades) == 2 # Buy and sell
sell_trade = engine.trades[1]
assert sell_trade.realized_pnl > 0
@pytest.mark.asyncio
async def test_unrealized_pnl_calculation(self, engine):
"""Test unrealized PnL calculation."""
await engine.update_price("BTC/USDT", 50000.0)
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
# Price rises to $55k
await engine.update_price("BTC/USDT", 55000.0)
position = engine.get_position("BTC/USDT")
expected_pnl = (55000.0 - 50000.0) * 0.1 # $500
assert abs(position.unrealized_pnl - expected_pnl) < 100 # Allow for slippage
def test_get_unrealized_pnl_no_positions(self, engine):
"""Test unrealized PnL with no positions."""
assert engine.get_unrealized_pnl() == 0.0
@pytest.mark.asyncio
async def test_get_position_summary(self, engine):
"""Test position summary generation."""
await engine.update_price("BTC/USDT", 50000.0)
await engine.update_price("ETH/USDT", 3000.0)
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
await engine.place_market_order(
symbol="ETH/USDT",
side=OrderSide.BUY,
amount=1.0,
)
summary = engine.get_position_summary()
assert isinstance(summary, PositionSummary)
assert summary.total_positions == 2
assert summary.total_market_value > 0
@pytest.mark.asyncio
async def test_get_account_summary(self, engine):
"""Test account summary generation."""
await engine.update_price("BTC/USDT", 50000.0)
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
summary = engine.get_account_summary()
assert isinstance(summary, AccountSummary)
assert summary.initial_capital == 10001.0 # 10000 USDT + 1 BTC worth
assert summary.total_trades == 1
assert summary.open_positions == 1
def test_trade_history_empty(self, engine):
"""Test trade history when no trades."""
history = engine.get_trade_history()
assert len(history) == 0
@pytest.mark.asyncio
async def test_trade_history_with_trades(self, engine):
"""Test trade history with trades."""
await engine.update_price("BTC/USDT", 50000.0)
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
history = engine.get_trade_history()
assert len(history) == 1
assert history[0].symbol == "BTC/USDT"
assert history[0].side == OrderSide.BUY
@pytest.mark.asyncio
async def test_trade_history_filtered(self, engine):
"""Test filtered trade history."""
await engine.update_price("BTC/USDT", 50000.0)
await engine.update_price("ETH/USDT", 3000.0)
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
await engine.place_market_order(
symbol="ETH/USDT",
side=OrderSide.BUY,
amount=1.0,
)
btc_trades = engine.get_trade_history(symbol="BTC/USDT")
assert len(btc_trades) == 1
assert btc_trades[0].symbol == "BTC/USDT"
@pytest.mark.asyncio
async def test_trade_history_time_filtered(self, engine):
"""Test trade history filtered by time."""
await engine.update_price("BTC/USDT", 50000.0)
before = datetime.now()
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
after = datetime.now()
# Get trades before order
early_trades = engine.get_trade_history(end_time=before)
assert len(early_trades) == 0
# Get trades after order
recent_trades = engine.get_trade_history(start_time=before)
assert len(recent_trades) == 1
@pytest.mark.asyncio
async def test_generate_report(self, engine):
"""Test report generation."""
await engine.update_price("BTC/USDT", 50000.0)
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
report = engine.generate_report()
assert isinstance(report, str)
assert "Paper Trading Report" in report
assert "BTC/USDT" in report
assert "Account Summary" in report
@pytest.mark.asyncio
async def test_reset(self, engine):
"""Test engine reset."""
await engine.update_price("BTC/USDT", 50000.0)
await engine.place_market_order(
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
)
assert len(engine.get_positions()) == 1
assert len(engine.trades) == 1
assert engine.total_realized_pnl == 0.0 # No sells yet
engine.reset()
# Everything should be cleared
assert len(engine.get_positions()) == 0
assert len(engine.trades) == 0
assert engine.total_realized_pnl == 0.0
assert engine.balances["USDT"].free == 10000.0
assert engine.balances["BTC"].free == 1.0
@pytest.mark.asyncio
async def test_price_feed_callable(self):
"""Test engine with price feed callable."""
def price_feed(symbol):
return {"BTC/USDT": 50000.0}.get(symbol)
engine = PaperTradingEngine(price_feed=price_feed)
price = engine._get_price("BTC/USDT")
assert price == 50000.0
class TestFeeConfig:
"""Tests for FeeConfig dataclass."""
def test_default_fees(self):
"""Test default fee configuration."""
config = FeeConfig()
assert config.maker_fee == 0.001
assert config.taker_fee == 0.001
def test_custom_fees(self):
"""Test custom fee configuration."""
config = FeeConfig(maker_fee=0.0005, taker_fee=0.0015)
assert config.maker_fee == 0.0005
assert config.taker_fee == 0.0015
class TestSlippageConfig:
"""Tests for SlippageConfig dataclass."""
def test_default_slippage(self):
"""Test default slippage configuration."""
config = SlippageConfig()
assert config.enabled is True
assert config.base_slippage_pct == 0.05
def test_disabled_slippage(self):
"""Test disabled slippage."""
config = SlippageConfig(enabled=False)
assert config.enabled is False
class TestTrade:
"""Tests for Trade dataclass."""
def test_trade_creation(self):
"""Test creating a Trade."""
trade = Trade(
trade_id="trade123",
order_id="order123",
symbol="BTC/USDT",
side=OrderSide.BUY,
amount=0.1,
price=50000.0,
fee=5.0,
fee_asset="USDT",
timestamp=datetime.now(),
)
assert trade.trade_id == "trade123"
assert trade.symbol == "BTC/USDT"
assert trade.side == OrderSide.BUY
assert trade.realized_pnl == 0.0
def test_trade_with_pnl(self):
"""Test creating a Trade with realized PnL."""
trade = Trade(
trade_id="trade123",
order_id="order123",
symbol="BTC/USDT",
side=OrderSide.SELL,
amount=0.1,
price=55000.0,
fee=5.5,
fee_asset="USDT",
timestamp=datetime.now(),
realized_pnl=500.0,
)
assert trade.realized_pnl == 500.0
class TestPaperTradingError:
"""Tests for PaperTradingError exception."""
def test_error_inheritance(self):
"""Test that PaperTradingError inherits from ExchangeError."""
assert issubclass(PaperTradingError, Exception)
def test_price_feed_error(self):
"""Test PriceFeedError exception."""
assert issubclass(PriceFeedError, PaperTradingError)