Keep bot operational when forecast model is unavailable
This commit is contained in:
@@ -89,6 +89,7 @@ def make_settings():
|
||||
time_series_probe_min_probability_up=0.55,
|
||||
time_series_probe_size_multiplier=0.40,
|
||||
time_series_rebound_fallback_enabled=True,
|
||||
time_series_trend_fallback_enabled=False,
|
||||
stop_loss_percent=0.02,
|
||||
stop_loss_exit_enabled=True,
|
||||
take_profit_percent=0.035,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import requests
|
||||
|
||||
from crypto_spot_bot.bybit import BybitClient, websocket_subscribe_message, _looks_like_leveraged_token, _looks_like_stablecoin
|
||||
|
||||
|
||||
@@ -86,6 +88,38 @@ def test_private_get_signs_the_same_query_it_sends(make_settings, tmp_path) -> N
|
||||
assert captured["headers"]["X-BAPI-SIGN"]
|
||||
|
||||
|
||||
def test_public_get_recreates_failed_tls_session_before_retry(make_settings, tmp_path, monkeypatch) -> None:
|
||||
client = BybitClient(make_settings(tmp_path))
|
||||
|
||||
class FailedSession:
|
||||
def get(self, *_args, **_kwargs):
|
||||
raise requests.exceptions.SSLError("invalid session id")
|
||||
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"retCode": 0, "result": {"ok": True}}
|
||||
|
||||
class WorkingSession:
|
||||
def get(self, *_args, **_kwargs):
|
||||
return Response()
|
||||
|
||||
resets = []
|
||||
client.session = FailedSession()
|
||||
|
||||
def reset_session() -> None:
|
||||
resets.append(True)
|
||||
client.session = WorkingSession()
|
||||
|
||||
monkeypatch.setattr(client, "_reset_session", reset_session)
|
||||
monkeypatch.setattr("crypto_spot_bot.bybit.time.sleep", lambda _seconds: None)
|
||||
|
||||
assert client.public_get("/v5/market/kline", {"symbol": "BTCUSDT"}) == {"ok": True}
|
||||
assert resets == [True]
|
||||
|
||||
|
||||
def test_websocket_subscribe_uses_configured_kline_interval() -> None:
|
||||
payload = websocket_subscribe_message(["BTCUSDT"], interval="60")
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tools.calibrate_torch_thresholds import (
|
||||
CalibrationResult,
|
||||
ForecastRecord,
|
||||
_average_selected_predictions,
|
||||
_apply_platt_calibration,
|
||||
_choose_recommendation,
|
||||
_full_backtest,
|
||||
_fit_platt_calibration,
|
||||
_entry_validation_skill,
|
||||
)
|
||||
from tools.train_torch_recurrent_forecaster import _ensemble_candidate
|
||||
|
||||
|
||||
def _result(*, trades: int, average: float, total: float, profit_factor: float) -> CalibrationResult:
|
||||
@@ -85,3 +90,81 @@ def test_entry_quality_never_falls_back_to_holdout_skill() -> None:
|
||||
|
||||
assert _entry_validation_skill(entry) == 0.12
|
||||
assert _entry_validation_skill({"skill": 0.99, "holdout_skill": 0.99}) == 0.0
|
||||
|
||||
|
||||
def test_batched_ensemble_averages_decoded_predictions() -> None:
|
||||
averaged = _average_selected_predictions(
|
||||
[
|
||||
{"expected_return": 0.01, "q50": 0.02, "probability_up": 0.6},
|
||||
{"expected_return": 0.03, "q50": 0.04, "probability_up": 0.8},
|
||||
]
|
||||
)
|
||||
|
||||
assert averaged == {
|
||||
"expected_return": 0.02,
|
||||
"q50": 0.03,
|
||||
"probability_up": 0.7,
|
||||
}
|
||||
|
||||
|
||||
def test_multi_seed_export_does_not_duplicate_first_member_weights() -> None:
|
||||
members = [
|
||||
{
|
||||
"validation_mae": 0.1,
|
||||
"state_dict": {"weight": [seed]},
|
||||
"head_weight": [[seed]],
|
||||
"head_bias": [seed],
|
||||
}
|
||||
for seed in (7, 19)
|
||||
]
|
||||
|
||||
exported = _ensemble_candidate(members, [7, 19])
|
||||
|
||||
assert exported["ensemble_size"] == 2
|
||||
assert exported["ensemble_seeds"] == [7, 19]
|
||||
assert len(exported["ensemble_members"]) == 2
|
||||
assert "state_dict" not in exported
|
||||
assert "head_weight" not in exported
|
||||
|
||||
|
||||
def test_single_seed_export_keeps_only_top_level_weights() -> None:
|
||||
exported = _ensemble_candidate(
|
||||
[
|
||||
{
|
||||
"validation_mae": 0.1,
|
||||
"state_dict": {"weight": [7]},
|
||||
"head_weight": [[7]],
|
||||
"head_bias": [7],
|
||||
}
|
||||
],
|
||||
[7],
|
||||
)
|
||||
|
||||
assert exported["ensemble_size"] == 1
|
||||
assert exported["state_dict"] == {"weight": [7]}
|
||||
assert "ensemble_members" not in exported
|
||||
|
||||
|
||||
def test_full_backtest_never_uses_global_threshold_for_ineligible_symbol() -> None:
|
||||
btc = [_record(index, 0.8, 1.0) for index in range(3)]
|
||||
eth = [_record(index, 0.8, 1.0) for index in range(3)]
|
||||
for record in eth:
|
||||
record.symbol = "ETHUSDT"
|
||||
thresholds = _result(trades=3, average=1.0, total=3.0, profit_factor=999.0)
|
||||
|
||||
replay = _full_backtest(
|
||||
btc + eth,
|
||||
thresholds,
|
||||
horizon=3,
|
||||
round_trip_cost=0.0,
|
||||
settings=SimpleNamespace(
|
||||
stop_loss_percent=0.04,
|
||||
take_profit_percent=0.035,
|
||||
stop_loss_exit_enabled=True,
|
||||
atr_trailing_multiplier=2.2,
|
||||
),
|
||||
symbol_thresholds={"BTCUSDT": thresholds},
|
||||
require_symbol_thresholds=True,
|
||||
)
|
||||
|
||||
assert {row["symbol"] for row in replay["symbol_breakdown"]} == {"BTCUSDT"}
|
||||
|
||||
@@ -2,9 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from crypto_spot_bot.models import Signal, utc_now
|
||||
from crypto_spot_bot.storage import MAX_SIGNAL_DIAGNOSTICS_BYTES, PRUNE_BATCH_SIZE, Storage
|
||||
from tools.compact_runtime_db import compact_database
|
||||
|
||||
|
||||
def test_hold_sampling_is_independent_for_each_reason_and_diagnostics_are_bounded(tmp_path) -> None:
|
||||
@@ -58,3 +60,29 @@ def test_prune_deletes_only_one_bounded_batch_per_table(tmp_path) -> None:
|
||||
|
||||
assert deleted["signals"] == PRUNE_BATCH_SIZE
|
||||
assert len(storage.recent_signals(PRUNE_BATCH_SIZE + 10)) == 5
|
||||
|
||||
|
||||
def test_runtime_compaction_preserves_durable_state_and_bounds_telemetry(tmp_path) -> None:
|
||||
database = tmp_path / "tradebot.sqlite3"
|
||||
storage = Storage(database)
|
||||
for index in range(10):
|
||||
storage.insert_signal(
|
||||
Signal("BTCUSDT", "BUY", 0.8, f"signal-{index}"),
|
||||
hold_sample_seconds=0,
|
||||
)
|
||||
storage.set_runtime("active", {"value": 1})
|
||||
|
||||
result = compact_database(
|
||||
database,
|
||||
recent_rows={"signals": 3, "equity": 0, "events": 0, "llm_advice": 0},
|
||||
)
|
||||
|
||||
compacted = Storage(database)
|
||||
assert [row["reason"] for row in compacted.recent_signals(10)] == [
|
||||
"signal-9",
|
||||
"signal-8",
|
||||
"signal-7",
|
||||
]
|
||||
assert compacted.get_runtime("active") == {"value": 1}
|
||||
assert Path(result["backup"]).is_file()
|
||||
assert result["rows"]["signals"] == 3
|
||||
|
||||
@@ -566,6 +566,71 @@ 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_uses_trend_fallback_when_model_is_not_ready(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
strategy_mode="torch_forecast",
|
||||
time_series_trend_fallback_enabled=True,
|
||||
time_series_require_quality_gate=True,
|
||||
time_series_require_fresh_model=True,
|
||||
max_position_usdt=50,
|
||||
)
|
||||
strategy = SpotStrategy(settings)
|
||||
ticker = Ticker("BTCUSDT", 105, 104.99, 105.01, 10_000_000, 1000, 1.0)
|
||||
|
||||
signal = strategy.entry_signal(
|
||||
"BTCUSDT",
|
||||
_trend_entry_candles(),
|
||||
ticker,
|
||||
open_positions_for_symbol=0,
|
||||
forecast={"usable": False, "model": "none", "quality_gate_passed": False},
|
||||
account={"equity": 100.0},
|
||||
trend_candles=_daily_trend_candles(),
|
||||
)
|
||||
|
||||
assert signal.action == "BUY"
|
||||
assert signal.diagnostics["trade_mode"] == "TREND_MACD_FALLBACK"
|
||||
assert signal.diagnostics["entry_path"] == "trend_macd_fallback"
|
||||
assert signal.diagnostics["forecast_fallback_reasons"] == [
|
||||
"torch_model_unavailable",
|
||||
"quality_gate_not_passed",
|
||||
"model_not_fresh",
|
||||
]
|
||||
|
||||
|
||||
def test_torch_forecast_uses_trend_exit_for_fallback_position(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
strategy_mode="torch_forecast",
|
||||
time_series_trend_fallback_enabled=True,
|
||||
)
|
||||
strategy = SpotStrategy(settings)
|
||||
candles = _trend_entry_candles()
|
||||
candles[-2].macd = 0.2
|
||||
candles[-2].macd_signal = 0.0
|
||||
candles[-1].macd = -0.1
|
||||
candles[-1].macd_signal = 0.0
|
||||
position = Position(
|
||||
1,
|
||||
"BTCUSDT",
|
||||
1,
|
||||
100,
|
||||
100,
|
||||
0.1,
|
||||
96,
|
||||
120,
|
||||
100,
|
||||
entry_diagnostics={"entry_path": "trend_macd_fallback"},
|
||||
)
|
||||
ticker = Ticker("BTCUSDT", 104, 103.99, 104.01, 1_000_000, 100, 0)
|
||||
|
||||
signal = strategy.exit_signal(position, candles, ticker, forecast={})
|
||||
|
||||
assert signal.action == "SELL"
|
||||
assert signal.diagnostics["trade_mode"] == "TREND_MACD_FALLBACK"
|
||||
assert "MACD" in signal.reason
|
||||
|
||||
|
||||
def test_torch_forecast_allows_explicit_manual_quality_override(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
|
||||
@@ -334,6 +334,29 @@ def test_time_series_forecaster_uses_symbol_calibration(make_settings, tmp_path)
|
||||
assert forecast.calibrated_min_confidence == 0.45
|
||||
|
||||
|
||||
def test_time_series_forecaster_blocks_symbol_outside_train_only_allowlist(make_settings, tmp_path) -> None:
|
||||
artifact_path = tmp_path / "lstm_forecaster.json"
|
||||
_write_torch_gru_artifact(artifact_path, head_bias=0.2)
|
||||
(tmp_path / "torch_threshold_calibration.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"validation": {"status": "pass", "passed": True},
|
||||
"eligible_symbols": ["ETHUSDT"],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
settings = make_settings(tmp_path, time_series_lstm_model_path=artifact_path)
|
||||
|
||||
forecast = TimeSeriesForecaster(settings).forecast(
|
||||
_candles_from_returns([0.0001] * 140), symbol="BTCUSDT"
|
||||
)
|
||||
|
||||
assert forecast.usable is True
|
||||
assert forecast.block_entry is True
|
||||
assert forecast.reason == "symbol excluded by train-only calibration"
|
||||
|
||||
|
||||
def test_time_series_forecaster_averages_ensemble_members(make_settings, tmp_path) -> None:
|
||||
artifact_path = tmp_path / "lstm_forecaster.json"
|
||||
_write_torch_gru_artifact(artifact_path, head_bias=0.9)
|
||||
@@ -343,6 +366,9 @@ def test_time_series_forecaster_averages_ensemble_members(make_settings, tmp_pat
|
||||
{"state_dict": entry["state_dict"], "head_weight": [0.0, 0.0], "head_bias": bias}
|
||||
for bias in (0.1, 0.3)
|
||||
]
|
||||
entry.pop("state_dict")
|
||||
entry.pop("head_weight")
|
||||
entry.pop("head_bias")
|
||||
artifact_path.write_text(json.dumps(artifact), encoding="utf-8")
|
||||
settings = make_settings(
|
||||
tmp_path,
|
||||
|
||||
@@ -48,6 +48,33 @@ def test_training_coordinator_preserves_boolean_resume_candidate_parameter(tmp_p
|
||||
assert requested["job"]["parameters"] == {"resume_candidate": True}
|
||||
|
||||
|
||||
def test_training_coordinator_sanitizes_independent_training_parameters(tmp_path) -> None:
|
||||
coordinator = TrainingCoordinator(tmp_path)
|
||||
|
||||
requested = coordinator.request_retrain(
|
||||
{
|
||||
"source": "recovery",
|
||||
"parameters": {
|
||||
"pooled": False,
|
||||
"limit": 6000,
|
||||
"ensemble_seeds": "7,19",
|
||||
"selection_folds": 3,
|
||||
"learning_rate": 0.0007,
|
||||
"weight_decay": 0.0005,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert requested["job"]["parameters"] == {
|
||||
"pooled": False,
|
||||
"limit": 6000,
|
||||
"ensemble_seeds": "7,19",
|
||||
"selection_folds": 3,
|
||||
"learning_rate": 0.0007,
|
||||
"weight_decay": 0.0005,
|
||||
}
|
||||
|
||||
|
||||
def test_training_coordinator_reports_worker_identity_from_heartbeat(tmp_path) -> None:
|
||||
coordinator = TrainingCoordinator(tmp_path)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user