mirror of
https://github.com/lucasmos/GoldRegime_X.git
synced 2026-11-02 06:26:15 +00:00
feat: enhance trading logic with short_threshold parameter and update logging for P&L tracking
This commit is contained in:
@@ -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
@@ -1 +1 @@
|
||||
{"timestamp": 1775218546.8000524, "tf": "M5", "best_score": 5.398471466267043}
|
||||
{"timestamp": 1775308945.3274121, "tf": "M5", "best_score": 1.333719333845955}
|
||||
+65
-31
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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&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
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user