| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | use eframe::egui;
|
| | use egui_plot::{Line, Plot, PlotPoints};
|
| | use serde::{Deserialize, Serialize};
|
| | use tokio::sync::mpsc;
|
| | use zeromq::{Socket, SocketRecv, SocketSend};
|
| | use std::fs::{self, OpenOptions};
|
| | use std::io::Write;
|
| | use std::path::PathBuf;
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | #[derive(Clone, Debug, Deserialize)]
|
| | #[allow(dead_code)]
|
| | struct PositionData {
|
| | ticket: u64,
|
| | #[serde(rename = "type")]
|
| | pos_type: String,
|
| | volume: f64,
|
| | price: f64,
|
| | profit: f64,
|
| | }
|
| |
|
| | #[derive(Clone, Debug, Deserialize)]
|
| | #[allow(dead_code)]
|
| | struct PendingOrderData {
|
| | ticket: u64,
|
| | #[serde(rename = "type")]
|
| | order_type: String,
|
| | volume: f64,
|
| | price: f64,
|
| | }
|
| |
|
| | #[derive(Clone, Debug, Deserialize)]
|
| | struct TickData {
|
| | symbol: String,
|
| | bid: f64,
|
| | ask: f64,
|
| | time: i64,
|
| | #[serde(default)]
|
| | volume: u64,
|
| |
|
| | #[serde(default)]
|
| | balance: f64,
|
| | #[serde(default)]
|
| | equity: f64,
|
| | #[serde(default)]
|
| | margin: f64,
|
| | #[serde(default)]
|
| | free_margin: f64,
|
| |
|
| | #[serde(default)]
|
| | min_lot: f64,
|
| | #[serde(default)]
|
| | max_lot: f64,
|
| | #[serde(default)]
|
| | lot_step: f64,
|
| |
|
| |
|
| | #[serde(default)]
|
| | positions: Vec<PositionData>,
|
| | #[serde(default)]
|
| | orders: Vec<PendingOrderData>,
|
| | }
|
| |
|
| | #[derive(Clone, Debug, Serialize)]
|
| | struct OrderRequest {
|
| | #[serde(rename = "type")]
|
| | order_type: String,
|
| | symbol: String,
|
| | volume: f64,
|
| | price: f64,
|
| | #[serde(default)]
|
| | ticket: u64,
|
| |
|
| | #[serde(skip_serializing_if = "Option::is_none")]
|
| | timeframe: Option<String>,
|
| | #[serde(skip_serializing_if = "Option::is_none")]
|
| | start: Option<String>,
|
| | #[serde(skip_serializing_if = "Option::is_none")]
|
| | end: Option<String>,
|
| | #[serde(skip_serializing_if = "Option::is_none")]
|
| | mode: Option<String>,
|
| | #[serde(skip_serializing_if = "Option::is_none")]
|
| | request_id: Option<u64>,
|
| | }
|
| |
|
| | #[derive(Clone, Debug, Deserialize)]
|
| | struct OrderResponse {
|
| | success: bool,
|
| | ticket: Option<i64>,
|
| | error: Option<String>,
|
| | message: Option<String>,
|
| | }
|
| |
|
| |
|
| | #[derive(Clone, Debug)]
|
| | struct OrderBreakline {
|
| | index: usize,
|
| | order_type: String,
|
| | ticket: i64,
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | struct Mt5ChartApp {
|
| |
|
| | tick_receiver: mpsc::Receiver<TickData>,
|
| | data: Vec<TickData>,
|
| | symbol: String,
|
| |
|
| |
|
| | balance: f64,
|
| | equity: f64,
|
| | margin: f64,
|
| | free_margin: f64,
|
| | min_lot: f64,
|
| | max_lot: f64,
|
| | lot_step: f64,
|
| |
|
| |
|
| | order_sender: mpsc::Sender<OrderRequest>,
|
| | response_receiver: mpsc::Receiver<OrderResponse>,
|
| |
|
| |
|
| | lot_size: f64,
|
| | lot_size_str: String,
|
| | limit_price: String,
|
| | #[allow(dead_code)]
|
| | stop_price: String,
|
| | last_order_result: Option<String>,
|
| |
|
| |
|
| | history_start_date: String,
|
| | history_end_date: String,
|
| | history_tf: String,
|
| | history_mode: String,
|
| |
|
| |
|
| | is_recording: bool,
|
| | live_record_file: Option<std::fs::File>,
|
| |
|
| |
|
| | positions: Vec<PositionData>,
|
| | pending_orders: Vec<PendingOrderData>,
|
| |
|
| |
|
| | output_dir: PathBuf,
|
| | request_counter: u64,
|
| |
|
| |
|
| | order_breaklines: Vec<OrderBreakline>,
|
| | pending_order_type: Option<String>,
|
| |
|
| |
|
| | pending_history_request: Option<(u64, String, String, String)>,
|
| | }
|
| |
|
| | impl Mt5ChartApp {
|
| | fn new(
|
| | tick_receiver: mpsc::Receiver<TickData>,
|
| | order_sender: mpsc::Sender<OrderRequest>,
|
| | response_receiver: mpsc::Receiver<OrderResponse>,
|
| | ) -> Self {
|
| |
|
| | let now = chrono::Local::now();
|
| | let today_str = now.format("%Y.%m.%d").to_string();
|
| |
|
| |
|
| | let output_dir = PathBuf::from("output");
|
| | fs::create_dir_all(&output_dir).ok();
|
| |
|
| | Self {
|
| | tick_receiver,
|
| | data: Vec::new(),
|
| | symbol: "Waiting for data...".to_string(),
|
| | balance: 0.0,
|
| | equity: 0.0,
|
| | margin: 0.0,
|
| | free_margin: 0.0,
|
| | min_lot: 0.01,
|
| | max_lot: 100.0,
|
| | lot_step: 0.01,
|
| | order_sender,
|
| | response_receiver,
|
| | lot_size: 0.01,
|
| | lot_size_str: "0.01".to_string(),
|
| | limit_price: "0.0".to_string(),
|
| | stop_price: "0.0".to_string(),
|
| | last_order_result: None,
|
| |
|
| | history_start_date: today_str.clone(),
|
| | history_end_date: today_str,
|
| | history_tf: "M1".to_string(),
|
| | history_mode: "OHLC".to_string(),
|
| |
|
| | is_recording: false,
|
| | live_record_file: None,
|
| |
|
| | positions: Vec::new(),
|
| | pending_orders: Vec::new(),
|
| |
|
| |
|
| | output_dir,
|
| | request_counter: 0,
|
| | order_breaklines: Vec::new(),
|
| | pending_order_type: None,
|
| | pending_history_request: None,
|
| | }
|
| | }
|
| |
|
| | fn send_order(&mut self, order_type: &str, price: Option<f64>, ticket: Option<u64>) {
|
| | let price_val = price.unwrap_or(0.0);
|
| | let ticket_val = ticket.unwrap_or(0);
|
| |
|
| |
|
| | if order_type.contains("market") {
|
| | self.pending_order_type = Some(order_type.to_string());
|
| | }
|
| |
|
| | let request = OrderRequest {
|
| | order_type: order_type.to_string(),
|
| | symbol: self.symbol.clone(),
|
| | volume: self.lot_size,
|
| | price: price_val,
|
| | ticket: ticket_val,
|
| | timeframe: None,
|
| | start: None,
|
| | end: None,
|
| | mode: None,
|
| | request_id: None,
|
| | };
|
| |
|
| | self.send_request_impl(request);
|
| | }
|
| |
|
| | fn send_download_request(&mut self) {
|
| |
|
| | self.request_counter += 1;
|
| |
|
| |
|
| | self.pending_history_request = Some((
|
| | self.request_counter,
|
| | self.symbol.replace("/", "-"),
|
| | self.history_tf.clone(),
|
| | self.history_mode.clone(),
|
| | ));
|
| |
|
| | let request = OrderRequest {
|
| | order_type: "download_history".to_string(),
|
| | symbol: self.symbol.clone(),
|
| | volume: 0.0,
|
| | price: 0.0,
|
| | ticket: 0,
|
| | timeframe: Some(self.history_tf.clone()),
|
| | start: Some(self.history_start_date.clone()),
|
| | end: Some(self.history_end_date.clone()),
|
| | mode: Some(self.history_mode.clone()),
|
| | request_id: Some(self.request_counter),
|
| | };
|
| |
|
| | self.send_request_impl(request);
|
| | }
|
| |
|
| | fn send_request_impl(&mut self, request: OrderRequest) {
|
| | if let Err(e) = self.order_sender.try_send(request) {
|
| | self.last_order_result = Some(format!("Failed to send: {}", e));
|
| | } else {
|
| | self.last_order_result = Some("Request sent...".to_string());
|
| | }
|
| | }
|
| |
|
| | fn adjust_lot_size(&mut self, delta: f64) {
|
| | let new_lot = self.lot_size + delta;
|
| |
|
| | let steps = (new_lot / self.lot_step).round();
|
| | self.lot_size = (steps * self.lot_step).max(self.min_lot).min(self.max_lot);
|
| | self.lot_size_str = format!("{:.2}", self.lot_size);
|
| | }
|
| |
|
| | fn toggle_recording(&mut self) {
|
| | self.is_recording = !self.is_recording;
|
| | if self.is_recording {
|
| |
|
| | self.request_counter += 1;
|
| | let filename = format!(
|
| | "{}/Live_{}_ID{:04}_{}.csv",
|
| | self.output_dir.display(),
|
| | self.symbol.replace("/", "-"),
|
| | self.request_counter,
|
| | chrono::Local::now().format("%Y%m%d_%H%M%S")
|
| | );
|
| | match OpenOptions::new().create(true).append(true).open(&filename) {
|
| | Ok(mut file) => {
|
| | let _ = writeln!(file, "Time,Bid,Ask,Volume");
|
| | self.live_record_file = Some(file);
|
| | self.last_order_result = Some(format!("Recording to {}", filename));
|
| | }
|
| | Err(e) => {
|
| | self.is_recording = false;
|
| | self.last_order_result = Some(format!("Rec Error: {}", e));
|
| | }
|
| | }
|
| | } else {
|
| | self.live_record_file = None;
|
| | self.last_order_result = Some("Recording Stopped".to_string());
|
| | }
|
| | }
|
| | }
|
| |
|
| | impl eframe::App for Mt5ChartApp {
|
| | fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
| |
|
| | while let Ok(tick) = self.tick_receiver.try_recv() {
|
| | self.symbol = tick.symbol.clone();
|
| |
|
| |
|
| | if self.is_recording {
|
| | if let Some(mut file) = self.live_record_file.as_ref() {
|
| | let _ = writeln!(file, "{},{},{},{}", tick.time, tick.bid, tick.ask, tick.volume);
|
| | }
|
| | }
|
| |
|
| |
|
| | if tick.balance > 0.0 {
|
| | self.balance = tick.balance;
|
| | self.equity = tick.equity;
|
| | self.margin = tick.margin;
|
| | self.free_margin = tick.free_margin;
|
| | self.min_lot = tick.min_lot;
|
| | self.max_lot = tick.max_lot;
|
| | if tick.lot_step > 0.0 {
|
| | self.lot_step = tick.lot_step;
|
| | }
|
| | }
|
| |
|
| |
|
| | self.positions = tick.positions.clone();
|
| | self.pending_orders = tick.orders.clone();
|
| |
|
| | self.data.push(tick);
|
| |
|
| | if self.data.len() > 2000 {
|
| | self.data.remove(0);
|
| | }
|
| | }
|
| |
|
| |
|
| | while let Ok(response) = self.response_receiver.try_recv() {
|
| | if response.success {
|
| |
|
| | if let Some(ref msg) = response.message {
|
| | if msg.contains("||CSV_DATA||") {
|
| |
|
| | let parts: Vec<&str> = msg.splitn(2, "||CSV_DATA||").collect();
|
| | if parts.len() == 2 {
|
| | let info_part = parts[0];
|
| | let csv_content = parts[1];
|
| |
|
| |
|
| | if let Some((id, symbol, tf, mode)) = self.pending_history_request.take() {
|
| | let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
| | let filename = format!(
|
| | "{}/History_{}_{}_{}_ID{:04}_{}.csv",
|
| | self.output_dir.display(),
|
| | symbol, tf, mode, id, timestamp
|
| | );
|
| |
|
| |
|
| | let csv_with_newlines = csv_content.replace("|NL|", "\n");
|
| |
|
| |
|
| | match std::fs::write(&filename, csv_with_newlines) {
|
| | Ok(_) => {
|
| | self.last_order_result = Some(format!(
|
| | "✓ {} → Saved to {}",
|
| | info_part, filename
|
| | ));
|
| | }
|
| | Err(e) => {
|
| | self.last_order_result = Some(format!(
|
| | "✗ Failed to save CSV: {}",
|
| | e
|
| | ));
|
| | }
|
| | }
|
| | } else {
|
| | self.last_order_result = Some(format!("✓ {}", info_part));
|
| | }
|
| | } else {
|
| | self.last_order_result = Some(format!("✓ {}", msg));
|
| | }
|
| | } else {
|
| | self.last_order_result = Some(format!("✓ {}", msg));
|
| | }
|
| | } else {
|
| |
|
| | if let Some(ref order_type) = self.pending_order_type.take() {
|
| | let breakline = OrderBreakline {
|
| | index: self.data.len().saturating_sub(1),
|
| | order_type: order_type.clone(),
|
| | ticket: response.ticket.unwrap_or(0),
|
| | };
|
| | self.order_breaklines.push(breakline);
|
| |
|
| | if self.order_breaklines.len() > 50 {
|
| | self.order_breaklines.remove(0);
|
| | }
|
| | }
|
| |
|
| | self.last_order_result = Some(format!(
|
| | "✓ Order executed! Ticket: {}",
|
| | response.ticket.unwrap_or(0)
|
| | ));
|
| | }
|
| | } else {
|
| | self.pending_order_type = None;
|
| | self.pending_history_request = None;
|
| | self.last_order_result = Some(format!(
|
| | "✗ Failed: {}",
|
| | response.error.unwrap_or_else(|| "Unknown error".to_string())
|
| | ));
|
| | }
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| | egui::SidePanel::left("trading_panel")
|
| | .min_width(280.0)
|
| | .show(ctx, |ui| {
|
| | ui.heading("📊 Trading Panel");
|
| | ui.separator();
|
| |
|
| |
|
| | ui.collapsing("💰 Account Info", |ui| {
|
| | egui::Grid::new("account_grid")
|
| | .num_columns(2)
|
| | .spacing([10.0, 4.0])
|
| | .show(ui, |ui| {
|
| | ui.label("Balance:");
|
| | ui.colored_label(egui::Color32::from_rgb(100, 200, 100), format!("${:.2}", self.balance));
|
| | ui.end_row();
|
| | ui.label("Equity:");
|
| | ui.colored_label(egui::Color32::from_rgb(100, 180, 255), format!("${:.2}", self.equity));
|
| | ui.end_row();
|
| | ui.label("Margin Used:");
|
| | ui.colored_label(egui::Color32::from_rgb(255, 200, 100), format!("${:.2}", self.margin));
|
| | ui.end_row();
|
| | ui.label("Free Margin:");
|
| | ui.colored_label(egui::Color32::from_rgb(100, 255, 200), format!("${:.2}", self.free_margin));
|
| | ui.end_row();
|
| | });
|
| | });
|
| |
|
| | ui.separator();
|
| |
|
| |
|
| | ui.heading("📂 Historical Data");
|
| | ui.add_space(5.0);
|
| |
|
| | egui::Grid::new("history_grid").num_columns(2).spacing([10.0, 5.0]).show(ui, |ui| {
|
| | ui.label("Start (yyyy.mm.dd):");
|
| | ui.add(egui::TextEdit::singleline(&mut self.history_start_date).desired_width(100.0));
|
| | ui.end_row();
|
| |
|
| | ui.label("End (yyyy.mm.dd):");
|
| | ui.add(egui::TextEdit::singleline(&mut self.history_end_date).desired_width(100.0));
|
| | ui.end_row();
|
| |
|
| | ui.label("Timeframe:");
|
| | egui::ComboBox::from_id_source("tf_combo")
|
| | .selected_text(&self.history_tf)
|
| | .show_ui(ui, |ui| {
|
| | ui.selectable_value(&mut self.history_tf, "M1".to_string(), "M1");
|
| | ui.selectable_value(&mut self.history_tf, "M5".to_string(), "M5");
|
| | ui.selectable_value(&mut self.history_tf, "M15".to_string(), "M15");
|
| | ui.selectable_value(&mut self.history_tf, "H1".to_string(), "H1");
|
| | ui.selectable_value(&mut self.history_tf, "D1".to_string(), "D1");
|
| | });
|
| | ui.end_row();
|
| |
|
| | ui.label("Mode:");
|
| | egui::ComboBox::from_id_source("mode_combo")
|
| | .selected_text(&self.history_mode)
|
| | .show_ui(ui, |ui| {
|
| | ui.selectable_value(&mut self.history_mode, "OHLC".to_string(), "OHLC");
|
| | ui.selectable_value(&mut self.history_mode, "TICKS".to_string(), "TICKS");
|
| | });
|
| | ui.end_row();
|
| | });
|
| |
|
| | ui.add_space(5.0);
|
| | if ui.button("⬇ Download History (CSV)").clicked() {
|
| | self.send_download_request();
|
| | }
|
| |
|
| | ui.separator();
|
| |
|
| |
|
| | ui.heading("🔴 Live Recording");
|
| | ui.horizontal(|ui| {
|
| | ui.label(if self.is_recording { "Recording..." } else { "Idle" });
|
| | if ui.button(if self.is_recording { "Stop" } else { "Start Recording" }).clicked() {
|
| | self.toggle_recording();
|
| | }
|
| | });
|
| |
|
| | ui.separator();
|
| |
|
| |
|
| | ui.heading("📦 Trade Controls");
|
| |
|
| |
|
| | ui.horizontal(|ui| {
|
| | if ui.button("−").clicked() { self.adjust_lot_size(-self.lot_step); }
|
| | let response = ui.add(egui::TextEdit::singleline(&mut self.lot_size_str).desired_width(60.0));
|
| | if response.lost_focus() {
|
| | if let Ok(parsed) = self.lot_size_str.parse::<f64>() {
|
| | self.lot_size = parsed.max(self.min_lot).min(self.max_lot);
|
| | self.lot_size_str = format!("{:.2}", self.lot_size);
|
| | }
|
| | }
|
| | if ui.button("+").clicked() { self.adjust_lot_size(self.lot_step); }
|
| |
|
| | ui.label(format!("Lots (Max: {:.1})", self.max_lot));
|
| | });
|
| |
|
| | ui.add_space(5.0);
|
| | ui.label("Market Orders:");
|
| | ui.horizontal(|ui| {
|
| | if ui.button("BUY").clicked() { self.send_order("market_buy", None, None); }
|
| | if ui.button("SELL").clicked() { self.send_order("market_sell", None, None); }
|
| | });
|
| |
|
| | ui.add_space(5.0);
|
| | ui.label("Pending Orders:");
|
| | ui.horizontal(|ui| {
|
| | ui.label("@ Price:");
|
| | ui.add(egui::TextEdit::singleline(&mut self.limit_price).desired_width(70.0));
|
| | });
|
| | ui.horizontal(|ui| {
|
| | let p = self.limit_price.parse().unwrap_or(0.0);
|
| | if ui.small_button("Buy Limit").clicked() { self.send_order("limit_buy", Some(p), None); }
|
| | if ui.small_button("Sell Limit").clicked() { self.send_order("limit_sell", Some(p), None); }
|
| | if ui.small_button("Buy Stop").clicked() { self.send_order("stop_buy", Some(p), None); }
|
| | if ui.small_button("Sell Stop").clicked() { self.send_order("stop_sell", Some(p), None); }
|
| | });
|
| |
|
| | ui.separator();
|
| |
|
| |
|
| | if let Some(ref result) = self.last_order_result {
|
| | ui.heading("📨 Last Message");
|
| | ui.label(result);
|
| | }
|
| |
|
| | ui.separator();
|
| |
|
| |
|
| | ui.collapsing("💼 Active Positions", |ui| {
|
| | if self.positions.is_empty() {
|
| | ui.label("No active positions");
|
| | } else {
|
| | let positions_clone = self.positions.clone();
|
| | for pos in positions_clone {
|
| | ui.horizontal(|ui| {
|
| | let color = if pos.pos_type == "BUY" {
|
| | egui::Color32::from_rgb(100, 200, 100)
|
| | } else {
|
| | egui::Color32::from_rgb(255, 100, 100)
|
| | };
|
| | ui.colored_label(color, format!(
|
| | "#{} {} {:.2}@{:.5} P:{:.2}",
|
| | pos.ticket, pos.pos_type, pos.volume, pos.price, pos.profit
|
| | ));
|
| | if ui.small_button("Close").clicked() {
|
| | self.send_order("close_position", Some(pos.price), Some(pos.ticket));
|
| | }
|
| | });
|
| | }
|
| | }
|
| | });
|
| |
|
| |
|
| | ui.collapsing("⏳ Pending Orders", |ui| {
|
| | if self.pending_orders.is_empty() {
|
| | ui.label("No pending orders");
|
| | } else {
|
| | let orders_clone = self.pending_orders.clone();
|
| | for order in orders_clone {
|
| | ui.horizontal(|ui| {
|
| | let color = if order.order_type.contains("BUY") {
|
| | egui::Color32::from_rgb(100, 150, 255)
|
| | } else {
|
| | egui::Color32::from_rgb(255, 150, 100)
|
| | };
|
| | ui.colored_label(color, format!(
|
| | "#{} {} {:.2}@{:.5}",
|
| | order.ticket, order.order_type, order.volume, order.price
|
| | ));
|
| | if ui.small_button("Cancel").clicked() {
|
| | self.send_order("cancel_order", Some(order.price), Some(order.ticket));
|
| | }
|
| | });
|
| | }
|
| | }
|
| | });
|
| | });
|
| |
|
| |
|
| |
|
| |
|
| | egui::CentralPanel::default().show(ctx, |ui| {
|
| | ui.heading(format!("📈 {}", self.symbol));
|
| |
|
| |
|
| | if let Some(last_tick) = self.data.last() {
|
| | ui.horizontal(|ui| {
|
| | ui.label(format!("{:.5} / {:.5}", last_tick.bid, last_tick.ask));
|
| | });
|
| | }
|
| |
|
| | ui.separator();
|
| |
|
| |
|
| | let time_map: Vec<i64> = self.data.iter().map(|t| t.time).collect();
|
| |
|
| | let plot = Plot::new("mt5_price_plot")
|
| | .legend(egui_plot::Legend::default())
|
| | .allow_boxed_zoom(true)
|
| | .allow_drag(true)
|
| | .allow_scroll(true)
|
| | .allow_zoom(true)
|
| | .x_axis_formatter(move |x, _range, _width| {
|
| | let idx = x.value.round() as isize;
|
| | if idx >= 0 && (idx as usize) < time_map.len() {
|
| | let timestamp = time_map[idx as usize];
|
| | let seconds = timestamp % 60;
|
| | let minutes = (timestamp / 60) % 60;
|
| | let hours = (timestamp / 3600) % 24;
|
| | return format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
|
| | }
|
| | "".to_string()
|
| | });
|
| |
|
| | plot.show(ui, |plot_ui| {
|
| | let bid_points: PlotPoints = self.data
|
| | .iter()
|
| | .enumerate()
|
| | .map(|(i, t)| [i as f64, t.bid])
|
| | .collect();
|
| |
|
| | let ask_points: PlotPoints = self.data
|
| | .iter()
|
| | .enumerate()
|
| | .map(|(i, t)| [i as f64, t.ask])
|
| | .collect();
|
| |
|
| | plot_ui.line(Line::new(bid_points).name("Bid").color(egui::Color32::from_rgb(100, 200, 100)));
|
| | plot_ui.line(Line::new(ask_points).name("Ask").color(egui::Color32::from_rgb(200, 100, 100)));
|
| |
|
| |
|
| | for pos in &self.positions {
|
| | let color = if pos.pos_type == "BUY" {
|
| | egui::Color32::from_rgb(50, 100, 255)
|
| | } else {
|
| | egui::Color32::from_rgb(255, 50, 50)
|
| | };
|
| |
|
| | plot_ui.hline(
|
| | egui_plot::HLine::new(pos.price)
|
| | .color(color)
|
| | .name(format!("{} #{}", pos.pos_type, pos.ticket))
|
| | .style(egui_plot::LineStyle::Dashed { length: 10.0 })
|
| | );
|
| | }
|
| |
|
| |
|
| | for breakline in &self.order_breaklines {
|
| | let color = if breakline.order_type.contains("buy") {
|
| | egui::Color32::from_rgb(0, 200, 100)
|
| | } else {
|
| | egui::Color32::from_rgb(255, 80, 80)
|
| | };
|
| |
|
| | plot_ui.vline(
|
| | egui_plot::VLine::new(breakline.index as f64)
|
| | .color(color)
|
| | .name(format!("Order #{}", breakline.ticket))
|
| | .width(2.0)
|
| | );
|
| | }
|
| | });
|
| | });
|
| |
|
| |
|
| | ctx.request_repaint();
|
| | }
|
| | }
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | #[tokio::main]
|
| | async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
| |
|
| | let (tick_tx, tick_rx) = mpsc::channel(100);
|
| |
|
| |
|
| | let (order_tx, mut order_rx) = mpsc::channel::<OrderRequest>(10);
|
| | let (response_tx, response_rx) = mpsc::channel::<OrderResponse>(10);
|
| |
|
| |
|
| |
|
| |
|
| | tokio::spawn(async move {
|
| | let mut socket = zeromq::SubSocket::new();
|
| | match socket.connect("tcp://127.0.0.1:5555").await {
|
| | Ok(_) => println!("Connected to ZMQ Tick Publisher on port 5555"),
|
| | Err(e) => eprintln!("Failed to connect to ZMQ tick publisher: {}", e),
|
| | }
|
| |
|
| | let _ = socket.subscribe("").await;
|
| |
|
| | loop {
|
| | match socket.recv().await {
|
| | Ok(msg) => {
|
| | if let Some(payload_bytes) = msg.get(0) {
|
| | if let Ok(json_str) = std::str::from_utf8(payload_bytes) {
|
| | match serde_json::from_str::<TickData>(json_str) {
|
| | Ok(tick) => {
|
| | if let Err(e) = tick_tx.send(tick).await {
|
| | eprintln!("Tick channel error: {}", e);
|
| | break;
|
| | }
|
| | }
|
| | Err(e) => eprintln!("JSON Parse Error: {}. Msg: {}", e, json_str),
|
| | }
|
| | }
|
| | }
|
| | }
|
| | Err(e) => {
|
| | eprintln!("ZMQ Tick Recv Error: {}", e);
|
| | tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
| | }
|
| | }
|
| | }
|
| | });
|
| |
|
| |
|
| |
|
| |
|
| | tokio::spawn(async move {
|
| | let mut socket = zeromq::ReqSocket::new();
|
| | match socket.connect("tcp://127.0.0.1:5556").await {
|
| | Ok(_) => println!("Connected to ZMQ Order Handler on port 5556"),
|
| | Err(e) => {
|
| | eprintln!("Failed to connect to ZMQ order handler: {}", e);
|
| | return;
|
| | }
|
| | }
|
| |
|
| | while let Some(order_request) = order_rx.recv().await {
|
| |
|
| | let json_request = match serde_json::to_string(&order_request) {
|
| | Ok(json) => json,
|
| | Err(e) => {
|
| | eprintln!("Failed to serialize order request: {}", e);
|
| | continue;
|
| | }
|
| | };
|
| |
|
| | println!("Sending request: {}", json_request);
|
| |
|
| |
|
| | if let Err(e) = socket.send(json_request.into()).await {
|
| | eprintln!("Failed to send: {}", e);
|
| | let _ = response_tx.send(OrderResponse {
|
| | success: false,
|
| | ticket: None,
|
| | error: Some(format!("Send failed: {}", e)),
|
| | message: None,
|
| | }).await;
|
| | continue;
|
| | }
|
| |
|
| |
|
| | match socket.recv().await {
|
| | Ok(msg) => {
|
| | if let Some(payload_bytes) = msg.get(0) {
|
| | if let Ok(json_str) = std::str::from_utf8(payload_bytes) {
|
| | println!("Received response: {}", json_str);
|
| | match serde_json::from_str::<OrderResponse>(json_str) {
|
| | Ok(response) => {
|
| | let _ = response_tx.send(response).await;
|
| | }
|
| | Err(e) => {
|
| | let _ = response_tx.send(OrderResponse {
|
| | success: false,
|
| | ticket: None,
|
| | error: Some(format!("Parse error: {}", e)),
|
| | message: None,
|
| | }).await;
|
| | }
|
| | }
|
| | }
|
| | }
|
| | }
|
| | Err(e) => {
|
| | eprintln!("Response recv error: {}", e);
|
| | let _ = response_tx.send(OrderResponse {
|
| | success: false,
|
| | ticket: None,
|
| | error: Some(format!("Recv failed: {}", e)),
|
| | message: None,
|
| | }).await;
|
| | }
|
| | }
|
| | }
|
| | });
|
| |
|
| |
|
| |
|
| |
|
| | let options = eframe::NativeOptions {
|
| | viewport: egui::ViewportBuilder::default()
|
| | .with_inner_size([1200.0, 800.0])
|
| | .with_title("Rust + ZMQ + MT5 Trading Chart"),
|
| | ..Default::default()
|
| | };
|
| |
|
| | eframe::run_native(
|
| | "Rust + ZMQ + MT5 Trading Chart",
|
| | options,
|
| | Box::new(|_cc| Box::new(Mt5ChartApp::new(tick_rx, order_tx, response_rx))),
|
| | ).map_err(|e| e.into())
|
| | }
|
| |
|