From 61a063cda74d695a388d29fd301f5122ad0694d7 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 26 Jun 2026 20:20:23 +0300 Subject: [PATCH] Use Kelly allocation for Torch position scaling --- .env | 8 +- .env.example | 12 +-- README.md | 16 ++-- crypto_spot_bot/bot.py | 3 + crypto_spot_bot/config.py | 15 +++- crypto_spot_bot/execution.py | 14 +++- crypto_spot_bot/market_data.py | 15 +++- crypto_spot_bot/strategy.py | 86 +++++++++++++++----- tests/test_execution.py | 29 ++++++- tests/test_strategy.py | 95 +++++++++++++++++++---- tools/install_windows_torch_retrainer.ps1 | 2 +- tools/run_retrain_until_replay8.ps1 | 6 +- 12 files changed, 239 insertions(+), 62 deletions(-) diff --git a/.env b/.env index 3db0baa..1efae6c 100644 --- a/.env +++ b/.env @@ -33,11 +33,11 @@ LEARNING_MIN_SAMPLES=3 LEARNING_MAX_ADJUSTMENT=0.12 LEARNING_MAX_POSITION_MULTIPLIER=1.6 MIN_POSITION_USDT=1 -MAX_POSITION_USDT=25 +MAX_POSITION_USDT=8 MAX_SYMBOL_EXPOSURE_USDT=25 MAX_TOTAL_EXPOSURE_USDT=75 -MAX_OPEN_POSITIONS=4 -MAX_POSITIONS_PER_SYMBOL=1 +MAX_OPEN_POSITIONS=24 +MAX_POSITIONS_PER_SYMBOL=6 GRID_TRADING_ENABLED=false GRID_ENTRY_CONFIDENCE=0.58 GRID_BUY_ZONE=0.45 @@ -46,7 +46,7 @@ REBOUND_TRADING_ENABLED=true REBOUND_ENTRY_CONFIDENCE=0.55 REBOUND_MIN_PROBABILITY=0.55 REBOUND_MAX_POSITION_USDT=6 -KELLY_SIZING_ENABLED=false +KELLY_SIZING_ENABLED=true KELLY_FRACTION=0.25 KELLY_MAX_FRACTION=0.20 RISK_PER_TRADE_PERCENT=0.01 diff --git a/.env.example b/.env.example index 695cbeb..467adcb 100644 --- a/.env.example +++ b/.env.example @@ -33,11 +33,11 @@ LEARNING_MIN_SAMPLES=3 LEARNING_MAX_ADJUSTMENT=0.12 LEARNING_MAX_POSITION_MULTIPLIER=1.6 MIN_POSITION_USDT=1 -MAX_POSITION_USDT=25 +MAX_POSITION_USDT=8 MAX_SYMBOL_EXPOSURE_USDT=25 MAX_TOTAL_EXPOSURE_USDT=75 -MAX_OPEN_POSITIONS=4 -MAX_POSITIONS_PER_SYMBOL=1 +MAX_OPEN_POSITIONS=24 +MAX_POSITIONS_PER_SYMBOL=6 GRID_TRADING_ENABLED=false GRID_ENTRY_CONFIDENCE=0.58 GRID_BUY_ZONE=0.45 @@ -46,7 +46,7 @@ REBOUND_TRADING_ENABLED=true REBOUND_ENTRY_CONFIDENCE=0.55 REBOUND_MIN_PROBABILITY=0.55 REBOUND_MAX_POSITION_USDT=6 -KELLY_SIZING_ENABLED=false +KELLY_SIZING_ENABLED=true KELLY_FRACTION=0.25 KELLY_MAX_FRACTION=0.20 RISK_PER_TRADE_PERCENT=0.01 @@ -61,8 +61,8 @@ TREND_RSI_MAX=65 TIME_SERIES_FORECAST_ENABLED=true TIME_SERIES_MIN_CANDLES=120 TIME_SERIES_FORECAST_HORIZON=3 -TIME_SERIES_MIN_EDGE_PERCENT=0.08 -TIME_SERIES_MIN_PROBABILITY_UP=0.58 +TIME_SERIES_MIN_EDGE_PERCENT=0.10 +TIME_SERIES_MIN_PROBABILITY_UP=0.47 TIME_SERIES_MIN_CONFIDENCE=0.4 TIME_SERIES_MAX_ADJUSTMENT=0.08 TIME_SERIES_LSTM_ENABLED=true diff --git a/README.md b/README.md index 6d0a3bf..44e3ba7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Spot-бот для демо-торговли криптовалютой на р ## Что реализовано - Реальные market data Bybit Spot: REST bootstrap и WebSocket-обновления. -- Фиксированный набор USDT spot-пар для основной стратегии: `BTCUSDT`, `ETHUSDT`, `SOLUSDT`, `LTCUSDT`. +- Фиксированный набор 12 USDT spot-пар для основной стратегии: `BTCUSDT`, `ETHUSDT`, `HYPEUSDT`, `SOLUSDT`, `XRPUSDT`, `XPLUSDT`, `WLDUSDT`, `MNTUSDT`, `HUSDT`, `XAUTUSDT`, `IPUSDT`, `AAVEUSDT`. - Paper trading с учетом cash, комиссий, проскальзывания, stop-loss, take-profit и trailing stop. - Spot-only логика: покупка базовой монеты за USDT и продажа обратно, без short и без плеча. - Live spot-ордеры явно отправляются без плеча: `category=spot`, `isLeverage=0`. @@ -88,7 +88,7 @@ powershell -ExecutionPolicy Bypass -File tools\run_torch_retrain.ps1 powershell -ExecutionPolicy Bypass -File tools\install_windows_torch_retrainer.ps1 ``` -По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 3000` на парах `BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_HORIZON`, `TORCH_RETRAIN_HORIZONS`, `TORCH_RETRAIN_CONTEXT_SYMBOLS`, `TORCH_RETRAIN_FEATURES`, `TORCH_RETRAIN_SEED`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`. +По умолчанию Windows-расписание переобучает PyTorch `LSTM/GRU` каждые 6 часов с `--limit 3000` на 12 spot-парах из `SYMBOLS`. Параметры можно переопределить через env: `TORCH_RETRAIN_SYMBOLS`, `TORCH_RETRAIN_LIMIT`, `TORCH_RETRAIN_LOOKBACKS`, `TORCH_RETRAIN_ARCHITECTURES`, `TORCH_RETRAIN_HIDDEN_SIZES`, `TORCH_RETRAIN_LAYERS`, `TORCH_RETRAIN_DROPOUTS`, `TORCH_RETRAIN_HORIZON`, `TORCH_RETRAIN_HORIZONS`, `TORCH_RETRAIN_CONTEXT_SYMBOLS`, `TORCH_RETRAIN_FEATURES`, `TORCH_RETRAIN_SEED`, `TORCH_RETRAIN_EPOCHS`, `TORCH_RETRAIN_PATIENCE`, `TORCH_RETRAIN_INTERVAL`, `TORCH_RETRAIN_ENV`. Если retrain запускается с `-DeployToPi`, после успешного guard он синхронизирует `runtime/lstm_forecaster.json`, `runtime/torch_retrain_guard.json` и `runtime/torch_threshold_calibration.json` на Raspberry Pi через SSH-ключ и перезапускает сервис `tradebot`. Отдельный запуск sync: @@ -137,11 +137,11 @@ LEARNING_MIN_SAMPLES=3 LEARNING_MAX_ADJUSTMENT=0.12 LEARNING_MAX_POSITION_MULTIPLIER=1.6 MIN_POSITION_USDT=1 -MAX_POSITION_USDT=25 +MAX_POSITION_USDT=8 MAX_SYMBOL_EXPOSURE_USDT=25 MAX_TOTAL_EXPOSURE_USDT=75 -MAX_OPEN_POSITIONS=4 -MAX_POSITIONS_PER_SYMBOL=1 +MAX_OPEN_POSITIONS=24 +MAX_POSITIONS_PER_SYMBOL=6 GRID_TRADING_ENABLED=false GRID_ENTRY_CONFIDENCE=0.58 GRID_BUY_ZONE=0.45 @@ -150,7 +150,7 @@ REBOUND_TRADING_ENABLED=true REBOUND_ENTRY_CONFIDENCE=0.55 REBOUND_MIN_PROBABILITY=0.55 REBOUND_MAX_POSITION_USDT=6 -KELLY_SIZING_ENABLED=false +KELLY_SIZING_ENABLED=true KELLY_FRACTION=0.25 KELLY_MAX_FRACTION=0.20 RISK_PER_TRADE_PERCENT=0.01 @@ -165,8 +165,8 @@ TREND_RSI_MAX=65 TIME_SERIES_FORECAST_ENABLED=true TIME_SERIES_MIN_CANDLES=120 TIME_SERIES_FORECAST_HORIZON=3 -TIME_SERIES_MIN_EDGE_PERCENT=0.08 -TIME_SERIES_MIN_PROBABILITY_UP=0.58 +TIME_SERIES_MIN_EDGE_PERCENT=0.10 +TIME_SERIES_MIN_PROBABILITY_UP=0.47 TIME_SERIES_MIN_CONFIDENCE=0.4 TIME_SERIES_MAX_ADJUSTMENT=0.08 TIME_SERIES_LSTM_ENABLED=true diff --git a/crypto_spot_bot/bot.py b/crypto_spot_bot/bot.py index 1e8b1e0..c83504b 100644 --- a/crypto_spot_bot/bot.py +++ b/crypto_spot_bot/bot.py @@ -156,6 +156,9 @@ class CryptoSpotBot: learning["adaptive_rules"] = self._with_exposure_context(learning.get("adaptive_rules") or {}) account = self.broker.account_state(prices) account["risk_guard"] = risk_guard + account["symbol"] = symbol + account["symbol_exposure_usdt"] = self.broker.symbol_exposure(symbol) + account["open_positions_for_symbol"] = open_count if risk_guard.get("block_new_entries"): self.storage.insert_signal( Signal( diff --git a/crypto_spot_bot/config.py b/crypto_spot_bot/config.py index 0c6819e..764fd96 100644 --- a/crypto_spot_bot/config.py +++ b/crypto_spot_bot/config.py @@ -5,7 +5,20 @@ from dataclasses import dataclass from pathlib import Path -FIXED_SPOT_SYMBOLS = ("BTCUSDT", "ETHUSDT", "SOLUSDT", "LTCUSDT") +FIXED_SPOT_SYMBOLS = ( + "BTCUSDT", + "ETHUSDT", + "HYPEUSDT", + "SOLUSDT", + "XRPUSDT", + "XPLUSDT", + "WLDUSDT", + "MNTUSDT", + "HUSDT", + "XAUTUSDT", + "IPUSDT", + "AAVEUSDT", +) STRATEGY_MODES = {"legacy", "trend_macd", "torch_forecast"} diff --git a/crypto_spot_bot/execution.py b/crypto_spot_bot/execution.py index e09a813..a4634b2 100644 --- a/crypto_spot_bot/execution.py +++ b/crypto_spot_bot/execution.py @@ -94,10 +94,7 @@ class PaperBroker: return False, "достигнут общий лимит открытых позиций" if self.settings.strategy_mode == "trend_macd" and len(self.positions_for_symbol(symbol)) >= 1: return False, "DCA/усреднение отключено: позиция по паре уже открыта" - dynamic_pair_limit = max( - self.settings.max_positions_per_symbol, - int(self.settings.max_symbol_exposure_usdt // max(self.settings.min_position_usdt, 0.01)), - ) + dynamic_pair_limit = _symbol_position_limit(self.settings) if len(self.positions_for_symbol(symbol)) >= dynamic_pair_limit: return False, "достигнут лимит позиций по паре" requested = requested_notional if requested_notional is not None else self.settings.min_position_usdt @@ -384,3 +381,12 @@ class LiveBroker(PaperBroker): def prices_from_tickers(tickers: Iterable[Ticker]) -> dict[str, float]: return {ticker.symbol: ticker.last_price for ticker in tickers} + + +def _symbol_position_limit(settings: Settings) -> int: + configured_limit = max(1, settings.max_positions_per_symbol) + exposure_based_limit = max( + 1, + int(settings.max_symbol_exposure_usdt // max(settings.min_position_usdt, 0.01)), + ) + return min(configured_limit, exposure_based_limit) diff --git a/crypto_spot_bot/market_data.py b/crypto_spot_bot/market_data.py index fa365f7..62c8b11 100644 --- a/crypto_spot_bot/market_data.py +++ b/crypto_spot_bot/market_data.py @@ -16,7 +16,20 @@ from crypto_spot_bot.models import Candle, Ticker, utc_now from crypto_spot_bot.storage import Storage -POPULAR_FALLBACK = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "LTCUSDT"] +POPULAR_FALLBACK = [ + "BTCUSDT", + "ETHUSDT", + "HYPEUSDT", + "SOLUSDT", + "XRPUSDT", + "XPLUSDT", + "WLDUSDT", + "MNTUSDT", + "HUSDT", + "XAUTUSDT", + "IPUSDT", + "AAVEUSDT", +] def _float(value: Any, default: float = 0.0) -> float: diff --git a/crypto_spot_bot/strategy.py b/crypto_spot_bot/strategy.py index 79f4a25..4225c40 100644 --- a/crypto_spot_bot/strategy.py +++ b/crypto_spot_bot/strategy.py @@ -632,7 +632,7 @@ def _torch_forecast_entry_signal( return Signal(symbol, "HOLD", 0.0, "torch_forecast: symbol position limit reached") stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08) - sizing = _torch_forecast_position_sizing(settings, account, stop_loss_percent, forecast) + sizing = _torch_forecast_position_sizing(settings, account, 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) @@ -721,6 +721,8 @@ def _torch_forecast_entry_signal( "rebound_probability": rebound.get("probability", 0.0), } edge_mode = "rebound_fallback" if fallback_rebound_entry_ok else "rebound" + risk_size_ok = position_notional >= settings.min_position_usdt + rebound_entry_sized_ok = rebound_entry_ok and risk_size_ok checks = { "torch_model_ok": model_ok, "quality_gate_ok": quality_gate_ok, @@ -732,7 +734,7 @@ def _torch_forecast_entry_signal( "confidence_ok": confidence >= settings.time_series_min_confidence, "spread_ok": spread_ok, "liquidity_ok": liquidity_ok, - "risk_size_ok": position_notional >= settings.min_position_usdt, + "risk_size_ok": risk_size_ok, } diagnostics = { "strategy_mode": "torch_forecast", @@ -756,6 +758,7 @@ def _torch_forecast_entry_signal( "model_rebound_entry_ok": model_rebound_entry_ok, "fallback_rebound_entry_ok": fallback_rebound_entry_ok, "rebound_entry_ok": rebound_entry_ok, + "rebound_entry_sized_ok": rebound_entry_sized_ok, "min_confidence": settings.time_series_min_confidence, "skill": skill, "quality_gate": forecast.get("quality_gate", {}), @@ -769,8 +772,8 @@ def _torch_forecast_entry_signal( "llm": {}, } base_entry_ok = all(checks.values()) - if base_entry_ok or rebound_entry_ok: - buy_confidence = max(confidence, float(rebound.get("probability", 0.0) or 0.0)) if rebound_entry_ok else confidence + if base_entry_ok or rebound_entry_sized_ok: + buy_confidence = max(confidence, float(rebound.get("probability", 0.0) or 0.0)) if rebound_entry_sized_ok else confidence entry_path = edge_mode if rebound_entry_ok and not base_entry_ok else edge_mode diagnostics["entry_path"] = entry_path if fallback_rebound_entry_ok and not base_entry_ok: @@ -961,8 +964,12 @@ def _torch_min_probability(settings: Settings) -> float: def _dynamic_symbol_position_limit(settings: Settings) -> int: - exposure_based_limit = int(settings.max_symbol_exposure_usdt // max(settings.min_position_usdt, 0.01)) - return max(1, settings.max_positions_per_symbol, exposure_based_limit) + configured_limit = max(1, settings.max_positions_per_symbol) + exposure_based_limit = max( + 1, + int(settings.max_symbol_exposure_usdt // max(settings.min_position_usdt, 0.01)), + ) + return min(configured_limit, exposure_based_limit) def _torch_forecast_confidence(settings: Settings, forecast: dict) -> float: @@ -982,30 +989,43 @@ def _torch_forecast_position_sizing( account: dict | None, stop_loss_percent: float, forecast: dict, + symbol: str | None = None, ) -> dict[str, float | str]: base = _trend_position_sizing(settings, account, stop_loss_percent) base_notional = float(base["notional_usdt"]) - if base_notional <= 0: + kelly = _kelly_position( + settings=settings, + final_score=_torch_forecast_confidence(settings, forecast), + forecast=forecast, + adaptive={}, + account=account, + symbol=symbol, + ) + expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0)) + probability_up = _safe_float(forecast.get("probability_up"), 0.5) + skill = max(0.0, _safe_float(forecast.get("skill"), 0.0)) + min_edge = max(0.01, settings.time_series_min_edge_percent) + edge_multiplier = _clamp(expected_return / max(min_edge * 3.0, 0.01), 0.25, 1.15) + probability_multiplier = _clamp(0.75 + (probability_up - 0.55) * 3.0, 0.50, 1.20) + skill_multiplier = _clamp(0.85 + skill * 0.60, 0.60, 1.15) + if settings.kelly_sizing_enabled: + raw = float(kelly["kelly_remaining_notional_usdt"]) * _risk_guard_multiplier(account) + notional = 0.0 if raw < settings.min_position_usdt else min(raw, settings.max_position_usdt) + elif base_notional <= 0: notional = 0.0 - edge_multiplier = probability_multiplier = skill_multiplier = 0.0 else: - expected_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0)) - probability_up = _safe_float(forecast.get("probability_up"), 0.5) - skill = max(0.0, _safe_float(forecast.get("skill"), 0.0)) - min_edge = max(0.01, settings.time_series_min_edge_percent) - edge_multiplier = _clamp(expected_return / max(min_edge * 3.0, 0.01), 0.25, 1.15) - probability_multiplier = _clamp(0.75 + (probability_up - 0.55) * 3.0, 0.50, 1.20) - skill_multiplier = _clamp(0.85 + skill * 0.60, 0.60, 1.15) raw = base_notional * edge_multiplier * probability_multiplier * skill_multiplier notional = 0.0 if raw < settings.min_position_usdt else min(raw, settings.max_position_usdt) return { **base, - "method": "torch_forecast_risk", + "method": "torch_forecast_fractional_kelly" if settings.kelly_sizing_enabled else "torch_forecast_risk", + "enabled": bool(settings.kelly_sizing_enabled), "notional_usdt": round(notional, 2), "base_notional_usdt": base["notional_usdt"], "torch_edge_multiplier": round(edge_multiplier, 4), "torch_probability_multiplier": round(probability_multiplier, 4), "torch_skill_multiplier": round(skill_multiplier, 4), + **kelly, } @@ -1168,6 +1188,7 @@ def _kelly_position( forecast: dict, adaptive: dict, account: dict | None, + symbol: str | None = None, ) -> dict[str, float | bool | str]: confidence_probability = _confidence_probability(final_score, settings.min_signal_confidence) probability_source = "confidence" @@ -1180,8 +1201,13 @@ def _kelly_position( 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) + base_win_return = max(0.0, take_profit - round_trip_cost) loss_return = max(0.0001, stop_loss + round_trip_cost) + expected_net_return = max(0.0, _safe_float(forecast.get("expected_return_percent"), 0.0) / 100.0) + implied_win_return = 0.0 + if probability > 0: + implied_win_return = max(0.0, (expected_net_return + (1.0 - probability) * loss_return) / probability) + win_return = max(base_win_return, implied_win_return) 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) @@ -1190,19 +1216,41 @@ def _kelly_position( 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) + 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) return { "kelly_probability": round(probability, 4), "kelly_probability_source": probability_source, "kelly_reward_loss_ratio": round(reward_loss_ratio, 4), + "kelly_win_return_percent": round(win_return * 100.0, 4), + "kelly_loss_return_percent": round(loss_return * 100.0, 4), + "kelly_expected_net_percent": round(expected_net_return * 100.0, 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), + "kelly_target_notional_usdt": round(target_notional, 2), + "kelly_open_symbol_exposure_usdt": round(open_symbol_exposure, 2), + "kelly_remaining_notional_usdt": round(remaining_notional, 2), + "kelly_notional_usdt": round(remaining_notional, 2), } +def _account_symbol_exposure(account: dict | None, symbol: str | None = None) -> float: + if not isinstance(account, dict): + return 0.0 + direct = _safe_float(account.get("symbol_exposure_usdt"), -1.0) + if direct >= 0: + return max(0.0, direct) + if not symbol: + symbol = str(account.get("symbol", "") or "") + exposures = account.get("symbol_exposures") + if isinstance(exposures, dict) and symbol: + return max(0.0, _safe_float(exposures.get(symbol), 0.0)) + return 0.0 + + 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_execution.py b/tests/test_execution.py index 2163fdf..36fa210 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -60,7 +60,7 @@ def test_paper_broker_uses_signal_notional_and_pair_exposure(make_settings, tmp_ max_symbol_exposure_usdt=6, max_total_exposure_usdt=50, max_open_positions=20, - max_positions_per_symbol=1, + max_positions_per_symbol=6, max_entries_per_minute=0, ) storage = Storage(settings.database_path) @@ -218,6 +218,33 @@ def test_torch_forecast_broker_allows_dynamic_entries_until_total_limit(make_set assert len(broker.open_positions()) == 3 +def test_torch_forecast_broker_respects_configured_symbol_position_limit(make_settings, tmp_path) -> None: + settings = make_settings( + tmp_path, + strategy_mode="torch_forecast", + min_position_usdt=1, + max_position_usdt=20, + max_symbol_exposure_usdt=20, + max_total_exposure_usdt=80, + max_open_positions=20, + max_positions_per_symbol=2, + max_entries_per_minute=0, + ) + storage = Storage(settings.database_path) + broker = PaperBroker(settings, storage) + ticker = Ticker("BTCUSDT", 100, 99.9, 100.1, 10_000_000, 100, 0) + instrument = Instrument("BTCUSDT", "BTC", "USDT", "Trading", 0.01, 0.000001, 0.000001, 1) + + first = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "first", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100}) + second = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "second", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100}) + third = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "third", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100}) + + assert first is not None + assert second is not None + assert third is None + assert len(broker.open_positions()) == 2 + + def test_trend_macd_closes_old_paper_positions_outside_symbol_universe(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, diff --git a/tests/test_strategy.py b/tests/test_strategy.py index f89cf89..769adb3 100644 --- a/tests/test_strategy.py +++ b/tests/test_strategy.py @@ -263,7 +263,8 @@ def test_torch_forecast_buys_only_from_positive_torch_edge(make_settings, tmp_pa assert signal.action == "BUY" assert signal.diagnostics["strategy_mode"] == "torch_forecast" assert signal.diagnostics["checks"]["torch_model_ok"] is True - assert signal.diagnostics["position_notional_usdt"] == 25.0 + assert signal.diagnostics["position_sizing"]["method"] == "torch_forecast_fractional_kelly" + assert settings.min_position_usdt <= signal.diagnostics["position_notional_usdt"] <= settings.max_position_usdt def test_torch_forecast_blocks_without_valid_torch_model(make_settings, tmp_path) -> None: @@ -328,6 +329,71 @@ def test_torch_forecast_allows_additional_entries_until_symbol_limit(make_settin assert "symbol position limit" in capped.reason +def test_torch_forecast_kelly_buys_only_remaining_symbol_allocation(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_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, + ) + strategy = SpotStrategy(settings) + ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0) + forecast = { + "usable": True, + "model": "torch_gru", + "expected_return_percent": 0.60, + "probability_up": 0.84, + "skill": 0.22, + "block_entry": False, + } + + first = strategy.entry_signal( + "BTCUSDT", + [], + ticker, + open_positions_for_symbol=0, + forecast=forecast, + account={"equity": 100.0, "symbol": "BTCUSDT", "symbol_exposure_usdt": 0.0}, + ) + second = strategy.entry_signal( + "BTCUSDT", + [], + ticker, + open_positions_for_symbol=1, + forecast=forecast, + account={"equity": 100.0, "symbol": "BTCUSDT", "symbol_exposure_usdt": 8.0}, + ) + filled = strategy.entry_signal( + "BTCUSDT", + [], + ticker, + open_positions_for_symbol=2, + forecast=forecast, + account={"equity": 100.0, "symbol": "BTCUSDT", "symbol_exposure_usdt": 20.0}, + ) + + first_sizing = first.diagnostics["position_sizing"] + second_sizing = second.diagnostics["position_sizing"] + assert first.action == "BUY" + assert first_sizing["method"] == "torch_forecast_fractional_kelly" + assert first_sizing["kelly_target_notional_usdt"] > settings.max_position_usdt + assert first.diagnostics["position_notional_usdt"] == settings.max_position_usdt + assert second.action == "BUY" + assert 1 <= second.diagnostics["position_notional_usdt"] < settings.max_position_usdt + assert second_sizing["kelly_open_symbol_exposure_usdt"] == 8.0 + assert filled.action == "HOLD" + assert filled.diagnostics["checks"]["risk_size_ok"] is False + + def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, @@ -362,7 +428,7 @@ def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> N assert signal.diagnostics["checks"]["quality_gate_ok"] is False -def test_torch_forecast_probe_buys_on_positive_high_probability(make_settings, tmp_path) -> None: +def test_torch_forecast_probe_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="torch_forecast", @@ -395,11 +461,11 @@ def test_torch_forecast_probe_buys_on_positive_high_probability(make_settings, t account={"equity": 100.0}, ) - assert signal.action == "BUY" + assert signal.action == "HOLD" assert signal.diagnostics["edge_mode"] == "probe" assert signal.diagnostics["checks"]["expected_edge_ok"] is True - assert signal.diagnostics["position_sizing"]["edge_mode"] == "probe" - assert settings.min_position_usdt <= signal.diagnostics["position_notional_usdt"] < settings.max_position_usdt + assert signal.diagnostics["checks"]["risk_size_ok"] is False + assert signal.diagnostics["position_notional_usdt"] == 0.0 def test_torch_forecast_probe_blocks_negative_expected_return(make_settings, tmp_path) -> None: @@ -436,7 +502,7 @@ def test_torch_forecast_probe_blocks_negative_expected_return(make_settings, tmp assert signal.diagnostics["checks"]["expected_edge_ok"] is False -def test_torch_forecast_rebound_overlay_buys_stabilized_drop(make_settings, tmp_path) -> None: +def test_torch_forecast_rebound_overlay_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="torch_forecast", @@ -479,12 +545,12 @@ def test_torch_forecast_rebound_overlay_buys_stabilized_drop(make_settings, tmp_ account={"equity": 100.0}, ) - assert signal.action == "BUY" - assert signal.diagnostics["entry_path"] == "rebound" + assert signal.action == "HOLD" assert signal.diagnostics["rebound"]["active"] is True - assert signal.diagnostics["edge_mode"] == "rebound" + assert signal.diagnostics["model_rebound_entry_ok"] is True + assert signal.diagnostics["rebound_entry_sized_ok"] is False assert signal.diagnostics["checks"]["expected_edge_ok"] is False - assert signal.diagnostics["position_notional_usdt"] <= settings.rebound_max_position_usdt + assert signal.diagnostics["checks"]["risk_size_ok"] is False def test_torch_forecast_rebound_overlay_does_not_buy_negative_forecast(make_settings, tmp_path) -> None: @@ -532,7 +598,7 @@ def test_torch_forecast_rebound_overlay_does_not_buy_negative_forecast(make_sett assert signal.diagnostics["edge_mode"] == "blocked" -def test_torch_forecast_rebound_fallback_buys_when_symbol_has_no_model(make_settings, tmp_path) -> None: +def test_torch_forecast_rebound_fallback_blocks_when_kelly_size_is_too_small(make_settings, tmp_path) -> None: settings = make_settings( tmp_path, strategy_mode="torch_forecast", @@ -571,12 +637,11 @@ def test_torch_forecast_rebound_fallback_buys_when_symbol_has_no_model(make_sett account={"equity": 100.0}, ) - assert signal.action == "BUY" - assert signal.diagnostics["entry_path"] == "rebound_fallback" + assert signal.action == "HOLD" assert signal.diagnostics["fallback_rebound_entry_ok"] is True + assert signal.diagnostics["rebound_entry_sized_ok"] is False assert signal.diagnostics["missing_torch_model"] is True - assert signal.diagnostics["edge_mode"] == "rebound_fallback" - assert signal.diagnostics["position_notional_usdt"] <= settings.rebound_max_position_usdt + assert signal.diagnostics["checks"]["risk_size_ok"] is False def test_torch_forecast_rebound_fallback_can_be_disabled(make_settings, tmp_path) -> None: diff --git a/tools/install_windows_torch_retrainer.ps1 b/tools/install_windows_torch_retrainer.ps1 index 5233342..e712e61 100644 --- a/tools/install_windows_torch_retrainer.ps1 +++ b/tools/install_windows_torch_retrainer.ps1 @@ -2,7 +2,7 @@ param( [string]$TaskName = "TradeBot PyTorch Forecaster Retrainer", [int]$EveryHours = 6, - [string]$Symbols = "BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT", + [string]$Symbols = "", [int]$Limit = 3000, [int]$Horizon = 0, [string]$Horizons = "", diff --git a/tools/run_retrain_until_replay8.ps1 b/tools/run_retrain_until_replay8.ps1 index 7865dfa..ac7ecbd 100644 --- a/tools/run_retrain_until_replay8.ps1 +++ b/tools/run_retrain_until_replay8.ps1 @@ -2,7 +2,7 @@ param( [int]$MinReplayTrades = 8, [int]$MaxAttempts = 0, - [string]$Symbols = "BTCUSDT,ETHUSDT,SOLUSDT,LTCUSDT", + [string]$Symbols = "", [int]$Limit = 3000, [switch]$DeployToPi, [string]$PiHost = "192.168.0.185", @@ -119,10 +119,12 @@ while ($true) { "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $Runner, - "-Symbols", $Symbols, "-Limit", $Limit.ToString(), "-Seed", $attemptSeed.ToString() ) + if ($Symbols) { + $runnerArgs += @("-Symbols", $Symbols) + } if ($DeployToPi) { $runnerArgs += "-DeployToPi" if ($PiHost) { $runnerArgs += @("-PiHost", $PiHost) }