feat: session filter, breakeven SL, daily loss limit, startup alert, multi-pair web

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 <noreply@anthropic.com>
This commit is contained in:
romysaputrasihananda
2026-06-12 00:48:45 +07:00
parent dbba172ed3
commit 272fca4f65
13 changed files with 431 additions and 103 deletions
+14
View File
@@ -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.
+17
View File
@@ -97,6 +97,23 @@ impl Mt5Client {
Ok(w.data)
}
pub async fn modify_position(
&self,
ticket: u64,
symbol: &str,
sl: f64,
tp: f64,
) -> Result<TradeResult, Mt5Error> {
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<TradeResult> = serde_json::from_str(&text)?;
Ok(w.data)
}
pub async fn cancel_order(&self, ticket: u64, symbol: &str) -> Result<TradeResult, Mt5Error> {
let req = TradeRequest::cancel(symbol, ticket);
#[derive(serde::Serialize)]
+23
View File
@@ -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<u64>,
/// Position ticket — required for TRADE_ACTION_SLTP (modify SL/TP)
#[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<u64>,
/// Allowed price deviation in points
#[serde(skip_serializing_if = "Option::is_none")]
pub deviation: Option<u32>,
@@ -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<String>, 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,
}
}
+102 -25
View File
@@ -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<Decimal>,
pub ema_period: usize,
pub date_from: Option<NaiveDate>,
pub date_to: Option<NaiveDate>,
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<Decimal>,
pub ema_period: usize,
pub date_from: Option<NaiveDate>,
pub date_to: Option<NaiveDate>,
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<u32>,
pub session_to_utc: Option<u32>,
/// 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!("─────────────────────────────────────────");
+127 -23
View File
@@ -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<Decimal>,
pub ema_period: usize,
pub poll_secs: u64,
pub mt5_base_url: String,
pub telegram: Option<TelegramConfig>,
pub poll_secs: u64,
pub mt5_base_url: String,
pub telegram: Option<TelegramConfig>,
// ── optimisations ────────────────────────────────────────────────────────
pub session_from_utc: Option<u32>,
pub session_to_utc: Option<u32>,
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!(
"🤖 <b>ARES Started</b>\n{} · {:?}\n\nSession <code>{}</code>\nRisk <code>{:.1}%</code>\nEMA <code>{}</code>\nBalance <code>${:.2}</code>",
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!(
"🔒 <b>BREAKEVEN</b>\n{} · {}\n\nSL moved to entry <code>{}</code>\nTP <code>{}</code>",
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<Decimal> = if cfg.ema_period > 0 && candles.len() >= cfg.ema_period {
let closes: Vec<Decimal> = 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);
+37 -19
View File
@@ -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<u32> = 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<u32> = 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 {
+18 -11
View File
@@ -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 0813 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 0813 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:0013: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() {
</h1>
<p className="text-[15px] text-ink-sub max-w-xl">
Historical simulation on real MT5 tick data. Includes spread costs, commission, and slippage.
London session filter (0813 UTC) applied.
</p>
</section>
@@ -74,7 +80,7 @@ export default function BacktestPage() {
<table className="data-table">
<thead>
<tr>
{["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 => (
<th key={h}>{h}</th>
))}
</tr>
@@ -82,10 +88,11 @@ export default function BacktestPage() {
<tbody>
{results.map((r, i) => (
<tr key={i} className={r.highlight ? "bg-s2" : ""}>
<td className="font-mono font-medium text-ink">{r.symbol}</td>
<td className="font-medium text-ink">{r.period}</td>
<td className="font-mono text-ink-sub">{r.tf}</td>
<td className="font-mono text-ink-sub">{r.risk}</td>
<td className="font-mono text-ink-md">{r.trades}</td>
<td className="font-mono text-ink-md">{r.trades ?? "—"}</td>
<td className="font-mono text-ink-md">{r.wr.toFixed(1)}%</td>
<td className="font-mono font-medium">
{r.pf != null
+36 -12
View File
@@ -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 (
<html lang="en">
<body className="min-h-screen antialiased">
<Nav />
<Nav version={version} />
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-12">
{children}
</main>
@@ -64,10 +67,26 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<path d="M10.2 15.5 H15.8" stroke="white" strokeWidth="1.8" strokeLinecap="round"/>
</svg>
<span className="font-mono text-sm font-semibold" style={{ letterSpacing: "0.18em", color: "var(--c-ink)" }}>ARES</span>
{version && (
<span className="text-[10px] font-mono text-ink-ter bg-s2 border border-hl px-1.5 py-0.5 rounded">
{version}
</span>
)}
</div>
<p className="text-sm leading-relaxed" style={{ color: "var(--c-ink-sub)" }}>
M5 Momentum FVG Scalper<br />Built in Rust · XAUUSDm
<p className="text-sm leading-relaxed mb-3" style={{ color: "var(--c-ink-sub)" }}>
Open-source algorithmic trading bot<br />built in Rust · M5 Momentum FVG
</p>
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-ink-sub hover:text-ink transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/>
</svg>
View on GitHub
</a>
</div>
<div>
<p className="eyebrow mb-4">Navigation</p>
@@ -75,6 +94,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<li><a href="/" className="hover:text-ink transition-colors">Dashboard</a></li>
<li><a href="/trades" className="hover:text-ink transition-colors">Trades</a></li>
<li><a href="/backtest" className="hover:text-ink transition-colors">Backtest</a></li>
<li>
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" className="hover:text-ink transition-colors">
GitHub
</a>
</li>
</ul>
</div>
<div>
+3 -2
View File
@@ -11,8 +11,9 @@ export default function DashboardPage() {
ARES Trading Bot
</h1>
<p className="text-[18px] text-ink-md leading-relaxed max-w-2xl mb-8">
M5 Momentum FVG scalper built in Rust. Targets Fair Value Gaps on XAUUSDm
with EMA-20 trend filter, automatic position sizing, and Telegram alerts.
Open-source algorithmic trading bot built in Rust. Momentum FVG scalper
with EMA trend filter, configurable session window, automatic position
sizing, and Telegram alerts runs on any MT5 symbol.
</p>
<div className="flex gap-3 flex-wrap">
<Link href="/trades" className="btn-primary">View Trades</Link>
+2 -2
View File
@@ -6,7 +6,7 @@ import TradesRefresher from "@/components/TradesRefresher";
export const metadata: Metadata = {
title: "Trade History",
description: "Live closed trades and equity curve for ARES — M5 Momentum FVG scalper on XAUUSDm.",
description: "Live closed trades and equity curve for ARES — M5 Momentum FVG scalper on XAUUSDm and BTCUSDm.",
};
export const dynamic = "force-dynamic";
@@ -20,7 +20,7 @@ export default async function TradesPage() {
try {
[account, deals] = await Promise.all([
getAccount(),
getAllDeals("2026-06-10T00:00:00"),
getAllDeals(),
]);
} catch { error = true; }
+26 -7
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import clsx from "clsx";
import { GITHUB_URL } from "@/lib/github";
const links = [
{ href: "/", label: "Dashboard" },
@@ -9,10 +10,10 @@ const links = [
{ href: "/backtest", label: "Backtest" },
];
export default function Nav() {
export default function Nav({ version }: { version?: string | null }) {
const path = usePathname();
return (
<nav className="sticky top-0 z-50" style={{ backgroundColor: 'var(--c-canvas)', borderBottom: '1px solid var(--c-hl)' }}>
<nav className="sticky top-0 z-50" style={{ backgroundColor: "var(--c-canvas)", borderBottom: "1px solid var(--c-hl)" }}>
<div className="max-w-5xl mx-auto px-4 sm:px-6 h-14 flex items-center gap-6">
{/* logo + wordmark */}
<Link href="/" className="flex items-center gap-2 group">
@@ -21,9 +22,14 @@ export default function Nav() {
<path d="M8 19 L13 8 L18 19" stroke="white" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M10.2 15.5 H15.8" stroke="white" strokeWidth="1.8" strokeLinecap="round"/>
</svg>
<span className="font-mono text-sm font-semibold tracking-widest text-ink group-hover:text-accent transition-colors" style={{ letterSpacing: '0.18em' }}>
<span className="font-mono text-sm font-semibold tracking-widest text-ink group-hover:text-accent transition-colors" style={{ letterSpacing: "0.18em" }}>
ARES
</span>
{version && (
<span className="hidden sm:inline text-[10px] font-mono text-ink-ter bg-s2 border border-hl px-1.5 py-0.5 rounded">
{version}
</span>
)}
</Link>
{/* nav links */}
@@ -44,10 +50,23 @@ export default function Nav() {
))}
</div>
{/* live indicator */}
<div className="ml-auto flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-bull pulse-dot" />
<span className="text-xs text-ink-sub font-medium tracking-eyebrow uppercase">Live</span>
{/* right side: github + live */}
<div className="ml-auto flex items-center gap-4">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="text-ink-ter hover:text-ink transition-colors"
aria-label="GitHub"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/>
</svg>
</a>
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-bull pulse-dot" />
<span className="text-xs text-ink-sub font-medium tracking-eyebrow uppercase">Live</span>
</div>
</div>
</div>
</nav>
+21
View File
@@ -0,0 +1,21 @@
const REPO = "RomySaputraSihananda/ares";
export async function getLatestVersion(): Promise<string | null> {
try {
const res = await fetch(
`https://api.github.com/repos/${REPO}/releases/latest`,
{
headers: { Accept: "application/vnd.github+json" },
next: { revalidate: 3600 },
}
);
if (!res.ok) return null;
const data = await res.json();
return (data.tag_name as string) ?? null;
} catch {
return null;
}
}
export const GITHUB_URL = `https://github.com/${REPO}`;
export const GITHUB_REPO = REPO;
+5 -2
View File
@@ -107,8 +107,11 @@ export async function getDeals(dateFrom: string, dateTo: string, symbol?: string
// Fetch all deals from startDate to now by querying one day at a time,
// working around the MT5 bridge per-request deal limit.
export async function getAllDeals(startDate: string): Promise<Deal[]> {
const start = new Date(startDate);
// startDate defaults to 90 days ago if omitted.
export async function getAllDeals(startDate?: string): Promise<Deal[]> {
const start = startDate
? new Date(startDate)
: (() => { const d = new Date(); d.setUTCDate(d.getUTCDate() - 90); return d; })();
const now = new Date();
const days: Array<[string, string]> = [];