From 272fca4f65e1063f928b626df78df0ba3b906df1 Mon Sep 17 00:00:00 2001 From: romysaputrasihananda Date: Fri, 12 Jun 2026 00:48:45 +0700 Subject: [PATCH] feat: session filter, breakeven SL, daily loss limit, startup alert, multi-pair web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bot improvements: - Session filter (SESSION_FROM_UTC/TO_UTC) — backtest confirms 08-13 UTC optimal for XAU - Breakeven SL management (BREAKEVEN_AT_RR) — disabled by default, hurts XAU momentum - Daily loss limit circuit breaker (DAILY_LOSS_LIMIT_PCT) - Telegram startup alert with symbol, session, risk, and balance - MT5 modify_position (TRADE_ACTION_SLTP) support in mt5-client Web updates: - Version badge auto-fetched from GitHub Releases API - GitHub icon link in Nav and footer - Multi-pair general (not XAUUSDm-specific) - BTCUSDm backtest results added, session params in params table - Trades page uses rolling 90-day window instead of hardcoded date Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 14 +++ crates/mt5-client/src/client.rs | 17 ++++ crates/mt5-client/src/types.rs | 23 +++++ src/backtest.rs | 127 +++++++++++++++++++++------ src/live.rs | 150 +++++++++++++++++++++++++++----- src/main.rs | 56 ++++++++---- web/app/backtest/page.tsx | 29 +++--- web/app/layout.tsx | 48 +++++++--- web/app/page.tsx | 5 +- web/app/trades/page.tsx | 4 +- web/components/Nav.tsx | 33 +++++-- web/lib/github.ts | 21 +++++ web/lib/mt5.ts | 7 +- 13 files changed, 431 insertions(+), 103 deletions(-) create mode 100644 web/lib/github.ts diff --git a/.env.example b/.env.example index 694b6c9..5e6abad 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,20 @@ BACKTEST_CANDLES=50000 # LIVE=true # LIVE_POLL_SECS=30 +# ── Optimisations ───────────────────────────────────────────────────────────── +# Session filter: only take new entries when UTC hour in [FROM, TO). +# Leave empty to disable. Recommended: 8-13 (London) based on live data. +# SESSION_FROM_UTC=8 +# SESSION_TO_UTC=13 + +# Breakeven: move SL to entry once price moves this multiple of SL-distance. +# 0 = disabled, 1.0 = move at 1:1 RR +# BREAKEVEN_AT_RR=1.0 + +# Daily loss limit: halt new entries if today's closed PnL < -(balance × pct). +# 0 = disabled, 0.03 = stop after -3% day +# DAILY_LOSS_LIMIT_PCT=0.03 + # ── Telegram Notifications ─────────────────────────────────────────────────── # Get token from @BotFather, chat_id from @userinfobot or Telegram API. # All three are optional — omit to disable notifications. diff --git a/crates/mt5-client/src/client.rs b/crates/mt5-client/src/client.rs index 0ce5af1..6b20883 100644 --- a/crates/mt5-client/src/client.rs +++ b/crates/mt5-client/src/client.rs @@ -97,6 +97,23 @@ impl Mt5Client { Ok(w.data) } + pub async fn modify_position( + &self, + ticket: u64, + symbol: &str, + sl: f64, + tp: f64, + ) -> Result { + let req = TradeRequest::modify_sltp(symbol, ticket, sl, tp); + #[derive(serde::Serialize)] + struct Body<'a> { request: &'a TradeRequest } + let url = format!("{}/order/send", self.base_url); + let text = self.fetch_text(self.http.post(&url).json(&Body { request: &req })).await?; + tracing::debug!(endpoint = %url, ticket, "modify position ok"); + let w: DataOne = serde_json::from_str(&text)?; + Ok(w.data) + } + pub async fn cancel_order(&self, ticket: u64, symbol: &str) -> Result { let req = TradeRequest::cancel(symbol, ticket); #[derive(serde::Serialize)] diff --git a/crates/mt5-client/src/types.rs b/crates/mt5-client/src/types.rs index 8498964..f054ed8 100644 --- a/crates/mt5-client/src/types.rs +++ b/crates/mt5-client/src/types.rs @@ -31,6 +31,9 @@ pub struct TradeRequest { /// Ticket number — required for TRADE_ACTION_REMOVE (cancel pending order) #[serde(skip_serializing_if = "Option::is_none")] pub order: Option, + /// Position ticket — required for TRADE_ACTION_SLTP (modify SL/TP) + #[serde(skip_serializing_if = "Option::is_none")] + pub position: Option, /// Allowed price deviation in points #[serde(skip_serializing_if = "Option::is_none")] pub deviation: Option, @@ -63,6 +66,25 @@ impl TradeRequest { magic: Some(magic), comment: Some(comment.into()), order: None, + position: None, + deviation: None, + } + } + + pub fn modify_sltp(symbol: impl Into, position: u64, sl: f64, tp: f64) -> Self { + // TRADE_ACTION_SLTP = 6 + Self { + action: 6, + symbol: symbol.into(), + volume: None, + order_type: None, + price: None, + sl: Some(sl), + tp: Some(tp), + magic: None, + comment: None, + order: None, + position: Some(position), deviation: None, } } @@ -81,6 +103,7 @@ impl TradeRequest { magic: None, comment: None, order: Some(ticket), + position: None, deviation: None, } } diff --git a/src/backtest.rs b/src/backtest.rs index 92a9f73..1a6a838 100644 --- a/src/backtest.rs +++ b/src/backtest.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use chrono::NaiveDate; +use chrono::{NaiveDate, Timelike}; use domain::Side; use rust_decimal::Decimal; @@ -10,26 +10,35 @@ use crate::helpers::{actual_entry, actual_exit, fmt_price, fmt_pnl, rolling_ema, #[derive(Debug, Clone)] pub struct BacktestConfig { - pub timeframe: domain::Timeframe, - pub candles: u32, - pub balance: Decimal, - pub risk_pct: Decimal, - pub body_pct_min: Decimal, - pub close_pct_min: Decimal, - pub fvg_expiry: usize, - pub min_fvg_pips: Decimal, - pub min_sl_pips: Decimal, - pub sl_buffer: Decimal, - pub min_rr: Decimal, - pub timeout_candles: usize, - pub commission: Decimal, - pub slippage_points: Decimal, - pub spread_override: Option, - pub ema_period: usize, - pub date_from: Option, - pub date_to: Option, - pub stop_out_pct: Decimal, - pub tf_str: String, + pub timeframe: domain::Timeframe, + pub candles: u32, + pub balance: Decimal, + pub risk_pct: Decimal, + pub body_pct_min: Decimal, + pub close_pct_min: Decimal, + pub fvg_expiry: usize, + pub min_fvg_pips: Decimal, + pub min_sl_pips: Decimal, + pub sl_buffer: Decimal, + pub min_rr: Decimal, + pub timeout_candles: usize, + pub commission: Decimal, + pub slippage_points: Decimal, + pub spread_override: Option, + pub ema_period: usize, + pub date_from: Option, + pub date_to: Option, + pub stop_out_pct: Decimal, + pub tf_str: String, + // ── new optimisations ──────────────────────────────────────────────────── + /// UTC hour range [from, to) allowed for new entries. None = no filter. + pub session_from_utc: Option, + pub session_to_utc: Option, + /// Move SL to entry once price moves this many × SL-distance in our favour. + /// 0 = disabled. + pub breakeven_at_rr: Decimal, + /// Stop trading today when realised PnL < -(balance × this). 0 = disabled. + pub daily_loss_limit_pct: Decimal, } // ── open trade ──────────────────────────────────────────────────────────────── @@ -43,6 +52,7 @@ struct OpenTrade { tp: Decimal, volume: Decimal, open_candle_idx: usize, + be_set: bool, // breakeven already applied } // ── entry point ─────────────────────────────────────────────────────────────── @@ -97,12 +107,44 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig let mut max_consec = 0u32; let mut cur_consec = 0u32; + // ── daily loss tracking ─────────────────────────────────────────────────── + let mut today_date = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap(); + let mut today_start_balance = balance; + let mut daily_halted = false; + 'outer: for i in 2..total { let candle = &candles[i]; let date = candle.time.date_naive(); + let hour = candle.time.time().hour(); + + // ── reset daily state on new day ────────────────────────────────────── + if date != today_date { + today_date = date; + today_start_balance = balance; + daily_halted = false; + } // ── manage open trade ──────────────────────────────────────────────── - if let Some(ref t) = open_trade { + if let Some(ref mut t) = open_trade { + // breakeven: move SL to entry once price moves breakeven_at_rr × sl_dist + if !t.be_set && cfg.breakeven_at_rr > Decimal::ZERO { + let sl_dist = (t.actual_entry - t.sl).abs(); + let be_trigger = match t.side { + Side::Long => t.actual_entry + sl_dist * cfg.breakeven_at_rr, + Side::Short => t.actual_entry - sl_dist * cfg.breakeven_at_rr, + }; + let triggered = match t.side { + Side::Long => candle.high >= be_trigger, + Side::Short => candle.low <= be_trigger, + }; + if triggered { + t.sl = t.actual_entry; + t.be_set = true; + tracing::debug!(%symbol, "breakeven SL set"); + } + } + + // timeout if cfg.timeout_candles > 0 && (i - t.open_candle_idx) >= cfg.timeout_candles { let t = open_trade.take().unwrap(); let exit_lvl = candle.close; @@ -132,6 +174,7 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig continue; } + // stop-out if cfg.stop_out_pct > Decimal::ZERO { let worst_price = match t.side { Side::Long => candle.low, @@ -204,8 +247,9 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig wins += 1; sum_wins += pnl; cur_consec = 0; } trades += 1; total_pnl += pnl; total_friction += friction; + let be_tag = if t.be_set { " [BE]" } else { "" }; println!( - "[{} {}] {} {} entry={} sl={} tp={} vol={:.2} → {label} exit={} friction={} pnl={} bal={:.2}", + "[{} {}] {} {} entry={} sl={} tp={} vol={:.2} → {label}{be_tag} exit={} friction={} pnl={} bal={:.2}", t.open_time, cfg.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, @@ -219,6 +263,26 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig if cfg.date_from.is_some_and(|d| date < d) { continue; } if cfg.date_to.is_some_and(|d| date > d) { continue; } + // ── daily loss limit ────────────────────────────────────────────────── + if daily_halted { continue; } + if cfg.daily_loss_limit_pct > Decimal::ZERO { + let daily_pnl = balance - today_start_balance; + let limit = -(today_start_balance * cfg.daily_loss_limit_pct); + if daily_pnl <= limit { + daily_halted = true; + tracing::debug!(%symbol, %date, "daily loss limit hit — halting rest of day"); + continue; + } + } + + // ── session filter ──────────────────────────────────────────────────── + if let (Some(from), Some(to)) = (cfg.session_from_utc, cfg.session_to_utc) { + if hour < from || hour >= to { + pending_fvg = None; // discard stale FVGs from outside session + continue; + } + } + // ── expire stale FVG ────────────────────────────────────────────────── if pending_fvg.as_ref().is_some_and(|f| i >= f.expiry_idx) { missed_fills += 1; @@ -275,6 +339,7 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig actual_entry: ae, sl, tp, volume: v, open_candle_idx: i, + be_set: false, }); pending_fvg = None; } @@ -323,11 +388,23 @@ pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig let pf = if sum_losses > Decimal::ZERO { sum_wins / sum_losses } else { Decimal::MAX }; let ret_pct = (balance - cfg.balance) / cfg.balance * Decimal::from(100u32); + let session_str = match (cfg.session_from_utc, cfg.session_to_utc) { + (Some(f), Some(t)) => format!(" session={}–{}UTC", f, t), + _ => String::new(), + }; + let be_str = if cfg.breakeven_at_rr > Decimal::ZERO { + format!(" be@{}×RR", cfg.breakeven_at_rr) + } else { String::new() }; + 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() }; + 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={}{}", - cfg.body_pct_min, cfg.close_pct_min, cfg.fvg_expiry, cfg.min_fvg_pips, cfg.min_sl_pips, cfg.min_rr, timeout_str); + 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); println!("Friction : spread={} slip={} commission/lot={}", fmt_price(spread_price, prec), fmt_price(slippage_price, prec), cfg.commission); println!("─────────────────────────────────────────"); diff --git a/src/live.rs b/src/live.rs index d7b765e..9c3eeae 100644 --- a/src/live.rs +++ b/src/live.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Timelike, Utc}; use domain::{Side, Timeframe}; use futures_util::StreamExt; use rust_decimal::Decimal; @@ -33,9 +33,14 @@ pub struct LiveConfig { pub slippage_points: Decimal, pub spread_override: Option, pub ema_period: usize, - pub poll_secs: u64, - pub mt5_base_url: String, - pub telegram: Option, + pub poll_secs: u64, + pub mt5_base_url: String, + pub telegram: Option, + // ── optimisations ──────────────────────────────────────────────────────── + pub session_from_utc: Option, + pub session_to_utc: Option, + pub breakeven_at_rr: Decimal, + pub daily_loss_limit_pct: Decimal, } // ── pending order state ─────────────────────────────────────────────────────── @@ -78,6 +83,8 @@ struct PosState { sl: f64, tp: f64, volume: f64, + #[serde(default)] + be_set: bool, } impl PosState { @@ -169,6 +176,27 @@ pub async fn run(mt5: &mt5_client::Mt5Client, cfg: &LiveConfig) -> Result<()> { } let http = reqwest::Client::new(); + + // startup notification + if let Some(tg) = &cfg.telegram { + let bal = mt5.account().await.ok() + .map(|a| d2f(a.balance)) + .unwrap_or(0.0); + let session_str = match (cfg.session_from_utc, cfg.session_to_utc) { + (Some(f), Some(t)) => format!("{f:02}:00–{t:02}:00 UTC"), + _ => "All hours".to_string(), + }; + let text = format!( + "🤖 ARES Started\n{} · {:?}\n\nSession {}\nRisk {:.1}%\nEMA {}\nBalance ${:.2}", + cfg.symbol, cfg.timeframe, + session_str, + d2f(cfg.risk_pct) * 100.0, + cfg.ema_period, + bal, + ); + let _ = tg.send(&http, &text).await; + } + let mut ticker = interval(Duration::from_secs(cfg.poll_secs)); loop { @@ -209,21 +237,67 @@ async fn tick( if has_position { // ensure PosState exists so SSE task can find it on close - if PosState::load(symbol).is_none() { - if let Some(pos) = positions.iter().find(|p| p.symbol == *symbol && p.magic == MAGIC) { - let tg_msg_id = State::load(symbol).and_then(|s| s.tg_message_id); - let ps = PosState { - ticket: pos.ticket, - tg_message_id: tg_msg_id, - side: format!("{:?}", pos.side), - entry: d2f(pos.price_open), - sl: d2f(pos.sl), - tp: d2f(pos.tp), - volume: d2f(pos.volume), - }; - let _ = ps.save(symbol); + let mut ps = if let Some(existing) = PosState::load(symbol) { + existing + } else if let Some(pos) = positions.iter().find(|p| p.symbol == *symbol && p.magic == MAGIC) { + let tg_msg_id = State::load(symbol).and_then(|s| s.tg_message_id); + let ps = PosState { + ticket: pos.ticket, + tg_message_id: tg_msg_id, + side: format!("{:?}", pos.side), + entry: d2f(pos.price_open), + sl: d2f(pos.sl), + tp: d2f(pos.tp), + volume: d2f(pos.volume), + be_set: false, + }; + let _ = ps.save(symbol); + ps + } else { + tracing::debug!(%symbol, "position open — skip"); + return Ok(()); + }; + + // ── breakeven SL management ─────────────────────────────────────────── + if !ps.be_set && cfg.breakeven_at_rr > Decimal::ZERO { + if let Some(pos) = positions.iter().find(|p| p.ticket == ps.ticket) { + let entry = Decimal::try_from(ps.entry).unwrap_or_default(); + let sl = Decimal::try_from(ps.sl).unwrap_or_default(); + let sl_dist = (entry - sl).abs(); + if sl_dist > Decimal::ZERO { + let be_trigger = match pos.side { + domain::Side::Long => entry + sl_dist * cfg.breakeven_at_rr, + domain::Side::Short => entry - sl_dist * cfg.breakeven_at_rr, + }; + let reached = match pos.side { + domain::Side::Long => pos.price_current >= be_trigger, + domain::Side::Short => pos.price_current <= be_trigger, + }; + if reached { + let new_sl = ps.entry; // move SL to entry + match mt5.modify_position(pos.ticket, symbol, new_sl, ps.tp).await { + Ok(r) if r.retcode == 10009 => { + tracing::info!(%symbol, ticket = pos.ticket, "breakeven SL set"); + ps.sl = new_sl; + ps.be_set = true; + let _ = ps.save(symbol); + if let (Some(tg), Some(msg_id)) = (&cfg.telegram, ps.tg_message_id) { + let text = format!( + "🔒 BREAKEVEN\n{} · {}\n\nSL moved to entry {}\nTP {}", + symbol, ps.side, + fp(ps.entry), fp(ps.tp), + ); + let _ = tg.edit(http, msg_id, &text).await; + } + } + Ok(r) => tracing::warn!(retcode = r.retcode, "breakeven modify retcode unexpected"), + Err(e) => tracing::warn!("breakeven modify failed: {e:#}"), + } + } + } } } + tracing::debug!(%symbol, "position open — skip"); return Ok(()); } @@ -260,7 +334,36 @@ async fn tick( return Ok(()); } - // ── 3. fetch candles ────────────────────────────────────────────────────── + // ── 3. session filter ───────────────────────────────────────────────────── + if let (Some(from), Some(to)) = (cfg.session_from_utc, cfg.session_to_utc) { + let hour = Utc::now().time().hour(); + if hour < from || hour >= to { + tracing::debug!(%symbol, hour, from, to, "outside session window — skip"); + return Ok(()); + } + } + + // ── 4. daily loss limit ─────────────────────────────────────────────────── + if cfg.daily_loss_limit_pct > Decimal::ZERO { + let now = Utc::now(); + let today_str = now.format("%Y-%m-%dT00:00:00").to_string(); + let now_str = now.format("%Y-%m-%dT%H:%M:%S").to_string(); + if let Ok(today_deals) = mt5.history_deals(&today_str, &now_str, Some(symbol)).await { + let daily_pnl: Decimal = today_deals.iter() + .filter(|d| d.entry == 1 && d.magic == MAGIC) + .map(|d| d.profit + d.commission + d.swap) + .sum(); + let acct = mt5.account().await.context("fetch account for daily limit")?; + let balance = Decimal::try_from(acct.balance).context("balance")?; + let limit = -(balance * cfg.daily_loss_limit_pct); + if daily_pnl <= limit { + tracing::info!(%symbol, %daily_pnl, %limit, "daily loss limit hit — no new trades today"); + return Ok(()); + } + } + } + + // ── 5. fetch candles ────────────────────────────────────────────────────── let candles = mt5 .rates_from_pos(symbol, cfg.timeframe, 0, CANDLE_FETCH) .await @@ -273,7 +376,7 @@ async fn tick( let impulse = &candles[n - 3]; let post = &candles[n - 2]; - // ── 4. EMA filter ───────────────────────────────────────────────────────── + // ── 6. EMA filter ───────────────────────────────────────────────────────── let ema_val: Option = if cfg.ema_period > 0 && candles.len() >= cfg.ema_period { let closes: Vec = candles.iter().map(|c| c.close).collect(); rolling_ema(&closes, cfg.ema_period)[n - 2] @@ -281,7 +384,7 @@ async fn tick( Some(Decimal::ZERO) }; - // ── 5. detect FVG ───────────────────────────────────────────────────────── + // ── 7. detect FVG ───────────────────────────────────────────────────────── let fvg = match detector::detect( pre, impulse, post, cfg.body_pct_min, cfg.close_pct_min, min_zone, @@ -300,7 +403,7 @@ async fn tick( }; if !ema_ok { return Ok(()); } - // ── 6. SL / TP ──────────────────────────────────────────────────────────── + // ── 8. SL / TP ──────────────────────────────────────────────────────────── let sl = match fvg.side { Side::Long => fvg.impulse_sl - cfg.sl_buffer, Side::Short => fvg.impulse_sl + cfg.sl_buffer, @@ -312,7 +415,7 @@ async fn tick( Side::Short => fvg.entry - sl_dist * cfg.min_rr, }; - // ── 7. position size ────────────────────────────────────────────────────── + // ── 9. position size ────────────────────────────────────────────────────── let acct = mt5.account().await.context("fetch account")?; let balance = Decimal::try_from(acct.balance).context("balance")?; let value_per_lot = if profit_is_usd || post.close == Decimal::ZERO { @@ -328,7 +431,7 @@ async fn tick( None => return Ok(()), }; - // ── 8. place order ──────────────────────────────────────────────────────── + // ── 10. place order ─────────────────────────────────────────────────────── let req = mt5_client::TradeRequest::limit( fvg.side, symbol.clone(), d2f(volume), d2f(fvg.entry), d2f(sl), d2f(tp), MAGIC, format!("ares-{}", post.time.format("%m%d-%H%M")), @@ -487,6 +590,7 @@ async fn on_position_opened( tg_message_id: tg_msg_id, side: if kind == 0 { "Long".to_string() } else { "Short".to_string() }, entry, sl, tp, volume, + be_set: false, }; let _ = ps.save(symbol); State::clear(symbol); diff --git a/src/main.rs b/src/main.rs index 8c5eb85..64aa84b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,18 +78,28 @@ async fn main() -> anyhow::Result<()> { }; // ── shared config ───────────────────────────────────────────────────────── - let risk_pct = env_dec("RISK_PCT", "0.01")?; - let body_pct_min = env_dec("BODY_PCT_MIN", "0.6")?; - let close_pct_min = env_dec("CLOSE_PCT_MIN", "0.8")?; - let fvg_expiry = env_usize("FVG_EXPIRY_CANDLES", "10")?; - let min_fvg_pips = env_dec("MIN_FVG_PIPS", "3")?; - let min_sl_pips = env_dec("MIN_SL_PIPS", "5")?; - let sl_buffer = env_dec("SL_BUFFER", "0")?; - let min_rr = env_dec("MIN_RR", "1.5")?; - let commission = env_dec("COMMISSION_PER_LOT", "0")?; - let slippage_points = env_dec("SLIPPAGE_POINTS", "5")?; - let spread_override = env_spread_override()?; - let ema_period = env_usize("EMA_PERIOD", "20")?; + let risk_pct = env_dec("RISK_PCT", "0.01")?; + let body_pct_min = env_dec("BODY_PCT_MIN", "0.6")?; + let close_pct_min = env_dec("CLOSE_PCT_MIN", "0.8")?; + let fvg_expiry = env_usize("FVG_EXPIRY_CANDLES", "10")?; + let min_fvg_pips = env_dec("MIN_FVG_PIPS", "3")?; + let min_sl_pips = env_dec("MIN_SL_PIPS", "5")?; + let sl_buffer = env_dec("SL_BUFFER", "0")?; + let min_rr = env_dec("MIN_RR", "1.5")?; + let commission = env_dec("COMMISSION_PER_LOT", "0")?; + let slippage_points = env_dec("SLIPPAGE_POINTS", "5")?; + let spread_override = env_spread_override()?; + 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 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, + }; + let session_to_utc: Option = match std::env::var("SESSION_TO_UTC") { + Ok(s) if !s.is_empty() => Some(s.parse().context("SESSION_TO_UTC")?), + _ => None, + }; let mt5 = Arc::new(mt5_client::Mt5Client::new(mt5_base_url.clone())); @@ -128,6 +138,10 @@ async fn main() -> anyhow::Result<()> { poll_secs, mt5_base_url: mt5_base_url.clone(), telegram, + session_from_utc, + session_to_utc, + breakeven_at_rr, + daily_loss_limit_pct, }; let mut handles = Vec::new(); @@ -148,8 +162,8 @@ async fn main() -> anyhow::Result<()> { // ── backtest mode ───────────────────────────────────────────────────────── let cfg = backtest::BacktestConfig { timeframe, - candles: env_u32("BACKTEST_CANDLES", "50000")?, - balance: env_dec("BACKTEST_BALANCE", "600")?, + candles: env_u32("BACKTEST_CANDLES", "50000")?, + balance: env_dec("BACKTEST_BALANCE", "600")?, risk_pct, body_pct_min, close_pct_min, @@ -158,15 +172,19 @@ async fn main() -> anyhow::Result<()> { min_sl_pips, sl_buffer, min_rr, - timeout_candles: env_usize("TIMEOUT_CANDLES", "0")?, + timeout_candles: env_usize("TIMEOUT_CANDLES", "0")?, commission, slippage_points, spread_override, ema_period, - date_from: env_date("DATE_FROM")?, - date_to: env_date("DATE_TO")?, - stop_out_pct: env_dec("STOP_OUT_PCT", "0.0")?, - tf_str: tf_str.clone(), + date_from: env_date("DATE_FROM")?, + date_to: env_date("DATE_TO")?, + stop_out_pct: env_dec("STOP_OUT_PCT", "0.0")?, + tf_str: tf_str.clone(), + session_from_utc, + session_to_utc, + breakeven_at_rr, + daily_loss_limit_pct, }; for symbol in &symbols { diff --git a/web/app/backtest/page.tsx b/web/app/backtest/page.tsx index 14d1786..9ef3d61 100644 --- a/web/app/backtest/page.tsx +++ b/web/app/backtest/page.tsx @@ -3,22 +3,27 @@ import Link from "next/link"; export const metadata: Metadata = { title: "Backtest Results", - description: "ARES backtest results: M5 Momentum FVG scalper on XAUUSDm. Best run: PF 1.42, +43.2% net return, −11.5% max drawdown.", + description: "ARES backtest results: M5 Momentum FVG scalper on XAUUSDm and BTCUSDm. London session filter, EMA-20 trend filter.", }; -const results = [ - { period: "1 Month", tf: "M5", risk: "1%", trades: 159, wr: 55.3, pf: 1.42, ret: 43.2, dd: -11.5, highlight: true }, - { period: "1 Month", tf: "M5", risk: "5%", trades: 159, wr: 55.3, pf: 1.26, ret: 390, dd: -156, highlight: false }, - { period: "1 Week", tf: "M5", risk: "1%", trades: 34, wr: 47.1, pf: 0.94, ret: -6.2, dd: -8.1, highlight: false }, - { period: "Yesterday", tf: "M1", risk: "1%", trades: 36, wr: 47.2, pf: 1.05, ret: 6.6, dd: -51, highlight: false }, - { period: "Yesterday", tf: "M5", risk: "1%", trades: 3, wr: 66.7, pf: null, ret: null, dd: null, highlight: false, note: "Too few trades" }, +const results: Array<{ + symbol: string; period: string; tf: string; risk: string; + trades: number | null; wr: number; pf: number | null; ret: number | null; dd: number | null; + highlight: boolean; note?: string; +}> = [ + { symbol: "XAUUSDm", period: "1 Month", tf: "M5", risk: "1%", trades: 159, wr: 55.3, pf: 1.42, ret: 43.2, dd: -11.5, highlight: true }, + { symbol: "XAUUSDm", period: "50k bars", tf: "M5", risk: "5%", trades: 1421, wr: 50.3, pf: 1.17, ret: null, dd: null, highlight: false, note: "Session 08–13 UTC" }, + { symbol: "BTCUSDm", period: "50k bars", tf: "M5", risk: "5%", trades: null, wr: 56.9, pf: 1.11, ret: null, dd: null, highlight: false, note: "Session 08–13 UTC" }, + { symbol: "XAUUSDm", period: "1 Month", tf: "M5", risk: "5%", trades: 159, wr: 55.3, pf: 1.26, ret: 390, dd: -156, highlight: false }, + { symbol: "XAUUSDm", period: "1 Week", tf: "M5", risk: "1%", trades: 34, wr: 47.1, pf: 0.94, ret: -6.2, dd: -8.1, highlight: false }, ]; const params = [ ["Timeframe", "M5"], - ["Symbol", "XAUUSDm"], + ["Symbols", "XAUUSDm · BTCUSDm"], + ["Session", "08:00–13:00 UTC"], ["EMA Period", "20"], - ["Min FVG Pips", "3"], + ["Min FVG Pips", "1"], ["Min SL Pips", "5"], ["Min RR", "1.5×"], ["FVG Expiry", "10 candles"], @@ -36,6 +41,7 @@ export default function BacktestPage() {

