143 lines
6.0 KiB
Python
143 lines
6.0 KiB
Python
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)
|