feat: initial Ares scalping bot — momentum FVG strategy

Strategy: detect 3-candle momentum FVG on M5, enter on retrace fill.

Signal logic:
- Candle[i-1] must be a momentum candle: body ≥ BODY_PCT_MIN (default 60%)
  and close in top/bottom CLOSE_PCT_MIN (default 80%) of range
- FVG must exist between candle[i-2] and candle[i] (gap on momentum side)
- FVG zone midpoint = limit entry level
- SL: just outside FVG zone (+ optional SL_BUFFER)
- TP: entry ± SL_distance × MIN_RR

Features:
- EMA trend filter (EMA_PERIOD, same timeframe)
- FVG expiry (FVG_EXPIRY_CANDLES): stale setups auto-invalidate
- Currency conversion for non-USD profit pairs (JPY, CAD, CHF)
- Full friction model: spread, slippage, commission
- Date range filter (DATE_FROM / DATE_TO)
- Same stats output format as Hermes

Dependencies: domain + mt5-client from hermes/crates (path deps)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
romysaputrasihananda
2026-06-09 04:01:40 +07:00
commit e0e421ed05
6 changed files with 2679 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# MT5 bridge URL (required)
MT5_BASE_URL=http://localhost:8080
# ── Symbol & Timeframe ──────────────────────────────────────────────────────
SYMBOL=EURUSDm
TIMEFRAME=M5
# ── Backtest ─────────────────────────────────────────────────────────────────
BACKTEST_BALANCE=600
BACKTEST_CANDLES=50000
# DATE_FROM=2025-01-01
# DATE_TO=2025-12-31
# ── Risk ─────────────────────────────────────────────────────────────────────
RISK_PCT=0.01 # risk per trade (0.01 = 1%)
# ── Momentum Candle Thresholds ───────────────────────────────────────────────
BODY_PCT_MIN=0.6 # minimum body/range ratio (0.6 = 60% body)
CLOSE_PCT_MIN=0.8 # close must be in top/bottom 20% of range
# ── FVG Setup ────────────────────────────────────────────────────────────────
FVG_EXPIRY_CANDLES=10 # invalidate setup after N candles without fill
SL_BUFFER=0 # extra buffer below/above FVG zone for SL (price units)
MIN_RR=1.5 # minimum reward:risk ratio
# ── Friction ─────────────────────────────────────────────────────────────────
COMMISSION_PER_LOT=7 # round-trip commission in USD
SLIPPAGE_POINTS=5 # extra SL slippage in MT5 points
SPREAD_OVERRIDE=0 # override spread (0 = Zero/Raw account)
# ── EMA Trend Filter ─────────────────────────────────────────────────────────
EMA_PERIOD=20 # 0 = disabled
+1
View File
@@ -0,0 +1 @@
/target
Generated
+2070
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "ares"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "ares"
path = "src/main.rs"
[dependencies]
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
rust_decimal = { version = "1", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
domain = { path = "../hermes/crates/domain" }
mt5-client = { path = "../hermes/crates/mt5-client" }
+154
View File
@@ -0,0 +1,154 @@
use domain::{Candle, Side};
use rust_decimal::Decimal;
/// A momentum FVG setup pending entry fill.
#[derive(Debug, Clone)]
pub struct PendingFvg {
pub side: Side,
pub zone_high: Decimal,
pub zone_low: Decimal,
pub entry: Decimal, // FVG midpoint — limit order level
pub expiry_idx: usize, // invalidate if not filled by this walk-forward index
}
impl PendingFvg {
/// Returns true if `c` touches the FVG zone (limit-order fill semantics).
pub fn is_touched(&self, c: &Candle) -> bool {
match self.side {
Side::Long => c.low <= self.zone_high,
Side::Short => c.high >= self.zone_low,
}
}
}
/// Classify a candle as momentum (Long/Short) or None.
///
/// Criteria:
/// - body/range >= `body_pct_min` (e.g. 0.6 = 60% body)
/// - close is in the top `close_pct_min` fraction for bullish
/// (or bottom for bearish); e.g. 0.8 means close ≥ 80% of range from low
pub fn momentum_side(c: &Candle, body_pct_min: Decimal, close_pct_min: Decimal) -> Option<Side> {
let range = c.high - c.low;
if range == Decimal::ZERO {
return None;
}
let body_pct = (c.close - c.open).abs() / range;
if body_pct < body_pct_min {
return None;
}
let close_pos = (c.close - c.low) / range; // 0 = at low, 1 = at high
if c.close > c.open && close_pos >= close_pct_min {
Some(Side::Long)
} else if c.close < c.open && close_pos <= (Decimal::ONE - close_pct_min) {
Some(Side::Short)
} else {
None
}
}
/// Attempt to build a PendingFvg from three consecutive candles `[pre, impulse, post]`.
///
/// Rules:
/// - `impulse` must qualify as a momentum candle
/// - There must be a price gap between `pre` and `post` matching the momentum side
/// (bullish: post.low > pre.high; bearish: post.high < pre.low)
///
/// `post_idx` is the walk-forward index of `post` (used to set expiry).
pub fn detect(
pre: &Candle,
impulse: &Candle,
post: &Candle,
body_pct_min: Decimal,
close_pct_min: Decimal,
post_idx: usize,
expiry_candles: usize,
) -> Option<PendingFvg> {
let side = momentum_side(impulse, body_pct_min, close_pct_min)?;
let (zone_low, zone_high) = match side {
Side::Long if post.low > pre.high => (pre.high, post.low),
Side::Short if post.high < pre.low => (post.high, pre.low),
_ => return None,
};
if zone_high <= zone_low {
return None;
}
let entry = (zone_high + zone_low) / Decimal::from(2u32);
Some(PendingFvg {
side,
zone_high,
zone_low,
entry,
expiry_idx: post_idx + expiry_candles,
})
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn candle(o: &str, h: &str, l: &str, c: &str) -> Candle {
Candle {
time: Utc::now(),
open: o.parse().unwrap(),
high: h.parse().unwrap(),
low: l.parse().unwrap(),
close: c.parse().unwrap(),
tick_volume: 100,
spread: 2,
real_volume: 0,
}
}
#[test]
fn momentum_bullish_detected() {
// body = 0.0080 / range = 0.0090 = 89%; close at top
let c = candle("1.1000", "1.1095", "1.1005", "1.1080");
let side = momentum_side(&c, "0.6".parse().unwrap(), "0.8".parse().unwrap());
assert_eq!(side, Some(Side::Long));
}
#[test]
fn momentum_bearish_detected() {
let c = candle("1.1080", "1.1085", "1.0995", "1.1005");
let side = momentum_side(&c, "0.6".parse().unwrap(), "0.8".parse().unwrap());
assert_eq!(side, Some(Side::Short));
}
#[test]
fn small_body_rejected() {
// doji — body is tiny
let c = candle("1.1040", "1.1090", "1.1000", "1.1045");
let side = momentum_side(&c, "0.6".parse().unwrap(), "0.8".parse().unwrap());
assert_eq!(side, None);
}
#[test]
fn bullish_fvg_detected() {
let pre = candle("1.1000", "1.1020", "1.0990", "1.1010");
let impulse = candle("1.1010", "1.1110", "1.1005", "1.1100"); // big bull
let post = candle("1.1090", "1.1130", "1.1070", "1.1120"); // low > pre.high
let fvg = detect(&pre, &impulse, &post, "0.6".parse().unwrap(), "0.8".parse().unwrap(), 10, 5);
assert!(fvg.is_some());
let fvg = fvg.unwrap();
assert_eq!(fvg.side, Side::Long);
assert_eq!(fvg.zone_low, "1.1020".parse::<Decimal>().unwrap()); // pre.high
assert_eq!(fvg.zone_high, "1.1070".parse::<Decimal>().unwrap()); // post.low
}
#[test]
fn no_gap_returns_none() {
// post.low <= pre.high — no gap
let pre = candle("1.1000", "1.1060", "1.0990", "1.1050");
let impulse = candle("1.1050", "1.1110", "1.1040", "1.1100");
let post = candle("1.1090", "1.1130", "1.1055", "1.1120"); // post.low=1.1055 < pre.high=1.1060
let fvg = detect(&pre, &impulse, &post, "0.6".parse().unwrap(), "0.8".parse().unwrap(), 10, 5);
assert!(fvg.is_none());
}
}
+401
View File
@@ -0,0 +1,401 @@
mod detector;
use anyhow::Context;
use chrono::NaiveDate;
use domain::Side;
use rust_decimal::Decimal;
// ── helpers ──────────────────────────────────────────────────────────────────
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()];
if prices.len() < period { return out; }
let seed: Decimal = prices[..period].iter().sum::<Decimal>() / Decimal::from(period);
out[period - 1] = Some(seed);
let mut ema = seed;
for i in period..prices.len() {
ema = prices[i] * k + ema * (Decimal::ONE - k);
out[i] = Some(ema);
}
out
}
fn size_position(
balance: Decimal,
risk_pct: Decimal,
sl_distance: Decimal,
value_per_lot: Decimal,
vol_step: Decimal,
min_vol: Decimal,
max_vol: Decimal,
) -> Option<Decimal> {
if sl_distance == Decimal::ZERO { return None; }
let raw = (balance * risk_pct) / (sl_distance * value_per_lot);
let volume = (raw / vol_step).floor() * vol_step;
if volume < min_vol { return None; }
Some(volume.min(max_vol))
}
fn actual_entry(side: Side, level: Decimal, spread: Decimal) -> Decimal {
match side {
Side::Long => level + spread,
Side::Short => level,
}
}
fn actual_exit(side: Side, level: Decimal, is_sl: bool, spread: Decimal, slip: Decimal) -> Decimal {
match (side, is_sl) {
(Side::Long, false) => level,
(Side::Long, true) => level - slip,
(Side::Short, false) => level + spread,
(Side::Short, true) => level + spread + slip,
}
}
fn fmt_price(d: Decimal, prec: usize) -> String { format!("{0:.1$}", d, prec) }
fn fmt_pnl(pnl: Decimal) -> String {
if pnl >= Decimal::ZERO { format!("+{:.2}", pnl) } else { format!("{:.2}", pnl) }
}
// ── open trade ────────────────────────────────────────────────────────────────
struct OpenTrade {
open_time: String,
side: Side,
entry_level: Decimal,
actual_entry: Decimal,
sl: Decimal,
tp: Decimal,
volume: Decimal,
}
// ── main ──────────────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let mt5_base_url = std::env::var("MT5_BASE_URL").context("MT5_BASE_URL missing")?;
let symbol = std::env::var("SYMBOL").context("SYMBOL missing")?;
let tf_str = std::env::var("TIMEFRAME").unwrap_or_else(|_| "M5".to_string());
let backtest_candles: u32 = std::env::var("BACKTEST_CANDLES")
.unwrap_or_else(|_| "50000".to_string()).parse().context("BACKTEST_CANDLES")?;
let backtest_balance: Decimal = std::env::var("BACKTEST_BALANCE")
.unwrap_or_else(|_| "600".to_string()).parse().context("BACKTEST_BALANCE")?;
let risk_pct: Decimal = std::env::var("RISK_PCT")
.unwrap_or_else(|_| "0.01".to_string()).parse().context("RISK_PCT")?;
let body_pct_min: Decimal = std::env::var("BODY_PCT_MIN")
.unwrap_or_else(|_| "0.6".to_string()).parse().context("BODY_PCT_MIN")?;
let close_pct_min: Decimal = std::env::var("CLOSE_PCT_MIN")
.unwrap_or_else(|_| "0.8".to_string()).parse().context("CLOSE_PCT_MIN")?;
let fvg_expiry: usize = std::env::var("FVG_EXPIRY_CANDLES")
.unwrap_or_else(|_| "10".to_string()).parse().context("FVG_EXPIRY_CANDLES")?;
let sl_buffer: Decimal = std::env::var("SL_BUFFER")
.unwrap_or_else(|_| "0".to_string()).parse().context("SL_BUFFER")?;
let min_rr: Decimal = std::env::var("MIN_RR")
.unwrap_or_else(|_| "1.5".to_string()).parse().context("MIN_RR")?;
let commission_per_lot: Decimal = std::env::var("COMMISSION_PER_LOT")
.unwrap_or_else(|_| "0".to_string()).parse().context("COMMISSION_PER_LOT")?;
let slippage_points: Decimal = std::env::var("SLIPPAGE_POINTS")
.unwrap_or_else(|_| "5".to_string()).parse().context("SLIPPAGE_POINTS")?;
let spread_override: Option<Decimal> = match std::env::var("SPREAD_OVERRIDE") {
Ok(s) => Some(s.parse().context("SPREAD_OVERRIDE")?),
Err(_) => None,
};
let ema_period: usize = std::env::var("EMA_PERIOD")
.unwrap_or_else(|_| "20".to_string()).parse().context("EMA_PERIOD")?;
let date_from: Option<NaiveDate> = match std::env::var("DATE_FROM") {
Ok(s) => Some(s.parse().context("DATE_FROM")?),
Err(_) => None,
};
let date_to: Option<NaiveDate> = match std::env::var("DATE_TO") {
Ok(s) => Some(s.parse().context("DATE_TO")?),
Err(_) => None,
};
let timeframe = tf_str.parse::<domain::Timeframe>().map_err(|e| anyhow::anyhow!("{e}"))?;
let mt5 = mt5_client::Mt5Client::new(mt5_base_url);
tracing::info!(symbol = %symbol, tf = %tf_str, backtest_candles, "fetching data");
let (sym_info, candles) = tokio::try_join!(
mt5.symbol(&symbol),
mt5.rates_from_pos(&symbol, timeframe, 0, backtest_candles),
)?;
let total = candles.len();
let contract_size = sym_info.trade_contract_size;
let point = sym_info.point;
let prec = sym_info.digits as usize;
let spread_price = spread_override.unwrap_or_else(|| Decimal::from(sym_info.spread) * point);
let slippage_price = slippage_points * point;
let profit_is_usd = sym_info.currency_profit.eq_ignore_ascii_case("USD");
let ema_vals: Vec<Option<Decimal>> = if ema_period > 0 {
let closes: Vec<Decimal> = candles.iter().map(|c| c.close).collect();
rolling_ema(&closes, ema_period)
} else {
vec![None; total]
};
tracing::info!(total, "starting walk-forward");
let mut balance = backtest_balance;
let mut peak = balance;
let mut max_drawdown = Decimal::ZERO;
let mut open_trade: Option<OpenTrade> = None;
let mut pending_fvg: Option<detector::PendingFvg> = None;
let mut trades = 0u32;
let mut wins = 0u32;
let mut losses = 0u32;
let mut timeouts = 0u32;
let mut missed_fills = 0u32;
let mut total_pnl = Decimal::ZERO;
let mut total_friction = Decimal::ZERO;
let mut sum_wins = Decimal::ZERO;
let mut sum_losses = Decimal::ZERO;
let mut max_consec = 0u32;
let mut cur_consec = 0u32;
for i in 2..total {
let candle = &candles[i];
let date = candle.time.date_naive();
// ── manage open trade ────────────────────────────────────────────────
if let Some(ref t) = open_trade {
let (sl_hit, tp_hit) = match t.side {
Side::Long => (candle.low <= t.sl, candle.high >= t.tp),
Side::Short => (candle.high >= t.sl, candle.low <= t.tp),
};
if sl_hit || tp_hit {
let t = open_trade.take().unwrap();
let is_sl = sl_hit;
let exit_lvl = if is_sl { t.sl } else { t.tp };
let label = if is_sl { "SL" } else { "TP" };
let exit = actual_exit(t.side, exit_lvl, is_sl, spread_price, slippage_price);
let commission = commission_per_lot * t.volume;
let profit_rate = if profit_is_usd || exit <= Decimal::ZERO {
Decimal::ONE
} else {
Decimal::ONE / exit
};
let pnl = (match t.side {
Side::Long => (exit - t.actual_entry) * t.volume * contract_size,
Side::Short => (t.actual_entry - exit) * t.volume * contract_size,
}) * profit_rate - commission;
let fl_rate = if profit_is_usd || exit_lvl <= Decimal::ZERO {
Decimal::ONE
} else {
Decimal::ONE / exit_lvl
};
let frictionless = (match t.side {
Side::Long => (exit_lvl - t.entry_level) * t.volume * contract_size,
Side::Short => (t.entry_level - exit_lvl) * t.volume * contract_size,
}) * fl_rate;
let friction = frictionless - pnl;
balance += pnl;
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();
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;
println!(
"[{} {}] {} {} entry={} sl={} tp={} vol={:.2}{label} exit={} friction={} pnl={} bal={:.2}",
t.open_time, tf_str, symbol,
if t.side == Side::Long { "LONG " } else { "SHORT" },
fmt_price(t.actual_entry, prec),
fmt_price(t.sl, prec),
fmt_price(t.tp, prec),
t.volume,
fmt_price(exit, prec),
fmt_pnl(-friction),
fmt_pnl(pnl), balance,
);
}
continue;
}
// ── date filter ───────────────────────────────────────────────────────
if date_from.is_some_and(|d| date < d) { continue; }
if date_to.is_some_and(|d| date > d) { continue; }
// ── expire stale FVG ──────────────────────────────────────────────────
if pending_fvg.as_ref().is_some_and(|f| i >= f.expiry_idx) {
pending_fvg = None;
}
// ── try to fill pending FVG ───────────────────────────────────────────
if let Some(ref fvg) = pending_fvg {
if fvg.is_touched(candle) {
let ema_ok = if ema_period > 0 {
match ema_vals.get(i).copied().flatten() {
Some(ema) => match fvg.side {
Side::Long => candle.close > ema,
Side::Short => candle.close < ema,
},
None => false,
}
} else {
true
};
if ema_ok {
let sl = match fvg.side {
Side::Long => fvg.zone_low - sl_buffer,
Side::Short => fvg.zone_high + sl_buffer,
};
let sl_dist = (fvg.entry - sl).abs();
let tp = match fvg.side {
Side::Long => fvg.entry + sl_dist * min_rr,
Side::Short => fvg.entry - sl_dist * min_rr,
};
let fill_ok = match fvg.side {
Side::Long => candle.low <= fvg.entry,
Side::Short => candle.high >= fvg.entry,
};
if !fill_ok {
missed_fills += 1;
pending_fvg = None;
continue;
}
let value_per_lot = if profit_is_usd || candle.close == Decimal::ZERO {
contract_size
} else {
contract_size / candle.close
};
match size_position(balance, risk_pct, sl_dist, value_per_lot,
sym_info.volume_step, sym_info.volume_min, sym_info.volume_max)
{
None => { pending_fvg = None; continue; }
Some(v) => {
let ae = actual_entry(fvg.side, fvg.entry, spread_price);
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,
});
pending_fvg = None;
}
}
} else {
pending_fvg = None;
}
}
continue;
}
// ── detect new momentum FVG ───────────────────────────────────────────
pending_fvg = detector::detect(
&candles[i - 2],
&candles[i - 1],
candle,
body_pct_min,
close_pct_min,
i,
fvg_expiry,
);
}
// ── end-of-data timeout ───────────────────────────────────────────────────
if let Some(t) = open_trade.take() {
let exit_lvl = candles.last().unwrap().close;
let exit = actual_exit(t.side, exit_lvl, false, spread_price, slippage_price);
let commission = commission_per_lot * t.volume;
let profit_rate = if profit_is_usd || exit <= Decimal::ZERO {
Decimal::ONE
} else {
Decimal::ONE / exit
};
let pnl = (match t.side {
Side::Long => (exit - t.actual_entry) * t.volume * contract_size,
Side::Short => (t.actual_entry - exit) * t.volume * contract_size,
}) * profit_rate - commission;
balance += pnl;
timeouts += 1;
trades += 1;
total_pnl += pnl;
println!(
"[{} {}] {} {} entry={} → TIMEOUT exit={} pnl={} bal={:.2}",
t.open_time, tf_str, symbol,
if t.side == Side::Long { "LONG " } else { "SHORT" },
fmt_price(t.actual_entry, prec),
fmt_price(exit, prec),
fmt_pnl(pnl), balance,
);
}
// ── summary ───────────────────────────────────────────────────────────────
let win_pct = if trades > 0 { wins as f64 / trades as f64 * 100.0 } else { 0.0 };
let loss_pct = if trades > 0 { losses as f64 / trades as f64 * 100.0 } else { 0.0 };
let timeout_pct = if trades > 0 { timeouts as f64 / trades as f64 * 100.0 } else { 0.0 };
let avg_win = if wins > 0 { sum_wins / Decimal::from(wins) } else { Decimal::ZERO };
let avg_loss = if losses > 0 { sum_losses / Decimal::from(losses) } else { Decimal::ZERO };
let expectancy = if trades > 0 { total_pnl / Decimal::from(trades) } else { Decimal::ZERO };
let pf = if sum_losses > Decimal::ZERO { sum_wins / sum_losses } else { Decimal::MAX };
let ret_pct = (balance - backtest_balance) / backtest_balance * Decimal::from(100u32);
println!("─────────────────────────────────────────");
println!("Ares Scalper: {} {} | {} candles", symbol, tf_str, total);
println!("Strategy : Momentum FVG body≥{body_pct_min} close≥{close_pct_min} expiry={fvg_expiry}c min_rr={min_rr}");
println!("Friction : spread={} slip={} commission/lot={}", fmt_price(spread_price, prec), fmt_price(slippage_price, prec), commission_per_lot);
println!("─────────────────────────────────────────");
println!("Trades : {trades}");
println!("Win : {wins} ({win_pct:.1}%)");
println!("Loss : {losses} ({loss_pct:.1}%)");
println!("Timeout : {timeouts} ({timeout_pct:.1}%)");
println!("Missed fills : {missed_fills}");
println!("Max consec loss: {max_consec}");
println!("─────────────────────────────────────────");
println!("Avg win : +{avg_win:.2}");
println!("Avg loss : -{avg_loss:.2}");
println!("Expectancy : {}", fmt_pnl(expectancy));
println!("Profit factor : {pf:.2}");
println!("Total friction : {}", fmt_pnl(-total_friction));
println!("─────────────────────────────────────────");
println!("Total PnL : {}", fmt_pnl(total_pnl));
println!("Max Drawdown : {max_drawdown:.2}");
println!("Return : {ret_pct:.1}%");
println!("Final Balance : {balance:.2}");
println!("─────────────────────────────────────────");
Ok(())
}