Historical simulation on real MT5 tick data. Includes spread costs, commission, and slippage. + London session filter (08–13 UTC) applied.

@@ -74,7 +80,7 @@ export default function BacktestPage() { - {["Period", "TF", "Risk", "Trades", "Win Rate", "Profit Factor", "Return", "Max DD", ""].map(h => ( + {["Symbol", "Period", "TF", "Risk", "Trades", "Win Rate", "Profit Factor", "Return", "Max DD", ""].map(h => ( ))} @@ -82,10 +88,11 @@ export default function BacktestPage() { {results.map((r, i) => ( + - +
{h}
{r.symbol} {r.period} {r.tf} {r.risk}{r.trades}{r.trades ?? "—"} {r.wr.toFixed(1)}% {r.pf != null diff --git a/web/app/layout.tsx b/web/app/layout.tsx index f17be50..7ac8f08 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,21 +1,22 @@ import type { Metadata } from "next"; import "./globals.css"; import Nav from "@/components/Nav"; +import { getLatestVersion, GITHUB_URL } from "@/lib/github"; const SITE_URL = "https://ares.romys.my.id"; export const metadata: Metadata = { metadataBase: new URL(SITE_URL), title: { - default: "ARES — Automated Gold Trading Bot", + default: "ARES — Algorithmic Trading Bot", template: "%s · ARES", }, description: - "Live forward-test results for ARES, an M5 Momentum FVG scalper built in Rust. Trades XAUUSDm with EMA-20 trend filter and automated risk management.", + "Open-source algorithmic trading bot built in Rust. Momentum FVG scalper with EMA trend filter, automated risk management, and live forward-test results.", keywords: [ - "algorithmic trading", "gold trading bot", "XAUUSD EA", + "algorithmic trading", "trading bot", "open source", "XAUUSD", "MT5 expert advisor", "Rust trading bot", "FVG scalper", - "forex robot", "automated trading", "ICT strategy", + "forex robot", "automated trading", "momentum strategy", ], authors: [{ name: "Romy Saputra Sihananda" }], creator: "Romy Saputra Sihananda", @@ -28,14 +29,14 @@ export const metadata: Metadata = { type: "website", url: SITE_URL, siteName: "ARES Trading Bot", - title: "ARES — Automated Gold Trading Bot", - description: "Live forward-test · M5 Momentum FVG scalper · XAUUSDm · Built in Rust", + title: "ARES — Algorithmic Trading Bot", + description: "Open-source Momentum FVG scalper built in Rust · Live forward-test · MT5", locale: "en_US", }, twitter: { card: "summary_large_image", - title: "ARES — Automated Gold Trading Bot", - description: "Live forward-test · M5 Momentum FVG scalper · XAUUSDm · Built in Rust", + title: "ARES — Algorithmic Trading Bot", + description: "Open-source Momentum FVG scalper built in Rust · Live forward-test · MT5", }, icons: { icon: "/favicon.svg", @@ -46,11 +47,13 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const version = await getLatestVersion(); + return ( -