import pytest from trademind.broker import InsufficientFunds, OrderRejected, PaperBroker from trademind.config import PaperConfig from trademind.models import Side @pytest.fixture def broker(paper_config) -> PaperBroker: return PaperBroker(paper_config) async def test_buy_applies_slippage_and_fee(broker): fill = await broker.execute("BTC/USDT", Side.BUY, 0.1, 30_000.0) assert fill.price == pytest.approx(30_000.0 * 1.0005) # 5 bps Slippage assert fill.fee_quote == pytest.approx(fill.notional * 0.001) assert await broker.cash() == pytest.approx(10_000.0 - fill.notional - fill.fee_quote) assert await broker.holdings("BTC/USDT") == pytest.approx(0.1) async def test_sell_applies_slippage_in_the_other_direction(broker): await broker.execute("BTC/USDT", Side.BUY, 0.1, 30_000.0) fill = await broker.execute("BTC/USDT", Side.SELL, 0.1, 31_000.0) assert fill.price == pytest.approx(31_000.0 * 0.9995) assert await broker.holdings("BTC/USDT") == 0.0 async def test_round_trip_at_constant_price_loses_exactly_the_costs(broker): price = 30_000.0 await broker.execute("BTC/USDT", Side.BUY, 0.1, price) await broker.execute("BTC/USDT", Side.SELL, 0.1, price) # 2 × 0,1 % Gebühr + 2 × 5 bps Slippage auf ein Volumen von ~3000 assert await broker.cash() == pytest.approx(10_000.0 - 9.0, abs=0.2) async def test_buy_is_scaled_down_to_available_cash(broker): fill = await broker.execute("BTC/USDT", Side.BUY, 10.0, 30_000.0) # 300k gewünscht assert fill.amount < 10.0 assert await broker.cash() == pytest.approx(0.0, abs=1e-6) async def test_buy_without_any_cash_raises(): broker = PaperBroker(PaperConfig(starting_balance=1.0, fee_rate=0.001)) broker.market_info = {"BTC/USDT": {"amount_precision": 4}} with pytest.raises(InsufficientFunds): await broker.execute("BTC/USDT", Side.BUY, 1.0, 30_000.0) async def test_sell_without_position_raises(broker): with pytest.raises(OrderRejected): await broker.execute("BTC/USDT", Side.SELL, 1.0, 30_000.0) async def test_sell_is_capped_at_held_amount(broker): await broker.execute("BTC/USDT", Side.BUY, 0.1, 30_000.0) fill = await broker.execute("BTC/USDT", Side.SELL, 5.0, 30_000.0) assert fill.amount == pytest.approx(0.1) async def test_volume_participation_caps_the_order(broker): fill = await broker.execute("BTC/USDT", Side.BUY, 0.1, 30_000.0, bar_volume=0.2) assert fill.amount == pytest.approx(0.02) # 10 % von 0,2 assert fill.requested_amount == pytest.approx(0.1) async def test_amount_precision_rounds_down(broker): broker.market_info = {"BTC/USDT": {"amount_precision": 3}} fill = await broker.execute("BTC/USDT", Side.BUY, 0.123456, 30_000.0) assert fill.amount == pytest.approx(0.123) async def test_step_size_precision_is_supported(broker): broker.market_info = {"BTC/USDT": {"amount_precision": 0.05}} fill = await broker.execute("BTC/USDT", Side.BUY, 0.17, 100.0) assert fill.amount == pytest.approx(0.15) async def test_invalid_inputs_are_rejected(broker): with pytest.raises(OrderRejected): await broker.execute("BTC/USDT", Side.BUY, 0.0, 30_000.0) with pytest.raises(OrderRejected): await broker.execute("BTC/USDT", Side.BUY, 1.0, 0.0) async def test_state_round_trip(broker): await broker.execute("BTC/USDT", Side.BUY, 0.05, 30_000.0) state = broker.state() restored = PaperBroker(broker.config) restored.restore(state["cash"], state["holdings"], state["total_fees"]) assert await restored.cash() == pytest.approx(await broker.cash()) assert await restored.holdings("BTC/USDT") == pytest.approx(0.05)