diff --git a/.env.example b/.env.example index 5e6abad..5b5140e 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,18 @@ BACKTEST_CANDLES=50000 # 0 = disabled, 0.03 = stop after -3% day # DAILY_LOSS_LIMIT_PCT=0.03 +# ATR quality filters (backtest + live): reject weak FVGs/impulses. +# Backtest shows ATR filter improves PF from 1.12 → 1.29 on XAUUSDm 20k candles. +# MIN_FVG_ATR_RATIO=0.3 # FVG zone must be >= 0.3× ATR(14) +# MIN_IMPULSE_ATR_RATIO=1.0 # Impulse candle body must be >= 1.0× ATR(14) + +# Pre-fill invalidation: cancel pending FVG if price closes past impulse SL before fill. +# Combined with ATR filter: PF 1.32, lowest DD. Recommended. +# PRE_FILL_INVALIDATE=true + +# Entry zone within FVG: 0.0 = zone edge (aggressive), 0.5 = midpoint (default), 1.0 = far edge (conservative). +# ENTRY_ZONE_PCT=0.5 + # ── Telegram Notifications ─────────────────────────────────────────────────── # Get token from @BotFather, chat_id from @userinfobot or Telegram API. # All three are optional — omit to disable notifications. diff --git a/src/backtest.rs b/src/backtest.rs index 1a6a838..e69c4c0 100644 --- a/src/backtest.rs +++ b/src/backtest.rs @@ -4,7 +4,7 @@ use domain::Side; use rust_decimal::Decimal; use crate::detector; -use crate::helpers::{actual_entry, actual_exit, fmt_price, fmt_pnl, rolling_ema, size_position}; +use crate::helpers::{actual_entry, actual_exit, fmt_price, fmt_pnl, rolling_atr, rolling_ema, size_position}; // ── config ──────────────────────────────────────────────────────────────────── @@ -39,6 +39,18 @@ pub struct BacktestConfig { pub breakeven_at_rr: Decimal, /// Stop trading today when realised PnL < -(balance × this). 0 = disabled. pub daily_loss_limit_pct: Decimal, + /// Close this fraction of position at TP1 then run remainder to TP2. 0 = disabled. + pub partial_close_pct: Decimal, + /// RR multiple for TP2 (only used when partial_close_pct > 0). + pub tp2_rr: Decimal, + /// Cancel pending FVG if price closes beyond impulse SL before fill. + pub pre_fill_invalidate: bool, + /// Min FVG zone width as multiple of ATR(14). 0 = disabled. + pub min_fvg_atr_ratio: Decimal, + /// Min impulse candle body as multiple of ATR(14). 0 = disabled. + pub min_impulse_atr_ratio: Decimal, + /// Entry position within FVG zone: 0.0 = zone edge (aggressive), 0.5 = midpoint, 1.0 = far edge (conservative). + pub entry_zone_pct: Decimal, } // ── open trade ──────────────────────────────────────────────────────────────── @@ -50,9 +62,13 @@ struct OpenTrade { actual_entry: Decimal, sl: Decimal, tp: Decimal, + tp2: Decimal, volume: Decimal, + volume_initial: Decimal, open_candle_idx: usize, - be_set: bool, // breakeven already applied + be_set: bool, + partial_done: bool, + partial_pnl: Decimal, } // ── entry point ─────────────────────────────────────────────────────────────── @@ -83,6 +99,7 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig } else { vec![None; total] }; + let atr_vals: Vec> = rolling_atr(&candles, 14); tracing::info!(total, %symbol, "starting walk-forward"); @@ -144,6 +161,51 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig } } + // partial TP1: close fraction, move SL to BE, let remainder run to TP2 + if !t.partial_done && cfg.partial_close_pct > Decimal::ZERO && t.tp2 > Decimal::ZERO { + let sl_now = match t.side { Side::Long => candle.low <= t.sl, Side::Short => candle.high >= t.sl }; + let tp1_now = match t.side { Side::Long => candle.high >= t.tp, Side::Short => candle.low <= t.tp }; + if !sl_now && tp1_now { + let step = sym_info.volume_step; + let close_vol = ((t.volume_initial * cfg.partial_close_pct) / step).floor() * step; + let close_vol = close_vol.max(sym_info.volume_min).min(t.volume); + let p_exit = actual_exit(t.side, t.tp, false, spread_price, slippage_price); + let commission_p = cfg.commission * close_vol; + let pr = if profit_is_usd || p_exit <= Decimal::ZERO { Decimal::ONE } else { Decimal::ONE / p_exit }; + let ppnl = (match t.side { + Side::Long => (p_exit - t.actual_entry) * close_vol * contract_size, + Side::Short => (t.actual_entry - p_exit) * close_vol * contract_size, + }) * pr - commission_p; + let fl_r = if profit_is_usd || t.tp <= Decimal::ZERO { Decimal::ONE } else { Decimal::ONE / t.tp }; + let fl_p = (match t.side { + Side::Long => (t.tp - t.entry_level) * close_vol * contract_size, + Side::Short => (t.entry_level - t.tp) * close_vol * contract_size, + }) * fl_r; + total_friction += fl_p - ppnl; + balance += ppnl; + if balance > peak { peak = balance; } + let dd = balance - peak; + if dd < max_drawdown { max_drawdown = dd; } + t.partial_pnl = ppnl; + t.partial_done = true; + t.sl = t.actual_entry; + t.be_set = true; + t.volume = t.volume - close_vol; + let old_tp = t.tp; + t.tp = t.tp2; + println!( + "[{} {}] {} {} entry={} vol={:.2} → PARTIAL_TP {:.0}% exit={} pnl={} remain={:.2}lot → TP2={} bal={:.2}", + t.open_time, cfg.tf_str, symbol, + if t.side == Side::Long { "LONG " } else { "SHORT" }, + fmt_price(t.actual_entry, prec), t.volume_initial, + cfg.partial_close_pct * Decimal::from(100u32), + fmt_price(p_exit, prec), fmt_pnl(ppnl), t.volume, + fmt_price(old_tp, prec), balance, + ); + continue; + } + } + // timeout if cfg.timeout_candles > 0 && (i - t.open_candle_idx) >= cfg.timeout_candles { let t = open_trade.take().unwrap(); @@ -239,14 +301,15 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig if balance > peak { peak = balance; } let dd = balance - peak; if dd < max_drawdown { max_drawdown = dd; } - if is_sl { - losses += 1; sum_losses += pnl.abs(); + let total_trade_pnl = t.partial_pnl + pnl; + if total_trade_pnl >= Decimal::ZERO { + wins += 1; sum_wins += total_trade_pnl; cur_consec = 0; + } else { + losses += 1; sum_losses += total_trade_pnl.abs(); cur_consec += 1; if cur_consec > max_consec { max_consec = cur_consec; } - } else { - wins += 1; sum_wins += pnl; cur_consec = 0; } - trades += 1; total_pnl += pnl; total_friction += friction; + trades += 1; total_pnl += total_trade_pnl; total_friction += friction; let be_tag = if t.be_set { " [BE]" } else { "" }; println!( "[{} {}] {} {} entry={} sl={} tp={} vol={:.2} → {label}{be_tag} exit={} friction={} pnl={} bal={:.2}", @@ -289,6 +352,17 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig pending_fvg = None; } + // ── pre-fill invalidation: cancel if price closes past impulse SL ───── + if cfg.pre_fill_invalidate { + if let Some(ref fvg) = pending_fvg { + let invalidated = match fvg.side { + Side::Long => candle.close < fvg.impulse_sl, + Side::Short => candle.close > fvg.impulse_sl, + }; + if invalidated { missed_fills += 1; pending_fvg = None; } + } + } + // ── try to fill pending FVG ─────────────────────────────────────────── if let Some(ref fvg) = pending_fvg { if fvg.is_touched(candle) { @@ -331,15 +405,23 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig { None => { pending_fvg = None; continue; } Some(v) => { - let ae = actual_entry(fvg.side, fvg.entry, spread_price); + let ae = actual_entry(fvg.side, fvg.entry, spread_price); + let tp2 = if cfg.partial_close_pct > Decimal::ZERO { + match fvg.side { + Side::Long => fvg.entry + sl_dist * cfg.tp2_rr, + Side::Short => fvg.entry - sl_dist * cfg.tp2_rr, + } + } else { Decimal::ZERO }; open_trade = Some(OpenTrade { open_time: candle.time.format("%Y-%m-%d %H:%M").to_string(), side: fvg.side, entry_level: fvg.entry, actual_entry: ae, - sl, tp, volume: v, + sl, tp, tp2, volume: v, volume_initial: v, open_candle_idx: i, be_set: false, + partial_done: false, + partial_pnl: Decimal::ZERO, }); pending_fvg = None; } @@ -352,10 +434,36 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig } // ── detect new momentum FVG ─────────────────────────────────────────── - pending_fvg = detector::detect( + let mut fvg = detector::detect( &candles[i - 2], &candles[i - 1], candle, cfg.body_pct_min, cfg.close_pct_min, min_zone_size, i, cfg.fvg_expiry, ); + + // ATR quality filters + if fvg.is_some() && (cfg.min_fvg_atr_ratio > Decimal::ZERO || cfg.min_impulse_atr_ratio > Decimal::ZERO) { + if let Some(atr) = atr_vals.get(i).copied().flatten() { + if let Some(ref f) = fvg { + let zone_width = f.zone_high - f.zone_low; + let impulse = &candles[i - 1]; + let impulse_body = (impulse.close - impulse.open).abs(); + let fvg_ok = cfg.min_fvg_atr_ratio == Decimal::ZERO || zone_width >= atr * cfg.min_fvg_atr_ratio; + let body_ok = cfg.min_impulse_atr_ratio == Decimal::ZERO || impulse_body >= atr * cfg.min_impulse_atr_ratio; + if !fvg_ok || !body_ok { fvg = None; } + } + } + } + + // Entry zone adjustment (0.0 = zone edge aggressive, 0.5 = midpoint, 1.0 = far edge conservative) + if let Some(ref mut f) = fvg { + if cfg.entry_zone_pct != Decimal::from_str_exact("0.5").unwrap_or_default() { + f.entry = match f.side { + Side::Long => f.zone_high - (f.zone_high - f.zone_low) * cfg.entry_zone_pct, + Side::Short => f.zone_low + (f.zone_high - f.zone_low) * cfg.entry_zone_pct, + }; + } + } + + pending_fvg = fvg; } // ── end-of-data timeout ─────────────────────────────────────────────────── @@ -398,13 +506,16 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig let dl_str = if cfg.daily_loss_limit_pct > Decimal::ZERO { format!(" daily_loss_limit={}%", cfg.daily_loss_limit_pct * Decimal::from(100u32)) } else { String::new() }; + let partial_str = if cfg.partial_close_pct > Decimal::ZERO { + format!(" partial={:.0}%@TP1+TP2@{}×RR", cfg.partial_close_pct * Decimal::from(100u32), cfg.tp2_rr) + } else { String::new() }; println!("─────────────────────────────────────────"); println!("Ares Scalper: {} {} | {} candles", symbol, cfg.tf_str, total); let timeout_str = if cfg.timeout_candles > 0 { format!(" timeout={}c", cfg.timeout_candles) } else { String::new() }; - println!("Strategy : Momentum FVG body≥{} close≥{} expiry={}c min_fvg={}pip min_sl={}pip min_rr={}{}{}{}{}", + println!("Strategy : Momentum FVG body≥{} close≥{} expiry={}c min_fvg={}pip min_sl={}pip min_rr={}{}{}{}{}{}", cfg.body_pct_min, cfg.close_pct_min, cfg.fvg_expiry, cfg.min_fvg_pips, cfg.min_sl_pips, cfg.min_rr, - timeout_str, session_str, be_str, dl_str); + timeout_str, session_str, be_str, dl_str, partial_str); println!("Friction : spread={} slip={} commission/lot={}", fmt_price(spread_price, prec), fmt_price(slippage_price, prec), cfg.commission); println!("─────────────────────────────────────────"); diff --git a/src/helpers.rs b/src/helpers.rs index 99b5544..d27dcda 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -1,6 +1,28 @@ -use domain::Side; +use domain::{Candle, Side}; use rust_decimal::Decimal; +/// Wilder's RMA-smoothed ATR(period). Returns None for indices < period. +pub fn rolling_atr(candles: &[Candle], period: usize) -> Vec> { + let n = candles.len(); + let mut out = vec![None; n]; + if n < period { return out; } + let trs: Vec = candles.iter().enumerate().map(|(i, c)| { + let hl = c.high - c.low; + if i == 0 { return hl; } + let pc = candles[i - 1].close; + hl.max((c.high - pc).abs()).max((c.low - pc).abs()) + }).collect(); + let seed: Decimal = trs[..period].iter().sum::() / Decimal::from(period); + out[period - 1] = Some(seed); + let mut atr = seed; + let k = Decimal::ONE / Decimal::from(period); + for i in period..n { + atr = trs[i] * k + atr * (Decimal::ONE - k); + out[i] = Some(atr); + } + out +} + pub fn rolling_ema(prices: &[Decimal], period: usize) -> Vec> { let k = Decimal::from(2u32) / Decimal::from((period + 1) as u32); let mut out = vec![None; prices.len()]; diff --git a/src/main.rs b/src/main.rs index 64aa84b..f25ef13 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,6 +92,12 @@ async fn main() -> anyhow::Result<()> { let ema_period = env_usize("EMA_PERIOD", "20")?; let breakeven_at_rr = env_dec("BREAKEVEN_AT_RR", "0")?; let daily_loss_limit_pct = env_dec("DAILY_LOSS_LIMIT_PCT", "0")?; + let partial_close_pct = env_dec("PARTIAL_CLOSE_PCT", "0")?; + let tp2_rr = env_dec("TP2_RR", "3.0")?; + let pre_fill_invalidate = env_str("PRE_FILL_INVALIDATE", "false") == "true"; + let min_fvg_atr_ratio = env_dec("MIN_FVG_ATR_RATIO", "0")?; + let min_impulse_atr_ratio = env_dec("MIN_IMPULSE_ATR_RATIO", "0")?; + let entry_zone_pct = env_dec("ENTRY_ZONE_PCT", "0.5")?; let session_from_utc: Option = match std::env::var("SESSION_FROM_UTC") { Ok(s) if !s.is_empty() => Some(s.parse().context("SESSION_FROM_UTC")?), _ => None, @@ -185,6 +191,12 @@ async fn main() -> anyhow::Result<()> { session_to_utc, breakeven_at_rr, daily_loss_limit_pct, + partial_close_pct, + tp2_rr, + pre_fill_invalidate, + min_fvg_atr_ratio, + min_impulse_atr_ratio, + entry_zone_pct, }; for symbol in &symbols {