from decimal import Decimal import pytest from llm_trade_bot.models import ( AccountSnapshot, Category, InstrumentRules, OpenTradeRequest, OrderType, Side, ) from llm_trade_bot.risk import OrderRejected, OrderValidator @pytest.fixture def engine() -> OrderValidator: return OrderValidator() @pytest.fixture def account() -> AccountSnapshot: return AccountSnapshot(Decimal(10000), Decimal(10000), Decimal(0), 0) @pytest.fixture def rules() -> InstrumentRules: return InstrumentRules(Decimal("0.1"), Decimal("0.001"), Decimal("0.001"), Decimal(5), Decimal(100)) def request(**overrides) -> OpenTradeRequest: values = { "category": Category.LINEAR, "symbol": "BTCUSDT", "side": Side.BUY, "order_type": OrderType.MARKET, "reference_price": Decimal(100), "limit_price": None, "stop_loss": Decimal(90), "take_profit": Decimal(120), "trailing_distance": None, "leverage": Decimal(3), "rationale": "test", } values.update(overrides) return OpenTradeRequest(**values) def test_uses_all_available_buying_power(engine, account, rules): result = engine.validate_open(request(), account, rules) assert result.quantity == Decimal(300) assert result.notional == Decimal(30000) def test_existing_exposure_does_not_cap_position(engine, rules): account = AccountSnapshot(Decimal(10000), Decimal(10000), Decimal(3900), 2) result = engine.validate_open(request(), account, rules) assert result.quantity == Decimal(300) assert result.notional == Decimal(30000) def test_explicit_quote_notional_sizes_order(engine, account, rules): result = engine.validate_open(request(), account, rules, quote_notional=Decimal("5000")) assert result.quantity == Decimal(50) assert result.notional == Decimal(5000) def test_available_balance_percent_sizes_margin_before_leverage(engine, account, rules): result = engine.validate_open( request(), account, rules, available_balance_percent=Decimal("25") ) assert result.quantity == Decimal(75) assert result.notional == Decimal(7500) @pytest.mark.parametrize( "quote_notional, available_balance_percent, message", [ (Decimal("1"), Decimal("50"), "either"), (Decimal("0"), None, "quote_notional"), (None, Decimal("101"), "available_balance_percent"), (Decimal("30001"), None, "exceeds available buying power"), ], ) def test_rejects_invalid_explicit_sizing( engine, account, rules, quote_notional, available_balance_percent, message ): with pytest.raises(OrderRejected, match=message): engine.validate_open( request(), account, rules, quote_notional=quote_notional, available_balance_percent=available_balance_percent, ) def test_minimum_quantity_error_reports_sizing_inputs(engine, rules): empty_account = AccountSnapshot(Decimal(10000), Decimal(0), Decimal(0), 0) with pytest.raises( OrderRejected, match=r"quantity 0.*minimum 0\.001.*available_balance=0.*leverage=3.*entry_price=100", ): engine.validate_open(request(), empty_account, rules) @pytest.mark.parametrize( "overrides, message", [ ({"leverage": Decimal(101)}, "Leverage"), ({"stop_loss": Decimal(101)}, "stop-loss"), ({"take_profit": None, "trailing_distance": None}, "take-profit or trailing"), ({"category": Category.SPOT, "order_type": OrderType.LIMIT, "limit_price": Decimal(100), "take_profit": None}, "Spot entry requires"), ], ) def test_rejects_unsafe_requests(engine, account, rules, overrides, message): with pytest.raises(OrderRejected, match=message): engine.validate_open(request(**overrides), account, rules)