from __future__ import annotations from decimal import ROUND_DOWN, Decimal from llm_trade_bot.models import ( AccountSnapshot, Category, InstrumentRules, OpenTradeRequest, OrderType, Side, ValidatedOrder, ) class OrderRejected(ValueError): """Raised when an order cannot be represented by the selected exchange.""" class OrderValidator: def validate_open( self, request: OpenTradeRequest, account: AccountSnapshot, instrument: InstrumentRules, quote_notional: Decimal | None = None, available_balance_percent: Decimal | None = None, ) -> ValidatedOrder: if account.equity <= 0: raise OrderRejected("Account equity must be positive") if request.order_type is OrderType.LIMIT and request.limit_price is None: raise OrderRejected("Limit order requires limit_price") entry = request.limit_price if request.order_type is OrderType.LIMIT else request.reference_price if entry is None or entry <= 0 or request.stop_loss <= 0: raise OrderRejected("Entry and stop-loss prices must be positive") self._validate_protection(request, entry) leverage = Decimal(1) if request.category is Category.SPOT else request.leverage if leverage < 1 or leverage > instrument.max_leverage: raise OrderRejected(f"Leverage must be between 1 and {instrument.max_leverage}") if quote_notional is not None and available_balance_percent is not None: raise OrderRejected("Set either quote_notional or available_balance_percent, not both") if quote_notional is not None and quote_notional <= 0: raise OrderRejected("quote_notional must be positive") if available_balance_percent is not None and not 0 < available_balance_percent <= 100: raise OrderRejected("available_balance_percent must be in (0, 100]") maximum_notional = account.available_balance * leverage if quote_notional is not None: requested_notional = quote_notional elif available_balance_percent is not None: requested_notional = maximum_notional * available_balance_percent / Decimal(100) else: requested_notional = maximum_notional if requested_notional > maximum_notional: raise OrderRejected( f"Requested quote notional {requested_notional} exceeds available buying power " f"{maximum_notional} (available_balance={account.available_balance}, leverage={leverage})" ) quantity = requested_notional / entry quantity = _round_down(quantity, instrument.qty_step) notional = quantity * entry if quantity < instrument.min_qty: raise OrderRejected( f"Calculated quantity {quantity} is below instrument minimum {instrument.min_qty} " f"(available_balance={account.available_balance}, leverage={leverage}, entry_price={entry})" ) if notional < instrument.min_notional: raise OrderRejected( f"Calculated notional {notional} is below instrument minimum {instrument.min_notional} " f"(available_balance={account.available_balance}, quantity={quantity}, entry_price={entry})" ) rounded_entry = _round_price(entry, instrument.tick_size) stop = _round_price(request.stop_loss, instrument.tick_size) take_profit = ( _round_price(request.take_profit, instrument.tick_size) if request.take_profit is not None else None ) trailing = ( _round_price(request.trailing_distance, instrument.tick_size) if request.trailing_distance is not None else None ) rounded_request = OpenTradeRequest( request.category, request.symbol, request.side, request.order_type, rounded_entry, rounded_entry if request.order_type is OrderType.LIMIT else None, stop, take_profit, trailing, leverage, request.rationale, ) self._validate_protection(rounded_request, rounded_entry) return ValidatedOrder( rounded_entry, quantity, quantity * rounded_entry, stop, take_profit, trailing, leverage, ) @staticmethod def _validate_protection(request: OpenTradeRequest, entry: Decimal) -> None: if request.side is Side.BUY: if request.stop_loss >= entry: raise OrderRejected("Buy stop-loss must be below entry") if request.take_profit is not None and request.take_profit <= entry: raise OrderRejected("Buy take-profit must be above entry") else: if request.stop_loss <= entry: raise OrderRejected("Sell stop-loss must be above entry") if request.take_profit is not None and request.take_profit >= entry: raise OrderRejected("Sell take-profit must be below entry") if request.category is Category.SPOT: if request.take_profit is None: raise OrderRejected("Spot entry requires take-profit") if request.trailing_distance is not None: raise OrderRejected("Trailing stop is not supported for Spot") elif request.take_profit is None and request.trailing_distance is None: raise OrderRejected("Linear entry requires take-profit or trailing stop") if request.trailing_distance is not None and request.trailing_distance <= 0: raise OrderRejected("Trailing distance must be positive") def _round_down(value: Decimal, step: Decimal) -> Decimal: if step <= 0: raise OrderRejected("Instrument step must be positive") return (value / step).to_integral_value(rounding=ROUND_DOWN) * step def _round_price(value: Decimal, tick: Decimal) -> Decimal: return _round_down(value, tick)