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:
romysaputrasihananda
2026-06-09 20:03:09 +07:00
co-authored by Claude Sonnet 4.6
parent e0e421ed05
commit a78dd26cf0
20 changed files with 1147 additions and 30 deletions
+13
View File
@@ -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"
+72
View File
@@ -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);
}
}
+52
View File
@@ -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);
}
}
+15
View File
@@ -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;
+106
View File
@@ -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""#);
}
}
+62
View File
@@ -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())
}
}
+70
View File
@@ -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");
}
}
+51
View File
@@ -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);
}
}
+119
View File
@@ -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());
}
}
+13
View File
@@ -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"
+218
View File
@@ -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");
}
}
+14
View File
@@ -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 },
}
+7
View File
@@ -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};
+65
View File
@@ -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,
}