Add fractional Kelly position sizing

This commit is contained in:
Codex
2026-06-20 22:22:19 +03:00
parent ccf457481b
commit 7bbb721da1
8 changed files with 147 additions and 21 deletions
+3
View File
@@ -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
+5 -2
View File
@@ -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
+3 -2
View File
@@ -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(
+6
View File
@@ -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),
+4
View File
@@ -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} свечей`],
+92 -17
View File
@@ -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(
+3
View File
@@ -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,
+31
View File
@@ -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)