mirror of
https://github.com/RomySaputraSihananda/ares.git
synced 2026-08-08 08:27:48 +00:00
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:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+2070
File diff suppressed because it is too large
Load Diff
+21
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user