feat: live trading, multi-pair backtest, README, CI workflow

- Add live trading loop (live.rs) with MT5 pending order placement,
  state persistence, and per-symbol tokio task for multi-pair
- Extract backtest engine to backtest.rs and shared helpers to helpers.rs
- Multi-pair support via SYMBOLS env var (comma-separated)
- Add GitHub Actions workflow: Linux musl + Windows release binaries
- Add README with strategy docs, config reference, backtest results
- Clean up warnings, remove unused env vars, tighten .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
romysaputrasihananda
2026-06-10 13:31:53 +07:00
co-authored by Claude Sonnet 4.6
parent ddd8c98af5
commit 7d094591b8
13 changed files with 1174 additions and 503 deletions
+27 -4
View File
@@ -1,7 +1,7 @@
use domain::{AccountInfo, Candle, Position, Symbol, Tick, Timeframe};
use crate::error::Mt5Error;
use crate::types::{ApiErrorBody, DataOne, DataVec, HealthStatus, OrderCheckResult, TradeRequest, TradeResult};
use crate::types::{ApiErrorBody, DataOne, DataVec, HealthStatus, OrderCheckResult, PendingOrder, TradeRequest, TradeResult};
pub struct Mt5Client {
base_url: String,
@@ -87,6 +87,27 @@ impl Mt5Client {
Ok(w.data)
}
pub async fn orders(&self, symbol: &str) -> Result<Vec<PendingOrder>, Mt5Error> {
let url = format!("{}/orders", self.base_url);
let text = self
.fetch_text(self.http.get(&url).query(&[("symbol", symbol)]))
.await?;
tracing::debug!(endpoint = %url, "mt5 response ok");
let w: DataVec<PendingOrder> = serde_json::from_str(&text)?;
Ok(w.data)
}
pub async fn cancel_order(&self, ticket: u64, symbol: &str) -> Result<TradeResult, Mt5Error> {
let req = TradeRequest::cancel(symbol, ticket);
#[derive(serde::Serialize)]
struct Body<'a> { request: &'a TradeRequest }
let url = format!("{}/order/send", self.base_url);
let text = self.fetch_text(self.http.post(&url).json(&Body { request: &req })).await?;
tracing::debug!(endpoint = %url, ticket, "cancel order ok");
let w: DataOne<TradeResult> = serde_json::from_str(&text)?;
Ok(w.data)
}
pub async fn order_check(&self, request: &TradeRequest) -> Result<OrderCheckResult, Mt5Error> {
#[derive(serde::Serialize)]
struct Body<'a> {
@@ -192,13 +213,15 @@ mod tests {
let tr = TradeRequest {
action: 1,
symbol: "BTCUSDm".into(),
volume: 0.01,
order_type: 0,
price: 60720.0,
volume: Some(0.01),
order_type: Some(0),
price: Some(60720.0),
sl: None,
tp: None,
magic: None,
comment: None,
order: None,
deviation: None,
};
let json = serde_json::to_string(&tr).unwrap();
assert!(json.contains(r#""type":0"#), "order_type must serialize as \"type\"");
+1 -1
View File
@@ -4,4 +4,4 @@ mod types;
pub use client::Mt5Client;
pub use error::Mt5Error;
pub use types::{HealthStatus, OrderCheckResult, TradeRequest, TradeResult};
pub use types::{HealthStatus, OrderCheckResult, PendingOrder, TradeRequest, TradeResult};
+76 -4
View File
@@ -14,10 +14,12 @@ pub struct HealthStatus {
pub struct TradeRequest {
pub action: u32,
pub symbol: String,
pub volume: f64,
#[serde(rename = "type")]
pub order_type: u32,
pub price: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub volume: Option<f64>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub order_type: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub price: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sl: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -26,6 +28,76 @@ pub struct TradeRequest {
pub magic: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
/// Ticket number — required for TRADE_ACTION_REMOVE (cancel pending order)
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<u64>,
/// Allowed price deviation in points
#[serde(skip_serializing_if = "Option::is_none")]
pub deviation: Option<u32>,
}
impl TradeRequest {
pub fn limit(
side: domain::Side,
symbol: impl Into<String>,
volume: f64,
price: f64,
sl: f64,
tp: f64,
magic: u64,
comment: impl Into<String>,
) -> Self {
// TRADE_ACTION_PENDING = 5; BUY_LIMIT = 2, SELL_LIMIT = 3
let order_type = match side {
domain::Side::Long => 2,
domain::Side::Short => 3,
};
Self {
action: 5,
symbol: symbol.into(),
volume: Some(volume),
order_type: Some(order_type),
price: Some(price),
sl: Some(sl),
tp: Some(tp),
magic: Some(magic),
comment: Some(comment.into()),
order: None,
deviation: None,
}
}
pub fn cancel(symbol: impl Into<String>, ticket: u64) -> Self {
// TRADE_ACTION_REMOVE = 8
Self {
action: 8,
symbol: symbol.into(),
volume: None,
order_type: None,
price: None,
sl: None,
tp: None,
magic: None,
comment: None,
order: Some(ticket),
deviation: None,
}
}
}
/// A pending (unfilled) limit order in MT5.
#[derive(Debug, Clone, Deserialize)]
pub struct PendingOrder {
pub ticket: u64,
pub symbol: String,
#[serde(rename = "type")]
pub order_type: u32,
pub volume_initial: f64,
pub price_open: f64,
pub sl: f64,
pub tp: f64,
pub magic: u64,
pub comment: String,
}
#[derive(Debug, Clone, Deserialize)]