Use Kelly allocation for Torch position scaling

This commit is contained in:
Codex
2026-06-26 20:20:23 +03:00
parent 87cb7e8fe3
commit 61a063cda7
12 changed files with 239 additions and 62 deletions
+28 -1
View File
@@ -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,
+80 -15
View File
@@ -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: