feat: enhance trading logic with short_threshold parameter and update logging for P&L tracking

This commit is contained in:
Luke Mosoti
2026-04-06 16:13:56 +03:00
parent 39a22adc42
commit 9e7c426e1c
9 changed files with 279 additions and 109 deletions
+5 -1
View File
@@ -102,6 +102,7 @@ def _train_for_tf(tf: str, balance: float, broker: str, params: dict):
broker=broker,
tf=tf,
prob_threshold=params.get("prob_threshold"),
short_threshold=params.get("short_threshold"),
)
return result, model_hmm, state_map, models_ensemble, thresholds, metrics, df_aligned, states_aligned, X, probabilities
@@ -325,7 +326,7 @@ def cmd_live(args):
if not _check_m5_readiness(tf):
sys.exit(1)
if args.account == "live":
if args.account == "live" and not args.yes:
print("\n" + "=" * 60)
print(" WARNING: LIVE ACCOUNT — real money is at risk.")
print(" Ensure --mode sync_validate passed before continuing.")
@@ -400,6 +401,7 @@ def cmd_report(args):
df_aligned, probabilities, states_aligned,
split_idx=split_idx, account_size=balance, broker=broker, tf=tf,
prob_threshold=params.get("prob_threshold"),
short_threshold=params.get("short_threshold"),
)
arm = AdaptiveRiskManager(balance)
@@ -523,6 +525,8 @@ def main():
parser.add_argument("--account", type=str, default="demo",
choices=["live", "demo"],
help="Account type for --mode live. 'live' requires confirmation.")
parser.add_argument("--yes", action="store_true",
help="Skip the interactive live-account confirmation (used when launched as a subprocess).")
args = parser.parse_args()
{
+1 -1
View File
@@ -1 +1 @@
{"timestamp": 1775218546.8000524, "tf": "M5", "best_score": 5.398471466267043}
{"timestamp": 1775308945.3274121, "tf": "M5", "best_score": 1.333719333845955}
+65 -31
View File
@@ -17,8 +17,24 @@ PROB_THRESHOLD = 0.65
CHOP_STATE = 2
def compute_signals(probabilities, hmm_states, threshold=PROB_THRESHOLD):
return ((probabilities > threshold) & (hmm_states != CHOP_STATE)).astype(int)
def compute_signals(probabilities, hmm_states, threshold=PROB_THRESHOLD,
short_threshold=None):
"""Generate directional signals: 1=BUY, -1=SELL, 0=no trade.
BUY when prob > threshold and state != Chop
SELL when prob < short_threshold and state != Chop
``short_threshold`` defaults to ``1 - threshold`` when not supplied,
which gives a symmetric no-trade zone around 0.5.
Note: short_threshold must be < threshold to avoid a no-trade zone gap
inversion. If they cross Optuna penalises the trial via the objective.
"""
if short_threshold is None:
short_threshold = 1.0 - threshold
not_chop = (hmm_states != CHOP_STATE)
buy = ((probabilities > threshold) & not_chop).astype(int)
sell = ((probabilities < short_threshold) & not_chop).astype(int)
return buy - sell # 1=BUY, -1=SELL, 0=hold
def compute_position_sizes(
@@ -30,6 +46,11 @@ def compute_position_sizes(
):
"""Size positions using 1% risk / (2×ATR) with optional dual-position multiplier.
``signals`` may be -1 (SELL), 0 (hold), or 1 (BUY). The sign is
preserved so that short positions produce negative sizes, which when
multiplied by positive next_returns yield negative (loss) gross returns
— correctly reflecting a losing short trade when price rises.
Args:
pos_per_trade: 1 for small accounts (single), 2 for growth accounts (dual/hedging).
"""
@@ -47,6 +68,8 @@ def apply_session_limits(
) -> np.ndarray:
"""Cap signals to the adaptive daily limit based on account size and HMM state.
Counts both BUY (+1) and SELL (-1) signals toward the daily cap.
For growth accounts (> $50), the limit is market-state-dependent:
- Bull / Bear: 3 signals/day
- Chop: 2 signals/day
@@ -71,13 +94,14 @@ def apply_session_limits(
max_daily = limits["max_daily_trades"]
seg = result[start:end]
trade_indices = np.where(seg == 1)[0]
# Count BUY (+1) and SELL (-1) trades together toward the daily cap
trade_indices = np.where(np.abs(seg) == 1)[0]
if len(trade_indices) > max_daily:
seg[trade_indices[max_daily:]] = 0
result[start:end] = seg
original = int(np.sum(signals))
limited = int(np.sum(result))
original = int(np.sum(np.abs(signals)))
limited = int(np.sum(np.abs(result)))
if original != limited:
logger.debug(
"Session limit (acct=$%.0f): %d -> %d trades",
@@ -87,34 +111,37 @@ def apply_session_limits(
def compute_trade_costs(signals: np.ndarray, broker: str = "standard") -> np.ndarray:
"""Apply spread + commission cost to every active trade (BUY or SELL)."""
config = BROKER_CONFIGS.get(broker, BROKER_CONFIGS["standard"])
cost_per_trade = config["spread_frac"] + config["commission_frac"]
return signals * cost_per_trade
return np.abs(signals) * cost_per_trade # cost is always positive
def _compute_metrics(strategy_returns, signals, tf: str = "H1"):
ann_factor = ANNUALIZATION_FACTORS.get(tf.upper(), ANNUALIZATION_FACTOR)
mean_ret = np.mean(strategy_returns)
std_ret = np.std(strategy_returns)
sharpe = (mean_ret / std_ret) * ann_factor if std_ret > 0 else 0.0
std_ret = np.std(strategy_returns)
sharpe = (mean_ret / std_ret) * ann_factor if std_ret > 0 else 0.0
cumulative = np.cumsum(strategy_returns)
cumulative = np.cumsum(strategy_returns)
running_max = np.maximum.accumulate(cumulative)
drawdowns = running_max - cumulative
max_dd = float(np.max(drawdowns)) if len(drawdowns) > 0 else 0.0
drawdowns = running_max - cumulative
max_dd = float(np.max(drawdowns)) if len(drawdowns) > 0 else 0.0
active = signals == 1
# Count both BUY (+1) and SELL (-1) as active trades
active = np.abs(signals) == 1
active_returns = strategy_returns[active[:len(strategy_returns)]]
n_trades = int(np.sum(active))
win_rate = float(np.sum(active_returns > 0) / len(active_returns)) if len(active_returns) > 0 else 0.0
total_return = float(np.exp(np.sum(strategy_returns)) - 1)
n_trades = int(np.sum(active))
win_rate = (float(np.sum(active_returns > 0) / len(active_returns))
if len(active_returns) > 0 else 0.0)
total_return = float(np.exp(np.sum(strategy_returns)) - 1)
return {
"sharpe_ratio": float(sharpe),
"max_drawdown": max_dd,
"win_rate": win_rate,
"win_rate": win_rate,
"total_return": total_return,
"n_trades": n_trades,
"n_trades": n_trades,
}
@@ -127,6 +154,7 @@ def vectorized_backtest(
broker: str = "standard",
tf: str = "H1",
prob_threshold: float = None,
short_threshold: float = None,
):
"""Vectorized backtest with adaptive session limits and broker costs.
@@ -138,42 +166,47 @@ def vectorized_backtest(
account_size: Account balance in USD; drives adaptive session limits.
broker: Broker profile key from ``BROKER_CONFIGS``.
tf: Timeframe string used for correct annualization ("H1" or "M15").
prob_threshold: XGBoost probability threshold for signal generation.
prob_threshold: BUY threshold — signal when prob > this value.
Defaults to PROB_THRESHOLD (0.65) if not supplied.
short_threshold: SELL threshold — signal when prob < this value.
Defaults to ``1 - prob_threshold`` if not supplied,
giving a symmetric no-trade zone around 0.5.
"""
log_returns = df["log_return"].values
atr_norm = df["atr_normalized"].values
atr_norm = df["atr_normalized"].values
buy_th = prob_threshold if prob_threshold is not None else PROB_THRESHOLD
raw_signals = compute_signals(
probabilities, hmm_states,
threshold=prob_threshold if prob_threshold is not None else PROB_THRESHOLD,
threshold=buy_th,
short_threshold=short_threshold, # None → symmetric default inside
)
signals = apply_session_limits(raw_signals, df.index, account_size, hmm_states)
# Determine pos_per_trade from the adaptive risk manager
arm = AdaptiveRiskManager(account_size)
base_limits = arm.get_trade_limits()
arm = AdaptiveRiskManager(account_size)
base_limits = arm.get_trade_limits()
pos_per_trade = base_limits["pos_per_trade"]
sizes = compute_position_sizes(signals, atr_norm, pos_per_trade=pos_per_trade)
next_returns = np.roll(log_returns, -1)
next_returns = np.roll(log_returns, -1)
next_returns[-1] = 0.0
gross_returns = sizes * next_returns
costs = compute_trade_costs(signals, broker=broker)
gross_returns = sizes * next_returns
costs = compute_trade_costs(signals, broker=broker)
strategy_returns = gross_returns - costs
result = _compute_metrics(strategy_returns, signals, tf=tf)
result.update({
"account_size": account_size,
"broker": broker,
"tf": tf,
"account_size": account_size,
"broker": broker,
"tf": tf,
"session_max_trades": arm.get_trade_limits()["max_daily_trades"],
"pos_per_trade": pos_per_trade,
"pos_per_trade": pos_per_trade,
})
if split_idx is not None and 0 < split_idx < len(strategy_returns):
is_m = _compute_metrics(strategy_returns[:split_idx], signals[:split_idx], tf)
is_m = _compute_metrics(strategy_returns[:split_idx], signals[:split_idx], tf)
oos_m = _compute_metrics(strategy_returns[split_idx:], signals[split_idx:], tf)
result["split_idx"] = split_idx
for k, v in is_m.items():
@@ -202,7 +235,8 @@ def vectorized_backtest(
"small" if arm.is_small_account else "growth",
arm.get_trade_limits()["max_daily_trades"],
pos_per_trade,
BROKER_CONFIGS.get(broker, {}).get("spread_frac", 0) + BROKER_CONFIGS.get(broker, {}).get("commission_frac", 0),
BROKER_CONFIGS.get(broker, {}).get("spread_frac", 0)
+ BROKER_CONFIGS.get(broker, {}).get("commission_frac", 0),
)
return result
+8
View File
@@ -1,4 +1,5 @@
import logging
import sys
from pathlib import Path
LOG_DIR = Path("logs")
@@ -12,6 +13,13 @@ def setup_logger(name: str, level: int = logging.INFO) -> logging.Logger:
logger.setLevel(level)
fmt = logging.Formatter("[%(asctime)s %(levelname)s] %(name)s: %(message)s")
sh = logging.StreamHandler()
# On Windows the default console encoding (cp1252) can't render emoji from
# button labels — reconfigure stdout to UTF-8 so log lines never crash.
try:
if hasattr(sh.stream, "reconfigure"):
sh.stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
sh.setLevel(level)
sh.setFormatter(fmt)
logger.addHandler(sh)
+95 -39
View File
@@ -16,7 +16,7 @@ double-count trades.
import time
import numpy as np
import pandas as pd
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from src.notifier import send_telegram_msg
from src.logger import setup_logger
@@ -46,16 +46,18 @@ logger = setup_logger(__name__)
DEFAULT_SYMBOL = "XAUUSD"
MAGIC_NUMBER = 123456 # must match GoldRegimeX.mq5 MagicNumber
CHOP_STATE = 2 # HMM Chop state index
ATR_MULTIPLIER = 2.0 # SL = entry ± ATR × 2.0 (all TFs)
DEFAULT_DEVIATION = 20 # fallback deviation for check_margin / send_market_order
N_BARS_WARMUP = 200 # bars fetched for Kalman/HMM warm-up
POLL_INTERVAL_SEC = 5 # seconds between bar-change checks
HIGH_VOL_SELF_TRANS_THRESHOLD = 0.70 # self-transition prob below this -> elevated deviation
MIN_SPREAD_RATIO = 1.5 # TP1 must be at least 1.5× spread to be viable
# SL = ATR × multiplier (per TF — M5 tighter to avoid noise-outs on scalps)
TF_ATR_MULTIPLIER = {"M5": 1.5, "M15": 2.0, "H1": 2.0}
# ── Per-timeframe signal thresholds and order parameters ──────────────────────
TF_PROB_THRESHOLD = {"M5": 0.70, "M15": 0.65, "H1": 0.65}
TF_SHORT_THRESHOLD = {"M5": 0.30, "M15": 0.35, "H1": 0.35}
TF_SHORT_THRESHOLD = {"M5": 0.30, "M15": 0.35, "H1": 0.35} # fallback only
TF_DEFAULT_DEV = {"M5": 30, "M15": 20, "H1": 20}
TF_HIGH_VOL_DEV = {"M5": 50, "M15": 50, "H1": 50}
@@ -63,8 +65,10 @@ TF_HIGH_VOL_DEV = {"M5": 50, "M15": 50, "H1": 50}
# Bull / Bear: TP1 quick partial, TP2 runner.
# Chop: tighter single TP — position 2 (runner) is skipped.
# Format: {regime: [tp1_mult, tp2_mult]}; single-element list = one TP, close all.
# M5 uses tighter mults (0.8 / 2.0) — TP1 locks in quick profit, TP2 is realistic
# for a scalp runner vs the original [1.0, 3.0] which rarely filled on M5.
TF_TP_CONFIG = {
"M5": {"trending": [1.0, 3.0], "chop": [0.8]},
"M5": {"trending": [0.8, 2.0], "chop": [0.6]},
"M15": {"trending": [1.5, 3.0], "chop": [1.5]},
"H1": {"trending": [1.5, 3.0], "chop": [1.5]},
}
@@ -302,6 +306,31 @@ def _close_position(ticket: int, mt5) -> None:
ticket, res.retcode if res else "None")
def _log_closed_pnl(tickets: list, mt5) -> None:
"""Query MT5 deal history for each closed ticket and log realized P&L."""
from src.notifier import send_telegram_msg
now = datetime.now(timezone.utc)
start = now - timedelta(hours=48) # wide window covers overnight gaps
for ticket in tickets:
try:
deals = mt5.history_deals_get(start, now, position=ticket)
if deals:
pnl = sum(d.profit for d in deals)
tag = "WIN ✅" if pnl > 0 else "LOSS ❌"
logger.info(
"Position #%d CLOSED — P&L: %+.2f USD [%s]",
ticket, pnl, "WIN" if pnl > 0 else "LOSS",
)
send_telegram_msg(
f"{'✅' if pnl > 0 else '❌'} <b>Trade closed</b> #{ticket}\n"
f"Realized P&L: <b>{pnl:+.2f} USD</b> [{tag}]"
)
else:
logger.info("Position #%d CLOSED (P&L unavailable — not yet in history).", ticket)
except Exception as exc:
logger.warning("Could not fetch P&L for ticket #%d: %s", ticket, exc)
def _build_live_df(
symbol: str,
tf_mt5: int,
@@ -553,11 +582,12 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, dry_run: bool, mt
obs_cov = obs_cov if obs_cov is not None else cfg_tf["obs_cov_default"]
trans_cov = trans_cov if trans_cov is not None else cfg_tf["trans_cov_default"]
# Use optimized prob threshold if available; otherwise fall back to TF hardcoded values.
# Short threshold is symmetric: 1 - prob_threshold.
# Use optimized thresholds if available; otherwise fall back to TF hardcoded values.
# Both prob_threshold (BUY) and short_threshold (SELL) are now Optuna-tuned
# so the optimizer finds the best asymmetric no-trade zone for each TF.
if prob_threshold_opt is not None:
prob_threshold = prob_threshold_opt
short_threshold = 1.0 - prob_threshold_opt
short_threshold = params.get("short_threshold", 1.0 - prob_threshold_opt)
else:
prob_threshold = TF_PROB_THRESHOLD.get(tf.upper(), 0.65)
short_threshold = TF_SHORT_THRESHOLD.get(tf.upper(), 0.35)
@@ -598,6 +628,21 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, dry_run: bool, mt
continue
bar_time = bars[0]["time"]
if bar_time == last_bar_time:
# Between bars: detect position closures and report P&L immediately
if not dry_run and signal_tracker["tickets"]:
open_set = {
p.ticket
for p in (mt5.positions_get(symbol=DEFAULT_SYMBOL) or [])
if p.magic == MAGIC_NUMBER
}
closed = [t for t in signal_tracker["tickets"] if t not in open_set]
if closed:
_log_closed_pnl(closed, mt5)
signal_tracker["tickets"] = [
t for t in signal_tracker["tickets"] if t in open_set
]
if not signal_tracker["tickets"]:
signal_tracker["tp1_hit"] = False
time.sleep(POLL_INTERVAL_SEC)
continue
last_bar_time = bar_time
@@ -613,33 +658,18 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, dry_run: bool, mt
arm = AdaptiveRiskManager(account_size)
arm.daily_trades = daily_trades # restore session count
# ── 4. Compute live features ──────────────────────────────────
# ── 4. Compute live features + probability ────────────────────
features_df, hmm_state, atr_price = compute_live_features(
DEFAULT_SYMBOL, tf_mt5, model_hmm, obs_cov, trans_cov,
feature_cols=feature_cols, mt5=mt5,
)
# ── 5. Session limit check ────────────────────────────────────
if not arm.can_trade(hmm_state):
logger.info(
"Session limit reached (state=%d daily=%d/%d). Skipping bar.",
hmm_state, arm.daily_trades,
arm.get_trade_limits(hmm_state)["max_daily_trades"],
)
time.sleep(POLL_INTERVAL_SEC)
continue
# ── 6. Skip if position already open ─────────────────────────
if has_open_position(DEFAULT_SYMBOL, MAGIC_NUMBER):
logger.debug("Open position exists — skipping bar.")
time.sleep(POLL_INTERVAL_SEC)
continue
bar_str = datetime.fromtimestamp(bar_time, timezone.utc).strftime("%Y-%m-%d %H:%M")
state_lbl = {0: "Bull", 1: "Bear", 2: "Chop"}.get(hmm_state, str(hmm_state))
max_trades_today = arm.get_trade_limits(hmm_state)["max_daily_trades"]
_, _probs = get_predictions_ensemble(models_xgb, thresholds_xgb, features_df)
prob = float(_probs[0])
# ── 5. Log bar info on every new bar (always visible) ─────────
bar_str = datetime.fromtimestamp(bar_time, timezone.utc).strftime("%Y-%m-%d %H:%M")
state_lbl = {0: "Bull", 1: "Bear", 2: "Chop"}.get(hmm_state, str(hmm_state))
max_trades_today = arm.get_trade_limits(hmm_state)["max_daily_trades"]
logger.info(
"Bar %s | state=%s | prob=%.3f | trades=%d/%d",
bar_str, hmm_state, prob,
@@ -653,22 +683,24 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, dry_run: bool, mt
+ (" 🔴 <i>DRY RUN</i>" if dry_run else "")
)
# ── 7b. Break-even SL and Chop-exit position management ───────
# ── 6. Position management (always runs — P&L, break-even, chop-exit)
if not dry_run and signal_tracker["tickets"]:
open_tickets = {
open_set = {
p.ticket
for p in (mt5.positions_get(symbol=DEFAULT_SYMBOL) or [])
if p.magic == MAGIC_NUMBER
}
active = [t for t in signal_tracker["tickets"] if t in open_tickets]
# TP1 hit: first position closed → move runner's SL to break-even
active = [t for t in signal_tracker["tickets"] if t in open_set]
closed = [t for t in signal_tracker["tickets"] if t not in open_set]
# Log P&L for any positions that closed since last bar
if closed:
_log_closed_pnl(closed, mt5)
# TP1 hit: first position gone → move runner's SL to break-even
if len(active) < len(signal_tracker["tickets"]) and not signal_tracker["tp1_hit"]:
signal_tracker["tp1_hit"] = True
for ticket in active:
_move_sl_to_breakeven(
ticket, signal_tracker["entry_price"], mt5,
)
# Regime shifted to Chop while runner is active → close immediately
_move_sl_to_breakeven(ticket, signal_tracker["entry_price"], mt5)
# Regime shifted to Chop while runner active → close immediately
if hmm_state == CHOP_STATE and active:
for ticket in active:
_close_position(ticket, mt5)
@@ -676,7 +708,28 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, dry_run: bool, mt
active = []
signal_tracker["tickets"] = active
# ── 8. Signal routing (TF-specific thresholds) ───────────────
# ── 7. Session limit check ────────────────────────────────────
if not arm.can_trade(hmm_state):
logger.info(
"Session limit reached (state=%d daily=%d/%d). Skipping bar.",
hmm_state, arm.daily_trades,
arm.get_trade_limits(hmm_state)["max_daily_trades"],
)
time.sleep(POLL_INTERVAL_SEC)
continue
# ── 8. Skip new signals if a position is still open ──────────
if has_open_position(DEFAULT_SYMBOL, MAGIC_NUMBER):
logger.info(
"Open position — holding (prob=%.3f state=%s).",
prob, state_lbl,
)
time.sleep(POLL_INTERVAL_SEC)
continue
# ── 9. Signal routing — BUY and SELL (both Optuna-validated) ─
# XGB predicts P(next bar UP). Both thresholds are tuned by Optuna
# against the IS/OOS backtester so shorts are statistically validated.
if prob > prob_threshold and hmm_state != CHOP_STATE:
direction = "BUY"
order_type = mt5.ORDER_TYPE_BUY
@@ -684,7 +737,10 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, dry_run: bool, mt
direction = "SELL"
order_type = mt5.ORDER_TYPE_SELL
else:
logger.debug("No signal (prob=%.3f state=%d). Holding.", prob, hmm_state)
logger.info(
"No signal (prob=%.3f state=%s need >%.3f or <%.3f).",
prob, state_lbl, prob_threshold, short_threshold,
)
time.sleep(POLL_INTERVAL_SEC)
continue
@@ -692,7 +748,7 @@ def _run_loop_inner(tf: str, broker: str, account_size: float, dry_run: bool, mt
tp_mults = _tp_multipliers(tf, hmm_state)
limits = arm.get_trade_limits(hmm_state)
pos_per_trade = min(limits["pos_per_trade"], len(tp_mults))
sl_distance = max(atr_price * ATR_MULTIPLIER, 0.01)
sl_distance = max(atr_price * TF_ATR_MULTIPLIER.get(tf.upper(), 2.0), 0.01)
lot_total = arm.calculate_lot_size(stop_loss_pips=sl_distance)
lot_per_pos = max(0.01, round(lot_total / pos_per_trade, 2))
+43 -7
View File
@@ -51,8 +51,10 @@ RAM_PAUSE_SEC = 30 # seconds to sleep when RAM is low
# Study tier objectives --------------------------------------------------------
# Small accounts ($15–$50): extreme drawdown protection — Sharpe - DD×5.0
# Growth accounts (>$50): balanced with higher frequency — Sharpe - DD×3.0
# Both tiers now use 15% DD cap — BUY+SELL doubles trade frequency so the
# previous 10% cap rejected every trial even when OOS Sharpe was acceptable.
TIER_CONFIGS = {
"small": {"dd_penalty": 5.0, "dd_limit": 0.10},
"small": {"dd_penalty": 5.0, "dd_limit": 0.15},
"growth": {"dd_penalty": 3.0, "dd_limit": 0.15},
}
@@ -76,16 +78,31 @@ def make_objective(balance: float = 15.0, broker: str = "standard", tf: str = "H
min_oos_trades = MIN_OOS_TRADES_BY_TF.get(tf.upper(), MIN_OOS_TRADES)
def objective(trial: optuna.Trial) -> float:
obs_cov = trial.suggest_float("obs_cov", 0.1, 5.0, log=True)
# M5: obs_cov floor raised to 1.0 — values below ~0.5 produce a
# degenerate Kalman filter where Bull/Chop become identical states
# and the HMM fires 500K+ transitions (every 5-min bar).
# H1/M15 keep the wide range since their bars carry more information.
if tf.upper() == "M5":
obs_cov = trial.suggest_float("obs_cov", 1.0, 5.0, log=True)
else:
obs_cov = trial.suggest_float("obs_cov", 0.1, 5.0, log=True)
trans_cov = trial.suggest_float("trans_cov", 0.001, 0.1, log=True)
n_states = trial.suggest_int("n_states", 2, 4)
# M5 probs cluster below 0.56 in live — narrow range forces the
# optimizer to find high-frequency signals in the 0.50–0.55 window.
# H1/M15 use a wider buffer since their bars carry more information.
# short_threshold must stay below prob_threshold (no-trade zone must exist).
if tf.upper() == "M5":
prob_threshold = trial.suggest_float("prob_threshold", 0.50, 0.55)
prob_threshold = trial.suggest_float("prob_threshold", 0.50, 0.55)
short_threshold = trial.suggest_float("short_threshold", 0.44, 0.50)
else:
prob_threshold = trial.suggest_float("prob_threshold", 0.52, 0.65)
prob_threshold = trial.suggest_float("prob_threshold", 0.52, 0.65)
short_threshold = trial.suggest_float("short_threshold", 0.35, 0.48)
# Guard: thresholds must not overlap — a crossover means every bar gets
# both a BUY and SELL signal simultaneously, which is nonsensical.
if short_threshold >= prob_threshold:
return -10.0
max_depth = trial.suggest_int("max_depth", 3, 5)
learning_rate = trial.suggest_float("learning_rate", 0.01, 0.3, log=True)
@@ -124,6 +141,7 @@ def make_objective(balance: float = 15.0, broker: str = "standard", tf: str = "H
broker=broker,
tf=tf,
prob_threshold=prob_threshold,
short_threshold=short_threshold,
)
# Score on OOS only to prevent IS data leakage
@@ -367,8 +385,26 @@ def get_best_params(
broker: str = "standard",
tf: str = "H1",
) -> dict:
"""Load the best hyperparameters from the persisted SQLite study."""
"""Load the best hyperparameters from the persisted SQLite study.
Tries the tier matching *balance* first. If that study doesn't exist yet
(e.g. growth tier has never been optimized) it falls back to the small tier
so that Optuna thresholds are always used rather than hardcoded defaults.
"""
tier = _get_tier(balance)
name = _study_name(broker=broker, tier=tier, tf=tf)
study = optuna.load_study(study_name=name, storage=STUDY_DB)
return study.best_params
try:
study = optuna.load_study(study_name=name, storage=STUDY_DB)
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=STUDY_DB)
return study.best_params
+49 -27
View File
@@ -5,17 +5,18 @@ the authorised user and dispatches them as subprocesses so the listener
thread is never blocked.
Keyboard layout:
┌──────────────────────────────┬─────────────────────┐
│ 🚀 START TRADING │ 🛑 STOP TRADING │
├──────────────────────────────┼─────────────────────┤
│ 📉 OPTIMIZE M5 (0.50-0.55) │ 📊 BOT STATUS │
└──────────────────────────────┴─────────────────────┘
┌──────────────┬──────────────┬──────────────┐
│ 🚀 START M5 │ 🚀 START M15 │ 🚀 START H1 │
├──────────────┴──────┬───────┴──────────────┤
│ 🛑 STOP TRADING │ 📉 OPTIMIZE M5 │
├─────────────────────┴──────────────────────┤
│ 📊 BOT STATUS │
└────────────────────────────────────────────┘
Required env vars (see .env.example):
TELEGRAM_BOT_TOKEN — BotFather token
TELEGRAM_CHAT_ID — Your chat ID (used for outbound heartbeat messages)
ALLOWED_USER_ID — Your numeric Telegram user ID (security gate)
LIVE_TF — Default TF for START TRADING (default: M5)
LIVE_BROKER — Default broker (default: headway_cent)
LIVE_BALANCE — Default balance (default: 15)
@@ -40,8 +41,8 @@ logger = setup_logger(__name__)
# one_time_keyboard=False → stays visible after every tap
_KEYBOARD = {
"keyboard": [
["🚀 START TRADING", "🛑 STOP TRADING"],
["📉 OPTIMIZE M5 (0.50-0.55)", "📊 BOT STATUS"],
["🚀 START M5", "🚀 START M15", "🚀 START H1"],
["🛑 STOP TRADING", "📉 OPTIMIZE M5 (0.50-0.55)", "📊 BOT STATUS"],
],
"resize_keyboard": True,
"one_time_keyboard": False,
@@ -101,8 +102,10 @@ def _handle(token: str, chat_id, user_id: str, text: str) -> None:
token, chat_id,
"<b>Gold Regime X — Command Center</b>\n\n"
"Use the buttons below to manage the trading system remotely.\n\n"
"<b>🚀 START TRADING</b> — Launch the live M5 loop\n"
"<b>🛑 STOP TRADING</b> — Terminate the live loop\n"
"<b>🚀 START M5</b> — Launch the live M5 trading loop\n"
"<b>🚀 START M15</b> — Launch the live M15 trading loop\n"
"<b>🚀 START H1</b> — Launch the live H1 trading loop\n"
"<b>🛑 STOP TRADING</b> — Terminate all active trading loops\n"
"<b>📉 OPTIMIZE M5</b> — Resume/start M5 optimisation (0.50–0.55 range)\n"
"<b>📊 BOT STATUS</b> — Live P&amp;L, daily trade count, process health",
)
@@ -113,26 +116,41 @@ def _handle(token: str, chat_id, user_id: str, text: str) -> None:
_api(token, "sendMessage", chat_id=chat_id, text="Unauthorized.")
return
tf = os.getenv("LIVE_TF", "M5")
broker = os.getenv("LIVE_BROKER", "headway_cent")
balance = os.getenv("LIVE_BALANCE", "15")
if text == "🚀 START TRADING":
if _proc_alive("trading"):
_reply(token, chat_id, "Trading is already running.")
def _start_tf(tf: str) -> None:
"""Launch a live trading loop for *tf* if one is not already running."""
key = f"trading_{tf}"
if _proc_alive(key):
_reply(token, chat_id, f"{tf} trading is already running.")
return
_procs["trading"] = subprocess.Popen([
_procs[key] = subprocess.Popen([
"python", "main.py",
"--mode", "live", "--account", "live",
"--mode", "live", "--account", "live", "--yes",
"--tf", tf, "--broker", broker, "--balance", balance,
])
_reply(token, chat_id,
f"<b>Trading started</b>\nTF={tf} broker={broker} balance=${balance}")
f"<b>{tf} trading started</b>\nbroker={broker} balance=${balance}")
if text == "🚀 START M5":
_start_tf("M5")
elif text == "🚀 START M15":
_start_tf("M15")
elif text == "🚀 START H1":
_start_tf("H1")
elif text == "🛑 STOP TRADING":
if _proc_alive("trading"):
_procs["trading"].terminate()
_reply(token, chat_id, "<b>Trading stopped.</b>")
stopped = []
for tf in ("M5", "M15", "H1"):
key = f"trading_{tf}"
if _proc_alive(key):
_procs[key].terminate()
stopped.append(tf)
if stopped:
_reply(token, chat_id, f"<b>Trading stopped.</b> ({', '.join(stopped)})")
else:
_reply(token, chat_id, "No active trading process found.")
@@ -155,14 +173,18 @@ def _handle(token: str, chat_id, user_id: str, text: str) -> None:
elif text == "📊 BOT STATUS":
# ── Process health ───────────────────────────────────────────────────
trading_icon = "✅" if _proc_alive("trading") else "❌"
lines = ["<b>System Status</b>"]
for tf in ("M5", "M15", "H1"):
key = f"trading_{tf}"
alive = _proc_alive(key)
icon = "✅" if alive else "❌"
state = "Running" if alive else "Stopped"
lines.append(f"{tf} Loop: {icon} {state}")
optimizer_icon = "🔄" if _proc_alive("optimizer") else "💤"
proc_block = (
"<b>System Status</b>\n"
f"Trading Loop: {trading_icon} {'Running' if _proc_alive('trading') else 'Stopped'}\n"
f"Optimizer: {optimizer_icon} {'Running' if _proc_alive('optimizer') else 'Idle'}\n"
f"{'—'*28}\n"
)
opt_state = "Running" if _proc_alive("optimizer") else "Idle"
lines.append(f"Optimizer: {optimizer_icon} {opt_state}")
lines.append("—" * 28)
proc_block = "\n".join(lines) + "\n"
# ── P&L report from auditor ──────────────────────────────────────────
# Session limit: 2/day for small accounts (≤$50), 3 otherwise
+11 -3
View File
@@ -126,12 +126,20 @@ class AdaptiveRiskManager:
}
def calculate_lot_size(self, stop_loss_pips: float) -> float:
"""1% risk rule with Headway Cent pip-value adjustment."""
"""1% risk rule for Headway cent XAUUSD.
On the Headway cent account the effective contract is 1 oz per lot, so
each $1 price move = $1 P&L per lot. Formula:
lot = (balance × 1%) / sl_price_distance
Example: $15 balance, 14.27-pt SL → $0.15 / 14.27 = 0.0105 → 0.01 lot.
This mirrors CentConverter.calculate_lot() which is already correct.
"""
risk_per_trade = self.balance * 0.01
if stop_loss_pips <= 0:
return 0.01
# Cent account pip value: stop_loss_pips × 0.01
lot_size = risk_per_trade / (stop_loss_pips * 0.01)
lot_size = risk_per_trade / stop_loss_pips
return max(0.01, round(lot_size, 2))
def can_trade(self, market_state: int = None) -> bool:
+2
View File
@@ -155,6 +155,8 @@ def run_validation(
account_size=account_size,
broker=broker,
tf=tf,
prob_threshold=params.get("prob_threshold"),
short_threshold=params.get("short_threshold"),
)
sharpe = result["sharpe_ratio"]