Allow exchange-minimum Kelly layers
This commit is contained in:
@@ -140,7 +140,7 @@ class CryptoSpotBot:
|
||||
symbol,
|
||||
"HOLD",
|
||||
0.0,
|
||||
"пауза после закрытия позиции",
|
||||
"пауза между входами по паре",
|
||||
{"cooldown_remaining_seconds": cooldown_seconds - age},
|
||||
)
|
||||
)
|
||||
@@ -150,6 +150,7 @@ class CryptoSpotBot:
|
||||
candles = self.market.candles.get(symbol, [])
|
||||
trend_candles = self.market.trend_candles.get(symbol, [])
|
||||
open_count = len(self.broker.positions_for_symbol(symbol))
|
||||
instrument = self.market.instruments.get(symbol)
|
||||
pattern = self.market.patterns.get(symbol, {})
|
||||
forecast = self.market.forecasts.get(symbol, {})
|
||||
learning = self.learner.adjustment_for(symbol, str(pattern.get("label", ""))).as_dict()
|
||||
@@ -159,6 +160,7 @@ class CryptoSpotBot:
|
||||
account["symbol"] = symbol
|
||||
account["symbol_exposure_usdt"] = self.broker.symbol_exposure(symbol)
|
||||
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"):
|
||||
self.storage.insert_signal(
|
||||
Signal(
|
||||
@@ -224,12 +226,14 @@ class CryptoSpotBot:
|
||||
)
|
||||
self.storage.insert_signal(signal)
|
||||
if signal.action == "BUY" and ticker is not None:
|
||||
self.broker.buy(
|
||||
position = self.broker.buy(
|
||||
signal,
|
||||
ticker,
|
||||
self.market.instruments.get(symbol),
|
||||
instrument,
|
||||
prices,
|
||||
)
|
||||
if position is not None:
|
||||
self._entry_cooldown_until[symbol] = utc_now()
|
||||
|
||||
@staticmethod
|
||||
def _risk_guard_for_symbol(risk_guard: dict, symbol: str) -> dict:
|
||||
|
||||
@@ -278,6 +278,10 @@ class PaperBroker:
|
||||
value = default
|
||||
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:
|
||||
minimum = max(0.0, self.settings.min_position_usdt)
|
||||
if instrument:
|
||||
|
||||
@@ -631,8 +631,11 @@ def _torch_forecast_entry_signal(
|
||||
if open_positions_for_symbol >= _dynamic_symbol_position_limit(settings):
|
||||
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)
|
||||
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"])
|
||||
expected_return = _safe_float(forecast.get("expected_return_percent"), 0.0)
|
||||
probability_up = _safe_float(forecast.get("probability_up"), 0.5)
|
||||
@@ -1218,7 +1221,28 @@ def _kelly_position(
|
||||
bankroll = settings.starting_balance_usdt
|
||||
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)
|
||||
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 {
|
||||
"kelly_probability": round(probability, 4),
|
||||
"kelly_probability_source": probability_source,
|
||||
@@ -1231,9 +1255,13 @@ def _kelly_position(
|
||||
"kelly_effective_fraction": round(effective_fraction, 4),
|
||||
"kelly_bankroll_usdt": round(bankroll, 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_raw_remaining_notional_usdt": round(raw_remaining_notional, 2),
|
||||
"kelly_remaining_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
|
||||
|
||||
|
||||
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:
|
||||
denominator = max(0.0001, 1.0 - min_signal_confidence)
|
||||
ratio = _clamp((final_score - min_signal_confidence) / denominator, 0.0, 1.0)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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:
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
|
||||
Reference in New Issue
Block a user