feat: implement autonomous Bybit demo trading agent

This commit is contained in:
Azimkin
2026-09-19 16:10:11 +02:00
commit 110b9fee0b
26 changed files with 3391 additions and 0 deletions

145
src/llm_trade_bot/risk.py Normal file
View File

@@ -0,0 +1,145 @@
from __future__ import annotations
from decimal import ROUND_DOWN, Decimal
from llm_trade_bot.config import RiskConfig
from llm_trade_bot.models import (
AccountSnapshot,
Category,
InstrumentRules,
OpenTradeRequest,
OrderType,
Side,
ValidatedOrder,
)
class RiskRejected(ValueError):
"""Raised when a requested order violates a hard safety rule."""
class RiskEngine:
def __init__(self, config: RiskConfig):
self._config = config
def validate_open(
self,
request: OpenTradeRequest,
account: AccountSnapshot,
instrument: InstrumentRules,
) -> ValidatedOrder:
if account.equity <= 0:
raise RiskRejected("Account equity must be positive")
if account.active_ideas >= self._config.max_positions:
raise RiskRejected("Maximum number of active ideas reached")
if request.category is Category.SPOT and request.side is Side.SELL:
raise RiskRejected("Opening spot shorts is not allowed")
if request.category is Category.SPOT and request.order_type is OrderType.MARKET:
raise RiskRejected("Spot entries must be Limit orders with attached TP/SL")
if request.order_type is OrderType.LIMIT and request.limit_price is None:
raise RiskRejected("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 RiskRejected("Entry and stop-loss prices must be positive")
self._validate_protection(request, entry)
max_risk = Decimal(str(self._config.max_risk_per_trade_pct))
if request.risk_pct <= 0 or request.risk_pct > max_risk:
raise RiskRejected(f"risk_pct must be in (0, {max_risk}]")
configured_leverage = Decimal(str(self._config.max_leverage))
allowed_leverage = min(configured_leverage, instrument.max_leverage)
leverage = Decimal(1) if request.category is Category.SPOT else request.leverage
if leverage < 1 or leverage > allowed_leverage:
raise RiskRejected(f"Leverage must be between 1 and {allowed_leverage}")
risk_amount = account.equity * request.risk_pct / Decimal(100)
per_unit_loss = abs(entry - request.stop_loss)
if per_unit_loss <= 0:
raise RiskRejected("Stop-loss must differ from entry")
risk_quantity = risk_amount / per_unit_loss
exposure_limit = account.equity * Decimal(str(self._config.max_total_exposure_pct)) / Decimal(100)
remaining_exposure = exposure_limit - account.gross_exposure
if remaining_exposure <= 0:
raise RiskRejected("Total exposure limit reached")
exposure_quantity = remaining_exposure / entry
buying_power = account.available_balance * leverage
balance_quantity = buying_power / entry
quantity = min(risk_quantity, exposure_quantity, balance_quantity)
quantity = _round_down(quantity, instrument.qty_step)
notional = quantity * entry
if quantity < instrument.min_qty:
raise RiskRejected("Calculated quantity is below instrument minimum")
if notional < instrument.min_notional:
raise RiskRejected("Calculated notional is below instrument minimum")
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,
request.risk_pct,
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 RiskRejected("Buy stop-loss must be below entry")
if request.take_profit is not None and request.take_profit <= entry:
raise RiskRejected("Buy take-profit must be above entry")
else:
if request.stop_loss <= entry:
raise RiskRejected("Sell stop-loss must be above entry")
if request.take_profit is not None and request.take_profit >= entry:
raise RiskRejected("Sell take-profit must be below entry")
if request.category is Category.SPOT:
if request.take_profit is None:
raise RiskRejected("Spot entry requires take-profit")
if request.trailing_distance is not None:
raise RiskRejected("Trailing stop is not supported for Spot")
elif request.take_profit is None and request.trailing_distance is None:
raise RiskRejected("Linear entry requires take-profit or trailing stop")
if request.trailing_distance is not None and request.trailing_distance <= 0:
raise RiskRejected("Trailing distance must be positive")
def _round_down(value: Decimal, step: Decimal) -> Decimal:
if step <= 0:
raise RiskRejected("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)