From 25651d7fa7d4f66e71cc8a421c554157e1d7814c Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 21 Jun 2026 08:16:03 +0300 Subject: [PATCH] Shift adaptive learning to growth sizing --- .env.example | 1 + README.md | 3 +- crypto_spot_bot/config.py | 2 + crypto_spot_bot/dashboard.py | 2 + crypto_spot_bot/learning.py | 81 ++++++++++++++++++++++++------------ crypto_spot_bot/strategy.py | 7 +--- tests/conftest.py | 1 + tests/test_learning.py | 55 ++++++++++++++++++++---- tests/test_strategy.py | 34 +++++++++++++++ 9 files changed, 144 insertions(+), 42 deletions(-) diff --git a/.env.example b/.env.example index 5c34c2f..c85a9a2 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,7 @@ LEARNING_ENABLED=true LEARNING_LOOKBACK_TRADES=120 LEARNING_MIN_SAMPLES=3 LEARNING_MAX_ADJUSTMENT=0.12 +LEARNING_MAX_POSITION_MULTIPLIER=1.6 MIN_POSITION_USDT=1 MAX_POSITION_USDT=20 MAX_SYMBOL_EXPOSURE_USDT=20 diff --git a/README.md b/README.md index 969600f..f47ba94 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Spot-бот для демо-торговли криптовалютой на р - Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча. - Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`. - Анализ шаблонов рынка: трендовый откат, пробой вверх/вниз, ускоренное падение, боковик, перепроданность с разворотом и объемный всплеск. -- Обучение на закрытых сделках: статистика PnL и win rate по символам и шаблонам входа корректирует уверенность новых входов в заданных пределах. +- Обучение на закрытых сделках: статистика PnL и win rate блокирует плохие пары/паттерны, а положительное матожидание масштабирует Kelly-размер входа. - LLM Advisor выключен по умолчанию; стратегия, обучение, grid и rebound работают без запросов к Ollama. - Динамический размер позиции: стратегия считает вход через fractional Kelly по вероятности прогноза, stop/take и издержкам, затем ограничивает сумму через `MIN_POSITION_USDT`..`MAX_POSITION_USDT` и лимиты экспозиции. - Автоматический grid-режим: бот включает grid-входы на боковике, покупает только в нижней части диапазона и выключает grid при падающих/опасных режимах. @@ -122,6 +122,7 @@ LEARNING_ENABLED=true LEARNING_LOOKBACK_TRADES=120 LEARNING_MIN_SAMPLES=3 LEARNING_MAX_ADJUSTMENT=0.12 +LEARNING_MAX_POSITION_MULTIPLIER=1.6 MIN_POSITION_USDT=1 MAX_POSITION_USDT=20 MAX_SYMBOL_EXPOSURE_USDT=20 diff --git a/crypto_spot_bot/config.py b/crypto_spot_bot/config.py index e91b183..abe5491 100644 --- a/crypto_spot_bot/config.py +++ b/crypto_spot_bot/config.py @@ -72,6 +72,7 @@ class Settings: learning_lookback_trades: int learning_min_samples: int learning_max_adjustment: float + learning_max_position_multiplier: float llm_advisor_enabled: bool ollama_base_url: str ollama_model: str @@ -195,6 +196,7 @@ def load_settings(env_file: str | Path | None = None) -> Settings: learning_lookback_trades=_int_env("LEARNING_LOOKBACK_TRADES", 120), learning_min_samples=_int_env("LEARNING_MIN_SAMPLES", 3), learning_max_adjustment=_float_env("LEARNING_MAX_ADJUSTMENT", 0.12), + learning_max_position_multiplier=_float_env("LEARNING_MAX_POSITION_MULTIPLIER", 1.6), llm_advisor_enabled=_bool_env("LLM_ADVISOR_ENABLED", False), ollama_base_url=os.getenv("OLLAMA_BASE_URL", "http://192.168.0.210:11434").rstrip("/"), ollama_model=os.getenv("OLLAMA_MODEL", "gemma4:e4b"), diff --git a/crypto_spot_bot/dashboard.py b/crypto_spot_bot/dashboard.py index 6ccc6ee..5ceab31 100644 --- a/crypto_spot_bot/dashboard.py +++ b/crypto_spot_bot/dashboard.py @@ -195,6 +195,7 @@ def _safe_config(settings: Settings) -> dict[str, Any]: "learning_lookback_trades": settings.learning_lookback_trades, "learning_min_samples": settings.learning_min_samples, "learning_max_adjustment": settings.learning_max_adjustment, + "learning_max_position_multiplier": settings.learning_max_position_multiplier, "min_position_usdt": settings.min_position_usdt, "max_position_usdt": settings.max_position_usdt, "max_symbol_exposure_usdt": settings.max_symbol_exposure_usdt, @@ -997,6 +998,7 @@ HTML = r""" ['Режим риска', rules.risk_mode || 'neutral'], ['Экспозиция / цель', `${money(rules.current_total_exposure_usdt || 0)} / ${money(rules.target_total_exposure_usdt || config.max_total_exposure_usdt || 0)}`], ['Порог входа', signedNum(Number(rules.entry_threshold_adjustment || 0), 4)], + ['Множитель Kelly', `${num(rules.effective_position_size_multiplier || rules.position_size_multiplier || 1, 2)}x`], ['Мин. удержание', `${rules.min_hold_seconds || config.min_hold_seconds || '-'} сек`], ['EMA-выход', exitRuleName(rules.ema_exit_mode)], ['RSI-выход', exitRuleName(rules.rsi_exit_mode)], diff --git a/crypto_spot_bot/learning.py b/crypto_spot_bot/learning.py index d91223c..94797c1 100644 --- a/crypto_spot_bot/learning.py +++ b/crypto_spot_bot/learning.py @@ -127,17 +127,27 @@ class TradeLearner: rules = dict(self.state.adaptive_rules or {}) symbol_adjustments = rules.get("symbol_threshold_adjustments") or {} pattern_adjustments = rules.get("pattern_threshold_adjustments") or {} + symbol_multipliers = rules.get("symbol_position_multipliers") or {} + pattern_multipliers = rules.get("pattern_position_multipliers") or {} symbol_adjustment = float(symbol_adjustments.get(symbol, 0.0) or 0.0) pattern_adjustment = float(pattern_adjustments.get(pattern, 0.0) or 0.0) base_adjustment = float(rules.get("entry_threshold_adjustment", 0.0) or 0.0) + base_multiplier = float(rules.get("position_size_multiplier", 1.0) or 1.0) + symbol_multiplier = float(symbol_multipliers.get(symbol, 1.0) or 1.0) + pattern_multiplier = float(pattern_multipliers.get(pattern, 1.0) or 1.0) + max_multiplier = max(1.0, self.settings.learning_max_position_multiplier) effective = _clamp( base_adjustment + symbol_adjustment + pattern_adjustment, -self.settings.learning_max_adjustment, self.settings.learning_max_adjustment, ) + effective_multiplier = _clamp(base_multiplier * symbol_multiplier * pattern_multiplier, 0.25, max_multiplier) rules["symbol_entry_threshold_adjustment"] = round(symbol_adjustment, 4) rules["pattern_entry_threshold_adjustment"] = round(pattern_adjustment, 4) rules["effective_entry_threshold_adjustment"] = round(effective, 4) + rules["symbol_position_size_multiplier"] = round(symbol_multiplier, 4) + rules["pattern_position_size_multiplier"] = round(pattern_multiplier, 4) + rules["effective_position_size_multiplier"] = round(effective_multiplier, 4) rules["symbol_blocked"] = symbol in set(rules.get("blocked_symbols") or []) rules["pattern_blocked"] = pattern in set(rules.get("blocked_patterns") or []) return rules @@ -210,42 +220,26 @@ def _build_adaptive_rules( win_rate = wins / sample_size if sample_size else 0.0 if total_net < 0: - threshold = _clamp( - (0.5 - win_rate) * 0.12 + min(abs(total_net) / max(sample_size, 1), 0.05), - 0.0, - settings.learning_max_adjustment, + rules["risk_mode"] = "selective" + rules["trade_permission"] = "selective_growth" + rules["reasons"].append( + "общая статистика обучения убыточна: глобальная защита капитала выключена, блокируются только плохие пары/паттерны" ) - rules["entry_threshold_adjustment"] = round(threshold, 4) - rules["risk_mode"] = "defensive" - rules["trade_permission"] = "capital_protection" - rules["reduce_exposure"] = True - rules["bad_market_entry_block"] = True - rules["target_total_exposure_usdt"] = round( - min(settings.max_total_exposure_usdt, max(settings.min_position_usdt, settings.starting_balance_usdt * 0.35)), - 2, - ) - rules["target_symbol_exposure_usdt"] = round( - min(settings.max_symbol_exposure_usdt, max(settings.min_position_usdt, settings.max_position_usdt * 0.5)), - 2, - ) - rules["min_hold_seconds"] = int(min(max(settings.min_hold_seconds * 2, 300), 900)) - if win_rate <= 0.25: - rules["stop_loss_percent"] = round(max(0.008, settings.stop_loss_percent * 0.85), 4) - rules["take_profit_percent"] = round( - max(settings.take_profit_percent, (_round_trip_cost_percent(settings) + 0.6) / 100), - 4, - ) - rules["reasons"].append("общая статистика обучения убыточна: вход ужесточен, риск снижен") elif win_rate >= 0.55: threshold = -min(settings.learning_max_adjustment / 2, (win_rate - 0.5) * 0.08) rules["entry_threshold_adjustment"] = round(threshold, 4) - rules["risk_mode"] = "expansion" - rules["reasons"].append("общая статистика обучения положительная: вход можно немного расширить") + rules["risk_mode"] = "growth" + rules["trade_permission"] = "positive_expectancy" + rules["position_size_multiplier"] = _global_position_multiplier(total_net, win_rate, sample_size, settings) + rules["reasons"].append("общая статистика обучения положительная: хорошие сетапы можно масштабировать по Kelly") for name, stat in symbol_stats.items(): adjustment = _threshold_adjustment_from_stat(stat, settings) if adjustment: rules["symbol_threshold_adjustments"][name] = adjustment + multiplier = _position_multiplier_from_stat(stat, settings) + if multiplier > 1.0: + rules["symbol_position_multipliers"][name] = multiplier if _should_block_stat(stat, settings): rules["blocked_symbols"].append(name) @@ -253,6 +247,9 @@ def _build_adaptive_rules( adjustment = _threshold_adjustment_from_stat(stat, settings) if adjustment: rules["pattern_threshold_adjustments"][name] = adjustment + multiplier = _position_multiplier_from_stat(stat, settings) + if multiplier > 1.0: + rules["pattern_position_multipliers"][name] = multiplier if _should_block_stat(stat, settings): rules["blocked_patterns"].append(name) @@ -286,6 +283,7 @@ def _neutral_rules(settings: Settings, reason: str | None = None) -> dict[str, A "win_rate": 0.0, "round_trip_cost_percent": round(_round_trip_cost_percent(settings), 4), "entry_threshold_adjustment": 0.0, + "position_size_multiplier": 1.0, "risk_mode": "neutral", "trade_permission": "normal", "allow_new_entries": True, @@ -305,6 +303,8 @@ def _neutral_rules(settings: Settings, reason: str | None = None) -> dict[str, A "trailing_stop_percent": settings.trailing_stop_percent, "symbol_threshold_adjustments": {}, "pattern_threshold_adjustments": {}, + "symbol_position_multipliers": {}, + "pattern_position_multipliers": {}, "blocked_symbols": [], "blocked_patterns": [], "exit_reason_stats": {}, @@ -389,6 +389,33 @@ def _threshold_adjustment_from_stat(stat: dict[str, Any], settings: Settings) -> return 0.0 +def _position_multiplier_from_stat(stat: dict[str, Any], settings: Settings) -> float: + sample_size = int(stat.get("sample_size", 0) or 0) + if sample_size < settings.learning_min_samples: + return 1.0 + net_pnl = float(stat.get("net_pnl", 0.0) or 0.0) + avg_pnl = float(stat.get("average_net_pnl", 0.0) or 0.0) + win_rate = float(stat.get("win_rate", 0.0) or 0.0) + if net_pnl <= 0 or avg_pnl <= 0 or win_rate < 0.50: + return 1.0 + cost_usdt = max(settings.min_position_usdt * _round_trip_cost_percent(settings) / 100, 0.01) + edge_score = _clamp(avg_pnl / cost_usdt, 0.0, 1.0) + win_score = _clamp((win_rate - 0.50) / 0.35, 0.0, 1.0) + raw = 1.0 + edge_score * 0.25 + win_score * 0.35 + return round(_clamp(raw, 1.0, max(1.0, settings.learning_max_position_multiplier)), 4) + + +def _global_position_multiplier(total_net: float, win_rate: float, sample_size: int, settings: Settings) -> float: + if sample_size < settings.learning_min_samples or total_net <= 0 or win_rate < 0.55: + return 1.0 + avg_pnl = total_net / max(sample_size, 1) + cost_usdt = max(settings.min_position_usdt * _round_trip_cost_percent(settings) / 100, 0.01) + edge_score = _clamp(avg_pnl / cost_usdt, 0.0, 1.0) + win_score = _clamp((win_rate - 0.55) / 0.30, 0.0, 1.0) + raw = 1.0 + edge_score * 0.15 + win_score * 0.20 + return round(_clamp(raw, 1.0, max(1.0, settings.learning_max_position_multiplier)), 4) + + def _is_bad_stat(stat: dict[str, Any], settings: Settings) -> bool: sample_size = int(stat.get("sample_size", 0) or 0) net_pnl = float(stat.get("net_pnl", 0.0) or 0.0) diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py index 8de7451..6f13bb1 100644 --- a/crypto_spot_bot/strategy.py +++ b/crypto_spot_bot/strategy.py @@ -534,11 +534,8 @@ def _position_risk_multiplier(forecast: dict | None, adaptive: dict | None) -> f multiplier *= 1.08 if volatility_percent >= 0.8: multiplier *= 0.70 - risk_mode = str((adaptive or {}).get("risk_mode", "neutral")).lower() - if risk_mode == "defensive": - multiplier *= 0.65 - elif risk_mode == "expansion": - multiplier *= 1.10 + learning_multiplier = _safe_float((adaptive or {}).get("effective_position_size_multiplier"), 1.0) + multiplier *= _clamp(learning_multiplier, 0.25, 2.0) return multiplier diff --git a/tests/conftest.py b/tests/conftest.py index 20aed3d..36fbb90 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,6 +38,7 @@ def make_settings(): learning_lookback_trades=120, learning_min_samples=3, learning_max_adjustment=0.12, + learning_max_position_multiplier=1.6, llm_advisor_enabled=False, ollama_base_url="http://192.168.0.210:11434", ollama_model="gemma4:e4b", diff --git a/tests/test_learning.py b/tests/test_learning.py index 7e09448..e560235 100644 --- a/tests/test_learning.py +++ b/tests/test_learning.py @@ -60,16 +60,18 @@ def test_trade_learner_builds_adaptive_rules_for_losing_ema_exit(make_settings, state = learner.refresh() rules = learner.rules_for("BTCUSDT", "нейтрально") - assert state.adaptive_rules["risk_mode"] == "defensive" + assert state.adaptive_rules["risk_mode"] == "selective" + assert state.adaptive_rules["trade_permission"] == "selective_growth" + assert state.adaptive_rules["reduce_exposure"] is False assert rules["ema_exit_mode"] == "profit_only" assert rules["effective_entry_threshold_adjustment"] > 0 - assert rules["min_hold_seconds"] > settings.min_hold_seconds + assert rules["min_hold_seconds"] == settings.min_hold_seconds -def test_trade_learner_enters_capital_protection_and_validates_rules(make_settings, tmp_path) -> None: +def test_trade_learner_blocks_only_bad_symbol_pattern(make_settings, tmp_path) -> None: settings = make_settings(tmp_path, learning_min_samples=3, starting_balance_usdt=100, max_total_exposure_usdt=80) storage = Storage(settings.database_path) - for value in (-0.12, -0.10, -0.08): + for _ in range(9): storage.insert_trade( Trade( id=None, @@ -78,7 +80,7 @@ def test_trade_learner_enters_capital_protection_and_validates_rules(make_settin qty=1, entry_price=100, exit_price=99, - net_pnl=value, + net_pnl=-0.10, reason="краткосрочный тренд ослаб ниже EMA50", entry_pattern="нейтрально", entry_confidence=0.7, @@ -91,9 +93,44 @@ def test_trade_learner_enters_capital_protection_and_validates_rules(make_settin state = learner.refresh() rules = state.adaptive_rules - assert rules["trade_permission"] == "capital_protection" - assert rules["reduce_exposure"] is True - assert rules["bad_market_entry_block"] is True - assert rules["target_total_exposure_usdt"] == 35.0 + assert rules["trade_permission"] == "selective_growth" + assert rules["reduce_exposure"] is False + assert rules["bad_market_entry_block"] is False + assert rules["target_total_exposure_usdt"] == settings.max_total_exposure_usdt + assert rules["blocked_symbols"] == ["ETHUSDT"] + assert rules["blocked_patterns"] == ["нейтрально"] assert rules["validation"]["status"] == "accepted" assert rules["validation"]["avoided_loss_usdt"] > 0 + + +def test_trade_learner_scales_positive_expectancy_setups(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, learning_min_samples=3, learning_max_position_multiplier=1.5) + storage = Storage(settings.database_path) + for _ in range(4): + storage.insert_trade( + Trade( + id=None, + symbol="SOLUSDT", + side="SELL", + qty=1, + entry_price=100, + exit_price=101, + net_pnl=0.08, + reason="test", + entry_pattern="пробой вверх", + entry_confidence=0.8, + opened_at=utc_now(), + closed_at=utc_now(), + ) + ) + + learner = TradeLearner(settings, storage) + state = learner.refresh() + rules = learner.rules_for("SOLUSDT", "пробой вверх") + + assert state.adaptive_rules["trade_permission"] == "positive_expectancy" + assert state.adaptive_rules["risk_mode"] == "growth" + assert rules["effective_entry_threshold_adjustment"] < 0 + assert rules["symbol_position_size_multiplier"] > 1.0 + assert rules["pattern_position_size_multiplier"] > 1.0 + assert 1.0 < rules["effective_position_size_multiplier"] <= settings.learning_max_position_multiplier diff --git a/tests/test_strategy.py b/tests/test_strategy.py index e9df7ec..8564428 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -229,6 +229,40 @@ def test_strategy_uses_fractional_kelly_position_size(make_settings, tmp_path) - assert signal.diagnostics["position_notional_usdt"] == settings.max_position_usdt +def test_strategy_scales_kelly_with_positive_learning_multiplier(make_settings, tmp_path) -> None: + settings = make_settings(tmp_path, max_position_usdt=50, 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, + ) + common = dict( + symbol="BTCUSDT", + candles=_ready_candles(), + ticker=ticker, + open_positions_for_symbol=0, + forecast={"usable": True, "probability_up": 0.62, "volatility_percent": 0.2}, + account={"equity": 200.0}, + ) + + neutral = strategy.entry_signal(**common) + scaled = strategy.entry_signal( + **common, + learning={"adaptive_rules": {"effective_position_size_multiplier": 1.5}}, + ) + + assert neutral.action == "BUY" + assert scaled.action == "BUY" + assert scaled.diagnostics["position_sizing"]["risk_multiplier"] > neutral.diagnostics["position_sizing"]["risk_multiplier"] + assert scaled.diagnostics["position_notional_usdt"] > neutral.diagnostics["position_notional_usdt"] + assert scaled.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)