From 7d094591b81e0225ffba6324660dba306db202ee Mon Sep 17 00:00:00 2001 From: romysaputrasihananda Date: Wed, 10 Jun 2026 13:31:53 +0700 Subject: [PATCH] feat: live trading, multi-pair backtest, README, CI workflow - Add live trading loop (live.rs) with MT5 pending order placement, state persistence, and per-symbol tokio task for multi-pair - Extract backtest engine to backtest.rs and shared helpers to helpers.rs - Multi-pair support via SYMBOLS env var (comma-separated) - Add GitHub Actions workflow: Linux musl + Windows release binaries - Add README with strategy docs, config reference, backtest results - Clean up warnings, remove unused env vars, tighten .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 59 ++-- .github/workflows/release.yml | 85 +++++ .gitignore | 2 + Cargo.lock | 1 + Cargo.toml | 1 + README.md | 111 ++++++ crates/mt5-client/src/client.rs | 31 +- crates/mt5-client/src/lib.rs | 2 +- crates/mt5-client/src/types.rs | 80 ++++- src/backtest.rs | 358 ++++++++++++++++++++ src/helpers.rs | 58 ++++ src/live.rs | 311 +++++++++++++++++ src/main.rs | 578 +++++++------------------------- 13 files changed, 1174 insertions(+), 503 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 README.md create mode 100644 src/backtest.rs create mode 100644 src/helpers.rs create mode 100644 src/live.rs diff --git a/.env.example b/.env.example index 9550083..5d27bb9 100644 --- a/.env.example +++ b/.env.example @@ -2,40 +2,41 @@ MT5_BASE_URL=http://localhost:8080 # ── Symbol & Timeframe ────────────────────────────────────────────────────── -# XAUUSDm: flagship (PF=1.47, +21606% over 18m) -# XAGUSDm: best risk-adjusted (PF=1.63, Max DD only -$192) -# BTCUSDm: viable (PF=1.25), USOILm: viable (PF=1.32) -# EURUSDm/GBPUSDm: negative edge — not recommended +# Single pair: SYMBOL=XAUUSDm +# Multi pair (comma-sep, overrides SYMBOL): +# SYMBOLS=XAUUSDm,XAGUSDm TIMEFRAME=M5 +# ── Risk ───────────────────────────────────────────────────────────────────── +RISK_PCT=0.01 + +# ── Momentum Candle Thresholds ─────────────────────────────────────────────── +BODY_PCT_MIN=0.5 +CLOSE_PCT_MIN=0.8 + +# ── FVG Setup ──────────────────────────────────────────────────────────────── +FVG_EXPIRY_CANDLES=10 +MIN_FVG_PIPS=1 +MIN_SL_PIPS=5 +SL_BUFFER=0 +MIN_RR=1.5 +# TIMEOUT_CANDLES=0 + +# ── Friction ───────────────────────────────────────────────────────────────── +COMMISSION_PER_LOT=7 +SLIPPAGE_POINTS=2 +SPREAD_OVERRIDE=0 + +# ── EMA Trend Filter ───────────────────────────────────────────────────────── +EMA_PERIOD=20 + # ── Backtest ───────────────────────────────────────────────────────────────── BACKTEST_BALANCE=600 -BACKTEST_CANDLES=99000 +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.5 # minimum body/range ratio — 0.50 optimal (PF 1.47 vs 1.43 at 0.60) -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 -MIN_FVG_PIPS=1 # minimum FVG zone width in pips (rejects non-gap patterns) -MIN_SL_PIPS=5 # minimum SL distance in pips (rejects degenerate setups) -SL_BUFFER=0 # extra buffer beyond impulse candle extreme for SL -MIN_RR=1.5 # minimum reward:risk ratio -TIMEOUT_CANDLES=0 # force close after N candles (0 = disabled) - -# ── 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 ───────────────────────────────────────────────────────── -# Critical: without EMA filter strategy loses money on XAU -# EMA20 is recommended (EMA10 overfits, EMA50 reduces returns significantly) -EMA_PERIOD=20 +# ── Live Trading ───────────────────────────────────────────────────────────── +# LIVE=true +# LIVE_POLL_SECS=30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ad62cde --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +name: Build & Release + +on: + push: + branches: [master] + tags: ["v*.*.*"] + pull_request: + branches: [master] + +jobs: + build: + name: Build — ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux x86_64 (static) + os: ubuntu-latest + target: x86_64-unknown-linux-musl + binary: ares + asset: ares-linux-x86_64 + + - name: Windows x86_64 + os: windows-latest + target: x86_64-pc-windows-msvc + binary: ares.exe + asset: ares-windows-x86_64.exe + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install musl-tools (Linux only) + if: matrix.target == 'x86_64-unknown-linux-musl' + run: sudo apt-get install -y musl-tools + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Build release binary + run: cargo build --release --target ${{ matrix.target }} + + - name: Rename binary + shell: bash + run: | + cp target/${{ matrix.target }}/release/${{ matrix.binary }} ${{ matrix.asset }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset }} + path: ${{ matrix.asset }} + retention-days: 7 + + release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: artifacts/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore index ea8c4bf..a070dc4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +.env +.ares_state_*.json diff --git a/Cargo.lock b/Cargo.lock index b482c2f..f6e4078 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,7 @@ dependencies = [ "mt5-client", "rust_decimal", "serde", + "serde_json", "tokio", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 182b454..4774aa5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ chrono = { version = "0.4", features = ["serde"] } dotenvy = "0.15" rust_decimal = { version = "1", features = ["serde"] } serde = { version = "1", features = ["derive"] } +serde_json = "1" tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf0bf6f --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# Ares + +M5 momentum FVG scalping bot for MetaTrader 5. Detects 3-candle impulse patterns that leave a Fair Value Gap, enters on limit orders back into the zone, and manages SL/TP automatically. + +## Strategy + +1. **Impulse detection** — a candle whose body ≥ `BODY_PCT_MIN` of its range, closing in the top/bottom `CLOSE_PCT_MIN` fraction +2. **FVG zone** — the gap between the pre-impulse candle and the post-impulse candle must be ≥ `MIN_FVG_PIPS` +3. **EMA filter** — trade only in the direction of the `EMA_PERIOD`-bar trend +4. **Entry** — limit order at the FVG zone edge; expires after `FVG_EXPIRY_CANDLES` bars if unfilled +5. **SL** — at the structural extreme of the impulse candle ± `SL_BUFFER` +6. **TP** — `MIN_RR × SL distance` from entry + +## Requirements + +- Rust (stable, 2024 edition) +- [MT5 HTTP Bridge](https://github.com/romysaputrasihananda/mt5-bridge) running and reachable at `MT5_BASE_URL` + +## Setup + +```bash +cp .env.example .env +# edit .env — set MT5_BASE_URL and your preferred params +``` + +## Usage + +### Backtest + +```bash +cargo run --release +``` + +Runs a walk-forward simulation over the last `BACKTEST_CANDLES` M5 bars and prints a summary. + +### Live trading + +```bash +LIVE=true cargo run --release +``` + +Polls MT5 every `LIVE_POLL_SECS` seconds. Places a pending limit order when a valid FVG is detected; cancels it when the setup expires. Uses magic number `19730` to identify its own orders and positions. + +State for each symbol is persisted in `.ares_state_{symbol}.json` so the bot survives restarts without orphaning orders. + +### Multi-pair + +```bash +SYMBOLS=XAUUSDm,USOILm cargo run --release +``` + +Backtest runs symbols sequentially; live mode runs one async task per symbol. + +## Configuration + +Copy `.env.example` to `.env` and adjust. All fields are optional except `MT5_BASE_URL` and at least one of `SYMBOL` / `SYMBOLS`. + +| Variable | Default | Description | +|---|---|---| +| `MT5_BASE_URL` | — | MT5 bridge base URL (required) | +| `SYMBOL` | — | Single symbol, e.g. `XAUUSDm` | +| `SYMBOLS` | — | Comma-separated list, overrides `SYMBOL` | +| `TIMEFRAME` | `M5` | Candle timeframe | +| `RISK_PCT` | `0.01` | Fraction of balance to risk per trade | +| `BODY_PCT_MIN` | `0.5` | Minimum body/range ratio for impulse candle | +| `CLOSE_PCT_MIN` | `0.8` | Close must be in top/bottom this fraction of range | +| `FVG_EXPIRY_CANDLES` | `10` | Bars before an unfilled setup is cancelled | +| `MIN_FVG_PIPS` | `1` | Minimum FVG zone width in pips | +| `MIN_SL_PIPS` | `5` | Minimum SL distance in pips | +| `SL_BUFFER` | `0` | Extra buffer beyond impulse extreme for SL | +| `MIN_RR` | `1.5` | Minimum reward:risk ratio | +| `EMA_PERIOD` | `20` | EMA trend filter period (0 = disabled) | +| `COMMISSION_PER_LOT` | `7` | Round-trip commission in USD | +| `SLIPPAGE_POINTS` | `2` | SL slippage in MT5 points | +| `SPREAD_OVERRIDE` | `0` | Override spread (0 = use live spread) | +| `BACKTEST_BALANCE` | `600` | Starting balance for backtest | +| `BACKTEST_CANDLES` | `50000` | Number of M5 bars to fetch | +| `DATE_FROM` | — | Optional backtest start date `YYYY-MM-DD` | +| `DATE_TO` | — | Optional backtest end date `YYYY-MM-DD` | +| `TIMEOUT_CANDLES` | `0` | Force-close open trade after N bars (0 = disabled) | +| `LIVE` | `false` | Set to `true` to enable live mode | +| `LIVE_POLL_SECS` | `30` | Poll interval for live mode | + +## Backtest results + +XAUUSDm M5 · 50 000 bars · $600 start · 1% risk · EMA20 · $7/lot commission + +| Metric | Value | +|---|---| +| Trades | 1 309 | +| Win rate | 50.9% | +| Profit factor | 1.24 | +| Return | +345% | +| Max drawdown | −$200 | + +> Results are in-sample. The dataset covers a period of elevated XAU volatility. Out-of-sample validation is recommended before live deployment. + +## Project layout + +``` +ares/ +├── src/ +│ ├── main.rs # entry point, env parsing, mode dispatch +│ ├── backtest.rs # walk-forward simulation engine +│ ├── live.rs # live trading loop +│ ├── detector.rs # momentum FVG detection +│ └── helpers.rs # shared math utilities +└── crates/ + ├── domain/ # shared types (Candle, Symbol, Timeframe, Side…) + └── mt5-client/ # async HTTP client for the MT5 bridge +``` diff --git a/crates/mt5-client/src/client.rs b/crates/mt5-client/src/client.rs index 1cb6475..0afa165 100644 --- a/crates/mt5-client/src/client.rs +++ b/crates/mt5-client/src/client.rs @@ -1,7 +1,7 @@ use domain::{AccountInfo, Candle, Position, Symbol, Tick, Timeframe}; use crate::error::Mt5Error; -use crate::types::{ApiErrorBody, DataOne, DataVec, HealthStatus, OrderCheckResult, TradeRequest, TradeResult}; +use crate::types::{ApiErrorBody, DataOne, DataVec, HealthStatus, OrderCheckResult, PendingOrder, TradeRequest, TradeResult}; pub struct Mt5Client { base_url: String, @@ -87,6 +87,27 @@ impl Mt5Client { Ok(w.data) } + pub async fn orders(&self, symbol: &str) -> Result, Mt5Error> { + let url = format!("{}/orders", self.base_url); + let text = self + .fetch_text(self.http.get(&url).query(&[("symbol", symbol)])) + .await?; + tracing::debug!(endpoint = %url, "mt5 response ok"); + let w: DataVec = 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)] + 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, "cancel order ok"); + let w: DataOne = serde_json::from_str(&text)?; + Ok(w.data) + } + pub async fn order_check(&self, request: &TradeRequest) -> Result { #[derive(serde::Serialize)] struct Body<'a> { @@ -192,13 +213,15 @@ mod tests { let tr = TradeRequest { action: 1, symbol: "BTCUSDm".into(), - volume: 0.01, - order_type: 0, - price: 60720.0, + volume: Some(0.01), + order_type: Some(0), + price: Some(60720.0), sl: None, tp: None, magic: None, comment: None, + order: None, + deviation: None, }; let json = serde_json::to_string(&tr).unwrap(); assert!(json.contains(r#""type":0"#), "order_type must serialize as \"type\""); diff --git a/crates/mt5-client/src/lib.rs b/crates/mt5-client/src/lib.rs index 8790844..b6a58b4 100644 --- a/crates/mt5-client/src/lib.rs +++ b/crates/mt5-client/src/lib.rs @@ -4,4 +4,4 @@ mod types; pub use client::Mt5Client; pub use error::Mt5Error; -pub use types::{HealthStatus, OrderCheckResult, TradeRequest, TradeResult}; +pub use types::{HealthStatus, OrderCheckResult, PendingOrder, TradeRequest, TradeResult}; diff --git a/crates/mt5-client/src/types.rs b/crates/mt5-client/src/types.rs index 232f6c6..e4a6cb6 100644 --- a/crates/mt5-client/src/types.rs +++ b/crates/mt5-client/src/types.rs @@ -14,10 +14,12 @@ pub struct HealthStatus { pub struct TradeRequest { pub action: u32, pub symbol: String, - pub volume: f64, - #[serde(rename = "type")] - pub order_type: u32, - pub price: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub volume: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub order_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, #[serde(skip_serializing_if = "Option::is_none")] pub sl: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -26,6 +28,76 @@ pub struct TradeRequest { pub magic: Option, #[serde(skip_serializing_if = "Option::is_none")] pub comment: Option, + /// Ticket number — required for TRADE_ACTION_REMOVE (cancel pending order) + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + /// Allowed price deviation in points + #[serde(skip_serializing_if = "Option::is_none")] + pub deviation: Option, +} + +impl TradeRequest { + pub fn limit( + side: domain::Side, + symbol: impl Into, + volume: f64, + price: f64, + sl: f64, + tp: f64, + magic: u64, + comment: impl Into, + ) -> Self { + // TRADE_ACTION_PENDING = 5; BUY_LIMIT = 2, SELL_LIMIT = 3 + let order_type = match side { + domain::Side::Long => 2, + domain::Side::Short => 3, + }; + Self { + action: 5, + symbol: symbol.into(), + volume: Some(volume), + order_type: Some(order_type), + price: Some(price), + sl: Some(sl), + tp: Some(tp), + magic: Some(magic), + comment: Some(comment.into()), + order: None, + deviation: None, + } + } + + pub fn cancel(symbol: impl Into, ticket: u64) -> Self { + // TRADE_ACTION_REMOVE = 8 + Self { + action: 8, + symbol: symbol.into(), + volume: None, + order_type: None, + price: None, + sl: None, + tp: None, + magic: None, + comment: None, + order: Some(ticket), + deviation: None, + } + } +} + +/// A pending (unfilled) limit order in MT5. +#[derive(Debug, Clone, Deserialize)] +pub struct PendingOrder { + pub ticket: u64, + pub symbol: String, + #[serde(rename = "type")] + pub order_type: u32, + pub volume_initial: f64, + pub price_open: f64, + pub sl: f64, + pub tp: f64, + pub magic: u64, + pub comment: String, } #[derive(Debug, Clone, Deserialize)] diff --git a/src/backtest.rs b/src/backtest.rs new file mode 100644 index 0000000..92a9f73 --- /dev/null +++ b/src/backtest.rs @@ -0,0 +1,358 @@ +use anyhow::Result; +use chrono::NaiveDate; +use domain::Side; +use rust_decimal::Decimal; + +use crate::detector; +use crate::helpers::{actual_entry, actual_exit, fmt_price, fmt_pnl, rolling_ema, size_position}; + +// ── config ──────────────────────────────────────────────────────────────────── + +#[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, +} + +// ── open trade ──────────────────────────────────────────────────────────────── + +struct OpenTrade { + open_time: String, + side: Side, + entry_level: Decimal, + actual_entry: Decimal, + sl: Decimal, + tp: Decimal, + volume: Decimal, + open_candle_idx: usize, +} + +// ── entry point ─────────────────────────────────────────────────────────────── + +pub async fn run(mt5: &mt5_client::Mt5Client, symbol: &str, cfg: &BacktestConfig) -> Result<()> { + tracing::info!(%symbol, tf = %cfg.tf_str, candles = cfg.candles, "fetching data"); + + let (sym_info, candles) = tokio::try_join!( + mt5.symbol(symbol), + mt5.rates_from_pos(symbol, cfg.timeframe, 0, cfg.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 = cfg.spread_override + .unwrap_or_else(|| Decimal::from(sym_info.spread) * point); + let slippage_price = cfg.slippage_points * point; + let profit_is_usd = sym_info.currency_profit.eq_ignore_ascii_case("USD"); + let pip_size = if sym_info.digits % 2 == 1 { point * Decimal::from(10u32) } else { point }; + let min_zone_size = cfg.min_fvg_pips * pip_size; + let min_sl_size = cfg.min_sl_pips * pip_size; + + let ema_vals: Vec> = if cfg.ema_period > 0 { + let closes: Vec = candles.iter().map(|c| c.close).collect(); + rolling_ema(&closes, cfg.ema_period) + } else { + vec![None; total] + }; + + tracing::info!(total, %symbol, "starting walk-forward"); + + let stop_out_balance = cfg.balance * cfg.stop_out_pct; + + let mut balance = cfg.balance; + let mut peak = balance; + let mut max_drawdown = Decimal::ZERO; + let mut open_trade: Option = None; + let mut pending_fvg: Option = None; + let mut margin_called = false; + + 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; + + 'outer: 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 { + if cfg.timeout_candles > 0 && (i - t.open_candle_idx) >= cfg.timeout_candles { + let t = open_trade.take().unwrap(); + let exit_lvl = candle.close; + let exit = actual_exit(t.side, exit_lvl, false, spread_price, slippage_price); + let commission = cfg.commission * 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; + if balance > peak { peak = balance; } + let dd = balance - peak; + if dd < max_drawdown { max_drawdown = dd; } + timeouts += 1; + trades += 1; + total_pnl += pnl; + if pnl >= Decimal::ZERO { wins += 1; sum_wins += pnl; cur_consec = 0; } + else { losses += 1; sum_losses += pnl.abs(); cur_consec += 1; if cur_consec > max_consec { max_consec = cur_consec; } } + println!( + "[{} {}] {} {} entry={} sl={} tp={} vol={:.2} → TIMEOUT exit={} 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, + fmt_price(exit, prec), fmt_pnl(pnl), balance, + ); + continue; + } + + if cfg.stop_out_pct > Decimal::ZERO { + let worst_price = match t.side { + Side::Long => candle.low, + Side::Short => candle.high, + }; + let pr_w = if profit_is_usd || worst_price <= Decimal::ZERO { Decimal::ONE } else { Decimal::ONE / worst_price }; + let unrealized_w = (match t.side { + Side::Long => (worst_price - t.actual_entry) * t.volume * contract_size, + Side::Short => (t.actual_entry - worst_price) * t.volume * contract_size, + }) * pr_w - cfg.commission * t.volume; + if balance + unrealized_w <= stop_out_balance { + let t = open_trade.take().unwrap(); + let exit = actual_exit(t.side, worst_price, true, spread_price, slippage_price); + let commission = cfg.commission * t.volume; + 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, + }) * pr_w - commission; + balance += pnl; + if balance > peak { peak = balance; } + let dd = balance - peak; + if dd < max_drawdown { max_drawdown = dd; } + trades += 1; losses += 1; + sum_losses += pnl.abs(); + cur_consec += 1; + if cur_consec > max_consec { max_consec = cur_consec; } + total_pnl += pnl; + println!( + "[{} {}] {} {} entry={} → STOP-OUT exit={} 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(exit, prec), fmt_pnl(pnl), balance, + ); + margin_called = true; + break 'outer; + } + } + + 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 = cfg.commission * 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, 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, + fmt_price(exit, prec), fmt_pnl(-friction), fmt_pnl(pnl), balance, + ); + } + continue; + } + + // ── date filter ─────────────────────────────────────────────────────── + if cfg.date_from.is_some_and(|d| date < d) { continue; } + if cfg.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) { + missed_fills += 1; + pending_fvg = None; + } + + // ── try to fill pending FVG ─────────────────────────────────────────── + if let Some(ref fvg) = pending_fvg { + if fvg.is_touched(candle) { + let ema_ok = if cfg.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.impulse_sl - cfg.sl_buffer, + Side::Short => fvg.impulse_sl + cfg.sl_buffer, + }; + let sl_dist = (fvg.entry - sl).abs(); + if sl_dist < min_sl_size { pending_fvg = None; continue; } + let tp = match fvg.side { + Side::Long => fvg.entry + sl_dist * cfg.min_rr, + Side::Short => fvg.entry - sl_dist * cfg.min_rr, + }; + let fill_ok = match fvg.side { + Side::Long => candle.low <= fvg.entry, + Side::Short => candle.high >= fvg.entry, + }; + if !fill_ok { continue; } + if balance <= stop_out_balance { + margin_called = true; break 'outer; + } + let value_per_lot = if profit_is_usd || candle.close == Decimal::ZERO { + contract_size + } else { + contract_size / candle.close + }; + match size_position(balance, cfg.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, + open_candle_idx: i, + }); + pending_fvg = None; + } + } + } else { + pending_fvg = None; + } + } + continue; + } + + // ── detect new momentum FVG ─────────────────────────────────────────── + pending_fvg = detector::detect( + &candles[i - 2], &candles[i - 1], candle, + cfg.body_pct_min, cfg.close_pct_min, min_zone_size, i, cfg.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 = cfg.commission * 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, cfg.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 - cfg.balance) / cfg.balance * Decimal::from(100u32); + + 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!("Friction : spread={} slip={} commission/lot={}", + fmt_price(spread_price, prec), fmt_price(slippage_price, prec), cfg.commission); + 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}"); + if margin_called { + println!("*** MARGIN CALL — stop-out at {:.1}% of initial balance ***", + cfg.stop_out_pct * Decimal::from(100u32)); + } + println!("─────────────────────────────────────────"); + + Ok(()) +} diff --git a/src/helpers.rs b/src/helpers.rs new file mode 100644 index 0000000..99b5544 --- /dev/null +++ b/src/helpers.rs @@ -0,0 +1,58 @@ +use domain::Side; +use rust_decimal::Decimal; + +pub fn rolling_ema(prices: &[Decimal], period: usize) -> Vec> { + 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::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 +} + +pub 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 { + 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)) +} + +pub fn actual_entry(side: Side, level: Decimal, spread: Decimal) -> Decimal { + match side { + Side::Long => level + spread, + Side::Short => level, + } +} + +pub 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, + } +} + +pub fn fmt_price(d: Decimal, prec: usize) -> String { format!("{0:.1$}", d, prec) } + +pub fn fmt_pnl(pnl: Decimal) -> String { + if pnl >= Decimal::ZERO { format!("+{:.2}", pnl) } else { format!("{:.2}", pnl) } +} + +pub fn d2f(d: Decimal) -> f64 { + d.to_string().parse().unwrap_or(0.0) +} diff --git a/src/live.rs b/src/live.rs new file mode 100644 index 0000000..2e76696 --- /dev/null +++ b/src/live.rs @@ -0,0 +1,311 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use domain::{Side, Timeframe}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tokio::time::{interval, Duration}; + +use crate::{detector, helpers::{d2f, fmt_price, rolling_ema, size_position}}; + +// Magic number that identifies all Ares orders/positions in MT5. +const MAGIC: u64 = 19730; + +// How many recent candles to fetch per tick (must cover EMA warm-up + 3 for FVG). +const CANDLE_FETCH: u32 = 100; + +// ── config ──────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct LiveConfig { + pub symbol: String, + pub timeframe: Timeframe, + pub risk_pct: Decimal, + pub body_pct_min: Decimal, + pub close_pct_min: Decimal, + pub fvg_expiry_candles: usize, + pub min_fvg_pips: Decimal, + pub min_sl_pips: Decimal, + pub sl_buffer: Decimal, + pub min_rr: Decimal, + pub slippage_points: Decimal, + pub spread_override: Option, + pub ema_period: usize, + pub poll_secs: u64, +} + +// ── persisted state ─────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize)] +struct State { + ticket: u64, + expires_at: DateTime, +} + +impl State { + fn path(symbol: &str) -> PathBuf { + PathBuf::from(format!(".ares_state_{symbol}.json")) + } + + fn load(symbol: &str) -> Option { + let content = std::fs::read_to_string(Self::path(symbol)).ok()?; + serde_json::from_str(&content).ok() + } + + fn save(&self, symbol: &str) -> Result<()> { + std::fs::write(Self::path(symbol), serde_json::to_string_pretty(self)?)?; + Ok(()) + } + + fn clear(symbol: &str) { + let _ = std::fs::remove_file(Self::path(symbol)); + } +} + +// ── entry point ─────────────────────────────────────────────────────────────── + +pub async fn run(mt5: &mt5_client::Mt5Client, cfg: &LiveConfig) -> Result<()> { + tracing::info!(symbol = %cfg.symbol, tf = ?cfg.timeframe, "live mode starting"); + + let sym_info = mt5.symbol(&cfg.symbol).await.context("fetch symbol info")?; + let point = sym_info.point; + let prec = sym_info.digits as usize; + let contract_size = sym_info.trade_contract_size; + let profit_is_usd = sym_info.currency_profit.eq_ignore_ascii_case("USD"); + let pip_size = if sym_info.digits % 2 == 1 { point * Decimal::from(10u32) } else { point }; + let min_sl = cfg.min_sl_pips * pip_size; + let min_zone = cfg.min_fvg_pips * pip_size; + let slip = cfg.slippage_points * point; + let spread = cfg.spread_override + .unwrap_or_else(|| Decimal::from(sym_info.spread) * point); + + let tf_mins = timeframe_minutes(cfg.timeframe); + let expiry_dur = chrono::Duration::minutes(tf_mins * cfg.fvg_expiry_candles as i64); + + let mut ticker = interval(Duration::from_secs(cfg.poll_secs)); + + loop { + ticker.tick().await; + + if let Err(e) = tick( + mt5, cfg, &sym_info, contract_size, point, prec, pip_size, min_sl, min_zone, + slip, spread, profit_is_usd, expiry_dur, + ) + .await + { + tracing::error!("tick error: {e:#}"); + } + } +} + +// ── single poll tick ────────────────────────────────────────────────────────── + +async fn tick( + mt5: &mt5_client::Mt5Client, + cfg: &LiveConfig, + sym_info: &domain::Symbol, + contract_size: Decimal, + _point: Decimal, + prec: usize, + _pip_size: Decimal, + min_sl: Decimal, + min_zone: Decimal, + _slip: Decimal, + _spread: Decimal, + profit_is_usd: bool, + expiry_dur: chrono::Duration, +) -> Result<()> { + let symbol = &cfg.symbol; + + // ── 1. check for open positions by this bot ─────────────────────────────── + let positions = mt5.positions().await.context("fetch positions")?; + let has_position = positions + .iter() + .any(|p| p.symbol == *symbol && p.magic == MAGIC); + + if has_position { + tracing::debug!(%symbol, "position already open — skip"); + return Ok(()); + } + + // ── 2. manage pending order state ──────────────────────────────────────── + if let Some(state) = State::load(symbol) { + let orders = mt5.orders(symbol).await.context("fetch orders")?; + let still_pending = orders.iter().any(|o| o.ticket == state.ticket && o.magic == MAGIC); + + if still_pending { + if Utc::now() < state.expires_at { + tracing::debug!(%symbol, ticket = state.ticket, "pending order alive — waiting"); + return Ok(()); + } + // expired — cancel + tracing::info!(%symbol, ticket = state.ticket, "FVG setup expired — cancelling order"); + match mt5.cancel_order(state.ticket, symbol).await { + Ok(r) => tracing::info!(retcode = r.retcode, "cancel ok"), + Err(e) => tracing::warn!("cancel failed: {e:#}"), + } + } else { + tracing::info!(%symbol, ticket = state.ticket, "pending order no longer in MT5 (filled/cancelled externally)"); + } + State::clear(symbol); + return Ok(()); + } + + // ── 3. fetch recent candles ─────────────────────────────────────────────── + let candles = mt5 + .rates_from_pos(symbol, cfg.timeframe, 0, CANDLE_FETCH) + .await + .context("fetch candles")?; + + if candles.len() < 5 { + tracing::warn!(%symbol, "too few candles"); + return Ok(()); + } + + let n = candles.len(); + + // Use last 3 fully-closed bars: [n-4], [n-3], [n-2] — skip [n-1] which may + // still be forming at poll time. + let pre = &candles[n - 4]; + let impulse = &candles[n - 3]; + let post = &candles[n - 2]; + let last_idx = n - 4; // detector uses absolute index only for expiry, we don't need it + + // ── 4. EMA trend 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(); + let emas = rolling_ema(&closes, cfg.ema_period); + emas[n - 2] + } else { + Some(Decimal::ZERO) + }; + + // ── 5. detect momentum FVG ──────────────────────────────────────────────── + let fvg = detector::detect( + pre, + impulse, + post, + cfg.body_pct_min, + cfg.close_pct_min, + min_zone, + last_idx, + cfg.fvg_expiry_candles, + ); + + let fvg = match fvg { + Some(f) => f, + None => return Ok(()), + }; + + // EMA filter + let ema_ok = match ema_val { + Some(ema) => match fvg.side { + Side::Long => post.close > ema, + Side::Short => post.close < ema, + }, + None => false, + }; + if !ema_ok { + tracing::debug!(%symbol, ?fvg.side, "EMA filter rejected FVG"); + return Ok(()); + } + + // ── 6. compute SL / TP ─────────────────────────────────────────────────── + let sl = match fvg.side { + Side::Long => fvg.impulse_sl - cfg.sl_buffer, + Side::Short => fvg.impulse_sl + cfg.sl_buffer, + }; + let sl_dist = (fvg.entry - sl).abs(); + if sl_dist < min_sl { + tracing::debug!(%symbol, %sl_dist, "SL too tight — skip"); + return Ok(()); + } + let tp = match fvg.side { + Side::Long => fvg.entry + sl_dist * cfg.min_rr, + Side::Short => fvg.entry - sl_dist * cfg.min_rr, + }; + + // ── 7. size position ────────────────────────────────────────────────────── + let acct = mt5.account().await.context("fetch account")?; + let balance = Decimal::try_from(acct.balance).context("balance conversion")?; + + let ref_price = post.close; + let value_per_lot = if profit_is_usd || ref_price == Decimal::ZERO { + contract_size + } else { + contract_size / ref_price + }; + + let volume = match size_position( + balance, cfg.risk_pct, sl_dist, value_per_lot, + sym_info.volume_step, sym_info.volume_min, sym_info.volume_max, + ) { + Some(v) => v, + None => { + tracing::warn!(%symbol, "position sizing returned None (SL=0 or too small)"); + return Ok(()); + } + }; + + // ── 8. place pending limit order ────────────────────────────────────────── + let entry_price = fvg.entry; + let req = mt5_client::TradeRequest::limit( + fvg.side, symbol.clone(), d2f(volume), d2f(entry_price), d2f(sl), d2f(tp), + MAGIC, format!("ares-{}", post.time.format("%m%d-%H%M")), + ); + + tracing::info!( + %symbol, side = ?fvg.side, + entry = %fmt_price(entry_price, prec), + sl = %fmt_price(sl, prec), + tp = %fmt_price(tp, prec), + vol = %volume, + bal = %balance, + "placing limit order", + ); + + let result = mt5.place_order(&req).await.context("place_order")?; + if result.retcode != 10009 { + tracing::error!(retcode = result.retcode, comment = %result.comment, "order rejected"); + return Ok(()); + } + + tracing::info!(ticket = result.order, "order placed"); + + let state = State { + ticket: result.order, + expires_at: Utc::now() + expiry_dur, + }; + state.save(symbol).context("save state")?; + + Ok(()) +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +fn timeframe_minutes(tf: Timeframe) -> i64 { + match tf { + Timeframe::M1 => 1, + Timeframe::M2 => 2, + Timeframe::M3 => 3, + Timeframe::M4 => 4, + Timeframe::M5 => 5, + Timeframe::M6 => 6, + Timeframe::M10 => 10, + Timeframe::M12 => 12, + Timeframe::M15 => 15, + Timeframe::M20 => 20, + Timeframe::M30 => 30, + Timeframe::H1 => 60, + Timeframe::H2 => 120, + Timeframe::H3 => 180, + Timeframe::H4 => 240, + Timeframe::H6 => 360, + Timeframe::H8 => 480, + Timeframe::H12 => 720, + Timeframe::D1 => 1440, + Timeframe::W1 => 10080, + Timeframe::Mn1 => 43200, + } +} + diff --git a/src/main.rs b/src/main.rs index d7adba2..dabd0e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,76 +1,52 @@ +mod backtest; mod detector; +mod helpers; +mod live; use anyhow::Context; use chrono::NaiveDate; -use domain::Side; use rust_decimal::Decimal; +use std::sync::Arc; -// ── helpers ────────────────────────────────────────────────────────────────── +pub use helpers::{d2f, rolling_ema, size_position}; -fn rolling_ema(prices: &[Decimal], period: usize) -> Vec> { - 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::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 +// ── env helpers ─────────────────────────────────────────────────────────────── + +fn env_str(key: &str, default: &str) -> String { + let v = std::env::var(key).unwrap_or_default(); + if v.is_empty() { default.to_string() } else { v } } -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 { - 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 env_dec(key: &str, default: &str) -> anyhow::Result { + env_str(key, default).parse().with_context(|| key.to_string()) } -fn actual_entry(side: Side, level: Decimal, spread: Decimal) -> Decimal { - match side { - Side::Long => level + spread, - Side::Short => level, +fn env_usize(key: &str, default: &str) -> anyhow::Result { + env_str(key, default).parse().with_context(|| key.to_string()) +} + +fn env_u32(key: &str, default: &str) -> anyhow::Result { + env_str(key, default).parse().with_context(|| key.to_string()) +} + +fn env_u64(key: &str, default: &str) -> anyhow::Result { + env_str(key, default).parse().with_context(|| key.to_string()) +} + +fn env_date(key: &str) -> anyhow::Result> { + match std::env::var(key) { + Ok(s) if !s.is_empty() => Ok(Some(s.parse().with_context(|| key.to_string())?)), + _ => Ok(None), } } -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 env_spread_override() -> anyhow::Result> { + match std::env::var("SPREAD_OVERRIDE") { + Ok(s) if !s.is_empty() && s != "0" => Ok(Some(s.parse().context("SPREAD_OVERRIDE")?)), + _ => Ok(None), } } -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, - open_candle_idx: usize, -} - // ── main ────────────────────────────────────────────────────────────────────── #[tokio::main] @@ -85,428 +61,100 @@ async fn main() -> anyhow::Result<()> { .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 tf_str = env_str("TIMEFRAME", "M5"); + let timeframe = tf_str.parse::().map_err(|e| anyhow::anyhow!("{e}"))?; - 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 min_fvg_pips: Decimal = std::env::var("MIN_FVG_PIPS") - .unwrap_or_else(|_| "3".to_string()).parse().context("MIN_FVG_PIPS")?; - let min_sl_pips: Decimal = std::env::var("MIN_SL_PIPS") - .unwrap_or_else(|_| "5".to_string()).parse().context("MIN_SL_PIPS")?; - 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 timeout_candles: usize = std::env::var("TIMEOUT_CANDLES") - .unwrap_or_else(|_| "0".to_string()).parse().context("TIMEOUT_CANDLES")?; - - 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 = 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 = match std::env::var("DATE_FROM") { - Ok(s) => Some(s.parse().context("DATE_FROM")?), - Err(_) => None, - }; - let date_to: Option = match std::env::var("DATE_TO") { - Ok(s) => Some(s.parse().context("DATE_TO")?), - Err(_) => None, - }; - - // Stop-out level as fraction of initial balance (0.0 = Exness default: equity hits $0). - // e.g. STOP_OUT_PCT=0.2 stops trading when equity drops to 20% of starting balance. - let stop_out_pct: Decimal = std::env::var("STOP_OUT_PCT") - .unwrap_or_else(|_| "0.0".to_string()).parse().context("STOP_OUT_PCT")?; - - let timeframe = tf_str.parse::().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"); - // for 5- or 3-decimal pairs (odd digit count) 1 pip = 10 points; for 2/4-decimal = 1 point - let pip_size = if sym_info.digits % 2 == 1 { point * Decimal::from(10u32) } else { point }; - let min_zone_size = min_fvg_pips * pip_size; - let min_sl_size = min_sl_pips * pip_size; - - let ema_vals: Vec> = if ema_period > 0 { - let closes: Vec = candles.iter().map(|c| c.close).collect(); - rolling_ema(&closes, ema_period) - } else { - vec![None; total] - }; - - tracing::info!(total, "starting walk-forward"); - - let stop_out_balance = backtest_balance * stop_out_pct; - - let mut balance = backtest_balance; - let mut peak = balance; - let mut max_drawdown = Decimal::ZERO; - let mut open_trade: Option = None; - let mut pending_fvg: Option = None; - let mut margin_called = false; - - 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 { - // timeout: force close after N candles - if timeout_candles > 0 && (i - t.open_candle_idx) >= timeout_candles { - let t = open_trade.take().unwrap(); - let exit_lvl = candle.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; - if balance > peak { peak = balance; } - let dd = balance - peak; - if dd < max_drawdown { max_drawdown = dd; } - timeouts += 1; - trades += 1; - total_pnl += pnl; - if pnl >= Decimal::ZERO { wins += 1; sum_wins += pnl; cur_consec = 0; } - else { losses += 1; sum_losses += pnl.abs(); cur_consec += 1; if cur_consec > max_consec { max_consec = cur_consec; } } - println!( - "[{} {}] {} {} entry={} sl={} tp={} vol={:.2} → 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(t.sl, prec), fmt_price(t.tp, prec), t.volume, - fmt_price(exit, prec), fmt_pnl(pnl), balance, - ); - continue; - } - // ── stop-out check: worst-case equity on this candle ───────────── - if stop_out_pct > Decimal::ZERO { - let worst_price = match t.side { - Side::Long => candle.low, - Side::Short => candle.high, - }; - let pr_w = if profit_is_usd || worst_price <= Decimal::ZERO { - Decimal::ONE - } else { - Decimal::ONE / worst_price - }; - let unrealized_w = (match t.side { - Side::Long => (worst_price - t.actual_entry) * t.volume * contract_size, - Side::Short => (t.actual_entry - worst_price) * t.volume * contract_size, - }) * pr_w - commission_per_lot * t.volume; - if balance + unrealized_w <= stop_out_balance { - let t = open_trade.take().unwrap(); - let exit = actual_exit(t.side, worst_price, true, spread_price, slippage_price); - let commission = commission_per_lot * t.volume; - 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, - }) * pr_w - commission; - balance += pnl; - if balance > peak { peak = balance; } - let dd = balance - peak; - if dd < max_drawdown { max_drawdown = dd; } - trades += 1; - losses += 1; - sum_losses += pnl.abs(); - cur_consec += 1; - if cur_consec > max_consec { max_consec = cur_consec; } - total_pnl += pnl; - println!( - "[{} {}] {} {} entry={} → STOP-OUT 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, - ); - margin_called = true; - break; - } - } - - 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; + // ── symbol list ─────────────────────────────────────────────────────────── + // SYMBOLS=XAUUSDm,XAGUSDm,BTCUSDm or SYMBOL=XAUUSDm + let symbols: Vec = { + let multi = std::env::var("SYMBOLS").unwrap_or_default(); + if !multi.is_empty() { + multi.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() + } else { + let single = std::env::var("SYMBOL").context("SYMBOL or SYMBOLS missing")?; + vec![single] } + }; - // ── date filter ─────────────────────────────────────────────────────── - if date_from.is_some_and(|d| date < d) { continue; } - if date_to.is_some_and(|d| date > d) { continue; } + // ── 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")?; - // ── expire stale FVG ────────────────────────────────────────────────── - if pending_fvg.as_ref().is_some_and(|f| i >= f.expiry_idx) { - missed_fills += 1; - pending_fvg = None; - } + let mt5 = Arc::new(mt5_client::Mt5Client::new(mt5_base_url)); - // ── 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 { - // SL placed at impulse candle's structural extreme, not zone edge - let sl = match fvg.side { - Side::Long => fvg.impulse_sl - sl_buffer, - Side::Short => fvg.impulse_sl + sl_buffer, - }; - let sl_dist = (fvg.entry - sl).abs(); - if sl_dist < min_sl_size { - pending_fvg = None; - continue; - } - 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 { - // zone touched but limit order not reached yet — keep FVG pending - continue; - } - - if balance <= stop_out_balance { - pending_fvg = None; - margin_called = true; - break; - } - - 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, - open_candle_idx: i, - }); - pending_fvg = None; - } - } - } else { - pending_fvg = None; - } - } - continue; - } - - // ── detect new momentum FVG ─────────────────────────────────────────── - pending_fvg = detector::detect( - &candles[i - 2], - &candles[i - 1], - candle, + // ── live mode ───────────────────────────────────────────────────────────── + let live_mode = std::env::var("LIVE").map(|v| v == "true" || v == "1").unwrap_or(false); + if live_mode { + let poll_secs = env_u64("LIVE_POLL_SECS", "30")?; + let base_cfg = live::LiveConfig { + symbol: String::new(), // filled per-spawn + timeframe, + risk_pct, body_pct_min, close_pct_min, - min_zone_size, - 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 + fvg_expiry_candles: fvg_expiry, + min_fvg_pips, + min_sl_pips, + sl_buffer, + min_rr, + slippage_points, + spread_override, + ema_period, + poll_secs, }; - 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, - ); + let mut handles = Vec::new(); + for symbol in symbols { + let mt5 = Arc::clone(&mt5); + let mut cfg = base_cfg.clone(); + cfg.symbol = symbol; + handles.push(tokio::spawn(async move { + if let Err(e) = live::run(&*mt5, &cfg).await { + tracing::error!(symbol = %cfg.symbol, "live loop error: {e:#}"); + } + })); + } + for h in handles { h.await.ok(); } + return Ok(()); } - // ── 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); + // ── backtest mode ───────────────────────────────────────────────────────── + let cfg = backtest::BacktestConfig { + timeframe, + candles: env_u32("BACKTEST_CANDLES", "50000")?, + balance: env_dec("BACKTEST_BALANCE", "600")?, + risk_pct, + body_pct_min, + close_pct_min, + fvg_expiry, + min_fvg_pips, + min_sl_pips, + sl_buffer, + min_rr, + 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(), + }; - println!("─────────────────────────────────────────"); - println!("Ares Scalper: {} {} | {} candles", symbol, tf_str, total); - let timeout_str = if timeout_candles > 0 { format!(" timeout={timeout_candles}c") } else { String::new() }; - println!("Strategy : Momentum FVG body≥{body_pct_min} close≥{close_pct_min} expiry={fvg_expiry}c min_fvg={min_fvg_pips}pip min_sl={min_sl_pips}pip min_rr={min_rr}{timeout_str}"); - 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}"); - if margin_called { - println!("*** MARGIN CALL — stop-out triggered at {:.1}% of initial balance ***", stop_out_pct * Decimal::from(100u32)); + for symbol in &symbols { + backtest::run(&mt5, symbol, &cfg).await?; } - println!("─────────────────────────────────────────"); Ok(()) }