diff --git a/.env.example b/.env.example index cd7fecc..466434f 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,9 @@ MT5_BASE_URL=http://localhost:8080 # ── Symbol & Timeframe ────────────────────────────────────────────────────── -SYMBOL=EURUSDm +# XAUUSDm is the best-tested pair (PF=1.33, +314% over 7m) +# EURUSDm and GBPUSDm show negative edge — not recommended +SYMBOL=XAUUSDm TIMEFRAME=M5 # ── Backtest ───────────────────────────────────────────────────────────────── @@ -20,8 +22,11 @@ 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 -SL_BUFFER=0 # extra buffer below/above FVG zone for SL (price units) +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 @@ -29,4 +34,6 @@ SLIPPAGE_POINTS=5 # extra SL slippage in MT5 points SPREAD_OVERRIDE=0 # override spread (0 = Zero/Raw account) # ── EMA Trend Filter ───────────────────────────────────────────────────────── -EMA_PERIOD=20 # 0 = disabled +# Critical: without EMA filter strategy loses money on XAU +# EMA20 is recommended (EMA10 overfits, EMA50 reduces returns significantly) +EMA_PERIOD=20 diff --git a/Cargo.lock b/Cargo.lock index 8812b55..b482c2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,6 +224,7 @@ dependencies = [ "chrono-tz", "rust_decimal", "serde", + "serde_json", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6db47c6..182b454 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,7 @@ +[workspace] +members = [".", "crates/domain", "crates/mt5-client"] +resolver = "2" + [package] name = "ares" version = "0.1.0" @@ -17,5 +21,5 @@ tokio = { version = "1", features = ["full"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -domain = { path = "../hermes/crates/domain" } -mt5-client = { path = "../hermes/crates/mt5-client" } +domain = { path = "crates/domain" } +mt5-client = { path = "crates/mt5-client" } diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml new file mode 100644 index 0000000..0b9d7e0 --- /dev/null +++ b/crates/domain/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "domain" +version = "0.1.0" +edition = "2024" + +[dependencies] +chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.10" +rust_decimal = { version = "1", features = ["serde"] } +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +serde_json = "1" diff --git a/crates/domain/src/account.rs b/crates/domain/src/account.rs new file mode 100644 index 0000000..017d470 --- /dev/null +++ b/crates/domain/src/account.rs @@ -0,0 +1,72 @@ +use rust_decimal::Decimal; +use serde::Deserialize; + +use crate::serde_helpers; + +#[derive(Debug, Clone, Deserialize)] +pub struct AccountInfo { + pub login: u64, + pub leverage: u32, + pub trade_allowed: bool, + pub trade_expert: bool, + pub currency: String, + pub currency_digits: u8, + pub server: String, + pub name: String, + pub company: String, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub balance: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub equity: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub profit: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub credit: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub margin: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub margin_free: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub margin_level: Decimal, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_from_api_json() { + // JSON real from MT5 bridge /account endpoint (live data) + let json = r#"{ + "login": 415817698, + "trade_mode": 0, + "leverage": 2000, + "limit_orders": 1024, + "margin_so_mode": 0, + "trade_allowed": true, + "trade_expert": true, + "margin_mode": 2, + "currency_digits": 2, + "fifo_close": false, + "balance": 5000.0, + "credit": 0.0, + "profit": 0.0, + "equity": 5000.0, + "margin": 0.0, + "margin_free": 5000.0, + "margin_level": 0.0, + "margin_so_call": 60.0, + "name": "Standard", + "server": "Exness-MT5Trial14", + "currency": "USD", + "company": "Exness Technologies Ltd" + }"#; + let a: AccountInfo = serde_json::from_str(json).unwrap(); + assert_eq!(a.login, 415817698); + assert_eq!(a.leverage, 2000); + assert_eq!(a.balance, "5000".parse::().unwrap()); + assert_eq!(a.currency, "USD"); + assert_eq!(a.server, "Exness-MT5Trial14"); + assert!(a.trade_allowed); + } +} diff --git a/crates/domain/src/candle.rs b/crates/domain/src/candle.rs new file mode 100644 index 0000000..e85738a --- /dev/null +++ b/crates/domain/src/candle.rs @@ -0,0 +1,52 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::Deserialize; + +use crate::serde_helpers; + +#[derive(Debug, Clone, Deserialize)] +pub struct Candle { + #[serde(with = "serde_helpers::naive_utc_secs")] + pub time: DateTime, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub open: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub high: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub low: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub close: Decimal, + pub tick_volume: u64, + pub spread: i32, + pub real_volume: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn deserializes_from_api_json() { + // JSON real from MT5 bridge /rates/from-pos?symbol=BTCUSDm&timeframe=TIMEFRAME_M5 + let json = r#"{ + "time": "2026-06-05T19:05:00", + "open": 59374.08, + "high": 59490.02, + "low": 59225.56, + "close": 59377.06, + "tick_volume": 1869, + "spread": 1008, + "real_volume": 0 + }"#; + let c: Candle = serde_json::from_str(json).unwrap(); + assert_eq!(c.time, chrono::Utc.with_ymd_and_hms(2026, 6, 5, 19, 5, 0).unwrap()); + assert_eq!(c.open, "59374.08".parse::().unwrap()); + assert_eq!(c.high, "59490.02".parse::().unwrap()); + assert_eq!(c.low, "59225.56".parse::().unwrap()); + assert_eq!(c.close, "59377.06".parse::().unwrap()); + assert_eq!(c.tick_volume, 1869); + assert_eq!(c.spread, 1008); + assert_eq!(c.real_volume, 0); + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs new file mode 100644 index 0000000..7a42adc --- /dev/null +++ b/crates/domain/src/lib.rs @@ -0,0 +1,15 @@ +mod serde_helpers; + +pub mod account; +pub mod candle; +pub mod position; +pub mod symbol; +pub mod tick; +pub mod timeframe; + +pub use account::AccountInfo; +pub use candle::Candle; +pub use position::{Position, Side}; +pub use symbol::Symbol; +pub use tick::Tick; +pub use timeframe::Timeframe; diff --git a/crates/domain/src/position.rs b/crates/domain/src/position.rs new file mode 100644 index 0000000..ab1d2f5 --- /dev/null +++ b/crates/domain/src/position.rs @@ -0,0 +1,106 @@ +use rust_decimal::Decimal; +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::serde_helpers; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Side { + Long, + Short, +} + +fn de_side<'de, D: Deserializer<'de>>(de: D) -> Result { + match u8::deserialize(de)? { + 0 => Ok(Side::Long), + 1 => Ok(Side::Short), + n => Err(serde::de::Error::custom(format!("unknown position type: {n}"))), + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Position { + pub ticket: u64, + pub symbol: String, + #[serde(rename = "type", deserialize_with = "de_side")] + pub side: Side, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub volume: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub price_open: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub sl: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub tp: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub price_current: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub swap: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub profit: Decimal, + pub comment: String, + pub magic: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn position_type_0_maps_to_long() { + // MT5 "type": 0 = BUY = Long. Extra fields (time, identifier, reason) are ignored by serde. + let json = r#"{ + "ticket": 123456789, + "symbol": "BTCUSDm", + "type": 0, + "volume": 0.1, + "price_open": 59000.0, + "sl": 58000.0, + "tp": 61000.0, + "price_current": 59500.0, + "swap": -5.0, + "profit": 50.0, + "comment": "test", + "magic": 0, + "time": "2026-06-01T10:00:00", + "identifier": 123456789, + "reason": 0 + }"#; + let p: Position = serde_json::from_str(json).unwrap(); + assert_eq!(p.side, Side::Long); + assert_eq!(p.ticket, 123456789); + assert_eq!(p.symbol, "BTCUSDm"); + assert_eq!(p.volume, "0.1".parse::().unwrap()); + assert_eq!(p.sl, "58000".parse::().unwrap()); + } + + #[test] + fn position_type_1_maps_to_short() { + let json = r#"{ + "ticket": 999, + "symbol": "ETHUSDm", + "type": 1, + "volume": 0.5, + "price_open": 1500.0, + "sl": 1600.0, + "tp": 1400.0, + "price_current": 1490.0, + "swap": 0.0, + "profit": 5.0, + "comment": "", + "magic": 42, + "time": "2026-06-01T12:00:00", + "identifier": 999, + "reason": 0 + }"#; + let p: Position = serde_json::from_str(json).unwrap(); + assert_eq!(p.side, Side::Short); + assert_eq!(p.magic, 42); + } + + #[test] + fn side_serializes_as_lowercase() { + assert_eq!(serde_json::to_string(&Side::Long).unwrap(), r#""long""#); + assert_eq!(serde_json::to_string(&Side::Short).unwrap(), r#""short""#); + } +} diff --git a/crates/domain/src/serde_helpers.rs b/crates/domain/src/serde_helpers.rs new file mode 100644 index 0000000..37141a9 --- /dev/null +++ b/crates/domain/src/serde_helpers.rs @@ -0,0 +1,62 @@ +#![allow(dead_code)] + +use rust_decimal::Decimal; +use serde::Deserializer; + +pub(crate) fn de_decimal<'de, D: Deserializer<'de>>(de: D) -> Result { + use serde::de::Visitor; + struct V; + impl<'de> Visitor<'de> for V { + type Value = Decimal; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a number") + } + fn visit_f64(self, v: f64) -> Result { + Decimal::try_from(v).map_err(E::custom) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Decimal::from(v)) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Decimal::from(v)) + } + } + de.deserialize_any(V) +} + +pub(crate) mod naive_utc_secs { + use chrono::{DateTime, NaiveDateTime, Utc}; + use serde::{Deserialize, Deserializer, Serializer}; + + const FMT: &str = "%Y-%m-%dT%H:%M:%S"; + + pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result, D::Error> { + let s = String::deserialize(de)?; + NaiveDateTime::parse_from_str(&s, FMT) + .map(|ndt| ndt.and_utc()) + .map_err(serde::de::Error::custom) + } + + pub fn serialize(dt: &DateTime, se: S) -> Result { + se.serialize_str(&dt.format(FMT).to_string()) + } +} + +pub(crate) mod naive_utc_ms { + use chrono::{DateTime, NaiveDateTime, Utc}; + use serde::{Deserialize, Deserializer, Serializer}; + + const FMT_PARSE: &str = "%Y-%m-%dT%H:%M:%S%.f"; // accept any precision when reading + const FMT_EMIT: &str = "%Y-%m-%dT%H:%M:%S%.3f"; // always write exactly .NNN + + pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result, D::Error> { + let s = String::deserialize(de)?; + NaiveDateTime::parse_from_str(&s, FMT_PARSE) + .map(|ndt| ndt.and_utc()) + .map_err(serde::de::Error::custom) + } + + pub fn serialize(dt: &DateTime, se: S) -> Result { + se.serialize_str(&dt.format(FMT_EMIT).to_string()) + } +} diff --git a/crates/domain/src/symbol.rs b/crates/domain/src/symbol.rs new file mode 100644 index 0000000..25a12c9 --- /dev/null +++ b/crates/domain/src/symbol.rs @@ -0,0 +1,70 @@ +use rust_decimal::Decimal; +use serde::Deserialize; + +use crate::serde_helpers; + +#[derive(Debug, Clone, Deserialize)] +pub struct Symbol { + pub name: String, + pub description: String, + pub digits: u8, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub point: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub bid: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub ask: Decimal, + pub spread: i32, + pub spread_float: bool, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub volume_min: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub volume_max: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub volume_step: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub trade_contract_size: Decimal, + pub currency_base: String, + pub currency_profit: String, + pub category: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserializes_subset_ignores_extra_fields() { + // JSON real from MT5 bridge /symbols — BCHUSDm has 80+ fields, we only capture 15 + let json = r#"{ + "name": "BCHUSDm", + "description": "Bitcoin Cash vs US Dollar", + "digits": 2, + "point": 0.01, + "bid": 202.51, + "ask": 217.51, + "spread": 1500, + "spread_float": true, + "volume_min": 0.1, + "volume_max": 20.0, + "volume_step": 0.01, + "trade_contract_size": 1.0, + "currency_base": "BCH", + "currency_profit": "USD", + "category": "Crypto", + "custom": false, + "chart_mode": 0, + "select": true, + "visible": true, + "session_deals": 0, + "unknown_future_field": "ignored" + }"#; + let s: Symbol = serde_json::from_str(json).unwrap(); + assert_eq!(s.name, "BCHUSDm"); + assert_eq!(s.digits, 2); + assert_eq!(s.bid, "202.51".parse::().unwrap()); + assert_eq!(s.ask, "217.51".parse::().unwrap()); + assert_eq!(s.currency_base, "BCH"); + assert_eq!(s.category, "Crypto"); + } +} diff --git a/crates/domain/src/tick.rs b/crates/domain/src/tick.rs new file mode 100644 index 0000000..496e5b4 --- /dev/null +++ b/crates/domain/src/tick.rs @@ -0,0 +1,51 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::Deserialize; + +use crate::serde_helpers; + +#[derive(Debug, Clone, Deserialize)] +pub struct Tick { + #[serde(with = "serde_helpers::naive_utc_secs")] + pub time: DateTime, + #[serde(with = "serde_helpers::naive_utc_ms")] + pub time_msc: DateTime, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub bid: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub ask: Decimal, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub last: Decimal, + pub volume: u64, + pub flags: u32, + #[serde(deserialize_with = "serde_helpers::de_decimal")] + pub volume_real: Decimal, +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn deserializes_from_api_json() { + // JSON real from MT5 bridge /ticks/from?symbol=BTCUSDm + let json = r#"{ + "time": "2026-06-05T00:00:00", + "bid": 63801.02, + "ask": 63811.1, + "last": 0.0, + "volume": 0, + "time_msc": "2026-06-05T00:00:00.234000", + "flags": 134, + "volume_real": 0.0 + }"#; + let t: Tick = serde_json::from_str(json).unwrap(); + assert_eq!(t.time, chrono::Utc.with_ymd_and_hms(2026, 6, 5, 0, 0, 0).unwrap()); + assert_eq!(t.bid, "63801.02".parse::().unwrap()); + assert_eq!(t.ask, "63811.1".parse::().unwrap()); + assert_eq!(t.flags, 134); + // time_msc must preserve 234ms + assert_eq!(t.time_msc.timestamp_subsec_millis(), 234); + } +} diff --git a/crates/domain/src/timeframe.rs b/crates/domain/src/timeframe.rs new file mode 100644 index 0000000..ce20daf --- /dev/null +++ b/crates/domain/src/timeframe.rs @@ -0,0 +1,119 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Timeframe { + #[serde(rename = "TIMEFRAME_M1")] M1, + #[serde(rename = "TIMEFRAME_M2")] M2, + #[serde(rename = "TIMEFRAME_M3")] M3, + #[serde(rename = "TIMEFRAME_M4")] M4, + #[serde(rename = "TIMEFRAME_M5")] M5, + #[serde(rename = "TIMEFRAME_M6")] M6, + #[serde(rename = "TIMEFRAME_M10")] M10, + #[serde(rename = "TIMEFRAME_M12")] M12, + #[serde(rename = "TIMEFRAME_M15")] M15, + #[serde(rename = "TIMEFRAME_M20")] M20, + #[serde(rename = "TIMEFRAME_M30")] M30, + #[serde(rename = "TIMEFRAME_H1")] H1, + #[serde(rename = "TIMEFRAME_H2")] H2, + #[serde(rename = "TIMEFRAME_H3")] H3, + #[serde(rename = "TIMEFRAME_H4")] H4, + #[serde(rename = "TIMEFRAME_H6")] H6, + #[serde(rename = "TIMEFRAME_H8")] H8, + #[serde(rename = "TIMEFRAME_H12")] H12, + #[serde(rename = "TIMEFRAME_D1")] D1, + #[serde(rename = "TIMEFRAME_W1")] W1, + #[serde(rename = "TIMEFRAME_MN1")] Mn1, +} + +impl Timeframe { + pub fn as_api_str(self) -> &'static str { + match self { + Self::M1 => "TIMEFRAME_M1", + Self::M2 => "TIMEFRAME_M2", + Self::M3 => "TIMEFRAME_M3", + Self::M4 => "TIMEFRAME_M4", + Self::M5 => "TIMEFRAME_M5", + Self::M6 => "TIMEFRAME_M6", + Self::M10 => "TIMEFRAME_M10", + Self::M12 => "TIMEFRAME_M12", + Self::M15 => "TIMEFRAME_M15", + Self::M20 => "TIMEFRAME_M20", + Self::M30 => "TIMEFRAME_M30", + Self::H1 => "TIMEFRAME_H1", + Self::H2 => "TIMEFRAME_H2", + Self::H3 => "TIMEFRAME_H3", + Self::H4 => "TIMEFRAME_H4", + Self::H6 => "TIMEFRAME_H6", + Self::H8 => "TIMEFRAME_H8", + Self::H12 => "TIMEFRAME_H12", + Self::D1 => "TIMEFRAME_D1", + Self::W1 => "TIMEFRAME_W1", + Self::Mn1 => "TIMEFRAME_MN1", + } + } +} + +impl std::str::FromStr for Timeframe { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "M1" => Ok(Self::M1), + "M2" => Ok(Self::M2), + "M3" => Ok(Self::M3), + "M4" => Ok(Self::M4), + "M5" => Ok(Self::M5), + "M6" => Ok(Self::M6), + "M10" => Ok(Self::M10), + "M12" => Ok(Self::M12), + "M15" => Ok(Self::M15), + "M20" => Ok(Self::M20), + "M30" => Ok(Self::M30), + "H1" => Ok(Self::H1), + "H2" => Ok(Self::H2), + "H3" => Ok(Self::H3), + "H4" => Ok(Self::H4), + "H6" => Ok(Self::H6), + "H8" => Ok(Self::H8), + "H12" => Ok(Self::H12), + "D1" => Ok(Self::D1), + "W1" => Ok(Self::W1), + "MN1" => Ok(Self::Mn1), + other => Err(format!("unknown timeframe: {other}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn as_api_str_spot_check() { + assert_eq!(Timeframe::M5.as_api_str(), "TIMEFRAME_M5"); + assert_eq!(Timeframe::H4.as_api_str(), "TIMEFRAME_H4"); + assert_eq!(Timeframe::D1.as_api_str(), "TIMEFRAME_D1"); + assert_eq!(Timeframe::Mn1.as_api_str(), "TIMEFRAME_MN1"); + } + + #[test] + fn serde_round_trip() { + let tf = Timeframe::H1; + let json = serde_json::to_string(&tf).unwrap(); + assert_eq!(json, r#""TIMEFRAME_H1""#); + let back: Timeframe = serde_json::from_str(&json).unwrap(); + assert_eq!(back, tf); + } + + #[test] + fn from_str_known_variant() { + let tf: Timeframe = "M15".parse().unwrap(); + assert_eq!(tf, Timeframe::M15); + } + + #[test] + fn from_str_unknown_returns_err() { + let result = "invalid".parse::(); + assert!(result.is_err()); + } +} diff --git a/crates/mt5-client/Cargo.toml b/crates/mt5-client/Cargo.toml new file mode 100644 index 0000000..b3b40c7 --- /dev/null +++ b/crates/mt5-client/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "mt5-client" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { path = "../domain" } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" diff --git a/crates/mt5-client/src/client.rs b/crates/mt5-client/src/client.rs new file mode 100644 index 0000000..1cb6475 --- /dev/null +++ b/crates/mt5-client/src/client.rs @@ -0,0 +1,218 @@ +use domain::{AccountInfo, Candle, Position, Symbol, Tick, Timeframe}; + +use crate::error::Mt5Error; +use crate::types::{ApiErrorBody, DataOne, DataVec, HealthStatus, OrderCheckResult, TradeRequest, TradeResult}; + +pub struct Mt5Client { + base_url: String, + http: reqwest::Client, +} + +impl Mt5Client { + pub fn new(base_url: impl Into) -> Self { + Self { base_url: base_url.into(), http: reqwest::Client::new() } + } + + async fn fetch_text(&self, req: reqwest::RequestBuilder) -> Result { + let resp = req.send().await?; + let status = resp.status(); + if !status.is_success() { + let code = status.as_u16(); + let body = resp.text().await.unwrap_or_default(); + let detail = serde_json::from_str::(&body) + .map(|e| e.detail) + .unwrap_or(body); + return Err(Mt5Error::Api { status: code, detail }); + } + Ok(resp.text().await?) + } + + pub async fn health(&self) -> Result { + let url = format!("{}/health", self.base_url); + let text = self.fetch_text(self.http.get(&url)).await?; + tracing::debug!(endpoint = %url, "mt5 response ok"); + Ok(serde_json::from_str(&text)?) + } + + pub async fn account(&self) -> Result { + let url = format!("{}/account", self.base_url); + let text = self.fetch_text(self.http.get(&url)).await?; + tracing::debug!(endpoint = %url, "mt5 response ok"); + let w: DataVec = serde_json::from_str(&text)?; + w.data.into_iter().next().ok_or(Mt5Error::Empty { endpoint: url }) + } + + pub async fn symbol(&self, name: &str) -> Result { + let url = format!("{}/symbols/{name}", self.base_url); + let text = self.fetch_text(self.http.get(&url)).await?; + tracing::debug!(endpoint = %url, "mt5 response ok"); + let w: DataVec = serde_json::from_str(&text)?; + w.data.into_iter().next().ok_or(Mt5Error::Empty { endpoint: url }) + } + + pub async fn tick(&self, symbol: &str) -> Result { + let url = format!("{}/symbols/{symbol}/tick", self.base_url); + let text = self.fetch_text(self.http.get(&url)).await?; + tracing::debug!(endpoint = %url, "mt5 response ok"); + let w: DataVec = serde_json::from_str(&text)?; + w.data.into_iter().next().ok_or(Mt5Error::Empty { endpoint: url }) + } + + pub async fn rates_from_pos( + &self, + symbol: &str, + timeframe: Timeframe, + start_pos: u32, + count: u32, + ) -> Result, Mt5Error> { + let url = format!("{}/rates/from-pos", self.base_url); + let text = self + .fetch_text(self.http.get(&url).query(&[ + ("symbol", symbol), + ("timeframe", timeframe.as_api_str()), + ("start_pos", &start_pos.to_string()), + ("count", &count.to_string()), + ])) + .await?; + tracing::debug!(endpoint = %url, "mt5 response ok"); + let w: DataVec = serde_json::from_str(&text)?; + Ok(w.data) + } + + pub async fn positions(&self) -> Result, Mt5Error> { + let url = format!("{}/positions", self.base_url); + let text = self.fetch_text(self.http.get(&url)).await?; + tracing::debug!(endpoint = %url, "mt5 response ok"); + let w: DataVec = serde_json::from_str(&text)?; + Ok(w.data) + } + + pub async fn order_check(&self, request: &TradeRequest) -> Result { + #[derive(serde::Serialize)] + struct Body<'a> { + request: &'a TradeRequest, + } + let url = format!("{}/order/check", self.base_url); + let text = self + .fetch_text(self.http.post(&url).json(&Body { request })) + .await?; + tracing::debug!(endpoint = %url, "mt5 response ok"); + let w: DataOne = serde_json::from_str(&text)?; + Ok(w.data) + } + + pub async fn place_order( + &self, + request: &TradeRequest, + ) -> Result { + #[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 })) + .await?; + tracing::debug!(endpoint = %url, "mt5 response ok"); + let w: DataOne = serde_json::from_str(&text)?; + Ok(w.data) + } +} + +#[cfg(test)] +mod tests { + use crate::types::{DataOne, DataVec, HealthStatus, OrderCheckResult, TradeRequest, TradeResult}; + use domain::{AccountInfo, Candle, Position, Symbol, Tick}; + + #[test] + fn parse_health() { + let raw = r#"{"status":"healthy","mt5_connected":true,"mt5_version":"unknown","api_version":"1.0.0"}"#; + let h: HealthStatus = serde_json::from_str(raw).unwrap(); + assert!(h.mt5_connected); + assert_eq!(h.status, "healthy"); + } + + #[test] + fn parse_account() { + let raw = r#"{"data":[{"login":415817698,"trade_mode":0,"leverage":2000,"limit_orders":1024,"margin_so_mode":0,"trade_allowed":true,"trade_expert":true,"margin_mode":2,"currency_digits":2,"fifo_close":false,"balance":5000.0,"credit":0.0,"profit":0.0,"equity":5000.0,"margin":0.0,"margin_free":5000.0,"margin_level":0.0,"margin_so_call":60.0,"margin_so_so":0.0,"margin_initial":0.0,"margin_maintenance":0.0,"assets":0.0,"liabilities":0.0,"commission_blocked":0.0,"name":"Standard","server":"Exness-MT5Trial14","currency":"USD","company":"Exness Technologies Ltd"}],"count":1,"format":"json"}"#; + let w: DataVec = serde_json::from_str(raw).unwrap(); + assert_eq!(w.data[0].login, 415817698); + assert_eq!(w.data[0].leverage, 2000); + } + + #[test] + fn parse_symbol() { + let raw = r#"{"data":[{"name":"BTCUSDm","description":"Bitcoin vs US Dollar","digits":2,"point":0.01,"bid":60708.14,"ask":60718.22,"spread":1008,"spread_float":true,"volume_min":0.01,"volume_max":200.0,"volume_step":0.01,"trade_contract_size":1.0,"currency_base":"BTC","currency_profit":"USD","category":"Crypto"}],"count":1,"format":"json"}"#; + let w: DataVec = serde_json::from_str(raw).unwrap(); + assert_eq!(w.data[0].name, "BTCUSDm"); + assert_eq!(w.data[0].digits, 2); + } + + #[test] + fn parse_tick() { + let raw = r#"{"data":[{"time":"2026-06-05T20:11:04","bid":60718.33,"ask":60728.41,"last":0.0,"volume":0,"time_msc":"2026-06-05T20:11:04.503000","flags":6,"volume_real":0.0}],"count":1,"format":"json"}"#; + let w: DataVec = serde_json::from_str(raw).unwrap(); + assert_eq!(w.data.len(), 1); + assert_eq!(w.data[0].flags, 6); + } + + #[test] + fn parse_rates() { + let raw = r#"{"data":[{"time":"2026-06-05T19:40:00","open":59765.98,"high":59874.83,"low":59516.94,"close":59707.76,"tick_volume":1371,"spread":1008,"real_volume":0},{"time":"2026-06-05T19:45:00","open":59707.6,"high":60144.13,"low":59699.21,"close":60132.58,"tick_volume":665,"spread":1008,"real_volume":0},{"time":"2026-06-05T20:10:00","open":60703.53,"high":60703.53,"low":60703.53,"close":60703.53,"tick_volume":1,"spread":1008,"real_volume":0}],"count":3,"format":"json"}"#; + let w: DataVec = serde_json::from_str(raw).unwrap(); + assert_eq!(w.data.len(), 3); + assert_eq!(w.data[0].tick_volume, 1371); + } + + #[test] + fn parse_positions_empty() { + let raw = r#"{"data":[],"count":0,"format":"json"}"#; + let w: DataVec = serde_json::from_str(raw).unwrap(); + assert!(w.data.is_empty()); + } + + #[test] + fn parse_order_check() { + let raw = r#"{"data":{"retcode":0,"balance":5000.0,"equity":5000.0,"profit":0.0,"margin":1.52,"margin_free":4998.48,"margin_level":328947.36842105264,"comment":"Done","request":{"action":1,"magic":0,"order":0,"symbol":"BTCUSDm","volume":0.01,"price":60720.0,"stoplimit":0.0,"sl":0.0,"tp":0.0,"deviation":0,"type":0,"type_filling":0,"type_time":0,"expiration":0,"comment":"","position":0,"position_by":0}},"count":1,"format":"json"}"#; + let w: DataOne = serde_json::from_str(raw).unwrap(); + assert_eq!(w.data.retcode, 0); + assert_eq!(w.data.comment, "Done"); + } + + #[test] + fn account_empty_data_is_error() { + use crate::error::Mt5Error; + let w: DataVec = serde_json::from_str(r#"{"data":[],"count":0,"format":"json"}"#).unwrap(); + let result = w.data.into_iter().next().ok_or(Mt5Error::Empty { endpoint: "/account".to_string() }); + assert!(matches!(result, Err(Mt5Error::Empty { .. }))); + } + + #[test] + fn serialize_trade_request() { + let tr = TradeRequest { + action: 1, + symbol: "BTCUSDm".into(), + volume: 0.01, + order_type: 0, + price: 60720.0, + sl: None, + tp: None, + magic: None, + comment: None, + }; + let json = serde_json::to_string(&tr).unwrap(); + assert!(json.contains(r#""type":0"#), "order_type must serialize as \"type\""); + assert!(json.contains(r#""action":1"#)); + assert!(!json.contains(r#""sl""#), "None fields must be omitted"); + assert!(!json.contains(r#""magic""#), "None fields must be omitted"); + } + + #[test] + fn parse_trade_result() { + let raw = r#"{"data":{"retcode":10009,"order":123456789,"comment":"Request executed"},"count":1,"format":"json"}"#; + let w: DataOne = serde_json::from_str(raw).unwrap(); + assert_eq!(w.data.retcode, 10009); + assert_eq!(w.data.order, 123456789); + assert_eq!(w.data.comment, "Request executed"); + } +} diff --git a/crates/mt5-client/src/error.rs b/crates/mt5-client/src/error.rs new file mode 100644 index 0000000..883839c --- /dev/null +++ b/crates/mt5-client/src/error.rs @@ -0,0 +1,14 @@ +#[derive(Debug, thiserror::Error)] +pub enum Mt5Error { + #[error("request failed: {0}")] + Http(#[from] reqwest::Error), + + #[error("bridge error ({status}): {detail}")] + Api { status: u16, detail: String }, + + #[error("response parse failed: {0}")] + Parse(#[from] serde_json::Error), + + #[error("empty response for {endpoint}")] + Empty { endpoint: String }, +} diff --git a/crates/mt5-client/src/lib.rs b/crates/mt5-client/src/lib.rs new file mode 100644 index 0000000..8790844 --- /dev/null +++ b/crates/mt5-client/src/lib.rs @@ -0,0 +1,7 @@ +mod client; +mod error; +mod types; + +pub use client::Mt5Client; +pub use error::Mt5Error; +pub use types::{HealthStatus, OrderCheckResult, TradeRequest, TradeResult}; diff --git a/crates/mt5-client/src/types.rs b/crates/mt5-client/src/types.rs new file mode 100644 index 0000000..232f6c6 --- /dev/null +++ b/crates/mt5-client/src/types.rs @@ -0,0 +1,65 @@ +use serde::{Deserialize, Serialize}; + +// ── public types ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Deserialize)] +pub struct HealthStatus { + pub status: String, + pub mt5_connected: bool, + pub mt5_version: String, + pub api_version: String, +} + +#[derive(Debug, Clone, Serialize)] +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 sl: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub magic: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub comment: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct OrderCheckResult { + pub retcode: u32, + pub balance: f64, + pub equity: f64, + pub profit: f64, + pub margin: f64, + pub margin_free: f64, + pub margin_level: f64, + pub comment: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TradeResult { + pub retcode: u32, + pub order: u64, + pub comment: String, +} + +// ── internal types (crate-visible only) ─────────────────────────────────────── + +#[derive(Deserialize)] +pub(crate) struct DataVec { + pub(crate) data: Vec, +} + +#[derive(Deserialize)] +pub(crate) struct DataOne { + pub(crate) data: T, +} + +#[derive(Deserialize)] +pub(crate) struct ApiErrorBody { + pub(crate) detail: String, +} diff --git a/docs/autonomous-session-2026-06-09.md b/docs/autonomous-session-2026-06-09.md new file mode 100644 index 0000000..702ff87 --- /dev/null +++ b/docs/autonomous-session-2026-06-09.md @@ -0,0 +1,170 @@ +# Autonomous Backtest Session — 2026-06-09 + +Dikerjakan saat user tidur. Ringkasan semua bug fix, backtest, dan temuan. + +--- + +## Bug Fixes + +### 1. `!fill_ok` premature FVG cancellation (kritikal) + +**Masalah:** Saat zona FVG disentuh candle tapi entry midpoint belum tercapai, kode langsung +membatalkan (`pending_fvg = None`). Akibatnya banyak setup yang valid ikut dibuang. + +**Fix:** Saat `is_touched && !fill_ok`, FVG tetap aktif dan dicoba di candle berikutnya. +FVG hanya dibatalkan saat: +- Expiry tercapai (`i >= expiry_idx`) → `missed_fills += 1` +- EMA filter tidak sesuai +- SL distance terlalu kecil + +**Dampak:** +``` +Sebelum fix: 993 trades, WR=48.3%, PF=1.33, Return=+314.7% +Sesudah fix: 1268 trades, WR=50.3%, PF=1.43, Return=+1183.3% +``` + +### 2. `missed_fills` counter dipindah ke expiry + +Sebelumnya counter dihitung setiap candle zona disentuh tanpa fill. Sekarang hanya dihitung +sekali saat setup expired tanpa pernah terisi. + +--- + +## Fitur Baru + +### 3. `TIMEOUT_CANDLES` (env var) + +Sama seperti Hermes — force close trade setelah N candles. Default = 0 (disabled). + +**Test result:** Hampir tidak berpengaruh di XAUUSDm: +``` +TIMEOUT=0 → PF=1.43, Return=1183% +TIMEOUT=24 → PF=1.42, Return=1088% +TIMEOUT=48 → PF=1.43, Return=1132% +``` +Artinya trade XAU selalu resolve (SL/TP) dalam <24 M5 candles (2 jam). + +--- + +## Parameter Sweep XAUUSDm + +### BODY_PCT_MIN (body/range minimum) +``` +0.50 → Trades=1351, PF=1.50, Return=1808% +0.55 → Trades=1320, PF=1.46, Return=1509% +0.60 → Trades=1268, PF=1.43, Return=1183% ← default +0.65 → Trades=1191, PF=1.43, Return=854% +0.70 → Trades=1102, PF=1.40, Return=575% +``` +Lower = more trades, slightly better PF. 0.60 adalah titik keseimbangan. + +### MIN_RR +``` +1.2 → WR=55.7%, PF=1.39, Return=784% +1.5 → WR=50.3%, PF=1.43, Return=1183% ← default +2.0 → WR=41.2%, PF=1.32, Return=853% +2.5 → WR=35.2%, PF=1.27, Return=688% +3.0 → WR=32.1%, PF=1.33, Return=1289% +``` +MIN_RR=1.5 memberikan balance terbaik. MIN_RR=3.0 menarik tapi WR 32% beresiko tinggi. + +### FVG_EXPIRY_CANDLES +``` +3 → PF=1.21, Return=170% +5 → PF=1.31, Return=401% +10 → PF=1.43, Return=1183% ← default +15 → PF=1.44, Return=972% +20 → PF=1.45, Return=796% +``` +10 adalah sweet spot — cukup waktu untuk fill, tidak terlalu lama jadi stale. + +### EMA_PERIOD (KRITIKAL) +``` +0 → WR=41.2%, PF=0.97, Return=-18% ← RUGI tanpa filter! +10 → WR=53.5%, PF=1.70, Return=1723% +15 → WR=50.1%, PF=1.46, Return=880% +20 → WR=50.3%, PF=1.43, Return=1183% ← default +30 → WR=47.6%, PF=1.26, Return=499% +50 → WR=44.4%, PF=1.11, Return=107% +``` +**EMA filter sangat kritikal** — tanpanya strategi rugi. EMA10 terlihat terbaik tapi +lebih beresiko overfitting. EMA20 adalah pilihan konservatif yang terbukti profitable +di EMA range 10-50. + +### SL_BUFFER +``` +0 → WR=50.3%, PF=1.43, Return=1183% ← default (terbaik) +0.5 → WR=48.0%, PF=1.29, Return=482% +1.0 → WR=47.0%, PF=1.27, Return=348% +2.0 → WR=45.2%, PF=1.20, Return=120% +``` +Buffer di luar impulse candle malah mengurangi performa. SL tepat di impulse low/high adalah optimal. + +--- + +## Hermes Validation + +Build dan backtest berhasil. BREAKEVEN_SL_1R=true bekerja dengan benar: +- GBPUSDm H1: 102 BE-SL exits, PF=4.27 — fitur berjalan normal. + +--- + +## Recommended Config (XAUUSDm) + +```env +SYMBOL=XAUUSDm +TIMEFRAME=M5 +BACKTEST_BALANCE=600 +BACKTEST_CANDLES=50000 +RISK_PCT=0.01 + +BODY_PCT_MIN=0.6 +CLOSE_PCT_MIN=0.8 +FVG_EXPIRY_CANDLES=10 +MIN_FVG_PIPS=1 +MIN_SL_PIPS=5 +SL_BUFFER=0 +MIN_RR=1.5 +TIMEOUT_CANDLES=0 + +EMA_PERIOD=20 # kritikal — jangan disable + +COMMISSION_PER_LOT=7 +SLIPPAGE_POINTS=5 +SPREAD_OVERRIDE=0 +``` + +**Hasil:** 1268 trades, WR=50.3%, PF=1.43, Return=+1183%, Max DD=-$550 (dari peak ~$8250 = 6.7%) + +--- + +## Peringatan / Risiko + +1. **Data hanya Sep 2025–Jun 2026** — XAU naik 65% dalam periode ini (bull run sangat kuat). + Strategy dengan EMA filter otomatis bias LONG. Performance di bear market atau ranging market + belum diketahui. + +2. **EMA adalah kunci** — tanpa EMA, strategi rugi. Ini menunjukkan edge bukan dari FVG pattern + saja, tapi kombinasi FVG + trend momentum. Hati-hati jika trend berbalik. + +3. **Compounding effect besar** — return +1183% karena compounding 1268 trades. Real-world + perlu ditest dengan RISK_PCT lebih kecil (0.005) di awal. + +4. **Post-fix multi-pair results (setelah fill_ok bug fix):** + ``` + GBPJPYm → WR=50.5%, PF=1.03, Return=+13.3% (was +10.3%) + EURUSDm → WR=49.5%, PF=1.15, Return=+9.0% (was -5.6% → NOW POSITIVE!) + GBPUSDm → WR=42.3%, PF=0.85, Return=-19.8% (still negative) + XAUUSDm → WR=50.3%, PF=1.43, Return=+1183.3% (flagship) + ``` + Bug fix meningkatkan EURUSDm dari negatif ke positif! + +--- + +## Next Steps (untuk user) + +1. Live micro-lot test di XAUUSDm dengan RISK_PCT=0.005 +2. Cari data XAU 2023–2024 untuk out-of-sample validation +3. Explore apakah EURUSDm bisa profitable dengan EMA=10 (mungkin lebih baik dari EMA=20) +4. GBPUSDm masih negatif (PF=0.85) — tidak direkomendasikan untuk sekarang +5. Monitor performa 2 minggu pertama live dengan DD limit manual diff --git a/src/detector.rs b/src/detector.rs index dd6c87a..02f0651 100644 --- a/src/detector.rs +++ b/src/detector.rs @@ -4,11 +4,12 @@ use rust_decimal::Decimal; /// A momentum FVG setup pending entry fill. #[derive(Debug, Clone)] pub struct PendingFvg { - pub side: Side, - pub zone_high: Decimal, - pub zone_low: Decimal, - pub entry: Decimal, // FVG midpoint — limit order level - pub expiry_idx: usize, // invalidate if not filled by this walk-forward index + pub side: Side, + pub zone_high: Decimal, + pub zone_low: Decimal, + pub entry: Decimal, // FVG midpoint — limit order level + pub impulse_sl: Decimal, // impulse candle's low (long) or high (short) — structural SL + pub expiry_idx: usize, // invalidate if not filled by this walk-forward index } impl PendingFvg { @@ -52,6 +53,7 @@ pub fn momentum_side(c: &Candle, body_pct_min: Decimal, close_pct_min: Decimal) /// - `impulse` must qualify as a momentum candle /// - There must be a price gap between `pre` and `post` matching the momentum side /// (bullish: post.low > pre.high; bearish: post.high < pre.low) +/// - FVG zone must be at least `min_zone_size` wide (rejects micro-gaps) /// /// `post_idx` is the walk-forward index of `post` (used to set expiry). pub fn detect( @@ -60,6 +62,7 @@ pub fn detect( post: &Candle, body_pct_min: Decimal, close_pct_min: Decimal, + min_zone_size: Decimal, post_idx: usize, expiry_candles: usize, ) -> Option { @@ -71,17 +74,22 @@ pub fn detect( _ => return None, }; - if zone_high <= zone_low { + if zone_high - zone_low < min_zone_size { return None; } let entry = (zone_high + zone_low) / Decimal::from(2u32); + let impulse_sl = match side { + Side::Long => impulse.low, + Side::Short => impulse.high, + }; Some(PendingFvg { side, zone_high, zone_low, entry, + impulse_sl, expiry_idx: post_idx + expiry_candles, }) } @@ -133,7 +141,8 @@ mod tests { let impulse = candle("1.1010", "1.1110", "1.1005", "1.1100"); // big bull let post = candle("1.1090", "1.1130", "1.1070", "1.1120"); // low > pre.high - let fvg = detect(&pre, &impulse, &post, "0.6".parse().unwrap(), "0.8".parse().unwrap(), 10, 5); + let min_zone: Decimal = "0.0001".parse().unwrap(); // 1 pip min — zone is 50 pips, passes + let fvg = detect(&pre, &impulse, &post, "0.6".parse().unwrap(), "0.8".parse().unwrap(), min_zone, 10, 5); assert!(fvg.is_some()); let fvg = fvg.unwrap(); assert_eq!(fvg.side, Side::Long); @@ -148,7 +157,8 @@ mod tests { let impulse = candle("1.1050", "1.1110", "1.1040", "1.1100"); let post = candle("1.1090", "1.1130", "1.1055", "1.1120"); // post.low=1.1055 < pre.high=1.1060 - let fvg = detect(&pre, &impulse, &post, "0.6".parse().unwrap(), "0.8".parse().unwrap(), 10, 5); + let min_zone: Decimal = "0.0001".parse().unwrap(); + let fvg = detect(&pre, &impulse, &post, "0.6".parse().unwrap(), "0.8".parse().unwrap(), min_zone, 10, 5); assert!(fvg.is_none()); } } diff --git a/src/main.rs b/src/main.rs index 5373ed8..b9a302d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,13 +61,14 @@ fn fmt_pnl(pnl: Decimal) -> String { // ── open trade ──────────────────────────────────────────────────────────────── struct OpenTrade { - open_time: String, - side: Side, - entry_level: Decimal, - actual_entry: Decimal, - sl: Decimal, - tp: Decimal, - volume: Decimal, + open_time: String, + side: Side, + entry_level: Decimal, + actual_entry: Decimal, + sl: Decimal, + tp: Decimal, + volume: Decimal, + open_candle_idx: usize, } // ── main ────────────────────────────────────────────────────────────────────── @@ -100,10 +101,16 @@ async fn main() -> anyhow::Result<()> { .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")?; @@ -143,6 +150,10 @@ async fn main() -> anyhow::Result<()> { 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(); @@ -177,6 +188,35 @@ async fn main() -> anyhow::Result<()> { // ── 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; + } 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), @@ -251,6 +291,7 @@ async fn main() -> anyhow::Result<()> { // ── expire stale FVG ────────────────────────────────────────────────── if pending_fvg.as_ref().is_some_and(|f| i >= f.expiry_idx) { + missed_fills += 1; pending_fvg = None; } @@ -270,11 +311,16 @@ async fn main() -> anyhow::Result<()> { }; if ema_ok { + // SL placed at impulse candle's structural extreme, not zone edge let sl = match fvg.side { - Side::Long => fvg.zone_low - sl_buffer, - Side::Short => fvg.zone_high + sl_buffer, + 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, @@ -285,8 +331,7 @@ async fn main() -> anyhow::Result<()> { Side::Short => candle.high >= fvg.entry, }; if !fill_ok { - missed_fills += 1; - pending_fvg = None; + // zone touched but limit order not reached yet — keep FVG pending continue; } @@ -303,13 +348,14 @@ async fn main() -> anyhow::Result<()> { 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, + 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, + volume: v, + open_candle_idx: i, }); pending_fvg = None; } @@ -328,6 +374,7 @@ async fn main() -> anyhow::Result<()> { candle, body_pct_min, close_pct_min, + min_zone_size, i, fvg_expiry, ); @@ -375,7 +422,8 @@ async fn main() -> anyhow::Result<()> { println!("─────────────────────────────────────────"); println!("Ares Scalper: {} {} | {} candles", symbol, tf_str, total); - println!("Strategy : Momentum FVG body≥{body_pct_min} close≥{close_pct_min} expiry={fvg_expiry}c min_rr={min_rr}"); + 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}");