From 7bbb721da1fa5b2066334966365a916bd573b829 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 20 Jun 2026 22:22:19 +0300 Subject: [PATCH] Add fractional Kelly position sizing --- .env.example | 3 + README.md | 7 ++- crypto_spot_bot/bot.py | 5 +- crypto_spot_bot/config.py | 6 ++ crypto_spot_bot/dashboard.py | 4 ++ crypto_spot_bot/strategy.py | 109 +++++++++++++++++++++++++++++------ tests/conftest.py | 3 + tests/test_strategy.py | 31 ++++++++++ 8 files changed, 147 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index 81ed191..5c34c2f 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,9 @@ REBOUND_TRADING_ENABLED=true REBOUND_ENTRY_CONFIDENCE=0.58 REBOUND_MIN_PROBABILITY=0.58 REBOUND_MAX_POSITION_USDT=6 +KELLY_SIZING_ENABLED=true +KELLY_FRACTION=0.25 +KELLY_MAX_FRACTION=0.20 TIME_SERIES_FORECAST_ENABLED=true TIME_SERIES_MIN_CANDLES=120 TIME_SERIES_VALIDATION_WINDOW=30 diff --git a/README.md b/README.md index d462a56..969600f 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@ Spot-бот для демо-торговли криптовалютой на р ## Что реализовано - Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления. -- Автовыбор популярных USDT spot-пар по `turnover24h`. +- Фиксированный набор USDT spot-пар: `BTCUSDT`, `ETHUSDT`, `HYPEUSDT`, `SOLUSDT`, `LTCUSDT`, `XRPUSDT`. - Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop. - Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча. - Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`. - Анализ шаблонов рынка: трендовый откат, пробой вверх/вниз, ускоренное падение, боковик, перепроданность с разворотом и объемный всплеск. - Обучение на закрытых сделках: статистика PnL и win rate по символам и шаблонам входа корректирует уверенность новых входов в заданных пределах. - LLM Advisor выключен по умолчанию; стратегия, обучение, grid и rebound работают без запросов к Ollama. -- Динамический размер позиции: стратегия записывает в сигнал размер входа в пределах `MIN_POSITION_USDT`..`MAX_POSITION_USDT`, а брокер ограничивает суммарную экспозицию по паре через `MAX_SYMBOL_EXPOSURE_USDT`. +- Динамический размер позиции: стратегия считает вход через fractional Kelly по вероятности прогноза, stop/take и издержкам, затем ограничивает сумму через `MIN_POSITION_USDT`..`MAX_POSITION_USDT` и лимиты экспозиции. - Автоматический grid-режим: бот включает grid-входы на боковике, покупает только в нижней части диапазона и выключает grid при падающих/опасных режимах. - Вероятностный rebound-вход: после снижения бот отдельно оценивает стабилизацию, отскок от локального low, RSI, объем и рыночные ограничения; такой вход ограничен меньшим размером позиции. - Прогнозирование временных рядов: walk-forward выбор между `naive`, `drift`, `EWMA`, `AR(1)`, `AR(3)` и экспортированными PyTorch `LSTM/GRU`-моделями для ожидаемой доходности плюс EWMA/GARCH-like прогноз волатильности. Прогноз влияет и на новые покупки, и на раннюю продажу при ухудшении ожидаемого движения. @@ -136,6 +136,9 @@ REBOUND_TRADING_ENABLED=true REBOUND_ENTRY_CONFIDENCE=0.58 REBOUND_MIN_PROBABILITY=0.58 REBOUND_MAX_POSITION_USDT=6 +KELLY_SIZING_ENABLED=true +KELLY_FRACTION=0.25 +KELLY_MAX_FRACTION=0.20 TIME_SERIES_FORECAST_ENABLED=true TIME_SERIES_MIN_CANDLES=120 TIME_SERIES_VALIDATION_WINDOW=30 diff --git a/crypto_spot_bot/bot.py b/crypto_spot_bot/bot.py index 257f580..b1bee00 100644 --- a/crypto_spot_bot/bot.py +++ b/crypto_spot_bot/bot.py @@ -146,6 +146,7 @@ class CryptoSpotBot: forecast = self.market.forecasts.get(symbol, {}) learning = self.learner.adjustment_for(symbol, str(pattern.get("label", ""))).as_dict() learning["adaptive_rules"] = self._with_exposure_context(learning.get("adaptive_rules") or {}) + account = self.broker.account_state(prices) llm = {} if ( self.settings.llm_advisor_enabled @@ -162,10 +163,10 @@ class CryptoSpotBot: pattern=pattern, learning=learning, open_positions_for_symbol=open_count, - account=self.broker.account_state(prices), + account=account, ) ).as_dict() - signal = self.strategy.entry_signal(symbol, candles, ticker, open_count, pattern, learning, llm, forecast) + signal = self.strategy.entry_signal(symbol, candles, ticker, open_count, pattern, learning, llm, forecast, account) self.storage.insert_signal(signal) if signal.action == "BUY" and ticker is not None: self.broker.buy( diff --git a/crypto_spot_bot/config.py b/crypto_spot_bot/config.py index 6c5fe4e..e91b183 100644 --- a/crypto_spot_bot/config.py +++ b/crypto_spot_bot/config.py @@ -92,6 +92,9 @@ class Settings: rebound_entry_confidence: float rebound_min_probability: float rebound_max_position_usdt: float + kelly_sizing_enabled: bool + kelly_fraction: float + kelly_max_fraction: float time_series_forecast_enabled: bool time_series_min_candles: int time_series_validation_window: int @@ -212,6 +215,9 @@ def load_settings(env_file: str | Path | None = None) -> Settings: rebound_entry_confidence=_float_env("REBOUND_ENTRY_CONFIDENCE", 0.58), rebound_min_probability=_float_env("REBOUND_MIN_PROBABILITY", 0.58), rebound_max_position_usdt=_float_env("REBOUND_MAX_POSITION_USDT", 6.0), + kelly_sizing_enabled=_bool_env("KELLY_SIZING_ENABLED", True), + kelly_fraction=_float_env("KELLY_FRACTION", 0.25), + kelly_max_fraction=_float_env("KELLY_MAX_FRACTION", 0.20), time_series_forecast_enabled=_bool_env("TIME_SERIES_FORECAST_ENABLED", True), time_series_min_candles=_int_env("TIME_SERIES_MIN_CANDLES", 120), time_series_validation_window=_int_env("TIME_SERIES_VALIDATION_WINDOW", 30), diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py index 304d1a6..6ccc6ee 100644 --- a/crypto_spot_bot/dashboard.py +++ b/crypto_spot_bot/dashboard.py @@ -209,6 +209,9 @@ def _safe_config(settings: Settings) -> dict[str, Any]: "rebound_entry_confidence": settings.rebound_entry_confidence, "rebound_min_probability": settings.rebound_min_probability, "rebound_max_position_usdt": settings.rebound_max_position_usdt, + "kelly_sizing_enabled": settings.kelly_sizing_enabled, + "kelly_fraction": settings.kelly_fraction, + "kelly_max_fraction": settings.kelly_max_fraction, "time_series_forecast_enabled": settings.time_series_forecast_enabled, "time_series_min_candles": settings.time_series_min_candles, "time_series_validation_window": settings.time_series_validation_window, @@ -930,6 +933,7 @@ HTML = r""" ['Rebound-режим', yesNo(config.rebound_trading_enabled)], ['Rebound порог / вероятность', `${num(config.rebound_entry_confidence, 2)} / ${num(config.rebound_min_probability, 2)}`], ['Rebound макс. размер', money(config.rebound_max_position_usdt)], + ['Kelly размер', `${yesNo(config.kelly_sizing_enabled)} · ${num(config.kelly_fraction, 2)}x · max ${num((config.kelly_max_fraction || 0) * 100, 1)}%`], ['Прогноз временных рядов', yesNo(config.time_series_forecast_enabled)], ['Модельный горизонт', `${config.time_series_forecast_horizon} свечи`], ['Walk-forward окно', `${config.time_series_validation_window} свечей`], diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py index 5484c5a..8de7451 100644 --- a/crypto_spot_bot/strategy.py +++ b/crypto_spot_bot/strategy.py @@ -21,6 +21,7 @@ class SpotStrategy: learning: dict | None = None, llm: dict | None = None, forecast: dict | None = None, + account: dict | None = None, ) -> Signal: if ticker is None: return Signal(symbol, "HOLD", 0.0, "нет ticker-данных") @@ -134,15 +135,16 @@ class SpotStrategy: pattern_blocks_entry = negative_pattern and not ( rebound["active"] and rebound_entry_score >= entry_threshold ) - position_notional = _position_notional( + position_sizing = _position_sizing( settings=self.settings, final_score=final_score, grid_active=grid["active"], rebound_active=rebound["active"], - llm=llm, forecast=forecast, adaptive=adaptive, + account=account, ) + position_notional = float(position_sizing["notional_usdt"]) trade_mode = "GRID" if grid["active"] else "REBOUND" if rebound["active"] else "NORMAL" diagnostics = { @@ -163,6 +165,7 @@ class SpotStrategy: "falling_market": falling_market, "open_positions_for_symbol": open_positions_for_symbol, "position_notional_usdt": position_notional, + "position_sizing": position_sizing, "trade_mode": trade_mode, "base_entry_threshold": round(base_entry_threshold, 4), "adaptive_entry_threshold_adjustment": round(adaptive_entry_adjustment, 4), @@ -473,16 +476,16 @@ def _clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) -def _position_notional( +def _position_sizing( *, settings: Settings, final_score: float, grid_active: bool, rebound_active: bool, - llm: dict, forecast: dict | None = None, adaptive: dict | None = None, -) -> float: + account: dict | None = None, +) -> dict[str, float | bool | str]: low = max(0.0, settings.min_position_usdt) high = max(low, settings.max_position_usdt) if grid_active: @@ -491,28 +494,100 @@ def _position_notional( high = max(low, min(high, settings.rebound_max_position_usdt)) denominator = max(0.0001, 1.0 - settings.min_signal_confidence) confidence_ratio = _clamp((final_score - settings.min_signal_confidence) / denominator, 0.0, 1.0) - raw = low + (high - low) * confidence_ratio - risk = str(llm.get("risk_level", "medium")).lower() - if risk == "high": - raw *= 0.55 - elif risk == "low": - raw *= 1.10 + confidence_notional = low + (high - low) * confidence_ratio + risk_multiplier = _position_risk_multiplier(forecast, adaptive) + method = "confidence" + raw = confidence_notional + kelly = _kelly_position( + settings=settings, + final_score=final_score, + forecast=forecast or {}, + adaptive=adaptive or {}, + account=account, + ) + if settings.kelly_sizing_enabled: + method = "fractional_kelly" + raw = float(kelly["kelly_notional_usdt"]) + raw *= risk_multiplier + notional = round(_clamp(raw, low, high), 2) + return { + "method": method, + "enabled": bool(settings.kelly_sizing_enabled), + "notional_usdt": notional, + "confidence_notional_usdt": round(confidence_notional, 2), + "risk_multiplier": round(risk_multiplier, 4), + "low_cap_usdt": round(low, 2), + "high_cap_usdt": round(high, 2), + **kelly, + } + + +def _position_risk_multiplier(forecast: dict | None, adaptive: dict | None) -> float: + multiplier = 1.0 forecast = forecast or {} if forecast.get("usable"): probability_up = _safe_float(forecast.get("probability_up"), 0.5) volatility_percent = _safe_float(forecast.get("volatility_percent"), 0.0) if probability_up < 0.52: - raw *= 0.75 + multiplier *= 0.75 elif probability_up >= 0.60: - raw *= 1.08 + multiplier *= 1.08 if volatility_percent >= 0.8: - raw *= 0.70 + multiplier *= 0.70 risk_mode = str((adaptive or {}).get("risk_mode", "neutral")).lower() if risk_mode == "defensive": - raw *= 0.65 + multiplier *= 0.65 elif risk_mode == "expansion": - raw *= 1.10 - return round(_clamp(raw, low, high), 2) + multiplier *= 1.10 + return multiplier + + +def _kelly_position( + *, + settings: Settings, + final_score: float, + forecast: dict, + adaptive: dict, + account: dict | None, +) -> dict[str, float | bool | str]: + confidence_probability = _confidence_probability(final_score, settings.min_signal_confidence) + probability_source = "confidence" + probability = confidence_probability + if forecast.get("usable"): + probability = _safe_float(forecast.get("probability_up"), confidence_probability) + probability_source = "forecast" + probability = _clamp(probability, 0.0, 1.0) + + stop_loss = _adaptive_percent(adaptive, "stop_loss_percent", settings.stop_loss_percent, 0.003, 0.08) + take_profit = _adaptive_percent(adaptive, "take_profit_percent", settings.take_profit_percent, 0.003, 0.20) + round_trip_cost = max(0.0, 2.0 * (settings.taker_fee_rate + settings.slippage_rate)) + win_return = max(0.0, take_profit - round_trip_cost) + loss_return = max(0.0001, stop_loss + round_trip_cost) + reward_loss_ratio = win_return / loss_return if loss_return > 0 else 0.0 + full_kelly = probability - ((1.0 - probability) / reward_loss_ratio) if reward_loss_ratio > 0 else 0.0 + full_kelly = max(0.0, full_kelly) + fractional_kelly = full_kelly * _clamp(settings.kelly_fraction, 0.0, 1.0) + effective_fraction = _clamp(fractional_kelly, 0.0, _clamp(settings.kelly_max_fraction, 0.0, 1.0)) + bankroll = _safe_float((account or {}).get("equity"), settings.starting_balance_usdt) + if bankroll <= 0: + bankroll = settings.starting_balance_usdt + kelly_notional = max(0.0, bankroll * effective_fraction) + return { + "kelly_probability": round(probability, 4), + "kelly_probability_source": probability_source, + "kelly_reward_loss_ratio": round(reward_loss_ratio, 4), + "kelly_full_fraction": round(full_kelly, 4), + "kelly_fractional_fraction": round(fractional_kelly, 4), + "kelly_effective_fraction": round(effective_fraction, 4), + "kelly_bankroll_usdt": round(bankroll, 2), + "kelly_notional_usdt": round(kelly_notional, 2), + } + + +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) + return 0.50 + ratio * 0.18 def _grid_state( diff --git a/tests/conftest.py b/tests/conftest.py index a80834c..20aed3d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,6 +58,9 @@ def make_settings(): rebound_entry_confidence=0.58, rebound_min_probability=0.58, rebound_max_position_usdt=6.0, + kelly_sizing_enabled=True, + kelly_fraction=0.25, + kelly_max_fraction=0.20, time_series_forecast_enabled=True, time_series_min_candles=120, time_series_validation_window=30, diff --git a/tests/test_strategy.py b/tests/test_strategy.py index 1ccbc33..e9df7ec 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -198,6 +198,37 @@ def test_strategy_activates_grid_and_sets_position_size(make_settings, tmp_path) assert 1 <= signal.diagnostics["position_notional_usdt"] <= settings.grid_max_position_usdt +def test_strategy_uses_fractional_kelly_position_size(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, max_position_usdt=20, kelly_fraction=0.25, kelly_max_fraction=0.20) + strategy = SpotStrategy(settings) + ticker = Ticker( + symbol="BTCUSDT", + last_price=101, + bid=100.99, + ask=101.01, + turnover_24h=10_000_000, + volume_24h=1000, + change_24h=1.0, + ) + + signal = strategy.entry_signal( + "BTCUSDT", + _ready_candles(), + ticker, + open_positions_for_symbol=0, + forecast={"usable": True, "probability_up": 0.70, "volatility_percent": 0.2}, + account={"equity": 200.0}, + ) + + sizing = signal.diagnostics["position_sizing"] + assert signal.action == "BUY" + assert sizing["method"] == "fractional_kelly" + assert sizing["kelly_probability_source"] == "forecast" + assert sizing["kelly_bankroll_usdt"] == 200.0 + assert sizing["kelly_effective_fraction"] > 0 + assert signal.diagnostics["position_notional_usdt"] == settings.max_position_usdt + + def test_strategy_buys_probabilistic_rebound_after_stabilized_drop(make_settings, tmp_path) -> None: settings = make_settings(tmp_path, rebound_entry_confidence=0.58, rebound_min_probability=0.58) strategy = SpotStrategy(settings)