mirror of
https://github.com/RomySaputraSihananda/ares.git
synced 2026-07-27 18:47:53 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
+30
-29
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1 +1,3 @@
|
||||
/target
|
||||
.env
|
||||
.ares_state_*.json
|
||||
|
||||
Generated
+1
@@ -48,6 +48,7 @@ dependencies = [
|
||||
"mt5-client",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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<Vec<PendingOrder>, 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<PendingOrder> = 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)]
|
||||
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<TradeResult> = serde_json::from_str(&text)?;
|
||||
Ok(w.data)
|
||||
}
|
||||
|
||||
pub async fn order_check(&self, request: &TradeRequest) -> Result<OrderCheckResult, Mt5Error> {
|
||||
#[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\"");
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<f64>,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub order_type: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub price: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sl: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -26,6 +28,76 @@ pub struct TradeRequest {
|
||||
pub magic: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub comment: Option<String>,
|
||||
/// Ticket number — required for TRADE_ACTION_REMOVE (cancel pending order)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<u64>,
|
||||
/// Allowed price deviation in points
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deviation: Option<u32>,
|
||||
}
|
||||
|
||||
impl TradeRequest {
|
||||
pub fn limit(
|
||||
side: domain::Side,
|
||||
symbol: impl Into<String>,
|
||||
volume: f64,
|
||||
price: f64,
|
||||
sl: f64,
|
||||
tp: f64,
|
||||
magic: u64,
|
||||
comment: impl Into<String>,
|
||||
) -> 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<String>, 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)]
|
||||
|
||||
+358
@@ -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<Decimal>,
|
||||
pub ema_period: usize,
|
||||
pub date_from: Option<NaiveDate>,
|
||||
pub date_to: Option<NaiveDate>,
|
||||
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<Option<Decimal>> = if cfg.ema_period > 0 {
|
||||
let closes: Vec<Decimal> = 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<OpenTrade> = None;
|
||||
let mut pending_fvg: Option<detector::PendingFvg> = 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(())
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use domain::Side;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
pub fn rolling_ema(prices: &[Decimal], period: usize) -> Vec<Option<Decimal>> {
|
||||
let k = Decimal::from(2u32) / Decimal::from((period + 1) as u32);
|
||||
let mut out = vec![None; prices.len()];
|
||||
if prices.len() < period { return out; }
|
||||
let seed: Decimal = prices[..period].iter().sum::<Decimal>() / Decimal::from(period);
|
||||
out[period - 1] = Some(seed);
|
||||
let mut ema = seed;
|
||||
for i in period..prices.len() {
|
||||
ema = prices[i] * k + ema * (Decimal::ONE - k);
|
||||
out[i] = Some(ema);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
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<Decimal> {
|
||||
if sl_distance == Decimal::ZERO { return None; }
|
||||
let raw = (balance * risk_pct) / (sl_distance * value_per_lot);
|
||||
let volume = (raw / vol_step).floor() * vol_step;
|
||||
if volume < min_vol { return None; }
|
||||
Some(volume.min(max_vol))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
+311
@@ -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<Decimal>,
|
||||
pub ema_period: usize,
|
||||
pub poll_secs: u64,
|
||||
}
|
||||
|
||||
// ── persisted state ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct State {
|
||||
ticket: u64,
|
||||
expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn path(symbol: &str) -> PathBuf {
|
||||
PathBuf::from(format!(".ares_state_{symbol}.json"))
|
||||
}
|
||||
|
||||
fn load(symbol: &str) -> Option<Self> {
|
||||
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<Decimal> = if cfg.ema_period > 0 && candles.len() >= cfg.ema_period {
|
||||
let closes: Vec<Decimal> = 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,
|
||||
}
|
||||
}
|
||||
|
||||
+113
-465
@@ -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<Option<Decimal>> {
|
||||
let k = Decimal::from(2u32) / Decimal::from((period + 1) as u32);
|
||||
let mut out = vec![None; prices.len()];
|
||||
if prices.len() < period { return out; }
|
||||
let seed: Decimal = prices[..period].iter().sum::<Decimal>() / Decimal::from(period);
|
||||
out[period - 1] = Some(seed);
|
||||
let mut ema = seed;
|
||||
for i in period..prices.len() {
|
||||
ema = prices[i] * k + ema * (Decimal::ONE - k);
|
||||
out[i] = Some(ema);
|
||||
}
|
||||
out
|
||||
// ── 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<Decimal> {
|
||||
if sl_distance == Decimal::ZERO { return None; }
|
||||
let raw = (balance * risk_pct) / (sl_distance * value_per_lot);
|
||||
let volume = (raw / vol_step).floor() * vol_step;
|
||||
if volume < min_vol { return None; }
|
||||
Some(volume.min(max_vol))
|
||||
fn env_dec(key: &str, default: &str) -> anyhow::Result<Decimal> {
|
||||
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<usize> {
|
||||
env_str(key, default).parse().with_context(|| key.to_string())
|
||||
}
|
||||
|
||||
fn env_u32(key: &str, default: &str) -> anyhow::Result<u32> {
|
||||
env_str(key, default).parse().with_context(|| key.to_string())
|
||||
}
|
||||
|
||||
fn env_u64(key: &str, default: &str) -> anyhow::Result<u64> {
|
||||
env_str(key, default).parse().with_context(|| key.to_string())
|
||||
}
|
||||
|
||||
fn env_date(key: &str) -> anyhow::Result<Option<NaiveDate>> {
|
||||
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<Option<Decimal>> {
|
||||
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::<domain::Timeframe>().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<Decimal> = match std::env::var("SPREAD_OVERRIDE") {
|
||||
Ok(s) => Some(s.parse().context("SPREAD_OVERRIDE")?),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
let ema_period: usize = std::env::var("EMA_PERIOD")
|
||||
.unwrap_or_else(|_| "20".to_string()).parse().context("EMA_PERIOD")?;
|
||||
|
||||
let date_from: Option<NaiveDate> = match std::env::var("DATE_FROM") {
|
||||
Ok(s) => Some(s.parse().context("DATE_FROM")?),
|
||||
Err(_) => None,
|
||||
};
|
||||
let date_to: Option<NaiveDate> = match std::env::var("DATE_TO") {
|
||||
Ok(s) => Some(s.parse().context("DATE_TO")?),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
// 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::<domain::Timeframe>().map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let mt5 = mt5_client::Mt5Client::new(mt5_base_url);
|
||||
|
||||
tracing::info!(symbol = %symbol, tf = %tf_str, backtest_candles, "fetching data");
|
||||
|
||||
let (sym_info, candles) = tokio::try_join!(
|
||||
mt5.symbol(&symbol),
|
||||
mt5.rates_from_pos(&symbol, timeframe, 0, backtest_candles),
|
||||
)?;
|
||||
|
||||
let total = candles.len();
|
||||
let contract_size = sym_info.trade_contract_size;
|
||||
let point = sym_info.point;
|
||||
let prec = sym_info.digits as usize;
|
||||
let spread_price = spread_override.unwrap_or_else(|| Decimal::from(sym_info.spread) * point);
|
||||
let slippage_price = slippage_points * point;
|
||||
let profit_is_usd = sym_info.currency_profit.eq_ignore_ascii_case("USD");
|
||||
// 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<Option<Decimal>> = if ema_period > 0 {
|
||||
let closes: Vec<Decimal> = candles.iter().map(|c| c.close).collect();
|
||||
rolling_ema(&closes, ema_period)
|
||||
} else {
|
||||
vec![None; total]
|
||||
};
|
||||
|
||||
tracing::info!(total, "starting walk-forward");
|
||||
|
||||
let 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<OpenTrade> = None;
|
||||
let mut pending_fvg: Option<detector::PendingFvg> = 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<String> = {
|
||||
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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user