Allow exchange-minimum Kelly layers
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user