mirror of
https://github.com/RomySaputraSihananda/ares.git
synced 2026-07-27 18:47:53 +00:00
feat: make ares self-contained workspace with bundled domain/mt5-client crates
Vendor domain and mt5-client from hermes into crates/ so the repo can be pushed to GitHub without external path dependencies. Also includes all bug fixes from autonomous session: - impulse SL fix (SL at impulse candle low/high, not zone edge) - pip_size fix for 5-decimal pairs - fill_ok premature cancellation fix - TIMEOUT_CANDLES, MIN_FVG_PIPS, MIN_SL_PIPS env vars Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+10
-3
@@ -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
|
||||
|
||||
Generated
+1
@@ -224,6 +224,7 @@ dependencies = [
|
||||
"chrono-tz",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+6
-2
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
@@ -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::<rust_decimal::Decimal>().unwrap());
|
||||
assert_eq!(a.currency, "USD");
|
||||
assert_eq!(a.server, "Exness-MT5Trial14");
|
||||
assert!(a.trade_allowed);
|
||||
}
|
||||
}
|
||||
@@ -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<Utc>,
|
||||
#[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::<rust_decimal::Decimal>().unwrap());
|
||||
assert_eq!(c.high, "59490.02".parse::<rust_decimal::Decimal>().unwrap());
|
||||
assert_eq!(c.low, "59225.56".parse::<rust_decimal::Decimal>().unwrap());
|
||||
assert_eq!(c.close, "59377.06".parse::<rust_decimal::Decimal>().unwrap());
|
||||
assert_eq!(c.tick_volume, 1869);
|
||||
assert_eq!(c.spread, 1008);
|
||||
assert_eq!(c.real_volume, 0);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Side, D::Error> {
|
||||
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::<rust_decimal::Decimal>().unwrap());
|
||||
assert_eq!(p.sl, "58000".parse::<rust_decimal::Decimal>().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""#);
|
||||
}
|
||||
}
|
||||
@@ -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<Decimal, D::Error> {
|
||||
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<E: serde::de::Error>(self, v: f64) -> Result<Decimal, E> {
|
||||
Decimal::try_from(v).map_err(E::custom)
|
||||
}
|
||||
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Decimal, E> {
|
||||
Ok(Decimal::from(v))
|
||||
}
|
||||
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Decimal, E> {
|
||||
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<DateTime<Utc>, 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<S: Serializer>(dt: &DateTime<Utc>, se: S) -> Result<S::Ok, S::Error> {
|
||||
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<DateTime<Utc>, 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<S: Serializer>(dt: &DateTime<Utc>, se: S) -> Result<S::Ok, S::Error> {
|
||||
se.serialize_str(&dt.format(FMT_EMIT).to_string())
|
||||
}
|
||||
}
|
||||
@@ -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::<rust_decimal::Decimal>().unwrap());
|
||||
assert_eq!(s.ask, "217.51".parse::<rust_decimal::Decimal>().unwrap());
|
||||
assert_eq!(s.currency_base, "BCH");
|
||||
assert_eq!(s.category, "Crypto");
|
||||
}
|
||||
}
|
||||
@@ -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<Utc>,
|
||||
#[serde(with = "serde_helpers::naive_utc_ms")]
|
||||
pub time_msc: DateTime<Utc>,
|
||||
#[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::<rust_decimal::Decimal>().unwrap());
|
||||
assert_eq!(t.ask, "63811.1".parse::<rust_decimal::Decimal>().unwrap());
|
||||
assert_eq!(t.flags, 134);
|
||||
// time_msc must preserve 234ms
|
||||
assert_eq!(t.time_msc.timestamp_subsec_millis(), 234);
|
||||
}
|
||||
}
|
||||
@@ -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<Self, Self::Err> {
|
||||
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::<Timeframe>();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<String>) -> Self {
|
||||
Self { base_url: base_url.into(), http: reqwest::Client::new() }
|
||||
}
|
||||
|
||||
async fn fetch_text(&self, req: reqwest::RequestBuilder) -> Result<String, Mt5Error> {
|
||||
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::<ApiErrorBody>(&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<HealthStatus, Mt5Error> {
|
||||
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<AccountInfo, Mt5Error> {
|
||||
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<AccountInfo> = serde_json::from_str(&text)?;
|
||||
w.data.into_iter().next().ok_or(Mt5Error::Empty { endpoint: url })
|
||||
}
|
||||
|
||||
pub async fn symbol(&self, name: &str) -> Result<Symbol, Mt5Error> {
|
||||
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<Symbol> = serde_json::from_str(&text)?;
|
||||
w.data.into_iter().next().ok_or(Mt5Error::Empty { endpoint: url })
|
||||
}
|
||||
|
||||
pub async fn tick(&self, symbol: &str) -> Result<Tick, Mt5Error> {
|
||||
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<Tick> = 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<Vec<Candle>, 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<Candle> = serde_json::from_str(&text)?;
|
||||
Ok(w.data)
|
||||
}
|
||||
|
||||
pub async fn positions(&self) -> Result<Vec<Position>, 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<Position> = 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> {
|
||||
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<OrderCheckResult> = serde_json::from_str(&text)?;
|
||||
Ok(w.data)
|
||||
}
|
||||
|
||||
pub async fn place_order(
|
||||
&self,
|
||||
request: &TradeRequest,
|
||||
) -> Result<TradeResult, Mt5Error> {
|
||||
#[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<TradeResult> = 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<AccountInfo> = 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<Symbol> = 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<Tick> = 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<Candle> = 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<Position> = 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<OrderCheckResult> = 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<AccountInfo> = 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<TradeResult> = 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");
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tp: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub magic: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[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<T> {
|
||||
pub(crate) data: Vec<T>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct DataOne<T> {
|
||||
pub(crate) data: T,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ApiErrorBody {
|
||||
pub(crate) detail: String,
|
||||
}
|
||||
@@ -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
|
||||
+18
-8
@@ -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<PendingFvg> {
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+65
-17
@@ -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<Option<Decimal>> = if ema_period > 0 {
|
||||
let closes: Vec<Decimal> = 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}");
|
||||
|
||||
Reference in New Issue
Block a user