Allow exchange-minimum Kelly layers

This commit is contained in:
Codex
2026-06-27 16:00:31 +03:00
parent 07d132632d
commit 7d84bf38cc
4 changed files with 117 additions and 5 deletions
+7 -3
View File
@@ -140,7 +140,7 @@ class CryptoSpotBot:
symbol, symbol,
"HOLD", "HOLD",
0.0, 0.0,
"пауза после закрытия позиции", "пауза между входами по паре",
{"cooldown_remaining_seconds": cooldown_seconds - age}, {"cooldown_remaining_seconds": cooldown_seconds - age},
) )
) )
@@ -150,6 +150,7 @@ class CryptoSpotBot:
candles = self.market.candles.get(symbol, []) candles = self.market.candles.get(symbol, [])
trend_candles = self.market.trend_candles.get(symbol, []) trend_candles = self.market.trend_candles.get(symbol, [])
open_count = len(self.broker.positions_for_symbol(symbol)) open_count = len(self.broker.positions_for_symbol(symbol))
instrument = self.market.instruments.get(symbol)
pattern = self.market.patterns.get(symbol, {}) pattern = self.market.patterns.get(symbol, {})
forecast = self.market.forecasts.get(symbol, {}) forecast = self.market.forecasts.get(symbol, {})
learning = self.learner.adjustment_for(symbol, str(pattern.get("label", ""))).as_dict() learning = self.learner.adjustment_for(symbol, str(pattern.get("label", ""))).as_dict()
@@ -159,6 +160,7 @@ class CryptoSpotBot:
account["symbol"] = symbol account["symbol"] = symbol
account["symbol_exposure_usdt"] = self.broker.symbol_exposure(symbol) account["symbol_exposure_usdt"] = self.broker.symbol_exposure(symbol)
account["open_positions_for_symbol"] = open_count account["open_positions_for_symbol"] = open_count
account["exchange_min_entry_usdt"] = self.broker.minimum_entry_budget(instrument, ticker)
if risk_guard.get("block_new_entries"): if risk_guard.get("block_new_entries"):
self.storage.insert_signal( self.storage.insert_signal(
Signal( Signal(
@@ -224,12 +226,14 @@ class CryptoSpotBot:
) )
self.storage.insert_signal(signal) self.storage.insert_signal(signal)
if signal.action == "BUY" and ticker is not None: if signal.action == "BUY" and ticker is not None:
self.broker.buy( position = self.broker.buy(
signal, signal,
ticker, ticker,
self.market.instruments.get(symbol), instrument,
prices, prices,
) )
if position is not None:
self._entry_cooldown_until[symbol] = utc_now()
@staticmethod @staticmethod
def _risk_guard_for_symbol(risk_guard: dict, symbol: str) -> dict: def _risk_guard_for_symbol(risk_guard: dict, symbol: str) -> dict:
+4
View File
@@ -278,6 +278,10 @@ class PaperBroker:
value = default value = default
return max(low, min(high, value)) return max(low, min(high, value))
def minimum_entry_budget(self, instrument: Instrument | None, ticker: Ticker | None = None) -> float:
fill_price = self._buy_price(ticker) if ticker is not None else None
return self._minimum_entry_budget(instrument, fill_price)
def _minimum_entry_budget(self, instrument: Instrument | None, fill_price: float | None = None) -> float: def _minimum_entry_budget(self, instrument: Instrument | None, fill_price: float | None = None) -> float:
minimum = max(0.0, self.settings.min_position_usdt) minimum = max(0.0, self.settings.min_position_usdt)
if instrument: if instrument:
+52 -2
View File
@@ -631,8 +631,11 @@ def _torch_forecast_entry_signal(
if open_positions_for_symbol >= _dynamic_symbol_position_limit(settings): if open_positions_for_symbol >= _dynamic_symbol_position_limit(settings):
return Signal(symbol, "HOLD", 0.0, "torch_forecast: symbol position limit reached") return Signal(symbol, "HOLD", 0.0, "torch_forecast: symbol position limit reached")
account_context = dict(account or {})
account_context.setdefault("symbol", symbol)
account_context.setdefault("open_positions_for_symbol", open_positions_for_symbol)
stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08) stop_loss_percent = _clamp(settings.stop_loss_percent, 0.003, 0.08)
sizing = _torch_forecast_position_sizing(settings, account, stop_loss_percent, forecast, symbol) sizing = _torch_forecast_position_sizing(settings, account_context, stop_loss_percent, forecast, symbol)
position_notional = float(sizing["notional_usdt"]) position_notional = float(sizing["notional_usdt"])
expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0) expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0)
probability_up = _safe_float(forecast.get("probability_up"), 0.5) probability_up = _safe_float(forecast.get("probability_up"), 0.5)
@@ -1218,7 +1221,28 @@ def _kelly_position(
bankroll = settings.starting_balance_usdt bankroll = settings.starting_balance_usdt
target_notional = max(0.0, bankroll * effective_fraction) target_notional = max(0.0, bankroll * effective_fraction)
open_symbol_exposure = _account_symbol_exposure(account, symbol) open_symbol_exposure = _account_symbol_exposure(account, symbol)
remaining_notional = max(0.0, target_notional - open_symbol_exposure) raw_remaining_notional = max(0.0, target_notional - open_symbol_exposure)
exchange_min_entry = _account_exchange_min_entry(account, settings)
remaining_notional = raw_remaining_notional
effective_target_notional = target_notional
layer_mode = False
if (
symbol
and target_notional > 0
and raw_remaining_notional < exchange_min_entry
and exchange_min_entry > settings.min_position_usdt + 1e-9
and _account_open_positions_for_symbol(account) > 0
):
room = min(
max(0.0, settings.max_position_usdt),
max(0.0, settings.max_symbol_exposure_usdt - open_symbol_exposure),
max(0.0, settings.max_total_exposure_usdt - _account_total_exposure(account)),
max(0.0, _safe_float((account or {}).get("cash"), settings.starting_balance_usdt) - settings.min_cash_reserve_usdt),
)
if room >= exchange_min_entry:
remaining_notional = exchange_min_entry
effective_target_notional = open_symbol_exposure + exchange_min_entry
layer_mode = True
return { return {
"kelly_probability": round(probability, 4), "kelly_probability": round(probability, 4),
"kelly_probability_source": probability_source, "kelly_probability_source": probability_source,
@@ -1231,9 +1255,13 @@ def _kelly_position(
"kelly_effective_fraction": round(effective_fraction, 4), "kelly_effective_fraction": round(effective_fraction, 4),
"kelly_bankroll_usdt": round(bankroll, 2), "kelly_bankroll_usdt": round(bankroll, 2),
"kelly_target_notional_usdt": round(target_notional, 2), "kelly_target_notional_usdt": round(target_notional, 2),
"kelly_effective_target_notional_usdt": round(effective_target_notional, 2),
"kelly_open_symbol_exposure_usdt": round(open_symbol_exposure, 2), "kelly_open_symbol_exposure_usdt": round(open_symbol_exposure, 2),
"kelly_raw_remaining_notional_usdt": round(raw_remaining_notional, 2),
"kelly_remaining_notional_usdt": round(remaining_notional, 2), "kelly_remaining_notional_usdt": round(remaining_notional, 2),
"kelly_notional_usdt": round(remaining_notional, 2), "kelly_notional_usdt": round(remaining_notional, 2),
"kelly_exchange_min_entry_usdt": round(exchange_min_entry, 2),
"kelly_layer_mode": layer_mode,
} }
@@ -1251,6 +1279,28 @@ def _account_symbol_exposure(account: dict | None, symbol: str | None = None) ->
return 0.0 return 0.0
def _account_total_exposure(account: dict | None) -> float:
if not isinstance(account, dict):
return 0.0
return max(0.0, _safe_float(account.get("exposure"), 0.0))
def _account_open_positions_for_symbol(account: dict | None) -> int:
if not isinstance(account, dict):
return 0
try:
return max(0, int(account.get("open_positions_for_symbol", 0)))
except (TypeError, ValueError):
return 0
def _account_exchange_min_entry(account: dict | None, settings: Settings) -> float:
minimum = max(0.0, settings.min_position_usdt)
if not isinstance(account, dict):
return minimum
return max(minimum, _safe_float(account.get("exchange_min_entry_usdt"), minimum))
def _confidence_probability(final_score: float, min_signal_confidence: float) -> float: def _confidence_probability(final_score: float, min_signal_confidence: float) -> float:
denominator = max(0.0001, 1.0 - min_signal_confidence) denominator = max(0.0001, 1.0 - min_signal_confidence)
ratio = _clamp((final_score - min_signal_confidence) / denominator, 0.0, 1.0) ratio = _clamp((final_score - min_signal_confidence) / denominator, 0.0, 1.0)
+54
View File
@@ -394,6 +394,60 @@ def test_torch_forecast_kelly_buys_only_remaining_symbol_allocation(make_setting
assert filled.diagnostics["checks"]["risk_size_ok"] is False assert filled.diagnostics["checks"]["risk_size_ok"] is False
def test_torch_forecast_kelly_allows_next_exchange_minimum_layer(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_total_exposure_usdt=75,
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,
time_series_min_confidence=0.4,
)
strategy = SpotStrategy(settings)
ticker = Ticker("HYPEUSDT", 63.14, 63.13, 63.15, 10_000_000, 1000, 1.0)
signal = strategy.entry_signal(
"HYPEUSDT",
[],
ticker,
open_positions_for_symbol=1,
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.2115,
"probability_up": 0.5163,
"skill": 0.0156,
"block_entry": False,
},
account={
"equity": 98.6,
"cash": 88.54,
"exposure": 10.07,
"symbol": "HYPEUSDT",
"symbol_exposure_usdt": 5.05,
"open_positions_for_symbol": 1,
"exchange_min_entry_usdt": 5.07,
},
)
sizing = signal.diagnostics["position_sizing"]
assert signal.action == "BUY"
assert signal.diagnostics["checks"]["risk_size_ok"] is True
assert sizing["kelly_target_notional_usdt"] < sizing["kelly_open_symbol_exposure_usdt"]
assert sizing["kelly_raw_remaining_notional_usdt"] == 0.0
assert sizing["kelly_layer_mode"] is True
assert signal.diagnostics["position_notional_usdt"] == 5.07
def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> None: def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> None:
settings = make_settings( settings = make_settings(
tmp_path, tmp_path,