from decimal import Decimal import pytest from llm_trade_bot.config import RiskConfig from llm_trade_bot.models import ( AccountSnapshot, Category, InstrumentRules, OpenTradeRequest, OrderType, Side, ) from llm_trade_bot.risk import RiskEngine, RiskRejected @pytest.fixture def engine() -> RiskEngine: return RiskEngine(RiskConfig()) @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, "risk_pct": Decimal(2), "leverage": Decimal(3), "rationale": "test", } values.update(overrides) return OpenTradeRequest(**values) def test_sizes_by_risk_and_exposure(engine, account, rules): result = engine.validate_open(request(), account, rules) assert result.quantity == Decimal(20) assert result.notional == Decimal(2000) def test_remaining_exposure_caps_position(engine, rules): account = AccountSnapshot(Decimal(10000), Decimal(10000), Decimal(3900), 2) result = engine.validate_open(request(), account, rules) assert result.quantity == Decimal(1) assert result.notional == Decimal(100) @pytest.mark.parametrize( "overrides, message", [ ({"risk_pct": Decimal("2.1")}, "risk_pct"), ({"leverage": Decimal(4)}, "Leverage"), ({"stop_loss": Decimal(101)}, "stop-loss"), ({"take_profit": None, "trailing_distance": None}, "take-profit or trailing"), ({"category": Category.SPOT, "order_type": OrderType.MARKET}, "Spot entries"), ({"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(RiskRejected, match=message): engine.validate_open(request(**overrides), account, rules) def test_rejects_max_positions(engine, rules): account = AccountSnapshot(Decimal(10000), Decimal(10000), Decimal(0), 5) with pytest.raises(RiskRejected, match="Maximum"): engine.validate_open(request(), account, rules)