mirror of
https://github.com/RomySaputraSihananda/ares.git
synced 2026-07-27 18:47:53 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user