Shift adaptive learning to growth sizing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)],
|
||||
|
||||
+54
-27
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+46
-9
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user