feat(backtest): ATR quality filter, pre-fill invalidation, entry zone config

Backtest improvements (XAUUSDm 20k candles vs baseline PF 1.12):
- ATR filter: FVG zone >= 0.3× ATR(14), impulse body >= 1.0× ATR(14) → PF 1.29, DD -3358
- Pre-fill invalidation: cancel pending FVG if price closes past impulse SL → PF 1.17
- ATR + pre-fill combined: PF 1.32, lowest DD -3230 (best overall)
- Entry zone config (ENTRY_ZONE_PCT): 0.0 aggressive / 0.5 midpoint / 1.0 conservative
  - Aggressive (0.0) hurts severely (PF 0.84), conservative (1.0) raises WR to 52.8%

New env vars: MIN_FVG_ATR_RATIO, MIN_IMPULSE_ATR_RATIO, PRE_FILL_INVALIDATE, ENTRY_ZONE_PCT
Rolling ATR(14) added to helpers using Wilder's RMA smoothing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
romysaputrasihananda
2026-06-12 01:44:58 +07:00
parent 272fca4f65
commit 9157c0d4f6
4 changed files with 170 additions and 13 deletions
+12
View File
@@ -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.
+123 -12
View File
@@ -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<Option<Decimal>> = 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!("─────────────────────────────────────────");
+23 -1
View File
@@ -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<Option<Decimal>> {
let n = candles.len();
let mut out = vec![None; n];
if n < period { return out; }
let trs: Vec<Decimal> = 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>() / 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<Option<Decimal>> {
let k = Decimal::from(2u32) / Decimal::from((period + 1) as u32);
let mut out = vec![None; prices.len()];
+12
View File
@@ -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<u32> = 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 {