feat: broker-specific model files and study DB separation

Cent and standard account models now live in separate files so
optimizing/training one account never overwrites the other:

- engine_hmm.py: get_model_path(tf, broker) → hmm_model_H1_headway_cent.pkl
- engine_xgb.py: get_ensemble_path(tf, broker) → xgb_ensemble_H1_standard.pkl
- optimizer.py: STUDY_DB replaced by _study_db(broker) →
  models/study_headway_cent.db + models/study_standard.db (fully isolated)
- main.py: _M5_META_PATH → _m5_meta_path(broker); thread broker through
  all model path calls, cmd_export, cmd_report, _check_m5_readiness
- validator.py: pass broker to hmm_model_path + get_ensemble_path
- mt5_trader.py: pass broker to hmm_model_path + get_ensemble_path

Backward-compat fallback to generic MODEL_PATH / ENSEMBLE_PKL_PATH
preserved if broker-specific file absent (e.g. models not yet retrained).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Luke Mosoti
2026-04-09 00:16:46 +03:00
co-authored by Claude Sonnet 4.6
parent cea8b3bab4
commit 59085649ee
6 changed files with 52 additions and 35 deletions
+21 -16
View File
@@ -36,11 +36,14 @@ def _resolve_balance(args) -> float:
return args.balance if args.balance is not None else args.min_cap
_M5_META_PATH = Path("models/m5_meta.json")
_M5_EXPIRY_HOURS = 120 # 5 days
def _check_m5_readiness(tf: str) -> bool:
def _m5_meta_path(broker: str) -> Path:
return Path(f"models/m5_meta_{broker}.json")
def _check_m5_readiness(tf: str, broker: str = "headway_cent") -> bool:
"""Return True if the M5 model is fresh enough for live trading.
The M5 timeframe is sensitive to microstructure changes, so the model
@@ -49,13 +52,14 @@ def _check_m5_readiness(tf: str) -> bool:
"""
if tf.upper() != "M5":
return True
if not _M5_META_PATH.exists():
meta_path = _m5_meta_path(broker)
if not meta_path.exists():
print(
"\nERROR: M5 model meta-data not found.\n"
"Run python main.py --mode optimize --tf M5 before live trading."
)
return False
meta = json.loads(_M5_META_PATH.read_text())
meta = json.loads(meta_path.read_text())
age_h = (time.time() - meta.get("timestamp", 0)) / 3600
if age_h > _M5_EXPIRY_HOURS:
print(
@@ -142,8 +146,9 @@ def cmd_optimize(args):
for k, v in study.best_params.items():
print(f" {k}: {v}")
if tf == "M5":
_M5_META_PATH.parent.mkdir(parents=True, exist_ok=True)
_M5_META_PATH.write_text(json.dumps({
meta_path = _m5_meta_path(broker)
meta_path.parent.mkdir(parents=True, exist_ok=True)
meta_path.write_text(json.dumps({
"timestamp": time.time(),
"tf": "M5",
"best_score": study.best_value,
@@ -167,8 +172,8 @@ def cmd_train(args):
params = {}
result, model_hmm, state_map, models_ensemble, thresholds, metrics, *_ = _train_for_tf(tf, balance, broker, params)
save_hmm(model_hmm, hmm_model_path(tf))
save_xgb_ensemble(models_ensemble, thresholds, metrics, get_ensemble_path(tf))
save_hmm(model_hmm, hmm_model_path(tf, broker))
save_xgb_ensemble(models_ensemble, thresholds, metrics, get_ensemble_path(tf, broker))
arm = AdaptiveRiskManager(balance, broker=broker)
limits = arm.get_trade_limits()
@@ -258,8 +263,9 @@ def cmd_compare(args):
def cmd_export(args):
tf = args.tf.upper()
xgb_path = get_ensemble_path(tf)
tf = args.tf.upper()
broker = args.broker
xgb_path = get_ensemble_path(tf, broker)
if not xgb_path.exists():
xgb_path = ENSEMBLE_PKL_PATH
try:
@@ -327,11 +333,10 @@ def cmd_demo(args):
balance = _resolve_balance(args)
tf = args.tf.upper()
if not _check_m5_readiness(tf):
if not _check_m5_readiness(tf, args.broker):
sys.exit(1)
logger.info(
"Starting demo loop — TF=%s broker=%s balance=$%.0f",
logger.info("Starting demo loop — TF=%s broker=%s balance=$%.0f",
tf, args.broker, balance,
)
run_live_loop(tf=tf, broker=args.broker, account_size=balance,
@@ -346,7 +351,7 @@ def cmd_live(args):
balance = _resolve_balance(args)
tf = args.tf.upper()
if not _check_m5_readiness(tf):
if not _check_m5_readiness(tf, args.broker):
sys.exit(1)
if not args.yes:
@@ -382,7 +387,7 @@ def cmd_report(args):
df = process_pipeline(obs_cov=params.get("obs_cov"), trans_cov=params.get("trans_cov"),
save=False, tf=tf)
_hmm_path = hmm_model_path(tf)
_hmm_path = hmm_model_path(tf, broker)
if not _hmm_path.exists():
_hmm_path = None # let the except branch fit a fresh one
try:
@@ -398,7 +403,7 @@ def cmd_report(args):
X, y, df_aligned = prepare_features(df, states)
_xgb_path = get_ensemble_path(tf)
_xgb_path = get_ensemble_path(tf, broker)
if not _xgb_path.exists():
_xgb_path = ENSEMBLE_PKL_PATH
try:
+7 -3
View File
@@ -10,9 +10,13 @@ logger = setup_logger(__name__)
MODEL_PATH = Path("models/hmm_model.pkl")
def get_model_path(tf: str) -> Path:
"""Return the TF-specific HMM model path, e.g. models/hmm_model_H1.pkl."""
return Path(f"models/hmm_model_{tf.upper()}.pkl")
def get_model_path(tf: str, broker: str = "headway_cent") -> Path:
"""Return the TF+broker-specific HMM model path.
Example: get_model_path("H1", "headway_cent") → models/hmm_model_H1_headway_cent.pkl
Falls back to the generic models/hmm_model_H1.pkl (then MODEL_PATH) if absent.
"""
return Path(f"models/hmm_model_{tf.upper()}_{broker}.pkl")
STATE_NAMES_3 = {0: "Bull", 1: "Bear", 2: "Chop"}
STATE_NAMES_2 = {0: "Bull", 1: "Bear"}
STATE_NAMES_4 = {0: "Bull", 1: "Bear", 2: "Chop_Low", 3: "Chop_High"}
+7 -3
View File
@@ -14,9 +14,13 @@ XGB_PKL_PATH = Path("models/xgb_model.pkl")
ENSEMBLE_PKL_PATH = Path("models/xgb_ensemble.pkl")
def get_ensemble_path(tf: str) -> Path:
"""Return the TF-specific XGB ensemble path, e.g. models/xgb_ensemble_H1.pkl."""
return Path(f"models/xgb_ensemble_{tf.upper()}.pkl")
def get_ensemble_path(tf: str, broker: str = "headway_cent") -> Path:
"""Return the TF+broker-specific XGB ensemble path.
Example: get_ensemble_path("H1", "headway_cent") → models/xgb_ensemble_H1_headway_cent.pkl
Falls back to the generic models/xgb_ensemble_H1.pkl (then ENSEMBLE_PKL_PATH) if absent.
"""
return Path(f"models/xgb_ensemble_{tf.upper()}_{broker}.pkl")
# Base features always present; usdchf_log_return is added when the USDCHF
# master CSV is available (run python main.py --mode consolidate first).
+3 -3
View File
@@ -645,8 +645,8 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
display_account_info(trading_balance=account_size)
# Load models — prefer TF-specific file; fall back to generic
hmm_path = hmm_model_path(tf)
# Load models — prefer broker+TF specific file; fall back to generic
hmm_path = hmm_model_path(tf, broker)
if not hmm_path.exists():
hmm_path = HMM_GENERIC_PATH
try:
@@ -654,7 +654,7 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, mt5,
except FileNotFoundError:
raise FileNotFoundError("HMM model not found. Run --mode train first.")
xgb_path = get_ensemble_path(tf)
xgb_path = get_ensemble_path(tf, broker)
if not xgb_path.exists():
xgb_path = XGB_GENERIC_PATH
try:
+9 -5
View File
@@ -36,7 +36,9 @@ except ImportError:
_PSUTIL_OK = False
logger.debug("psutil not installed — RAM guard disabled.")
STUDY_DB = "sqlite:///models/study.db"
def _study_db(broker: str) -> str:
"""Return the SQLite storage URL for a given broker, e.g. sqlite:///models/study_headway_cent.db."""
return f"sqlite:///models/study_{broker}.db"
DD_HARD_LIMIT = 0.15
# Minimum OOS trades before a trial is considered scoreable.
# M5 has 288 bars/day — the OOS window (~2yr) allows up to ~870 trades at
@@ -350,12 +352,13 @@ def run_optimization(
"""
tier = _get_tier(balance)
name = _study_name(broker=broker, tier=tier, tf=tf)
storage = _study_db(broker)
os.makedirs("models", exist_ok=True)
study = optuna.create_study(
study_name=name,
storage=STUDY_DB,
storage=storage,
direction="maximize",
load_if_exists=True, # crash-safe resume
pruner=optuna.pruners.MedianPruner(),
@@ -370,7 +373,7 @@ def run_optimization(
if already_done > 0:
pct = already_done / n_trials * 100
print(
f"\nFailsafe: {already_done}/{n_trials} trials already in study.db "
f"\nFailsafe: {already_done}/{n_trials} trials already in study_{broker}.db "
f"({pct:.0f}% complete). "
f"Resuming from trial #{already_done + 1} — {remaining} remaining.\n"
)
@@ -450,8 +453,9 @@ 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=STUDY_DB)
study = optuna.load_study(study_name=name, storage=storage)
return study.best_params
except Exception:
if tier == "small":
@@ -463,5 +467,5 @@ def get_best_params(
"Growth study '%s' not found — using small-tier params from '%s'.",
name, fallback,
)
study = optuna.load_study(study_name=fallback, storage=STUDY_DB)
study = optuna.load_study(study_name=fallback, storage=storage)
return study.best_params
+5 -5
View File
@@ -127,8 +127,8 @@ def run_validation(
logger.info("Loaded %d bars from %s for validation", len(df), sync_data_path)
df = _apply_features(df, tf, obs_cov, trans_cov)
# Load models — prefer TF-specific file; fall back to generic
hmm_path = hmm_model_path(tf)
# Load models — prefer broker+TF specific file; fall back to generic
hmm_path = hmm_model_path(tf, broker)
if not hmm_path.exists():
hmm_path = HMM_GENERIC_PATH
if hmm_path.exists():
@@ -144,13 +144,13 @@ def run_validation(
"Run python main.py --mode train first."
)
xgb_path = get_ensemble_path(tf)
xgb_path = get_ensemble_path(tf, broker)
if not xgb_path.exists():
xgb_path = XGB_GENERIC_PATH
if xgb_path.exists():
logger.warning(
"No TF-specific XGB ensemble found for %s; falling back to %s. "
"Run --mode train --tf %s to create a dedicated model.", tf, xgb_path, tf
"No broker+TF XGB ensemble found for %s/%s; falling back to %s. "
"Run --mode train --tf %s --broker %s to create a dedicated model.", tf, broker, xgb_path, tf, broker
)
try:
models_xgb, thresholds_xgb, _ = load_xgb_ensemble(xgb_path)