Train Torch model for 12 spot pairs

This commit is contained in:
Курнат Андрей
2026-06-25 22:39:25 +03:00
parent 27205af73e
commit 87cb7e8fe3
18 changed files with 2326467 additions and 619561 deletions
+1
View File
@@ -87,6 +87,7 @@ def make_settings():
time_series_probe_min_edge_percent=0.02,
time_series_probe_min_probability_up=0.55,
time_series_probe_size_multiplier=0.40,
time_series_rebound_fallback_enabled=True,
stop_loss_percent=0.02,
take_profit_percent=0.035,
trailing_stop_percent=0.015,
+29 -4
View File
@@ -84,7 +84,7 @@ def test_default_symbols_are_fixed_trend_pairs(tmp_path, monkeypatch) -> None:
assert settings.time_series_forecast_enabled is True
def test_torch_forecast_forces_fixed_symbols(tmp_path, monkeypatch) -> None:
def test_torch_forecast_keeps_configured_symbol_selection(tmp_path, monkeypatch) -> None:
for key in (
"AUTO_SELECT_SYMBOLS",
"TOP_SYMBOLS_COUNT",
@@ -110,7 +110,32 @@ def test_torch_forecast_forces_fixed_symbols(tmp_path, monkeypatch) -> None:
settings = load_settings(env_file)
assert settings.auto_select_symbols is False
assert settings.top_symbols_count == len(FIXED_SPOT_SYMBOLS)
assert settings.symbols == FIXED_SPOT_SYMBOLS
assert settings.auto_select_symbols is True
assert settings.top_symbols_count == 9
assert settings.symbols == ("DOGEUSDT", "XRPUSDT")
assert settings.time_series_forecast_enabled is True
def test_auto_select_uses_empty_symbol_list(tmp_path, monkeypatch) -> None:
for key in ("AUTO_SELECT_SYMBOLS", "TOP_SYMBOLS_COUNT", "SYMBOLS", "STRATEGY_MODE"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("TRADING_MODE", "paper")
env_file = tmp_path / ".env"
env_file.write_text(
"\n".join(
[
"TRADING_MODE=paper",
"STRATEGY_MODE=torch_forecast",
"AUTO_SELECT_SYMBOLS=true",
"TOP_SYMBOLS_COUNT=12",
"SYMBOLS=",
]
),
encoding="utf-8",
)
settings = load_settings(env_file)
assert settings.auto_select_symbols is True
assert settings.top_symbols_count == 12
assert settings.symbols == ()
+1
View File
@@ -43,6 +43,7 @@ def test_safe_config_summarizes_torch_forecast_artifact(make_settings, tmp_path)
assert config["time_series_probe_min_edge_percent"] == 0.02
assert config["time_series_probe_min_probability_up"] == 0.55
assert config["time_series_probe_size_multiplier"] == 0.40
assert config["time_series_rebound_fallback_enabled"] is True
assert config["time_series_model_artifact"] == {
"available": True,
"type": "pytorch_recurrent_forecaster",
+36 -3
View File
@@ -54,6 +54,7 @@ def test_paper_broker_limits_fast_entries_per_minute(make_settings, tmp_path) ->
def test_paper_broker_uses_signal_notional_and_pair_exposure(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=6,
@@ -100,6 +101,34 @@ def test_paper_broker_uses_signal_notional_and_pair_exposure(make_settings, tmp_
assert 5.5 <= broker.symbol_exposure("BTCUSDT") <= 6.0
def test_paper_broker_raises_small_signal_to_exchange_min_notional(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
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=20,
max_entries_per_minute=0,
)
storage = Storage(settings.database_path)
broker = PaperBroker(settings, storage)
ticker = Ticker("XRPUSDT", 1.0, 0.999, 1.001, 10_000_000, 100, 0)
instrument = Instrument("XRPUSDT", "XRP", "USDT", "Trading", 0.0001, 0.01, 0.01, 5)
position = broker.buy(
Signal("XRPUSDT", "BUY", 0.8, "small rebound", {"position_notional_usdt": 1.5}),
ticker,
instrument,
{"XRPUSDT": 1.0},
)
assert position is not None
assert position.notional_usdt >= instrument.min_notional_value
assert position.notional_usdt <= settings.max_position_usdt
def test_paper_broker_respects_adaptive_exposure_target(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
@@ -160,7 +189,7 @@ def test_trend_macd_broker_blocks_dca_for_same_symbol(make_settings, tmp_path) -
assert len(broker.open_positions()) == 1
def test_torch_forecast_broker_blocks_dca_for_same_symbol(make_settings, tmp_path) -> None:
def test_torch_forecast_broker_allows_dynamic_entries_until_total_limit(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
@@ -179,10 +208,14 @@ def test_torch_forecast_broker_blocks_dca_for_same_symbol(make_settings, tmp_pat
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})
fourth = broker.buy(Signal("BTCUSDT", "BUY", 0.8, "fourth", {"position_notional_usdt": 2}), ticker, instrument, {"BTCUSDT": 100})
assert first is not None
assert second is None
assert len(broker.open_positions()) == 1
assert second is not None
assert third is not None
assert fourth is None
assert len(broker.open_positions()) == 3
def test_trend_macd_closes_old_paper_positions_outside_symbol_universe(make_settings, tmp_path) -> None:
+296
View File
@@ -284,6 +284,50 @@ def test_torch_forecast_blocks_without_valid_torch_model(make_settings, tmp_path
assert signal.diagnostics["checks"]["torch_model_ok"] is False
def test_torch_forecast_allows_additional_entries_until_symbol_limit(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
min_position_usdt=1,
max_symbol_exposure_usdt=3,
max_positions_per_symbol=3,
max_position_usdt=25,
stop_loss_percent=0.04,
risk_per_trade_percent=0.01,
)
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.36,
"probability_up": 0.66,
"skill": 0.22,
"block_entry": False,
}
additional = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=1,
forecast=forecast,
account={"equity": 100.0},
)
capped = strategy.entry_signal(
"BTCUSDT",
[],
ticker,
open_positions_for_symbol=3,
forecast=forecast,
account={"equity": 100.0},
)
assert additional.action == "BUY"
assert capped.action == "HOLD"
assert "symbol position limit" in capped.reason
def test_torch_forecast_blocks_failed_quality_gate(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
@@ -392,6 +436,190 @@ 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:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
rebound_max_position_usdt=6.0,
time_series_min_edge_percent=0.10,
time_series_probe_min_probability_up=0.55,
max_position_usdt=25,
stop_loss_percent=0.04,
risk_per_trade_percent=0.01,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="BTCUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"BTCUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": 0.01,
"probability_up": 0.56,
"skill": 0.05,
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "BUY"
assert signal.diagnostics["entry_path"] == "rebound"
assert signal.diagnostics["rebound"]["active"] is True
assert signal.diagnostics["edge_mode"] == "rebound"
assert signal.diagnostics["checks"]["expected_edge_ok"] is False
assert signal.diagnostics["position_notional_usdt"] <= settings.rebound_max_position_usdt
def test_torch_forecast_rebound_overlay_does_not_buy_negative_forecast(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
time_series_min_edge_percent=0.10,
time_series_probe_min_probability_up=0.55,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="BTCUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"BTCUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": True,
"model": "torch_gru",
"expected_return_percent": -0.01,
"probability_up": 0.56,
"skill": 0.05,
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["rebound"]["active"] is True
assert signal.diagnostics["rebound_entry_ok"] is False
assert signal.diagnostics["edge_mode"] == "blocked"
def test_torch_forecast_rebound_fallback_buys_when_symbol_has_no_model(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
rebound_max_position_usdt=6.0,
time_series_rebound_fallback_enabled=True,
stop_loss_percent=0.04,
risk_per_trade_percent=0.01,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="HYPEUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"HYPEUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "BUY"
assert signal.diagnostics["entry_path"] == "rebound_fallback"
assert signal.diagnostics["fallback_rebound_entry_ok"] is True
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
def test_torch_forecast_rebound_fallback_can_be_disabled(make_settings, tmp_path) -> None:
settings = make_settings(
tmp_path,
strategy_mode="torch_forecast",
rebound_trading_enabled=True,
rebound_entry_confidence=0.55,
rebound_min_probability=0.55,
time_series_rebound_fallback_enabled=False,
)
strategy = SpotStrategy(settings)
candles = _rebound_candles()
ticker = Ticker(
symbol="HYPEUSDT",
last_price=candles[-1].close,
bid=candles[-1].close * 0.9999,
ask=candles[-1].close * 1.0001,
turnover_24h=10_000_000,
volume_24h=1000,
change_24h=-2.0,
)
signal = strategy.entry_signal(
"HYPEUSDT",
candles,
ticker,
open_positions_for_symbol=0,
pattern={"label": "нисходящий тренд", "score": 0.28},
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
account={"equity": 100.0},
)
assert signal.action == "HOLD"
assert signal.diagnostics["missing_torch_model"] is True
assert signal.diagnostics["fallback_rebound_entry_ok"] is False
def test_torch_forecast_exits_when_forecast_turns_negative(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", stop_loss_percent=0.04)
strategy = SpotStrategy(settings)
@@ -416,6 +644,74 @@ def test_torch_forecast_exits_when_forecast_turns_negative(make_settings, tmp_pa
assert "torch_forecast" in signal.reason
def test_torch_forecast_rebound_fallback_holds_without_model(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast", min_hold_seconds=180)
strategy = SpotStrategy(settings)
position = Position(
1,
"XRPUSDT",
5,
1.0,
5.0,
0.005,
0.96,
1.035,
1.0,
entry_diagnostics={"entry_path": "rebound_fallback", "edge_mode": "rebound_fallback"},
)
ticker = Ticker("XRPUSDT", 1.001, 1.0009, 1.0011, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(close=1.0, ema50=0.98),
ticker,
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
)
assert signal.action == "HOLD"
assert signal.diagnostics["rebound_fallback_position"] is True
assert "rebound fallback" in signal.reason
def test_torch_forecast_rebound_fallback_still_sells_take_profit(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path, strategy_mode="torch_forecast")
strategy = SpotStrategy(settings)
position = Position(
1,
"XRPUSDT",
5,
1.0,
5.0,
0.005,
0.96,
1.035,
1.04,
opened_at=utc_now() - timedelta(seconds=600),
entry_diagnostics={"entry_path": "rebound_fallback", "edge_mode": "rebound_fallback"},
)
ticker = Ticker("XRPUSDT", 1.036, 1.0359, 1.0361, 10_000_000, 1000, 1.0)
signal = strategy.exit_signal(
position,
_trend_entry_candles(close=1.0, ema50=0.98),
ticker,
forecast={
"usable": False,
"model": "none",
"reason": "no valid PyTorch LSTM/GRU model for symbol",
"block_entry": False,
},
)
assert signal.action == "SELL"
assert "take-profit" in signal.reason
def test_strategy_emits_buy_when_score_passes_threshold(make_settings, tmp_path) -> None:
settings = make_settings(tmp_path)
strategy = SpotStrategy(settings)