fix: study.db migration fallback + degenerate HMM training guard

- optimizer.get_best_params: if study_<broker>.db not found, fall back to
  legacy models/study.db so optimization runs before the per-broker split
  are not silently discarded (prevents 'using defaults' fallback)
- main.cmd_train: abort and sys.exit(1) before saving if HMM min state
  persistence < 0.70 — prevents deploying a garbage degenerate model when
  Optuna params were not loaded; prints clear remediation steps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Luke Mosoti
2026-04-09 00:27:20 +03:00
co-authored by Claude Sonnet 4.6
parent 59085649ee
commit 1c62499071
2 changed files with 55 additions and 15 deletions
+19
View File
@@ -172,6 +172,25 @@ def cmd_train(args):
params = {}
result, model_hmm, state_map, models_ensemble, thresholds, metrics, *_ = _train_for_tf(tf, balance, broker, params)
# Guard: refuse to save a degenerate HMM — identical state means + near-zero persistence
# indicate the Kalman/HMM params were bad (usually because Optuna params weren't loaded).
_min_persist = min(model_hmm.transmat_[i, i] for i in range(model_hmm.n_components))
if _min_persist < 0.70:
logger.critical(
"TRAINING ABORTED: degenerate HMM (min persistence=%.4f). "
"Model NOT saved. Usually caused by missing Optuna study for tf=%s broker=%s. "
"Ensure --mode optimize has completed and re-run --mode train.",
_min_persist, tf, broker,
)
print(
f"\nERROR: Degenerate HMM detected (min state persistence={_min_persist:.4f} < 0.70).\n"
f"The model was NOT saved — saving it would only produce garbage signals.\n"
f"\nFix: python main.py --mode optimize --tf {tf} --broker {broker} --trials 300\n"
f"Then: python main.py --mode train --tf {tf} --broker {broker}\n"
)
sys.exit(1)
save_hmm(model_hmm, hmm_model_path(tf, broker))
save_xgb_ensemble(models_ensemble, thresholds, metrics, get_ensemble_path(tf, broker))
+36 -15
View File
@@ -454,18 +454,39 @@ def get_best_params(
tier = _get_tier(balance)
name = _study_name(broker=broker, tier=tier, tf=tf)
storage = _study_db(broker)
try:
study = optuna.load_study(study_name=name, storage=storage)
return study.best_params
except Exception:
if tier == "small":
raise # small tier is the baseline — nothing to fall back to
# Growth (or future tiers) — reuse the small-tier study params so live
# trading always benefits from Optuna thresholds.
fallback = _study_name(broker=broker, tier="small", tf=tf)
logger.info(
"Growth study '%s' not found — using small-tier params from '%s'.",
name, fallback,
)
study = optuna.load_study(study_name=fallback, storage=storage)
return study.best_params
def _try_load(db: str) -> dict | None:
try:
study = optuna.load_study(study_name=name, storage=db)
return study.best_params
except Exception:
return None
params = _try_load(storage)
if params is not None:
return params
# Legacy fallback: studies optimised before per-broker DB split were saved
# to models/study.db. Try it so users don't lose their optimisation work.
_legacy = "sqlite:///models/study.db"
if storage != _legacy and os.path.exists("models/study.db"):
params = _try_load(_legacy)
if params is not None:
logger.info(
"Loaded params for study '%s' from legacy models/study.db. "
"Re-run --mode optimize to persist to study_%s.db.",
name, broker,
)
return params
if tier == "small":
raise KeyError(f"No Optuna study '{name}' found in {storage} or study.db")
# Growth (or future tiers) — reuse the small-tier study params so live
# trading always benefits from Optuna thresholds.
fallback = _study_name(broker=broker, tier="small", tf=tf)
logger.info(
"Growth study '%s' not found — using small-tier params from '%s'.",
name, fallback,
)
study = optuna.load_study(study_name=fallback, storage=storage)
return study.best_params