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
co-authored by Claude Sonnet 4.6
parent dbba172ed3
commit 272fca4f65
13 changed files with 431 additions and 103 deletions
+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 {