// ============================================================================ // app.rs -- Application State & eframe::App Implementation // ============================================================================ // Central state struct shared across all UI modules. // Tab switching pauses inactive tab computation. // ============================================================================ use crate::gmp_engine::{IntervalProfile, RawTick}; use crate::zmq_bridge::{OrderRequest, OrderResponse, PositionData, PendingOrderData, TickData}; use crate::tab_time; use crate::tab_trade; use crate::trading_panel; use std::io::Write; use std::path::PathBuf; use tokio::sync::mpsc; // ─── Tab Enum ──────────────────────────────────────────────────────────────── #[derive(Clone, Copy, PartialEq, Eq)] pub enum TabMode { TimeBased, TradeBased, } // ─── Application State ────────────────────────────────────────────────────── pub struct AppState { // ZMQ channels pub tick_rx: mpsc::Receiver, pub order_tx: mpsc::Sender, pub response_rx: mpsc::Receiver, // Raw tick buffer (rolling) pub raw_ticks: Vec, pub symbol: String, // Account pub balance: f64, pub equity: f64, pub margin: f64, pub free_margin: f64, pub min_lot: f64, pub max_lot: f64, pub lot_step: f64, // Positions & orders (from latest tick) pub positions: Vec, pub pending_orders: Vec, // Trading UI pub lot_size: f64, pub lot_size_str: String, pub limit_price_str: String, pub last_status: Option, // Chart config pub bin_size: f64, pub bin_size_str: String, pub active_tab: TabMode, // Time-based tab pub time_interval_secs: u64, pub time_input_str: String, pub time_parse_err: Option, pub time_profiles: Vec, pub profiles_dirty: bool, pub show_footprint: bool, // Trade-based tab pub trade_interval: usize, pub trade_input_str: String, pub trade_profiles: Vec, pub profiles_dirty_trade: bool, pub show_footprint_trade: bool, // History download pub history_start: String, pub history_end: String, pub history_tf: String, pub history_mode: String, // Live recording pub is_recording: bool, pub live_record_file: Option, // Output pub output_dir: PathBuf, pub request_counter: u64, pub pending_history: Option<(u64, String, String, String)>, // Max ticks to keep in memory max_ticks: usize, } impl AppState { pub fn new( tick_rx: mpsc::Receiver, order_tx: mpsc::Sender, response_rx: mpsc::Receiver, ) -> Self { let now = chrono::Local::now(); let today = now.format("%Y.%m.%d").to_string(); let output_dir = PathBuf::from("output"); std::fs::create_dir_all(&output_dir).ok(); Self { tick_rx, order_tx, response_rx, raw_ticks: Vec::new(), symbol: "Waiting...".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, positions: Vec::new(), pending_orders: Vec::new(), lot_size: 0.01, lot_size_str: "0.01".to_string(), limit_price_str: "0.0".to_string(), last_status: None, bin_size: 1.0, bin_size_str: "1".to_string(), active_tab: TabMode::TimeBased, time_interval_secs: 60, time_input_str: "1m".to_string(), time_parse_err: None, time_profiles: Vec::new(), profiles_dirty: true, show_footprint: false, trade_interval: 50, trade_input_str: "50".to_string(), trade_profiles: Vec::new(), profiles_dirty_trade: true, show_footprint_trade: false, history_start: today.clone(), history_end: today, history_tf: "M1".to_string(), history_mode: "TICKS".to_string(), is_recording: false, live_record_file: None, output_dir, request_counter: 0, pending_history: None, max_ticks: 50_000, } } } // ─── eframe::App ───────────────────────────────────────────────────────────── pub struct GmpTerminalApp { pub state: AppState, } impl GmpTerminalApp { pub fn new( tick_rx: mpsc::Receiver, order_tx: mpsc::Sender, response_rx: mpsc::Receiver, ) -> Self { Self { state: AppState::new(tick_rx, order_tx, response_rx), } } } impl eframe::App for GmpTerminalApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let state = &mut self.state; // ── Drain tick channel ── let mut new_ticks = false; while let Ok(tick) = state.tick_rx.try_recv() { state.symbol = tick.symbol.clone(); // Record if active if state.is_recording { if let Some(ref mut file) = state.live_record_file { let _ = writeln!( file, "{},{},{},{}", tick.time, tick.bid, tick.ask, tick.volume ); } } // Update account info if tick.balance > 0.0 { state.balance = tick.balance; state.equity = tick.equity; state.margin = tick.margin; state.free_margin = tick.free_margin; state.min_lot = tick.min_lot; state.max_lot = tick.max_lot; if tick.lot_step > 0.0 { state.lot_step = tick.lot_step; } } // Update positions/orders state.positions = tick.positions.clone(); state.pending_orders = tick.orders.clone(); // Use mid-price (avg of bid/ask) for GMP computation let mid = (tick.bid + tick.ask) * 0.5; let idx = state.raw_ticks.len(); state.raw_ticks.push(RawTick { price: mid, time: tick.time, index: idx, }); new_ticks = true; } // Trim buffer if state.raw_ticks.len() > state.max_ticks { let excess = state.raw_ticks.len() - state.max_ticks; state.raw_ticks.drain(..excess); } // Mark profiles dirty if new ticks arrived if new_ticks { match state.active_tab { TabMode::TimeBased => state.profiles_dirty = true, TabMode::TradeBased => state.profiles_dirty_trade = true, } } // ── Drain order responses ── while let Ok(resp) = state.response_rx.try_recv() { if resp.success { if let Some(ref msg) = resp.message { if msg.contains("||CSV_DATA||") { handle_csv_response(state, msg); } else { state.last_status = Some(format!("OK: {}", msg)); } } else { state.last_status = Some(format!( "OK: ticket {}", resp.ticket.unwrap_or(0) )); } } else { state.pending_history = None; state.last_status = Some(format!( "FAIL: {}", resp.error.unwrap_or_else(|| "Unknown".to_string()) )); } } // ── Side Panel ── egui::SidePanel::left("trading_panel") .min_width(260.0) .max_width(300.0) .show(ctx, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { trading_panel::render_trading_panel(ui, state); }); }); // ── Central Panel ── egui::CentralPanel::default().show(ctx, |ui| { // Header ui.horizontal(|ui| { ui.heading(&state.symbol); if let Some(last) = state.raw_ticks.last() { let price = last.price; ui.label(format!("{:.5}", price)); } }); // Tab bar ui.horizontal(|ui| { if ui .selectable_label(state.active_tab == TabMode::TimeBased, "Time-Based") .clicked() { state.active_tab = TabMode::TimeBased; state.profiles_dirty = true; } if ui .selectable_label(state.active_tab == TabMode::TradeBased, "Trade-Based") .clicked() { state.active_tab = TabMode::TradeBased; state.profiles_dirty_trade = true; } }); ui.separator(); // Active tab content -- only the selected tab computes match state.active_tab { TabMode::TimeBased => tab_time::render(ui, state), TabMode::TradeBased => tab_trade::render(ui, state), } }); // Continuous repaint ctx.request_repaint(); } } // ─── CSV Response Handler ──────────────────────────────────────────────────── fn handle_csv_response(state: &mut AppState, msg: &str) { let parts: Vec<&str> = msg.splitn(2, "||CSV_DATA||").collect(); if parts.len() == 2 { let info = parts[0]; let csv = parts[1]; if let Some((id, sym, tf, mode)) = state.pending_history.take() { let ts = chrono::Local::now().format("%Y%m%d_%H%M%S"); let filename = format!( "{}/History_{}_{}_{}_ID{:04}_{}.csv", state.output_dir.display(), sym, tf, mode, id, ts ); let csv_clean = csv.replace("|NL|", "\n"); match std::fs::write(&filename, csv_clean) { Ok(_) => { state.last_status = Some(format!("Saved: {} ({})", filename, info)); } Err(e) => { state.last_status = Some(format!("Save error: {}", e)); } } } else { state.last_status = Some(format!("OK: {}", info)); } } else { state.last_status = Some(format!("OK: {}", msg)); } }