Fix exchange minimum sizing for buys
This commit is contained in:
@@ -54,7 +54,7 @@ RISK_GUARD_ENABLED=true
|
|||||||
RISK_RECENT_TRADE_WINDOW=20
|
RISK_RECENT_TRADE_WINDOW=20
|
||||||
RISK_MAX_CONSECUTIVE_LOSSES=4
|
RISK_MAX_CONSECUTIVE_LOSSES=4
|
||||||
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
|
RISK_MIN_RECENT_PROFIT_FACTOR=0.85
|
||||||
RISK_REDUCE_MULTIPLIER=0.50
|
RISK_REDUCE_MULTIPLIER=1.0
|
||||||
ATR_TRAILING_MULTIPLIER=2.2
|
ATR_TRAILING_MULTIPLIER=2.2
|
||||||
TREND_RSI_MIN=45
|
TREND_RSI_MIN=45
|
||||||
TREND_RSI_MAX=65
|
TREND_RSI_MAX=65
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from decimal import Decimal, ROUND_DOWN
|
from decimal import Decimal, ROUND_DOWN, ROUND_UP
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -25,6 +25,15 @@ def _round_step(value: float, step: float) -> float:
|
|||||||
return float(rounded * step_decimal)
|
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:
|
class PaperBroker:
|
||||||
def __init__(self, settings: Settings, storage: Storage):
|
def __init__(self, settings: Settings, storage: Storage):
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -133,13 +142,15 @@ class PaperBroker:
|
|||||||
) -> Position | None:
|
) -> Position | None:
|
||||||
|
|
||||||
fill_price = self._buy_price(ticker)
|
fill_price = self._buy_price(ticker)
|
||||||
minimum_budget = self._minimum_entry_budget(instrument)
|
minimum_budget = self._minimum_entry_budget(instrument, fill_price)
|
||||||
notional = self._entry_budget(signal, ticker, minimum_notional=minimum_budget)
|
budget = self._entry_budget(signal, ticker, minimum_notional=minimum_budget)
|
||||||
if notional < max(self.settings.min_position_usdt, minimum_budget):
|
if budget < max(self.settings.min_position_usdt, minimum_budget):
|
||||||
self.storage.event(f"{ticker.symbol}: покупка пропущена, adaptive-лимит экспозиции исчерпан", "WARN")
|
self.storage.event(f"{ticker.symbol}: покупка пропущена, adaptive-лимит экспозиции исчерпан", "WARN")
|
||||||
return None
|
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)
|
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:
|
if instrument and qty < instrument.min_order_qty:
|
||||||
self.storage.event(f"{ticker.symbol}: количество ниже minOrderQty Bybit", "WARN")
|
self.storage.event(f"{ticker.symbol}: количество ниже minOrderQty Bybit", "WARN")
|
||||||
return None
|
return None
|
||||||
@@ -267,13 +278,44 @@ class PaperBroker:
|
|||||||
value = default
|
value = default
|
||||||
return max(low, min(high, value))
|
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)
|
minimum = max(0.0, self.settings.min_position_usdt)
|
||||||
if instrument and instrument.min_notional_value > 0:
|
if instrument:
|
||||||
exchange_minimum = instrument.min_notional_value * (1 + self.settings.taker_fee_rate) * 1.002 + 0.01
|
exchange_notional = max(0.0, instrument.min_notional_value)
|
||||||
minimum = max(minimum, exchange_minimum)
|
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
|
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(
|
def _entry_budget(
|
||||||
self,
|
self,
|
||||||
signal: Signal,
|
signal: Signal,
|
||||||
@@ -335,7 +377,8 @@ class LiveBroker(PaperBroker):
|
|||||||
instrument: Instrument | None,
|
instrument: Instrument | None,
|
||||||
prices: dict[str, float],
|
prices: dict[str, float],
|
||||||
) -> Position | None:
|
) -> 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(
|
requested_notional = min(
|
||||||
max(self._signal_notional(signal), minimum_budget),
|
max(self._signal_notional(signal), minimum_budget),
|
||||||
self.settings.live_order_max_usdt,
|
self.settings.live_order_max_usdt,
|
||||||
|
|||||||
@@ -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
|
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:
|
def test_paper_broker_respects_adaptive_exposure_target(make_settings, tmp_path) -> None:
|
||||||
settings = make_settings(
|
settings = make_settings(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
|
|||||||
Reference in New Issue
Block a user