diff --git a/.env b/.env index 1efae6c..6646f2b 100644 --- a/.env +++ b/.env @@ -54,7 +54,7 @@ RISK_GUARD_ENABLED=true RISK_RECENT_TRADE_WINDOW=20 RISK_MAX_CONSECUTIVE_LOSSES=4 RISK_MIN_RECENT_PROFIT_FACTOR=0.85 -RISK_REDUCE_MULTIPLIER=0.50 +RISK_REDUCE_MULTIPLIER=1.0 ATR_TRAILING_MULTIPLIER=2.2 TREND_RSI_MIN=45 TREND_RSI_MAX=65 diff --git a/crypto_spot_bot/execution.py b/crypto_spot_bot/execution.py index a4634b2..6944ed3 100644 --- a/crypto_spot_bot/execution.py +++ b/crypto_spot_bot/execution.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import deque from datetime import timedelta -from decimal import Decimal, ROUND_DOWN +from decimal import Decimal, ROUND_DOWN, ROUND_UP from typing import Iterable from uuid import uuid4 @@ -25,6 +25,15 @@ def _round_step(value: float, step: float) -> float: return float(rounded * step_decimal) +def _round_step_up(value: float, step: float) -> float: + if step <= 0: + return value + value_decimal = Decimal(str(value)) + step_decimal = Decimal(str(step)) + rounded = (value_decimal / step_decimal).to_integral_value(rounding=ROUND_UP) + return float(rounded * step_decimal) + + class PaperBroker: def __init__(self, settings: Settings, storage: Storage): self.settings = settings @@ -133,13 +142,15 @@ class PaperBroker: ) -> Position | None: fill_price = self._buy_price(ticker) - minimum_budget = self._minimum_entry_budget(instrument) - notional = self._entry_budget(signal, ticker, minimum_notional=minimum_budget) - if notional < max(self.settings.min_position_usdt, minimum_budget): + minimum_budget = self._minimum_entry_budget(instrument, fill_price) + budget = self._entry_budget(signal, ticker, minimum_notional=minimum_budget) + if budget < max(self.settings.min_position_usdt, minimum_budget): self.storage.event(f"{ticker.symbol}: покупка пропущена, adaptive-лимит экспозиции исчерпан", "WARN") return None - notional = notional / (1 + self.settings.taker_fee_rate) + notional = budget / (1 + self.settings.taker_fee_rate) qty = _round_step(notional / fill_price, instrument.qty_step if instrument else 0) + if instrument: + qty = self._raise_qty_to_exchange_minimum(qty, fill_price, instrument, budget) if instrument and qty < instrument.min_order_qty: self.storage.event(f"{ticker.symbol}: количество ниже minOrderQty Bybit", "WARN") return None @@ -267,13 +278,44 @@ class PaperBroker: value = default return max(low, min(high, value)) - def _minimum_entry_budget(self, instrument: Instrument | None) -> float: + def _minimum_entry_budget(self, instrument: Instrument | None, fill_price: float | None = None) -> float: minimum = max(0.0, self.settings.min_position_usdt) - if instrument and instrument.min_notional_value > 0: - exchange_minimum = instrument.min_notional_value * (1 + self.settings.taker_fee_rate) * 1.002 + 0.01 - minimum = max(minimum, exchange_minimum) + if instrument: + exchange_notional = max(0.0, instrument.min_notional_value) + if fill_price and fill_price > 0: + minimum_qty = max(0.0, instrument.min_order_qty) + if exchange_notional > 0: + minimum_qty = max( + minimum_qty, + _round_step_up(exchange_notional / fill_price, instrument.qty_step), + ) + if minimum_qty > 0: + exchange_notional = max(exchange_notional, minimum_qty * fill_price) + if exchange_notional > 0: + exchange_minimum = exchange_notional * (1 + self.settings.taker_fee_rate) * 1.002 + 0.01 + minimum = max(minimum, exchange_minimum) return minimum + def _raise_qty_to_exchange_minimum( + self, + qty: float, + fill_price: float, + instrument: Instrument, + budget: float, + ) -> float: + minimum_qty = max(0.0, instrument.min_order_qty) + if instrument.min_notional_value > 0 and fill_price > 0: + minimum_qty = max( + minimum_qty, + _round_step_up(instrument.min_notional_value / fill_price, instrument.qty_step), + ) + if minimum_qty <= qty: + return qty + minimum_cost = minimum_qty * fill_price * (1 + self.settings.taker_fee_rate) + if minimum_cost <= budget + 1e-9: + return minimum_qty + return qty + def _entry_budget( self, signal: Signal, @@ -335,7 +377,8 @@ class LiveBroker(PaperBroker): instrument: Instrument | None, prices: dict[str, float], ) -> Position | None: - minimum_budget = self._minimum_entry_budget(instrument) + fill_price = self._buy_price(ticker) + minimum_budget = self._minimum_entry_budget(instrument, fill_price) requested_notional = min( max(self._signal_notional(signal), minimum_budget), self.settings.live_order_max_usdt, diff --git a/tests/test_execution.py b/tests/test_execution.py index 36fa210..fd402aa 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -129,6 +129,35 @@ def test_paper_broker_raises_small_signal_to_exchange_min_notional(make_settings assert position.notional_usdt <= settings.max_position_usdt +def test_paper_broker_rounds_small_order_up_to_exchange_qty_step(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + min_position_usdt=1, + max_position_usdt=20, + max_symbol_exposure_usdt=20, + max_total_exposure_usdt=80, + max_open_positions=20, + max_positions_per_symbol=20, + max_entries_per_minute=0, + ) + storage = Storage(settings.database_path) + broker = PaperBroker(settings, storage) + ticker = Ticker("HYPEUSDT", 39.6, 39.59, 39.61, 10_000_000, 100, 0) + instrument = Instrument("HYPEUSDT", "HYPE", "USDT", "Trading", 0.001, 0.01, 0.01, 1) + + position = broker.buy( + Signal("HYPEUSDT", "BUY", 0.8, "small torch edge", {"position_notional_usdt": 1.05}), + ticker, + instrument, + {"HYPEUSDT": 39.6}, + ) + + assert position is not None + assert position.qty == 0.03 + assert position.notional_usdt >= instrument.min_notional_value + assert position.notional_usdt <= settings.max_position_usdt + + def test_paper_broker_respects_adaptive_exposure_target(make_settings, tmp_path) -> None: settings = make_settings( tmp_path,