diff --git a/crypto_spot_bot/bot.py b/crypto_spot_bot/bot.py index c83504b..e24ea21 100644 --- a/crypto_spot_bot/bot.py +++ b/crypto_spot_bot/bot.py @@ -140,7 +140,7 @@ class CryptoSpotBot: symbol, "HOLD", 0.0, - "пауза после закрытия позиции", + "пауза между входами по паре", {"cooldown_remaining_seconds": cooldown_seconds - age}, ) ) @@ -150,6 +150,7 @@ class CryptoSpotBot: candles = self.market.candles.get(symbol, []) trend_candles = self.market.trend_candles.get(symbol, []) open_count = len(self.broker.positions_for_symbol(symbol)) + instrument = self.market.instruments.get(symbol) pattern = self.market.patterns.get(symbol, {}) forecast = self.market.forecasts.get(symbol, {}) learning = self.learner.adjustment_for(symbol, str(pattern.get("label", ""))).as_dict() @@ -159,6 +160,7 @@ class CryptoSpotBot: account["symbol"] = symbol account["symbol_exposure_usdt"] = self.broker.symbol_exposure(symbol) account["open_positions_for_symbol"] = open_count + account["exchange_min_entry_usdt"] = self.broker.minimum_entry_budget(instrument, ticker) if risk_guard.get("block_new_entries"): self.storage.insert_signal( Signal( @@ -224,12 +226,14 @@ class CryptoSpotBot: ) self.storage.insert_signal(signal) if signal.action == "BUY" and ticker is not None: - self.broker.buy( + position = self.broker.buy( signal, ticker, - self.market.instruments.get(symbol), + instrument, prices, ) + if position is not None: + self._entry_cooldown_until[symbol] = utc_now() @staticmethod def _risk_guard_for_symbol(risk_guard: dict, symbol: str) -> dict: diff --git a/crypto_spot_bot/execution.py b/crypto_spot_bot/execution.py index 6944ed3..621f950 100644 --- a/crypto_spot_bot/execution.py +++ b/crypto_spot_bot/execution.py @@ -278,6 +278,10 @@ class PaperBroker: value = default return max(low, min(high, value)) + def minimum_entry_budget(self, instrument: Instrument | None, ticker: Ticker | None = None) -> float: + fill_price = self._buy_price(ticker) if ticker is not None else None + return self._minimum_entry_budget(instrument, fill_price) + 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: diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py index 4225c40..f0d6b45 100644 --- a/crypto_spot_bot/strategy.py +++ b/crypto_spot_bot/strategy.py @@ -631,8 +631,11 @@ def _torch_forecast_entry_signal( if open_positions_for_symbol >= _dynamic_symbol_position_limit(settings): return Signal(symbol, "HOLD", 0.0, "torch_forecast: symbol position limit reached") + account_context = dict(account or {}) + account_context.setdefault("symbol", symbol) + account_context.setdefault("open_positions_for_symbol", open_positions_for_symbol) stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08) - sizing = _torch_forecast_position_sizing(settings, account, stop_loss_percent, forecast, symbol) + sizing = _torch_forecast_position_sizing(settings, account_context, stop_loss_percent, forecast, symbol) position_notional = float(sizing["notional_usdt"]) expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0) probability_up = _safe_float(forecast.get("probability_up"), 0.5) @@ -1218,7 +1221,28 @@ def _kelly_position( bankroll = settings.starting_balance_usdt target_notional = max(0.0, bankroll * effective_fraction) open_symbol_exposure = _account_symbol_exposure(account, symbol) - remaining_notional = max(0.0, target_notional - open_symbol_exposure) + raw_remaining_notional = max(0.0, target_notional - open_symbol_exposure) + exchange_min_entry = _account_exchange_min_entry(account, settings) + remaining_notional = raw_remaining_notional + effective_target_notional = target_notional + layer_mode = False + if ( + symbol + and target_notional > 0 + and raw_remaining_notional < exchange_min_entry + and exchange_min_entry > settings.min_position_usdt + 1e-9 + and _account_open_positions_for_symbol(account) > 0 + ): + room = min( + max(0.0, settings.max_position_usdt), + max(0.0, settings.max_symbol_exposure_usdt - open_symbol_exposure), + max(0.0, settings.max_total_exposure_usdt - _account_total_exposure(account)), + max(0.0, _safe_float((account or {}).get("cash"), settings.starting_balance_usdt) - settings.min_cash_reserve_usdt), + ) + if room >= exchange_min_entry: + remaining_notional = exchange_min_entry + effective_target_notional = open_symbol_exposure + exchange_min_entry + layer_mode = True return { "kelly_probability": round(probability, 4), "kelly_probability_source": probability_source, @@ -1231,9 +1255,13 @@ def _kelly_position( "kelly_effective_fraction": round(effective_fraction, 4), "kelly_bankroll_usdt": round(bankroll, 2), "kelly_target_notional_usdt": round(target_notional, 2), + "kelly_effective_target_notional_usdt": round(effective_target_notional, 2), "kelly_open_symbol_exposure_usdt": round(open_symbol_exposure, 2), + "kelly_raw_remaining_notional_usdt": round(raw_remaining_notional, 2), "kelly_remaining_notional_usdt": round(remaining_notional, 2), "kelly_notional_usdt": round(remaining_notional, 2), + "kelly_exchange_min_entry_usdt": round(exchange_min_entry, 2), + "kelly_layer_mode": layer_mode, } @@ -1251,6 +1279,28 @@ def _account_symbol_exposure(account: dict | None, symbol: str | None = None) -> return 0.0 +def _account_total_exposure(account: dict | None) -> float: + if not isinstance(account, dict): + return 0.0 + return max(0.0, _safe_float(account.get("exposure"), 0.0)) + + +def _account_open_positions_for_symbol(account: dict | None) -> int: + if not isinstance(account, dict): + return 0 + try: + return max(0, int(account.get("open_positions_for_symbol", 0))) + except (TypeError, ValueError): + return 0 + + +def _account_exchange_min_entry(account: dict | None, settings: Settings) -> float: + minimum = max(0.0, settings.min_position_usdt) + if not isinstance(account, dict): + return minimum + return max(minimum, _safe_float(account.get("exchange_min_entry_usdt"), minimum)) + + def _confidence_probability(final_score: float, min_signal_confidence: float) -> float: denominator = max(0.0001, 1.0 - min_signal_confidence) ratio = _clamp((final_score - min_signal_confidence) / denominator, 0.0, 1.0) diff --git a/tests/test_strategy.py b/tests/test_strategy.py index 769adb3..1208876 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -394,6 +394,60 @@ def test_torch_forecast_kelly_buys_only_remaining_symbol_allocation(make_setting assert filled.diagnostics["checks"]["risk_size_ok"] is False +def test_torch_forecast_kelly_allows_next_exchange_minimum_layer(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + strategy_mode="torch_forecast", + min_position_usdt=1, + max_position_usdt=8, + max_symbol_exposure_usdt=25, + max_total_exposure_usdt=75, + max_positions_per_symbol=6, + stop_loss_percent=0.04, + take_profit_percent=0.035, + kelly_sizing_enabled=True, + kelly_fraction=0.25, + kelly_max_fraction=0.20, + time_series_min_edge_percent=0.10, + time_series_min_probability_up=0.47, + time_series_min_confidence=0.4, + ) + strategy = SpotStrategy(settings) + ticker = Ticker("HYPEUSDT", 63.14, 63.13, 63.15, 10_000_000, 1000, 1.0) + + signal = strategy.entry_signal( + "HYPEUSDT", + [], + ticker, + open_positions_for_symbol=1, + forecast={ + "usable": True, + "model": "torch_gru", + "expected_return_percent": 0.2115, + "probability_up": 0.5163, + "skill": 0.0156, + "block_entry": False, + }, + account={ + "equity": 98.6, + "cash": 88.54, + "exposure": 10.07, + "symbol": "HYPEUSDT", + "symbol_exposure_usdt": 5.05, + "open_positions_for_symbol": 1, + "exchange_min_entry_usdt": 5.07, + }, + ) + + sizing = signal.diagnostics["position_sizing"] + assert signal.action == "BUY" + assert signal.diagnostics["checks"]["risk_size_ok"] is True + assert sizing["kelly_target_notional_usdt"] < sizing["kelly_open_symbol_exposure_usdt"] + assert sizing["kelly_raw_remaining_notional_usdt"] == 0.0 + assert sizing["kelly_layer_mode"] is True + assert signal.diagnostics["position_notional_usdt"] == 5.07 + + def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> None: settings = make_settings( tmp_path,