1pub mod ui_projection;
2pub mod chart;
3pub mod dashboard;
4pub mod network_metrics;
5
6use std::collections::HashMap;
7
8use ratatui::layout::{Constraint, Direction, Layout, Rect};
9use ratatui::style::{Color, Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table};
12use ratatui::Frame;
13
14use crate::event::{AppEvent, AssetPnlEntry, LogDomain, LogLevel, LogRecord, WsConnectionStatus};
15use crate::model::candle::{Candle, CandleBuilder};
16use crate::model::order::{Fill, OrderSide};
17use crate::model::position::Position;
18use crate::model::signal::Signal;
19use crate::order_manager::{OrderHistoryFill, OrderHistoryStats, OrderUpdate};
20use crate::order_store;
21use crate::risk_module::RateBudgetSnapshot;
22use crate::strategy_catalog::{strategy_kind_categories, strategy_kind_labels};
23use crate::ui::network_metrics::{classify_health, count_since, percentile, rate_per_sec, ratio_pct, NetworkHealth};
24
25use ui_projection::UiProjection;
26use ui_projection::AssetEntry;
27use chart::{FillMarker, PriceChart};
28use dashboard::{KeybindBar, LogPanel, OrderHistoryPanel, OrderLogPanel, PositionPanel, StatusBar, StrategyMetricsPanel};
29
30const MAX_LOG_MESSAGES: usize = 200;
31const MAX_FILL_MARKERS: usize = 200;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum GridTab {
35 Assets,
36 Strategies,
37 Risk,
38 Network,
39 SystemLog,
40}
41
42#[derive(Debug, Clone)]
43pub struct StrategyLastEvent {
44 pub side: OrderSide,
45 pub price: Option<f64>,
46 pub timestamp_ms: u64,
47 pub is_filled: bool,
48}
49
50#[derive(Debug, Clone)]
51pub struct ViewState {
52 pub is_grid_open: bool,
53 pub selected_grid_tab: GridTab,
54 pub selected_symbol_index: usize,
55 pub selected_strategy_index: usize,
56 pub is_on_panel_selected: bool,
57 pub is_symbol_selector_open: bool,
58 pub selected_symbol_selector_index: usize,
59 pub is_strategy_selector_open: bool,
60 pub selected_strategy_selector_index: usize,
61 pub is_account_popup_open: bool,
62 pub is_history_popup_open: bool,
63 pub is_focus_popup_open: bool,
64 pub is_strategy_editor_open: bool,
65}
66
67pub struct AppState {
68 pub symbol: String,
69 pub strategy_label: String,
70 pub candles: Vec<Candle>,
71 pub current_candle: Option<CandleBuilder>,
72 pub candle_interval_ms: u64,
73 pub timeframe: String,
74 pub price_history_len: usize,
75 pub position: Position,
76 pub last_signal: Option<Signal>,
77 pub last_order: Option<OrderUpdate>,
78 pub open_order_history: Vec<String>,
79 pub filled_order_history: Vec<String>,
80 pub fast_sma: Option<f64>,
81 pub slow_sma: Option<f64>,
82 pub ws_connected: bool,
83 pub paused: bool,
84 pub tick_count: u64,
85 pub log_messages: Vec<String>,
86 pub log_records: Vec<LogRecord>,
87 pub balances: HashMap<String, f64>,
88 pub initial_equity_usdt: Option<f64>,
89 pub current_equity_usdt: Option<f64>,
90 pub history_estimated_total_pnl_usdt: Option<f64>,
91 pub fill_markers: Vec<FillMarker>,
92 pub history_trade_count: u32,
93 pub history_win_count: u32,
94 pub history_lose_count: u32,
95 pub history_realized_pnl: f64,
96 pub asset_pnl_by_symbol: HashMap<String, AssetPnlEntry>,
97 pub strategy_stats: HashMap<String, OrderHistoryStats>,
98 pub history_fills: Vec<OrderHistoryFill>,
99 pub last_price_update_ms: Option<u64>,
100 pub last_price_event_ms: Option<u64>,
101 pub last_price_latency_ms: Option<u64>,
102 pub last_order_history_update_ms: Option<u64>,
103 pub last_order_history_event_ms: Option<u64>,
104 pub last_order_history_latency_ms: Option<u64>,
105 pub trade_stats_reset_warned: bool,
106 pub symbol_selector_open: bool,
107 pub symbol_selector_index: usize,
108 pub symbol_items: Vec<String>,
109 pub strategy_selector_open: bool,
110 pub strategy_selector_index: usize,
111 pub strategy_items: Vec<String>,
112 pub strategy_item_symbols: Vec<String>,
113 pub strategy_item_active: Vec<bool>,
114 pub strategy_item_created_at_ms: Vec<i64>,
115 pub strategy_item_total_running_ms: Vec<u64>,
116 pub account_popup_open: bool,
117 pub history_popup_open: bool,
118 pub focus_popup_open: bool,
119 pub strategy_editor_open: bool,
120 pub strategy_editor_kind_category_selector_open: bool,
121 pub strategy_editor_kind_selector_open: bool,
122 pub strategy_editor_index: usize,
123 pub strategy_editor_field: usize,
124 pub strategy_editor_kind_category_items: Vec<String>,
125 pub strategy_editor_kind_category_index: usize,
126 pub strategy_editor_kind_popup_items: Vec<String>,
127 pub strategy_editor_kind_popup_labels: Vec<Option<String>>,
128 pub strategy_editor_kind_items: Vec<String>,
129 pub strategy_editor_kind_selector_index: usize,
130 pub strategy_editor_kind_index: usize,
131 pub strategy_editor_symbol_index: usize,
132 pub strategy_editor_fast: usize,
133 pub strategy_editor_slow: usize,
134 pub strategy_editor_cooldown: u64,
135 pub grid_symbol_index: usize,
136 pub grid_strategy_index: usize,
137 pub grid_select_on_panel: bool,
138 pub grid_tab: GridTab,
139 pub strategy_last_event_by_tag: HashMap<String, StrategyLastEvent>,
140 pub network_tick_drop_count: u64,
141 pub network_reconnect_count: u64,
142 pub network_tick_latencies_ms: Vec<u64>,
143 pub network_fill_latencies_ms: Vec<u64>,
144 pub network_order_sync_latencies_ms: Vec<u64>,
145 pub network_tick_in_timestamps_ms: Vec<u64>,
146 pub network_tick_drop_timestamps_ms: Vec<u64>,
147 pub network_reconnect_timestamps_ms: Vec<u64>,
148 pub network_disconnect_timestamps_ms: Vec<u64>,
149 pub network_last_fill_ms: Option<u64>,
150 pub network_pending_submit_ms_by_intent: HashMap<String, u64>,
151 pub history_rows: Vec<String>,
152 pub history_bucket: order_store::HistoryBucket,
153 pub last_applied_fee: String,
154 pub grid_open: bool,
155 pub ui_projection: UiProjection,
156 pub rate_budget_global: RateBudgetSnapshot,
157 pub rate_budget_orders: RateBudgetSnapshot,
158 pub rate_budget_account: RateBudgetSnapshot,
159 pub rate_budget_market_data: RateBudgetSnapshot,
160}
161
162impl AppState {
163 pub fn new(
164 symbol: &str,
165 strategy_label: &str,
166 price_history_len: usize,
167 candle_interval_ms: u64,
168 timeframe: &str,
169 ) -> Self {
170 Self {
171 symbol: symbol.to_string(),
172 strategy_label: strategy_label.to_string(),
173 candles: Vec::with_capacity(price_history_len),
174 current_candle: None,
175 candle_interval_ms,
176 timeframe: timeframe.to_string(),
177 price_history_len,
178 position: Position::new(symbol.to_string()),
179 last_signal: None,
180 last_order: None,
181 open_order_history: Vec::new(),
182 filled_order_history: Vec::new(),
183 fast_sma: None,
184 slow_sma: None,
185 ws_connected: false,
186 paused: false,
187 tick_count: 0,
188 log_messages: Vec::new(),
189 log_records: Vec::new(),
190 balances: HashMap::new(),
191 initial_equity_usdt: None,
192 current_equity_usdt: None,
193 history_estimated_total_pnl_usdt: None,
194 fill_markers: Vec::new(),
195 history_trade_count: 0,
196 history_win_count: 0,
197 history_lose_count: 0,
198 history_realized_pnl: 0.0,
199 asset_pnl_by_symbol: HashMap::new(),
200 strategy_stats: HashMap::new(),
201 history_fills: Vec::new(),
202 last_price_update_ms: None,
203 last_price_event_ms: None,
204 last_price_latency_ms: None,
205 last_order_history_update_ms: None,
206 last_order_history_event_ms: None,
207 last_order_history_latency_ms: None,
208 trade_stats_reset_warned: false,
209 symbol_selector_open: false,
210 symbol_selector_index: 0,
211 symbol_items: Vec::new(),
212 strategy_selector_open: false,
213 strategy_selector_index: 0,
214 strategy_items: vec![
215 "MA(Config)".to_string(),
216 "MA(Fast 5/20)".to_string(),
217 "MA(Slow 20/60)".to_string(),
218 "RSA(RSI 14 30/70)".to_string(),
219 ],
220 strategy_item_symbols: vec![
221 symbol.to_ascii_uppercase(),
222 symbol.to_ascii_uppercase(),
223 symbol.to_ascii_uppercase(),
224 symbol.to_ascii_uppercase(),
225 ],
226 strategy_item_active: vec![false, false, false, false],
227 strategy_item_created_at_ms: vec![0, 0, 0, 0],
228 strategy_item_total_running_ms: vec![0, 0, 0, 0],
229 account_popup_open: false,
230 history_popup_open: false,
231 focus_popup_open: false,
232 strategy_editor_open: false,
233 strategy_editor_kind_category_selector_open: false,
234 strategy_editor_kind_selector_open: false,
235 strategy_editor_index: 0,
236 strategy_editor_field: 0,
237 strategy_editor_kind_category_items: strategy_kind_categories(),
238 strategy_editor_kind_category_index: 0,
239 strategy_editor_kind_popup_items: Vec::new(),
240 strategy_editor_kind_popup_labels: Vec::new(),
241 strategy_editor_kind_items: strategy_kind_labels(),
242 strategy_editor_kind_selector_index: 0,
243 strategy_editor_kind_index: 0,
244 strategy_editor_symbol_index: 0,
245 strategy_editor_fast: 5,
246 strategy_editor_slow: 20,
247 strategy_editor_cooldown: 1,
248 grid_symbol_index: 0,
249 grid_strategy_index: 0,
250 grid_select_on_panel: true,
251 grid_tab: GridTab::Strategies,
252 strategy_last_event_by_tag: HashMap::new(),
253 network_tick_drop_count: 0,
254 network_reconnect_count: 0,
255 network_tick_latencies_ms: Vec::new(),
256 network_fill_latencies_ms: Vec::new(),
257 network_order_sync_latencies_ms: Vec::new(),
258 network_tick_in_timestamps_ms: Vec::new(),
259 network_tick_drop_timestamps_ms: Vec::new(),
260 network_reconnect_timestamps_ms: Vec::new(),
261 network_disconnect_timestamps_ms: Vec::new(),
262 network_last_fill_ms: None,
263 network_pending_submit_ms_by_intent: HashMap::new(),
264 history_rows: Vec::new(),
265 history_bucket: order_store::HistoryBucket::Day,
266 last_applied_fee: "---".to_string(),
267 grid_open: false,
268 ui_projection: UiProjection::new(),
269 rate_budget_global: RateBudgetSnapshot {
270 used: 0,
271 limit: 0,
272 reset_in_ms: 0,
273 },
274 rate_budget_orders: RateBudgetSnapshot {
275 used: 0,
276 limit: 0,
277 reset_in_ms: 0,
278 },
279 rate_budget_account: RateBudgetSnapshot {
280 used: 0,
281 limit: 0,
282 reset_in_ms: 0,
283 },
284 rate_budget_market_data: RateBudgetSnapshot {
285 used: 0,
286 limit: 0,
287 reset_in_ms: 0,
288 },
289 }
290 }
291
292 pub fn last_price(&self) -> Option<f64> {
294 self.current_candle
295 .as_ref()
296 .map(|cb| cb.close)
297 .or_else(|| self.candles.last().map(|c| c.close))
298 }
299
300 pub fn push_log(&mut self, msg: String) {
301 self.log_messages.push(msg);
302 if self.log_messages.len() > MAX_LOG_MESSAGES {
303 self.log_messages.remove(0);
304 }
305 }
306
307 pub fn push_log_record(&mut self, record: LogRecord) {
308 self.log_records.push(record.clone());
309 if self.log_records.len() > MAX_LOG_MESSAGES {
310 self.log_records.remove(0);
311 }
312 self.push_log(format_log_record_compact(&record));
313 }
314
315 fn push_latency_sample(samples: &mut Vec<u64>, value: u64) {
316 const MAX_SAMPLES: usize = 200;
317 samples.push(value);
318 if samples.len() > MAX_SAMPLES {
319 let drop_n = samples.len() - MAX_SAMPLES;
320 samples.drain(..drop_n);
321 }
322 }
323
324 fn push_network_event_sample(samples: &mut Vec<u64>, ts_ms: u64) {
325 samples.push(ts_ms);
326 let lower = ts_ms.saturating_sub(60_000);
327 samples.retain(|&v| v >= lower);
328 }
329
330 fn prune_network_event_windows(&mut self, now_ms: u64) {
331 let lower = now_ms.saturating_sub(60_000);
332 self.network_tick_in_timestamps_ms.retain(|&v| v >= lower);
333 self.network_tick_drop_timestamps_ms.retain(|&v| v >= lower);
334 self.network_reconnect_timestamps_ms.retain(|&v| v >= lower);
335 self.network_disconnect_timestamps_ms.retain(|&v| v >= lower);
336 }
337
338 pub fn view_state(&self) -> ViewState {
341 ViewState {
342 is_grid_open: self.grid_open,
343 selected_grid_tab: self.grid_tab,
344 selected_symbol_index: self.grid_symbol_index,
345 selected_strategy_index: self.grid_strategy_index,
346 is_on_panel_selected: self.grid_select_on_panel,
347 is_symbol_selector_open: self.symbol_selector_open,
348 selected_symbol_selector_index: self.symbol_selector_index,
349 is_strategy_selector_open: self.strategy_selector_open,
350 selected_strategy_selector_index: self.strategy_selector_index,
351 is_account_popup_open: self.account_popup_open,
352 is_history_popup_open: self.history_popup_open,
353 is_focus_popup_open: self.focus_popup_open,
354 is_strategy_editor_open: self.strategy_editor_open,
355 }
356 }
357
358 pub fn is_grid_open(&self) -> bool {
359 self.grid_open
360 }
361 pub fn set_grid_open(&mut self, open: bool) {
362 self.grid_open = open;
363 }
364 pub fn grid_tab(&self) -> GridTab {
365 self.grid_tab
366 }
367 pub fn set_grid_tab(&mut self, tab: GridTab) {
368 self.grid_tab = tab;
369 }
370 pub fn selected_grid_symbol_index(&self) -> usize {
371 self.grid_symbol_index
372 }
373 pub fn set_selected_grid_symbol_index(&mut self, idx: usize) {
374 self.grid_symbol_index = idx;
375 }
376 pub fn selected_grid_strategy_index(&self) -> usize {
377 self.grid_strategy_index
378 }
379 pub fn set_selected_grid_strategy_index(&mut self, idx: usize) {
380 self.grid_strategy_index = idx;
381 }
382 pub fn is_on_panel_selected(&self) -> bool {
383 self.grid_select_on_panel
384 }
385 pub fn set_on_panel_selected(&mut self, selected: bool) {
386 self.grid_select_on_panel = selected;
387 }
388 pub fn is_symbol_selector_open(&self) -> bool {
389 self.symbol_selector_open
390 }
391 pub fn set_symbol_selector_open(&mut self, open: bool) {
392 self.symbol_selector_open = open;
393 }
394 pub fn symbol_selector_index(&self) -> usize {
395 self.symbol_selector_index
396 }
397 pub fn set_symbol_selector_index(&mut self, idx: usize) {
398 self.symbol_selector_index = idx;
399 }
400 pub fn is_strategy_selector_open(&self) -> bool {
401 self.strategy_selector_open
402 }
403 pub fn set_strategy_selector_open(&mut self, open: bool) {
404 self.strategy_selector_open = open;
405 }
406 pub fn strategy_selector_index(&self) -> usize {
407 self.strategy_selector_index
408 }
409 pub fn set_strategy_selector_index(&mut self, idx: usize) {
410 self.strategy_selector_index = idx;
411 }
412 pub fn is_account_popup_open(&self) -> bool {
413 self.account_popup_open
414 }
415 pub fn set_account_popup_open(&mut self, open: bool) {
416 self.account_popup_open = open;
417 }
418 pub fn is_history_popup_open(&self) -> bool {
419 self.history_popup_open
420 }
421 pub fn set_history_popup_open(&mut self, open: bool) {
422 self.history_popup_open = open;
423 }
424 pub fn is_focus_popup_open(&self) -> bool {
425 self.focus_popup_open
426 }
427 pub fn set_focus_popup_open(&mut self, open: bool) {
428 self.focus_popup_open = open;
429 }
430 pub fn is_strategy_editor_open(&self) -> bool {
431 self.strategy_editor_open
432 }
433 pub fn set_strategy_editor_open(&mut self, open: bool) {
434 self.strategy_editor_open = open;
435 }
436 pub fn focus_symbol(&self) -> Option<&str> {
437 self.ui_projection.focus.symbol.as_deref()
438 }
439 pub fn focus_strategy_id(&self) -> Option<&str> {
440 self.ui_projection.focus.strategy_id.as_deref()
441 }
442 pub fn set_focus_symbol(&mut self, symbol: Option<String>) {
443 self.ui_projection.focus.symbol = symbol;
444 }
445 pub fn set_focus_strategy_id(&mut self, strategy_id: Option<String>) {
446 self.ui_projection.focus.strategy_id = strategy_id;
447 }
448 pub fn focus_pair(&self) -> (Option<String>, Option<String>) {
449 (
450 self.ui_projection.focus.symbol.clone(),
451 self.ui_projection.focus.strategy_id.clone(),
452 )
453 }
454 pub fn assets_view(&self) -> &[AssetEntry] {
455 &self.ui_projection.assets
456 }
457
458 pub fn refresh_history_rows(&mut self) {
459 match order_store::load_realized_returns_by_bucket(self.history_bucket, 400) {
460 Ok(rows) => {
461 use std::collections::{BTreeMap, BTreeSet};
462
463 let mut date_set: BTreeSet<String> = BTreeSet::new();
464 let mut ticker_map: BTreeMap<String, BTreeMap<String, f64>> = BTreeMap::new();
465 for row in rows {
466 date_set.insert(row.date.clone());
467 ticker_map
468 .entry(row.symbol.clone())
469 .or_default()
470 .insert(row.date, row.realized_return_pct);
471 }
472
473 let mut dates: Vec<String> = date_set.into_iter().collect();
475 dates.sort();
476 const MAX_DATE_COLS: usize = 6;
477 if dates.len() > MAX_DATE_COLS {
478 dates = dates[dates.len() - MAX_DATE_COLS..].to_vec();
479 }
480
481 let mut lines = Vec::new();
482 if dates.is_empty() {
483 lines.push("Ticker (no daily realized roi data)".to_string());
484 self.history_rows = lines;
485 return;
486 }
487
488 let mut header = format!("{:<14}", "Ticker");
489 for d in &dates {
490 header.push_str(&format!(" {:>10}", d));
491 }
492 lines.push(header);
493
494 for (ticker, by_date) in ticker_map {
495 let mut line = format!("{:<14}", ticker);
496 for d in &dates {
497 let cell = by_date
498 .get(d)
499 .map(|v| format!("{:.2}%", v))
500 .unwrap_or_else(|| "-".to_string());
501 line.push_str(&format!(" {:>10}", cell));
502 }
503 lines.push(line);
504 }
505 self.history_rows = lines;
506 }
507 Err(e) => {
508 self.history_rows = vec![
509 "Ticker Date RealizedROI RealizedPnL".to_string(),
510 format!("(failed to load history: {})", e),
511 ];
512 }
513 }
514 }
515
516 fn refresh_equity_usdt(&mut self) {
517 let usdt = self.balances.get("USDT").copied().unwrap_or(0.0);
518 let btc = self.balances.get("BTC").copied().unwrap_or(0.0);
519 let mark_price = self
520 .last_price()
521 .or_else(|| (self.position.entry_price > 0.0).then_some(self.position.entry_price));
522 if let Some(price) = mark_price {
523 let total = usdt + btc * price;
524 self.current_equity_usdt = Some(total);
525 self.recompute_initial_equity_from_history();
526 }
527 }
528
529 fn recompute_initial_equity_from_history(&mut self) {
530 if let Some(current) = self.current_equity_usdt {
531 if let Some(total_pnl) = self.history_estimated_total_pnl_usdt {
532 self.initial_equity_usdt = Some(current - total_pnl);
533 } else if self.history_trade_count == 0 && self.initial_equity_usdt.is_none() {
534 self.initial_equity_usdt = Some(current);
535 }
536 }
537 }
538
539 fn candle_index_for_timestamp(&self, timestamp_ms: u64) -> Option<usize> {
540 if let Some((idx, _)) = self
541 .candles
542 .iter()
543 .enumerate()
544 .find(|(_, c)| timestamp_ms >= c.open_time && timestamp_ms < c.close_time)
545 {
546 return Some(idx);
547 }
548 if let Some(cb) = &self.current_candle {
549 if cb.contains(timestamp_ms) {
550 return Some(self.candles.len());
551 }
552 }
553 if let Some((idx, _)) = self
556 .candles
557 .iter()
558 .enumerate()
559 .rev()
560 .find(|(_, c)| c.open_time <= timestamp_ms)
561 {
562 return Some(idx);
563 }
564 None
565 }
566
567 fn rebuild_fill_markers_from_history(&mut self, fills: &[OrderHistoryFill]) {
568 self.fill_markers.clear();
569 for fill in fills {
570 if let Some(candle_index) = self.candle_index_for_timestamp(fill.timestamp_ms) {
571 self.fill_markers.push(FillMarker {
572 candle_index,
573 price: fill.price,
574 side: fill.side,
575 });
576 }
577 }
578 if self.fill_markers.len() > MAX_FILL_MARKERS {
579 let excess = self.fill_markers.len() - MAX_FILL_MARKERS;
580 self.fill_markers.drain(..excess);
581 }
582 }
583
584 fn sync_projection_portfolio_summary(&mut self) {
585 self.ui_projection.portfolio.total_equity_usdt = self.current_equity_usdt;
586 self.ui_projection.portfolio.total_realized_pnl_usdt = self.history_realized_pnl;
587 self.ui_projection.portfolio.total_unrealized_pnl_usdt = self.position.unrealized_pnl;
588 self.ui_projection.portfolio.ws_connected = self.ws_connected;
589 }
590
591 fn ensure_projection_focus_defaults(&mut self) {
592 if self.ui_projection.focus.symbol.is_none() {
593 self.ui_projection.focus.symbol = Some(self.symbol.clone());
594 }
595 if self.ui_projection.focus.strategy_id.is_none() {
596 self.ui_projection.focus.strategy_id = Some(self.strategy_label.clone());
597 }
598 }
599
600 fn rebuild_projection_preserve_focus(&mut self, prev_focus: (Option<String>, Option<String>)) {
601 let mut next = UiProjection::from_legacy(self);
602 if prev_focus.0.is_some() {
603 next.focus.symbol = prev_focus.0;
604 }
605 if prev_focus.1.is_some() {
606 next.focus.strategy_id = prev_focus.1;
607 }
608 self.ui_projection = next;
609 self.ensure_projection_focus_defaults();
610 }
611
612 pub fn apply(&mut self, event: AppEvent) {
613 let prev_focus = self.focus_pair();
614 let mut rebuild_projection = false;
615 match event {
616 AppEvent::MarketTick(tick) => {
617 rebuild_projection = true;
618 self.tick_count += 1;
619 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
620 self.last_price_update_ms = Some(now_ms);
621 self.last_price_event_ms = Some(tick.timestamp_ms);
622 self.last_price_latency_ms = Some(now_ms.saturating_sub(tick.timestamp_ms));
623 Self::push_network_event_sample(&mut self.network_tick_in_timestamps_ms, now_ms);
624 if let Some(lat) = self.last_price_latency_ms {
625 Self::push_latency_sample(&mut self.network_tick_latencies_ms, lat);
626 }
627
628 let should_new = match &self.current_candle {
630 Some(cb) => !cb.contains(tick.timestamp_ms),
631 None => true,
632 };
633 if should_new {
634 if let Some(cb) = self.current_candle.take() {
635 self.candles.push(cb.finish());
636 if self.candles.len() > self.price_history_len {
637 self.candles.remove(0);
638 self.fill_markers.retain_mut(|m| {
640 if m.candle_index == 0 {
641 false
642 } else {
643 m.candle_index -= 1;
644 true
645 }
646 });
647 }
648 }
649 self.current_candle = Some(CandleBuilder::new(
650 tick.price,
651 tick.timestamp_ms,
652 self.candle_interval_ms,
653 ));
654 } else if let Some(cb) = self.current_candle.as_mut() {
655 cb.update(tick.price);
656 } else {
657 self.current_candle = Some(CandleBuilder::new(
659 tick.price,
660 tick.timestamp_ms,
661 self.candle_interval_ms,
662 ));
663 self.push_log("[WARN] Recovered missing current candle state".to_string());
664 }
665
666 self.position.update_unrealized_pnl(tick.price);
667 self.refresh_equity_usdt();
668 }
669 AppEvent::StrategySignal {
670 ref signal,
671 symbol,
672 source_tag,
673 price,
674 timestamp_ms,
675 } => {
676 self.last_signal = Some(signal.clone());
677 let source_tag = source_tag.to_ascii_lowercase();
678 match signal {
679 Signal::Buy { .. } => {
680 let should_emit = self
681 .strategy_last_event_by_tag
682 .get(&source_tag)
683 .map(|e| e.side != OrderSide::Buy || timestamp_ms.saturating_sub(e.timestamp_ms) >= 1000)
684 .unwrap_or(true);
685 if should_emit {
686 let mut record = LogRecord::new(
687 LogLevel::Info,
688 LogDomain::Strategy,
689 "signal.emit",
690 format!(
691 "side=BUY price={}",
692 price
693 .map(|v| format!("{:.4}", v))
694 .unwrap_or_else(|| "-".to_string())
695 ),
696 );
697 record.symbol = Some(symbol.clone());
698 record.strategy_tag = Some(source_tag.clone());
699 self.push_log_record(record);
700 }
701 self.strategy_last_event_by_tag.insert(
702 source_tag.clone(),
703 StrategyLastEvent {
704 side: OrderSide::Buy,
705 price,
706 timestamp_ms,
707 is_filled: false,
708 },
709 );
710 }
711 Signal::Sell { .. } => {
712 let should_emit = self
713 .strategy_last_event_by_tag
714 .get(&source_tag)
715 .map(|e| e.side != OrderSide::Sell || timestamp_ms.saturating_sub(e.timestamp_ms) >= 1000)
716 .unwrap_or(true);
717 if should_emit {
718 let mut record = LogRecord::new(
719 LogLevel::Info,
720 LogDomain::Strategy,
721 "signal.emit",
722 format!(
723 "side=SELL price={}",
724 price
725 .map(|v| format!("{:.4}", v))
726 .unwrap_or_else(|| "-".to_string())
727 ),
728 );
729 record.symbol = Some(symbol.clone());
730 record.strategy_tag = Some(source_tag.clone());
731 self.push_log_record(record);
732 }
733 self.strategy_last_event_by_tag.insert(
734 source_tag.clone(),
735 StrategyLastEvent {
736 side: OrderSide::Sell,
737 price,
738 timestamp_ms,
739 is_filled: false,
740 },
741 );
742 }
743 Signal::Hold => {}
744 }
745 }
746 AppEvent::StrategyState { fast_sma, slow_sma } => {
747 self.fast_sma = fast_sma;
748 self.slow_sma = slow_sma;
749 }
750 AppEvent::OrderUpdate(ref update) => {
751 rebuild_projection = true;
752 match update {
753 OrderUpdate::Filled {
754 intent_id,
755 client_order_id,
756 side,
757 fills,
758 avg_price,
759 } => {
760 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
761 let source_tag = parse_source_tag_from_client_order_id(client_order_id)
762 .map(|s| s.to_ascii_lowercase());
763 if let Some(submit_ms) = self.network_pending_submit_ms_by_intent.remove(intent_id)
764 {
765 Self::push_latency_sample(
766 &mut self.network_fill_latencies_ms,
767 now_ms.saturating_sub(submit_ms),
768 );
769 } else if let Some(signal_ms) = source_tag
770 .as_deref()
771 .and_then(|tag| self.strategy_last_event_by_tag.get(tag))
772 .map(|e| e.timestamp_ms)
773 {
774 Self::push_latency_sample(
776 &mut self.network_fill_latencies_ms,
777 now_ms.saturating_sub(signal_ms),
778 );
779 }
780 self.network_last_fill_ms = Some(now_ms);
781 if let Some(source_tag) = source_tag {
782 self.strategy_last_event_by_tag.insert(
783 source_tag,
784 StrategyLastEvent {
785 side: *side,
786 price: Some(*avg_price),
787 timestamp_ms: now_ms,
788 is_filled: true,
789 },
790 );
791 }
792 if let Some(summary) = format_last_applied_fee(&self.symbol, fills) {
793 self.last_applied_fee = summary;
794 }
795 self.position.apply_fill(*side, fills);
796 self.refresh_equity_usdt();
797 let candle_index = if self.current_candle.is_some() {
798 self.candles.len()
799 } else {
800 self.candles.len().saturating_sub(1)
801 };
802 self.fill_markers.push(FillMarker {
803 candle_index,
804 price: *avg_price,
805 side: *side,
806 });
807 if self.fill_markers.len() > MAX_FILL_MARKERS {
808 self.fill_markers.remove(0);
809 }
810 let mut record = LogRecord::new(
811 LogLevel::Info,
812 LogDomain::Order,
813 "fill.received",
814 format!(
815 "side={} client_order_id={} intent_id={} avg_price={:.2}",
816 side, client_order_id, intent_id, avg_price
817 ),
818 );
819 record.symbol = Some(self.symbol.clone());
820 record.strategy_tag =
821 parse_source_tag_from_client_order_id(client_order_id).map(|s| s.to_ascii_lowercase());
822 self.push_log_record(record);
823 }
824 OrderUpdate::Submitted {
825 intent_id,
826 client_order_id,
827 server_order_id,
828 } => {
829 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
830 self.network_pending_submit_ms_by_intent
831 .insert(intent_id.clone(), now_ms);
832 self.refresh_equity_usdt();
833 let mut record = LogRecord::new(
834 LogLevel::Info,
835 LogDomain::Order,
836 "submit.accepted",
837 format!(
838 "client_order_id={} server_order_id={} intent_id={}",
839 client_order_id, server_order_id, intent_id
840 ),
841 );
842 record.symbol = Some(self.symbol.clone());
843 record.strategy_tag =
844 parse_source_tag_from_client_order_id(client_order_id).map(|s| s.to_ascii_lowercase());
845 self.push_log_record(record);
846 }
847 OrderUpdate::Rejected {
848 intent_id,
849 client_order_id,
850 reason_code,
851 reason,
852 } => {
853 let level = if reason_code == "risk.qty_too_small" {
854 LogLevel::Warn
855 } else {
856 LogLevel::Error
857 };
858 let mut record = LogRecord::new(
859 level,
860 LogDomain::Order,
861 "reject.received",
862 format!(
863 "client_order_id={} intent_id={} reason_code={} reason={}",
864 client_order_id, intent_id, reason_code, reason
865 ),
866 );
867 record.symbol = Some(self.symbol.clone());
868 record.strategy_tag =
869 parse_source_tag_from_client_order_id(client_order_id).map(|s| s.to_ascii_lowercase());
870 self.push_log_record(record);
871 }
872 }
873 self.last_order = Some(update.clone());
874 }
875 AppEvent::WsStatus(ref status) => match status {
876 WsConnectionStatus::Connected => {
877 self.ws_connected = true;
878 }
879 WsConnectionStatus::Disconnected => {
880 self.ws_connected = false;
881 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
882 Self::push_network_event_sample(&mut self.network_disconnect_timestamps_ms, now_ms);
883 self.push_log("[WARN] WebSocket Disconnected".to_string());
884 }
885 WsConnectionStatus::Reconnecting { attempt, delay_ms } => {
886 self.ws_connected = false;
887 self.network_reconnect_count += 1;
888 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
889 Self::push_network_event_sample(&mut self.network_reconnect_timestamps_ms, now_ms);
890 self.push_log(format!(
891 "[WARN] Reconnecting (attempt {}, wait {}ms)",
892 attempt, delay_ms
893 ));
894 }
895 },
896 AppEvent::HistoricalCandles {
897 candles,
898 interval_ms,
899 interval,
900 } => {
901 rebuild_projection = true;
902 self.candles = candles;
903 if self.candles.len() > self.price_history_len {
904 let excess = self.candles.len() - self.price_history_len;
905 self.candles.drain(..excess);
906 }
907 self.candle_interval_ms = interval_ms;
908 self.timeframe = interval;
909 self.current_candle = None;
910 let fills = self.history_fills.clone();
911 self.rebuild_fill_markers_from_history(&fills);
912 self.push_log(format!(
913 "Switched to {} ({} candles)",
914 self.timeframe,
915 self.candles.len()
916 ));
917 }
918 AppEvent::BalanceUpdate(balances) => {
919 rebuild_projection = true;
920 self.balances = balances;
921 self.refresh_equity_usdt();
922 }
923 AppEvent::OrderHistoryUpdate(snapshot) => {
924 rebuild_projection = true;
925 let mut open = Vec::new();
926 let mut filled = Vec::new();
927
928 for row in snapshot.rows {
929 let status = row.split_whitespace().nth(1).unwrap_or_default();
930 if status == "FILLED" {
931 filled.push(row);
932 } else {
933 open.push(row);
934 }
935 }
936
937 if open.len() > MAX_LOG_MESSAGES {
938 let excess = open.len() - MAX_LOG_MESSAGES;
939 open.drain(..excess);
940 }
941 if filled.len() > MAX_LOG_MESSAGES {
942 let excess = filled.len() - MAX_LOG_MESSAGES;
943 filled.drain(..excess);
944 }
945
946 self.open_order_history = open;
947 self.filled_order_history = filled;
948 if snapshot.trade_data_complete {
949 let stats_looks_reset = snapshot.stats.trade_count == 0
950 && (self.history_trade_count > 0 || !self.history_fills.is_empty());
951 if stats_looks_reset {
952 if !self.trade_stats_reset_warned {
953 self.push_log(
954 "[WARN] Ignored transient trade stats reset from order-history sync"
955 .to_string(),
956 );
957 self.trade_stats_reset_warned = true;
958 }
959 } else {
960 self.trade_stats_reset_warned = false;
961 self.history_trade_count = snapshot.stats.trade_count;
962 self.history_win_count = snapshot.stats.win_count;
963 self.history_lose_count = snapshot.stats.lose_count;
964 self.history_realized_pnl = snapshot.stats.realized_pnl;
965 if snapshot.open_qty > f64::EPSILON {
968 self.position.side = Some(OrderSide::Buy);
969 self.position.qty = snapshot.open_qty;
970 self.position.entry_price = snapshot.open_entry_price;
971 if let Some(px) = self.last_price() {
972 self.position.unrealized_pnl =
973 (px - snapshot.open_entry_price) * snapshot.open_qty;
974 }
975 } else {
976 self.position.side = None;
977 self.position.qty = 0.0;
978 self.position.entry_price = 0.0;
979 self.position.unrealized_pnl = 0.0;
980 }
981 }
982 if !snapshot.fills.is_empty() || self.history_fills.is_empty() {
983 self.history_fills = snapshot.fills.clone();
984 self.rebuild_fill_markers_from_history(&snapshot.fills);
985 }
986 self.history_estimated_total_pnl_usdt = snapshot.estimated_total_pnl_usdt;
987 self.recompute_initial_equity_from_history();
988 }
989 self.last_order_history_update_ms = Some(snapshot.fetched_at_ms);
990 self.last_order_history_event_ms = snapshot.latest_event_ms;
991 self.last_order_history_latency_ms = Some(snapshot.fetch_latency_ms);
992 Self::push_latency_sample(
993 &mut self.network_order_sync_latencies_ms,
994 snapshot.fetch_latency_ms,
995 );
996 self.refresh_history_rows();
997 }
998 AppEvent::StrategyStatsUpdate { strategy_stats } => {
999 rebuild_projection = true;
1000 self.strategy_stats = strategy_stats;
1001 }
1002 AppEvent::AssetPnlUpdate { by_symbol } => {
1003 rebuild_projection = true;
1004 self.asset_pnl_by_symbol = by_symbol;
1005 }
1006 AppEvent::RiskRateSnapshot {
1007 global,
1008 orders,
1009 account,
1010 market_data,
1011 } => {
1012 self.rate_budget_global = global;
1013 self.rate_budget_orders = orders;
1014 self.rate_budget_account = account;
1015 self.rate_budget_market_data = market_data;
1016 }
1017 AppEvent::TickDropped => {
1018 self.network_tick_drop_count = self.network_tick_drop_count.saturating_add(1);
1019 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1020 Self::push_network_event_sample(&mut self.network_tick_drop_timestamps_ms, now_ms);
1021 }
1022 AppEvent::LogRecord(record) => {
1023 self.push_log_record(record);
1024 }
1025 AppEvent::LogMessage(msg) => {
1026 self.push_log(msg);
1027 }
1028 AppEvent::Error(msg) => {
1029 self.push_log(format!("[ERR] {}", msg));
1030 }
1031 }
1032 self.prune_network_event_windows(chrono::Utc::now().timestamp_millis() as u64);
1033 self.sync_projection_portfolio_summary();
1034 if rebuild_projection {
1035 self.rebuild_projection_preserve_focus(prev_focus);
1036 } else {
1037 self.ensure_projection_focus_defaults();
1038 }
1039 }
1040}
1041
1042pub fn render(frame: &mut Frame, state: &AppState) {
1043 let view = state.view_state();
1044 if view.is_grid_open {
1045 render_grid_popup(frame, state);
1046 if view.is_strategy_editor_open {
1047 render_strategy_editor_popup(frame, state);
1048 }
1049 return;
1050 }
1051
1052 let outer = Layout::default()
1053 .direction(Direction::Vertical)
1054 .constraints([
1055 Constraint::Length(1), Constraint::Min(8), Constraint::Length(5), Constraint::Length(6), Constraint::Length(8), Constraint::Length(1), ])
1062 .split(frame.area());
1063
1064 frame.render_widget(
1066 StatusBar {
1067 symbol: &state.symbol,
1068 strategy_label: &state.strategy_label,
1069 ws_connected: state.ws_connected,
1070 paused: state.paused,
1071 timeframe: &state.timeframe,
1072 last_price_update_ms: state.last_price_update_ms,
1073 last_price_latency_ms: state.last_price_latency_ms,
1074 last_order_history_update_ms: state.last_order_history_update_ms,
1075 last_order_history_latency_ms: state.last_order_history_latency_ms,
1076 },
1077 outer[0],
1078 );
1079
1080 let main_area = Layout::default()
1082 .direction(Direction::Horizontal)
1083 .constraints([Constraint::Min(40), Constraint::Length(24)])
1084 .split(outer[1]);
1085 let selected_strategy_stats =
1086 strategy_stats_for_item(&state.strategy_stats, &state.strategy_label, &state.symbol)
1087 .cloned()
1088 .unwrap_or_default();
1089
1090 let current_price = state.last_price();
1092 frame.render_widget(
1093 PriceChart::new(&state.candles, &state.symbol)
1094 .current_candle(state.current_candle.as_ref())
1095 .fill_markers(&state.fill_markers)
1096 .fast_sma(state.fast_sma)
1097 .slow_sma(state.slow_sma),
1098 main_area[0],
1099 );
1100
1101 let right_panels = Layout::default()
1103 .direction(Direction::Vertical)
1104 .constraints([Constraint::Min(9), Constraint::Length(8)])
1105 .split(main_area[1]);
1106 frame.render_widget(
1107 PositionPanel::new(
1108 &state.position,
1109 current_price,
1110 &state.last_applied_fee,
1111 ),
1112 right_panels[0],
1113 );
1114 frame.render_widget(
1115 StrategyMetricsPanel::new(
1116 &state.strategy_label,
1117 selected_strategy_stats.trade_count,
1118 selected_strategy_stats.win_count,
1119 selected_strategy_stats.lose_count,
1120 selected_strategy_stats.realized_pnl,
1121 ),
1122 right_panels[1],
1123 );
1124
1125 frame.render_widget(
1127 OrderLogPanel::new(
1128 &state.last_signal,
1129 &state.last_order,
1130 state.fast_sma,
1131 state.slow_sma,
1132 selected_strategy_stats.trade_count,
1133 selected_strategy_stats.win_count,
1134 selected_strategy_stats.lose_count,
1135 selected_strategy_stats.realized_pnl,
1136 ),
1137 outer[2],
1138 );
1139
1140 frame.render_widget(
1142 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1143 outer[3],
1144 );
1145
1146 frame.render_widget(LogPanel::new(&state.log_messages), outer[4]);
1148
1149 frame.render_widget(KeybindBar, outer[5]);
1151
1152 if view.is_symbol_selector_open {
1153 render_selector_popup(
1154 frame,
1155 " Select Symbol ",
1156 &state.symbol_items,
1157 view.selected_symbol_selector_index,
1158 None,
1159 None,
1160 None,
1161 );
1162 } else if view.is_strategy_selector_open {
1163 let selected_strategy_symbol = state
1164 .strategy_item_symbols
1165 .get(view.selected_strategy_selector_index)
1166 .map(String::as_str)
1167 .unwrap_or(state.symbol.as_str());
1168 render_selector_popup(
1169 frame,
1170 " Select Strategy ",
1171 &state.strategy_items,
1172 view.selected_strategy_selector_index,
1173 Some(&state.strategy_stats),
1174 Some(OrderHistoryStats {
1175 trade_count: state.history_trade_count,
1176 win_count: state.history_win_count,
1177 lose_count: state.history_lose_count,
1178 realized_pnl: state.history_realized_pnl,
1179 }),
1180 Some(selected_strategy_symbol),
1181 );
1182 } else if view.is_account_popup_open {
1183 render_account_popup(frame, &state.balances);
1184 } else if view.is_history_popup_open {
1185 render_history_popup(frame, &state.history_rows, state.history_bucket);
1186 } else if view.is_focus_popup_open {
1187 render_focus_popup(frame, state);
1188 } else if view.is_strategy_editor_open {
1189 render_strategy_editor_popup(frame, state);
1190 }
1191}
1192
1193fn render_focus_popup(frame: &mut Frame, state: &AppState) {
1194 let area = frame.area();
1195 let popup = Rect {
1196 x: area.x + 1,
1197 y: area.y + 1,
1198 width: area.width.saturating_sub(2).max(70),
1199 height: area.height.saturating_sub(2).max(22),
1200 };
1201 frame.render_widget(Clear, popup);
1202 let block = Block::default()
1203 .title(" Focus View (Drill-down) ")
1204 .borders(Borders::ALL)
1205 .border_style(Style::default().fg(Color::Green));
1206 let inner = block.inner(popup);
1207 frame.render_widget(block, popup);
1208
1209 let rows = Layout::default()
1210 .direction(Direction::Vertical)
1211 .constraints([
1212 Constraint::Length(2),
1213 Constraint::Min(8),
1214 Constraint::Length(7),
1215 ])
1216 .split(inner);
1217
1218 let focus_symbol = state.focus_symbol().unwrap_or(&state.symbol);
1219 let focus_strategy = state.focus_strategy_id().unwrap_or(&state.strategy_label);
1220 let focus_strategy_stats = strategy_stats_for_item(
1221 &state.strategy_stats,
1222 focus_strategy,
1223 focus_symbol,
1224 )
1225 .cloned()
1226 .unwrap_or_default();
1227 frame.render_widget(
1228 Paragraph::new(vec![
1229 Line::from(vec![
1230 Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
1231 Span::styled(
1232 focus_symbol,
1233 Style::default()
1234 .fg(Color::Cyan)
1235 .add_modifier(Modifier::BOLD),
1236 ),
1237 Span::styled(" Strategy: ", Style::default().fg(Color::DarkGray)),
1238 Span::styled(
1239 focus_strategy,
1240 Style::default()
1241 .fg(Color::Magenta)
1242 .add_modifier(Modifier::BOLD),
1243 ),
1244 ]),
1245 Line::from(Span::styled(
1246 "Reuse legacy chart/position/history widgets. Press [F]/[Esc] to close.",
1247 Style::default().fg(Color::DarkGray),
1248 )),
1249 ]),
1250 rows[0],
1251 );
1252
1253 let main_cols = Layout::default()
1254 .direction(Direction::Horizontal)
1255 .constraints([Constraint::Min(48), Constraint::Length(28)])
1256 .split(rows[1]);
1257
1258 frame.render_widget(
1259 PriceChart::new(&state.candles, focus_symbol)
1260 .current_candle(state.current_candle.as_ref())
1261 .fill_markers(&state.fill_markers)
1262 .fast_sma(state.fast_sma)
1263 .slow_sma(state.slow_sma),
1264 main_cols[0],
1265 );
1266 let focus_right = Layout::default()
1267 .direction(Direction::Vertical)
1268 .constraints([Constraint::Min(8), Constraint::Length(8)])
1269 .split(main_cols[1]);
1270 frame.render_widget(
1271 PositionPanel::new(
1272 &state.position,
1273 state.last_price(),
1274 &state.last_applied_fee,
1275 ),
1276 focus_right[0],
1277 );
1278 frame.render_widget(
1279 StrategyMetricsPanel::new(
1280 focus_strategy,
1281 focus_strategy_stats.trade_count,
1282 focus_strategy_stats.win_count,
1283 focus_strategy_stats.lose_count,
1284 focus_strategy_stats.realized_pnl,
1285 ),
1286 focus_right[1],
1287 );
1288
1289 frame.render_widget(
1290 OrderHistoryPanel::new(&state.open_order_history, &state.filled_order_history),
1291 rows[2],
1292 );
1293}
1294
1295fn render_grid_popup(frame: &mut Frame, state: &AppState) {
1296 let view = state.view_state();
1297 let area = frame.area();
1298 let popup = area;
1299 frame.render_widget(Clear, popup);
1300 let block = Block::default()
1301 .title(" Portfolio Grid ")
1302 .borders(Borders::ALL)
1303 .border_style(Style::default().fg(Color::Cyan));
1304 let inner = block.inner(popup);
1305 frame.render_widget(block, popup);
1306
1307 let root = Layout::default()
1308 .direction(Direction::Vertical)
1309 .constraints([Constraint::Length(2), Constraint::Min(1)])
1310 .split(inner);
1311 let tab_area = root[0];
1312 let body_area = root[1];
1313
1314 let tab_span = |tab: GridTab, key: &str, label: &str| -> Span<'_> {
1315 let selected = view.selected_grid_tab == tab;
1316 Span::styled(
1317 format!("[{} {}]", key, label),
1318 if selected {
1319 Style::default()
1320 .fg(Color::Yellow)
1321 .add_modifier(Modifier::BOLD)
1322 } else {
1323 Style::default().fg(Color::DarkGray)
1324 },
1325 )
1326 };
1327 frame.render_widget(
1328 Paragraph::new(Line::from(vec![
1329 tab_span(GridTab::Assets, "1", "Assets"),
1330 Span::raw(" "),
1331 tab_span(GridTab::Strategies, "2", "Strategies"),
1332 Span::raw(" "),
1333 tab_span(GridTab::Risk, "3", "Risk"),
1334 Span::raw(" "),
1335 tab_span(GridTab::Network, "4", "Network"),
1336 Span::raw(" "),
1337 tab_span(GridTab::SystemLog, "5", "SystemLog"),
1338 ])),
1339 tab_area,
1340 );
1341
1342 let global_pressure =
1343 state.rate_budget_global.used as f64 / (state.rate_budget_global.limit.max(1) as f64);
1344 let orders_pressure =
1345 state.rate_budget_orders.used as f64 / (state.rate_budget_orders.limit.max(1) as f64);
1346 let account_pressure =
1347 state.rate_budget_account.used as f64 / (state.rate_budget_account.limit.max(1) as f64);
1348 let market_pressure = state.rate_budget_market_data.used as f64
1349 / (state.rate_budget_market_data.limit.max(1) as f64);
1350 let max_pressure = global_pressure
1351 .max(orders_pressure)
1352 .max(account_pressure)
1353 .max(market_pressure);
1354 let (risk_label, risk_color) = if max_pressure >= 0.90 {
1355 ("CRIT", Color::Red)
1356 } else if max_pressure >= 0.70 {
1357 ("WARN", Color::Yellow)
1358 } else {
1359 ("OK", Color::Green)
1360 };
1361
1362 if view.selected_grid_tab == GridTab::Assets {
1363 let chunks = Layout::default()
1364 .direction(Direction::Vertical)
1365 .constraints([Constraint::Min(3), Constraint::Length(1)])
1366 .split(body_area);
1367 let asset_header = Row::new(vec![
1368 Cell::from("Symbol"),
1369 Cell::from("Qty"),
1370 Cell::from("Price"),
1371 Cell::from("RlzPnL"),
1372 Cell::from("UnrPnL"),
1373 ])
1374 .style(Style::default().fg(Color::DarkGray));
1375 let mut asset_rows: Vec<Row> = state
1376 .assets_view()
1377 .iter()
1378 .map(|a| {
1379 let price = a
1380 .last_price
1381 .map(|v| format!("{:.2}", v))
1382 .unwrap_or_else(|| "---".to_string());
1383 Row::new(vec![
1384 Cell::from(a.symbol.clone()),
1385 Cell::from(format!("{:.5}", a.position_qty)),
1386 Cell::from(price),
1387 Cell::from(format!("{:+.4}", a.realized_pnl_usdt)),
1388 Cell::from(format!("{:+.4}", a.unrealized_pnl_usdt)),
1389 ])
1390 })
1391 .collect();
1392 if asset_rows.is_empty() {
1393 asset_rows.push(
1394 Row::new(vec![
1395 Cell::from("(no assets)"),
1396 Cell::from("-"),
1397 Cell::from("-"),
1398 Cell::from("-"),
1399 Cell::from("-"),
1400 ])
1401 .style(Style::default().fg(Color::DarkGray)),
1402 );
1403 }
1404 frame.render_widget(
1405 Table::new(
1406 asset_rows,
1407 [
1408 Constraint::Length(16),
1409 Constraint::Length(12),
1410 Constraint::Length(10),
1411 Constraint::Length(10),
1412 Constraint::Length(10),
1413 ],
1414 )
1415 .header(asset_header)
1416 .column_spacing(1)
1417 .block(
1418 Block::default()
1419 .title(format!(" Assets | Total {} ", state.assets_view().len()))
1420 .borders(Borders::ALL)
1421 .border_style(Style::default().fg(Color::DarkGray)),
1422 ),
1423 chunks[0],
1424 );
1425 frame.render_widget(Paragraph::new("[1/2/3] tab [G/Esc] close"), chunks[1]);
1426 return;
1427 }
1428
1429 if view.selected_grid_tab == GridTab::Risk {
1430 let chunks = Layout::default()
1431 .direction(Direction::Vertical)
1432 .constraints([
1433 Constraint::Length(2),
1434 Constraint::Length(4),
1435 Constraint::Min(3),
1436 Constraint::Length(1),
1437 ])
1438 .split(body_area);
1439 frame.render_widget(
1440 Paragraph::new(Line::from(vec![
1441 Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
1442 Span::styled(
1443 risk_label,
1444 Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1445 ),
1446 Span::styled(
1447 " (70%=WARN, 90%=CRIT)",
1448 Style::default().fg(Color::DarkGray),
1449 ),
1450 ])),
1451 chunks[0],
1452 );
1453 let risk_rows = vec![
1454 Row::new(vec![
1455 Cell::from("GLOBAL"),
1456 Cell::from(format!(
1457 "{}/{}",
1458 state.rate_budget_global.used, state.rate_budget_global.limit
1459 )),
1460 Cell::from(format!("{}ms", state.rate_budget_global.reset_in_ms)),
1461 ]),
1462 Row::new(vec![
1463 Cell::from("ORDERS"),
1464 Cell::from(format!(
1465 "{}/{}",
1466 state.rate_budget_orders.used, state.rate_budget_orders.limit
1467 )),
1468 Cell::from(format!("{}ms", state.rate_budget_orders.reset_in_ms)),
1469 ]),
1470 Row::new(vec![
1471 Cell::from("ACCOUNT"),
1472 Cell::from(format!(
1473 "{}/{}",
1474 state.rate_budget_account.used, state.rate_budget_account.limit
1475 )),
1476 Cell::from(format!("{}ms", state.rate_budget_account.reset_in_ms)),
1477 ]),
1478 Row::new(vec![
1479 Cell::from("MARKET"),
1480 Cell::from(format!(
1481 "{}/{}",
1482 state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1483 )),
1484 Cell::from(format!("{}ms", state.rate_budget_market_data.reset_in_ms)),
1485 ]),
1486 ];
1487 frame.render_widget(
1488 Table::new(
1489 risk_rows,
1490 [
1491 Constraint::Length(10),
1492 Constraint::Length(16),
1493 Constraint::Length(12),
1494 ],
1495 )
1496 .header(Row::new(vec![
1497 Cell::from("Group"),
1498 Cell::from("Used/Limit"),
1499 Cell::from("Reset In"),
1500 ]))
1501 .column_spacing(1)
1502 .block(
1503 Block::default()
1504 .title(" Risk Budgets ")
1505 .borders(Borders::ALL)
1506 .border_style(Style::default().fg(Color::DarkGray)),
1507 ),
1508 chunks[1],
1509 );
1510 let recent_rejections: Vec<&String> = state
1511 .log_messages
1512 .iter()
1513 .filter(|m| m.contains("order.reject.received"))
1514 .rev()
1515 .take(20)
1516 .collect();
1517 let mut lines = vec![Line::from(Span::styled(
1518 "Recent Rejections",
1519 Style::default()
1520 .fg(Color::Cyan)
1521 .add_modifier(Modifier::BOLD),
1522 ))];
1523 for msg in recent_rejections.into_iter().rev() {
1524 lines.push(Line::from(Span::styled(
1525 msg.as_str(),
1526 Style::default().fg(Color::Red),
1527 )));
1528 }
1529 if lines.len() == 1 {
1530 lines.push(Line::from(Span::styled(
1531 "(no rejections yet)",
1532 Style::default().fg(Color::DarkGray),
1533 )));
1534 }
1535 frame.render_widget(
1536 Paragraph::new(lines).block(
1537 Block::default()
1538 .borders(Borders::ALL)
1539 .border_style(Style::default().fg(Color::DarkGray)),
1540 ),
1541 chunks[2],
1542 );
1543 frame.render_widget(Paragraph::new("[1/2/3/4/5] tab [G/Esc] close"), chunks[3]);
1544 return;
1545 }
1546
1547 if view.selected_grid_tab == GridTab::Network {
1548 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1549 let tick_in_1s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 1_000);
1550 let tick_in_10s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 10_000);
1551 let tick_in_60s = count_since(&state.network_tick_in_timestamps_ms, now_ms, 60_000);
1552 let tick_drop_1s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 1_000);
1553 let tick_drop_10s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 10_000);
1554 let tick_drop_60s = count_since(&state.network_tick_drop_timestamps_ms, now_ms, 60_000);
1555 let reconnect_60s = count_since(&state.network_reconnect_timestamps_ms, now_ms, 60_000);
1556 let disconnect_60s = count_since(&state.network_disconnect_timestamps_ms, now_ms, 60_000);
1557
1558 let tick_in_rate_1s = rate_per_sec(tick_in_1s, 1.0);
1559 let tick_drop_rate_1s = rate_per_sec(tick_drop_1s, 1.0);
1560 let tick_drop_rate_10s = rate_per_sec(tick_drop_10s, 10.0);
1561 let tick_drop_rate_60s = rate_per_sec(tick_drop_60s, 60.0);
1562 let tick_drop_ratio_10s = ratio_pct(tick_drop_10s, tick_in_10s.saturating_add(tick_drop_10s));
1563 let tick_drop_ratio_60s = ratio_pct(tick_drop_60s, tick_in_60s.saturating_add(tick_drop_60s));
1564 let reconnect_rate_60s = reconnect_60s as f64;
1565 let disconnect_rate_60s = disconnect_60s as f64;
1566 let heartbeat_gap_ms = state.last_price_update_ms.map(|ts| now_ms.saturating_sub(ts));
1567 let tick_p95_ms = percentile(&state.network_tick_latencies_ms, 95);
1568 let health = classify_health(
1569 state.ws_connected,
1570 tick_drop_ratio_10s,
1571 reconnect_rate_60s,
1572 tick_p95_ms,
1573 heartbeat_gap_ms,
1574 );
1575 let (health_label, health_color) = match health {
1576 NetworkHealth::Ok => ("OK", Color::Green),
1577 NetworkHealth::Warn => ("WARN", Color::Yellow),
1578 NetworkHealth::Crit => ("CRIT", Color::Red),
1579 };
1580
1581 let chunks = Layout::default()
1582 .direction(Direction::Vertical)
1583 .constraints([
1584 Constraint::Length(2),
1585 Constraint::Min(6),
1586 Constraint::Length(6),
1587 Constraint::Length(1),
1588 ])
1589 .split(body_area);
1590 frame.render_widget(
1591 Paragraph::new(Line::from(vec![
1592 Span::styled("Health: ", Style::default().fg(Color::DarkGray)),
1593 Span::styled(
1594 health_label,
1595 Style::default()
1596 .fg(health_color)
1597 .add_modifier(Modifier::BOLD),
1598 ),
1599 Span::styled(" WS: ", Style::default().fg(Color::DarkGray)),
1600 Span::styled(
1601 if state.ws_connected {
1602 "CONNECTED"
1603 } else {
1604 "DISCONNECTED"
1605 },
1606 Style::default().fg(if state.ws_connected {
1607 Color::Green
1608 } else {
1609 Color::Red
1610 }),
1611 ),
1612 Span::styled(
1613 format!(
1614 " in1s={:.1}/s drop10s={:.2}/s ratio10s={:.2}% reconn60s={:.0}/min",
1615 tick_in_rate_1s, tick_drop_rate_10s, tick_drop_ratio_10s, reconnect_rate_60s
1616 ),
1617 Style::default().fg(Color::DarkGray),
1618 ),
1619 ])),
1620 chunks[0],
1621 );
1622
1623 let tick_stats = latency_stats(&state.network_tick_latencies_ms);
1624 let fill_stats = latency_stats(&state.network_fill_latencies_ms);
1625 let sync_stats = latency_stats(&state.network_order_sync_latencies_ms);
1626 let last_fill_age = state
1627 .network_last_fill_ms
1628 .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
1629 .unwrap_or_else(|| "-".to_string());
1630 let rows = vec![
1631 Row::new(vec![
1632 Cell::from("Tick Latency"),
1633 Cell::from(tick_stats.0),
1634 Cell::from(tick_stats.1),
1635 Cell::from(tick_stats.2),
1636 Cell::from(
1637 state
1638 .last_price_latency_ms
1639 .map(|v| format!("{}ms", v))
1640 .unwrap_or_else(|| "-".to_string()),
1641 ),
1642 ]),
1643 Row::new(vec![
1644 Cell::from("Fill Latency"),
1645 Cell::from(fill_stats.0),
1646 Cell::from(fill_stats.1),
1647 Cell::from(fill_stats.2),
1648 Cell::from(last_fill_age),
1649 ]),
1650 Row::new(vec![
1651 Cell::from("Order Sync"),
1652 Cell::from(sync_stats.0),
1653 Cell::from(sync_stats.1),
1654 Cell::from(sync_stats.2),
1655 Cell::from(
1656 state
1657 .last_order_history_latency_ms
1658 .map(|v| format!("{}ms", v))
1659 .unwrap_or_else(|| "-".to_string()),
1660 ),
1661 ]),
1662 ];
1663 frame.render_widget(
1664 Table::new(
1665 rows,
1666 [
1667 Constraint::Length(14),
1668 Constraint::Length(12),
1669 Constraint::Length(12),
1670 Constraint::Length(12),
1671 Constraint::Length(14),
1672 ],
1673 )
1674 .header(Row::new(vec![
1675 Cell::from("Metric"),
1676 Cell::from("p50"),
1677 Cell::from("p95"),
1678 Cell::from("p99"),
1679 Cell::from("last/age"),
1680 ]))
1681 .column_spacing(1)
1682 .block(
1683 Block::default()
1684 .title(" Network Metrics ")
1685 .borders(Borders::ALL)
1686 .border_style(Style::default().fg(Color::DarkGray)),
1687 ),
1688 chunks[1],
1689 );
1690
1691 let summary_rows = vec![
1692 Row::new(vec![
1693 Cell::from("tick_drop_rate_1s"),
1694 Cell::from(format!("{:.2}/s", tick_drop_rate_1s)),
1695 Cell::from("tick_drop_rate_60s"),
1696 Cell::from(format!("{:.2}/s", tick_drop_rate_60s)),
1697 ]),
1698 Row::new(vec![
1699 Cell::from("drop_ratio_60s"),
1700 Cell::from(format!("{:.2}%", tick_drop_ratio_60s)),
1701 Cell::from("disconnect_rate_60s"),
1702 Cell::from(format!("{:.0}/min", disconnect_rate_60s)),
1703 ]),
1704 Row::new(vec![
1705 Cell::from("last_tick_age"),
1706 Cell::from(
1707 heartbeat_gap_ms
1708 .map(format_age_ms)
1709 .unwrap_or_else(|| "-".to_string()),
1710 ),
1711 Cell::from("last_order_update_age"),
1712 Cell::from(
1713 state
1714 .last_order_history_update_ms
1715 .map(|ts| format_age_ms(now_ms.saturating_sub(ts)))
1716 .unwrap_or_else(|| "-".to_string()),
1717 ),
1718 ]),
1719 Row::new(vec![
1720 Cell::from("tick_drop_total"),
1721 Cell::from(state.network_tick_drop_count.to_string()),
1722 Cell::from("reconnect_total"),
1723 Cell::from(state.network_reconnect_count.to_string()),
1724 ]),
1725 ];
1726 frame.render_widget(
1727 Table::new(
1728 summary_rows,
1729 [
1730 Constraint::Length(20),
1731 Constraint::Length(18),
1732 Constraint::Length(20),
1733 Constraint::Length(18),
1734 ],
1735 )
1736 .column_spacing(1)
1737 .block(
1738 Block::default()
1739 .title(" Network Summary ")
1740 .borders(Borders::ALL)
1741 .border_style(Style::default().fg(Color::DarkGray)),
1742 ),
1743 chunks[2],
1744 );
1745 frame.render_widget(Paragraph::new("[1/2/3/4/5] tab [G/Esc] close"), chunks[3]);
1746 return;
1747 }
1748
1749 if view.selected_grid_tab == GridTab::SystemLog {
1750 let chunks = Layout::default()
1751 .direction(Direction::Vertical)
1752 .constraints([Constraint::Min(6), Constraint::Length(1)])
1753 .split(body_area);
1754 let max_rows = chunks[0].height.saturating_sub(2) as usize;
1755 let mut log_rows: Vec<Row> = state
1756 .log_messages
1757 .iter()
1758 .rev()
1759 .take(max_rows.max(1))
1760 .rev()
1761 .map(|line| Row::new(vec![Cell::from(line.clone())]))
1762 .collect();
1763 if log_rows.is_empty() {
1764 log_rows.push(
1765 Row::new(vec![Cell::from("(no system logs yet)")])
1766 .style(Style::default().fg(Color::DarkGray)),
1767 );
1768 }
1769 frame.render_widget(
1770 Table::new(log_rows, [Constraint::Min(1)])
1771 .header(Row::new(vec![Cell::from("Message")]).style(Style::default().fg(Color::DarkGray)))
1772 .column_spacing(1)
1773 .block(
1774 Block::default()
1775 .title(" System Log ")
1776 .borders(Borders::ALL)
1777 .border_style(Style::default().fg(Color::DarkGray)),
1778 ),
1779 chunks[0],
1780 );
1781 frame.render_widget(Paragraph::new("[1/2/3/4/5] tab [G/Esc] close"), chunks[1]);
1782 return;
1783 }
1784
1785 let selected_symbol = state
1786 .symbol_items
1787 .get(view.selected_symbol_index)
1788 .map(String::as_str)
1789 .unwrap_or(state.symbol.as_str());
1790 let strategy_chunks = Layout::default()
1791 .direction(Direction::Vertical)
1792 .constraints([
1793 Constraint::Length(2),
1794 Constraint::Length(3),
1795 Constraint::Min(12),
1796 Constraint::Length(1),
1797 ])
1798 .split(body_area);
1799
1800 let mut on_indices: Vec<usize> = Vec::new();
1801 let mut off_indices: Vec<usize> = Vec::new();
1802 for idx in 0..state.strategy_items.len() {
1803 if state
1804 .strategy_item_active
1805 .get(idx)
1806 .copied()
1807 .unwrap_or(false)
1808 {
1809 on_indices.push(idx);
1810 } else {
1811 off_indices.push(idx);
1812 }
1813 }
1814 let on_weight = on_indices.len().max(1) as u32;
1815 let off_weight = off_indices.len().max(1) as u32;
1816
1817 frame.render_widget(
1818 Paragraph::new(Line::from(vec![
1819 Span::styled("Risk: ", Style::default().fg(Color::DarkGray)),
1820 Span::styled(
1821 risk_label,
1822 Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
1823 ),
1824 Span::styled(" GLOBAL ", Style::default().fg(Color::DarkGray)),
1825 Span::styled(
1826 format!(
1827 "{}/{}",
1828 state.rate_budget_global.used, state.rate_budget_global.limit
1829 ),
1830 Style::default().fg(if global_pressure >= 0.9 {
1831 Color::Red
1832 } else if global_pressure >= 0.7 {
1833 Color::Yellow
1834 } else {
1835 Color::Cyan
1836 }),
1837 ),
1838 Span::styled(" ORD ", Style::default().fg(Color::DarkGray)),
1839 Span::styled(
1840 format!(
1841 "{}/{}",
1842 state.rate_budget_orders.used, state.rate_budget_orders.limit
1843 ),
1844 Style::default().fg(if orders_pressure >= 0.9 {
1845 Color::Red
1846 } else if orders_pressure >= 0.7 {
1847 Color::Yellow
1848 } else {
1849 Color::Cyan
1850 }),
1851 ),
1852 Span::styled(" ACC ", Style::default().fg(Color::DarkGray)),
1853 Span::styled(
1854 format!(
1855 "{}/{}",
1856 state.rate_budget_account.used, state.rate_budget_account.limit
1857 ),
1858 Style::default().fg(if account_pressure >= 0.9 {
1859 Color::Red
1860 } else if account_pressure >= 0.7 {
1861 Color::Yellow
1862 } else {
1863 Color::Cyan
1864 }),
1865 ),
1866 Span::styled(" MKT ", Style::default().fg(Color::DarkGray)),
1867 Span::styled(
1868 format!(
1869 "{}/{}",
1870 state.rate_budget_market_data.used, state.rate_budget_market_data.limit
1871 ),
1872 Style::default().fg(if market_pressure >= 0.9 {
1873 Color::Red
1874 } else if market_pressure >= 0.7 {
1875 Color::Yellow
1876 } else {
1877 Color::Cyan
1878 }),
1879 ),
1880 ])),
1881 strategy_chunks[0],
1882 );
1883
1884 let strategy_area = strategy_chunks[2];
1885 let min_panel_height: u16 = 6;
1886 let total_height = strategy_area.height;
1887 let (on_height, off_height) = if total_height >= min_panel_height.saturating_mul(2) {
1888 let total_weight = on_weight + off_weight;
1889 let mut on_h =
1890 ((total_height as u32 * on_weight) / total_weight).max(min_panel_height as u32) as u16;
1891 let max_on_h = total_height.saturating_sub(min_panel_height);
1892 if on_h > max_on_h {
1893 on_h = max_on_h;
1894 }
1895 let off_h = total_height.saturating_sub(on_h);
1896 (on_h, off_h)
1897 } else {
1898 let on_h = (total_height / 2).max(1);
1899 let off_h = total_height.saturating_sub(on_h).max(1);
1900 (on_h, off_h)
1901 };
1902 let on_area = Rect {
1903 x: strategy_area.x,
1904 y: strategy_area.y,
1905 width: strategy_area.width,
1906 height: on_height,
1907 };
1908 let off_area = Rect {
1909 x: strategy_area.x,
1910 y: strategy_area.y.saturating_add(on_height),
1911 width: strategy_area.width,
1912 height: off_height,
1913 };
1914
1915 let pnl_sum_for_indices = |indices: &[usize], state: &AppState| -> f64 {
1916 indices
1917 .iter()
1918 .map(|idx| {
1919 let item = state
1920 .strategy_items
1921 .get(*idx)
1922 .map(String::as_str)
1923 .unwrap_or("-");
1924 let row_symbol = state
1925 .strategy_item_symbols
1926 .get(*idx)
1927 .map(String::as_str)
1928 .unwrap_or(state.symbol.as_str());
1929 strategy_stats_for_item(&state.strategy_stats, item, row_symbol)
1930 .map(|s| s.realized_pnl)
1931 .unwrap_or(0.0)
1932 })
1933 .sum()
1934 };
1935 let on_pnl_sum = pnl_sum_for_indices(&on_indices, state);
1936 let off_pnl_sum = pnl_sum_for_indices(&off_indices, state);
1937 let total_pnl_sum = on_pnl_sum + off_pnl_sum;
1938
1939 let total_row = Row::new(vec![
1940 Cell::from("ON Total"),
1941 Cell::from(on_indices.len().to_string()),
1942 Cell::from(format!("{:+.4}", on_pnl_sum)),
1943 Cell::from("OFF Total"),
1944 Cell::from(off_indices.len().to_string()),
1945 Cell::from(format!("{:+.4}", off_pnl_sum)),
1946 Cell::from("All Total"),
1947 Cell::from(format!("{:+.4}", total_pnl_sum)),
1948 ]);
1949 let total_table = Table::new(
1950 vec![total_row],
1951 [
1952 Constraint::Length(10),
1953 Constraint::Length(5),
1954 Constraint::Length(12),
1955 Constraint::Length(10),
1956 Constraint::Length(5),
1957 Constraint::Length(12),
1958 Constraint::Length(10),
1959 Constraint::Length(12),
1960 ],
1961 )
1962 .column_spacing(1)
1963 .block(
1964 Block::default()
1965 .title(" Total ")
1966 .borders(Borders::ALL)
1967 .border_style(Style::default().fg(Color::DarkGray)),
1968 );
1969 frame.render_widget(total_table, strategy_chunks[1]);
1970
1971 let render_strategy_window = |frame: &mut Frame,
1972 area: Rect,
1973 title: &str,
1974 indices: &[usize],
1975 state: &AppState,
1976 pnl_sum: f64,
1977 selected_panel: bool| {
1978 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
1979 let inner_height = area.height.saturating_sub(2);
1980 let row_capacity = inner_height.saturating_sub(1) as usize;
1981 let selected_pos = indices
1982 .iter()
1983 .position(|idx| *idx == view.selected_strategy_index);
1984 let window_start = if row_capacity == 0 {
1985 0
1986 } else if let Some(pos) = selected_pos {
1987 pos.saturating_sub(row_capacity.saturating_sub(1))
1988 } else {
1989 0
1990 };
1991 let window_end = if row_capacity == 0 {
1992 0
1993 } else {
1994 (window_start + row_capacity).min(indices.len())
1995 };
1996 let visible_indices = if indices.is_empty() || row_capacity == 0 {
1997 &indices[0..0]
1998 } else {
1999 &indices[window_start..window_end]
2000 };
2001 let header = Row::new(vec![
2002 Cell::from(" "),
2003 Cell::from("Symbol"),
2004 Cell::from("Strategy"),
2005 Cell::from("Run"),
2006 Cell::from("Last"),
2007 Cell::from("Px"),
2008 Cell::from("Age"),
2009 Cell::from("W"),
2010 Cell::from("L"),
2011 Cell::from("T"),
2012 Cell::from("PnL"),
2013 ])
2014 .style(Style::default().fg(Color::DarkGray));
2015 let mut rows: Vec<Row> = visible_indices
2016 .iter()
2017 .map(|idx| {
2018 let row_symbol = state
2019 .strategy_item_symbols
2020 .get(*idx)
2021 .map(String::as_str)
2022 .unwrap_or("-");
2023 let item = state
2024 .strategy_items
2025 .get(*idx)
2026 .cloned()
2027 .unwrap_or_else(|| "-".to_string());
2028 let running = state
2029 .strategy_item_total_running_ms
2030 .get(*idx)
2031 .copied()
2032 .map(format_running_time)
2033 .unwrap_or_else(|| "-".to_string());
2034 let stats = strategy_stats_for_item(&state.strategy_stats, &item, row_symbol);
2035 let source_tag = source_tag_for_strategy_item(&item);
2036 let last_evt = source_tag
2037 .as_ref()
2038 .and_then(|tag| state.strategy_last_event_by_tag.get(tag));
2039 let (last_label, last_px, last_age, last_style) = if let Some(evt) = last_evt {
2040 let age = now_ms.saturating_sub(evt.timestamp_ms);
2041 let age_txt = if age < 1_000 {
2042 format!("{}ms", age)
2043 } else if age < 60_000 {
2044 format!("{}s", age / 1_000)
2045 } else {
2046 format!("{}m", age / 60_000)
2047 };
2048 let side_txt = match evt.side {
2049 OrderSide::Buy => "BUY",
2050 OrderSide::Sell => "SELL",
2051 };
2052 let px_txt = evt
2053 .price
2054 .map(|v| format!("{:.2}", v))
2055 .unwrap_or_else(|| "-".to_string());
2056 let style = match evt.side {
2057 OrderSide::Buy => Style::default()
2058 .fg(Color::Green)
2059 .add_modifier(Modifier::BOLD),
2060 OrderSide::Sell => {
2061 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
2062 }
2063 };
2064 (side_txt.to_string(), px_txt, age_txt, style)
2065 } else {
2066 (
2067 "-".to_string(),
2068 "-".to_string(),
2069 "-".to_string(),
2070 Style::default().fg(Color::DarkGray),
2071 )
2072 };
2073 let (w, l, t, pnl) = if let Some(s) = stats {
2074 (
2075 s.win_count.to_string(),
2076 s.lose_count.to_string(),
2077 s.trade_count.to_string(),
2078 format!("{:+.4}", s.realized_pnl),
2079 )
2080 } else {
2081 (
2082 "0".to_string(),
2083 "0".to_string(),
2084 "0".to_string(),
2085 "+0.0000".to_string(),
2086 )
2087 };
2088 let marker = if *idx == view.selected_strategy_index {
2089 "▶"
2090 } else {
2091 " "
2092 };
2093 let mut row = Row::new(vec![
2094 Cell::from(marker),
2095 Cell::from(row_symbol.to_string()),
2096 Cell::from(item),
2097 Cell::from(running),
2098 Cell::from(last_label).style(last_style),
2099 Cell::from(last_px),
2100 Cell::from(last_age),
2101 Cell::from(w),
2102 Cell::from(l),
2103 Cell::from(t),
2104 Cell::from(pnl),
2105 ]);
2106 if *idx == view.selected_strategy_index {
2107 row = row.style(
2108 Style::default()
2109 .fg(Color::Yellow)
2110 .add_modifier(Modifier::BOLD),
2111 );
2112 }
2113 row
2114 })
2115 .collect();
2116
2117 if rows.is_empty() {
2118 rows.push(
2119 Row::new(vec![
2120 Cell::from(" "),
2121 Cell::from("-"),
2122 Cell::from("(empty)"),
2123 Cell::from("-"),
2124 Cell::from("-"),
2125 Cell::from("-"),
2126 Cell::from("-"),
2127 Cell::from("-"),
2128 Cell::from("-"),
2129 Cell::from("-"),
2130 Cell::from("-"),
2131 ])
2132 .style(Style::default().fg(Color::DarkGray)),
2133 );
2134 }
2135
2136 let table = Table::new(
2137 rows,
2138 [
2139 Constraint::Length(2),
2140 Constraint::Length(12),
2141 Constraint::Min(14),
2142 Constraint::Length(9),
2143 Constraint::Length(5),
2144 Constraint::Length(9),
2145 Constraint::Length(6),
2146 Constraint::Length(3),
2147 Constraint::Length(3),
2148 Constraint::Length(4),
2149 Constraint::Length(11),
2150 ],
2151 )
2152 .header(header)
2153 .column_spacing(1)
2154 .block(
2155 Block::default()
2156 .title(format!(
2157 "{} | Total {:+.4} | {}/{}",
2158 title,
2159 pnl_sum,
2160 visible_indices.len(),
2161 indices.len()
2162 ))
2163 .borders(Borders::ALL)
2164 .border_style(if selected_panel {
2165 Style::default().fg(Color::Yellow)
2166 } else if risk_label == "CRIT" {
2167 Style::default().fg(Color::Red)
2168 } else if risk_label == "WARN" {
2169 Style::default().fg(Color::Yellow)
2170 } else {
2171 Style::default().fg(Color::DarkGray)
2172 }),
2173 );
2174 frame.render_widget(table, area);
2175 };
2176
2177 render_strategy_window(
2178 frame,
2179 on_area,
2180 " ON Strategies ",
2181 &on_indices,
2182 state,
2183 on_pnl_sum,
2184 view.is_on_panel_selected,
2185 );
2186 render_strategy_window(
2187 frame,
2188 off_area,
2189 " OFF Strategies ",
2190 &off_indices,
2191 state,
2192 off_pnl_sum,
2193 !view.is_on_panel_selected,
2194 );
2195 frame.render_widget(
2196 Paragraph::new(Line::from(vec![
2197 Span::styled("Symbol: ", Style::default().fg(Color::DarkGray)),
2198 Span::styled(
2199 selected_symbol,
2200 Style::default()
2201 .fg(Color::Green)
2202 .add_modifier(Modifier::BOLD),
2203 ),
2204 Span::styled(
2205 " [1/2/3/4]tab [Tab]panel [N]new [C]cfg [O]on/off [X]del [J/K]strategy [H/L]symbol [Enter/F]run [G/Esc]close",
2206 Style::default().fg(Color::DarkGray),
2207 ),
2208 ])),
2209 strategy_chunks[3],
2210 );
2211}
2212
2213fn format_running_time(total_running_ms: u64) -> String {
2214 let total_sec = total_running_ms / 1000;
2215 let days = total_sec / 86_400;
2216 let hours = (total_sec % 86_400) / 3_600;
2217 let minutes = (total_sec % 3_600) / 60;
2218 if days > 0 {
2219 format!("{}d {:02}h", days, hours)
2220 } else {
2221 format!("{:02}h {:02}m", hours, minutes)
2222 }
2223}
2224
2225fn format_age_ms(age_ms: u64) -> String {
2226 if age_ms < 1_000 {
2227 format!("{}ms", age_ms)
2228 } else if age_ms < 60_000 {
2229 format!("{}s", age_ms / 1_000)
2230 } else {
2231 format!("{}m", age_ms / 60_000)
2232 }
2233}
2234
2235fn latency_stats(samples: &[u64]) -> (String, String, String) {
2236 let p50 = percentile(samples, 50);
2237 let p95 = percentile(samples, 95);
2238 let p99 = percentile(samples, 99);
2239 (
2240 p50.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2241 p95.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2242 p99.map(|v| format!("{}ms", v)).unwrap_or_else(|| "-".to_string()),
2243 )
2244}
2245
2246fn render_strategy_editor_popup(frame: &mut Frame, state: &AppState) {
2247 let area = frame.area();
2248 let popup = Rect {
2249 x: area.x + 8,
2250 y: area.y + 4,
2251 width: area.width.saturating_sub(16).max(50),
2252 height: area.height.saturating_sub(8).max(12),
2253 };
2254 frame.render_widget(Clear, popup);
2255 let block = Block::default()
2256 .title(" Strategy Config ")
2257 .borders(Borders::ALL)
2258 .border_style(Style::default().fg(Color::Yellow));
2259 let inner = block.inner(popup);
2260 frame.render_widget(block, popup);
2261 let selected_name = state
2262 .strategy_items
2263 .get(state.strategy_editor_index)
2264 .map(String::as_str)
2265 .unwrap_or("Unknown");
2266 let strategy_kind = state
2267 .strategy_editor_kind_items
2268 .get(state.strategy_editor_kind_index)
2269 .map(String::as_str)
2270 .unwrap_or("MA");
2271 let is_rsa = strategy_kind.eq_ignore_ascii_case("RSA");
2272 let is_atr = strategy_kind.eq_ignore_ascii_case("ATR");
2273 let is_chb = strategy_kind.eq_ignore_ascii_case("CHB");
2274 let period_1_label = if is_rsa {
2275 "RSI Period"
2276 } else if is_atr {
2277 "ATR Period"
2278 } else if is_chb {
2279 "Entry Window"
2280 } else {
2281 "Fast Period"
2282 };
2283 let period_2_label = if is_rsa {
2284 "Upper RSI"
2285 } else if is_atr {
2286 "Threshold x100"
2287 } else if is_chb {
2288 "Exit Window"
2289 } else {
2290 "Slow Period"
2291 };
2292 let rows = [
2293 ("Strategy", strategy_kind.to_string()),
2294 (
2295 "Symbol",
2296 state
2297 .symbol_items
2298 .get(state.strategy_editor_symbol_index)
2299 .cloned()
2300 .unwrap_or_else(|| state.symbol.clone()),
2301 ),
2302 (period_1_label, state.strategy_editor_fast.to_string()),
2303 (period_2_label, state.strategy_editor_slow.to_string()),
2304 ("Cooldown Tick", state.strategy_editor_cooldown.to_string()),
2305 ];
2306 let mut lines = vec![
2307 Line::from(vec![
2308 Span::styled("Target: ", Style::default().fg(Color::DarkGray)),
2309 Span::styled(
2310 selected_name,
2311 Style::default()
2312 .fg(Color::White)
2313 .add_modifier(Modifier::BOLD),
2314 ),
2315 ]),
2316 Line::from(Span::styled(
2317 "Use [J/K] field, [H/L] value, [Enter] save, [Esc] cancel",
2318 Style::default().fg(Color::DarkGray),
2319 )),
2320 ];
2321 if is_rsa {
2322 let lower = 100usize.saturating_sub(state.strategy_editor_slow.clamp(51, 95));
2323 lines.push(Line::from(Span::styled(
2324 format!("RSA lower threshold auto-derived: {}", lower),
2325 Style::default().fg(Color::DarkGray),
2326 )));
2327 } else if is_atr {
2328 let threshold_x100 = state.strategy_editor_slow.clamp(110, 500);
2329 lines.push(Line::from(Span::styled(
2330 format!("ATR expansion threshold: {:.2}x", threshold_x100 as f64 / 100.0),
2331 Style::default().fg(Color::DarkGray),
2332 )));
2333 } else if is_chb {
2334 lines.push(Line::from(Span::styled(
2335 "CHB breakout: buy on entry high break, sell on exit low break",
2336 Style::default().fg(Color::DarkGray),
2337 )));
2338 }
2339 for (idx, (name, value)) in rows.iter().enumerate() {
2340 let marker = if idx == state.strategy_editor_field {
2341 "▶ "
2342 } else {
2343 " "
2344 };
2345 let style = if idx == state.strategy_editor_field {
2346 Style::default()
2347 .fg(Color::Yellow)
2348 .add_modifier(Modifier::BOLD)
2349 } else {
2350 Style::default().fg(Color::White)
2351 };
2352 lines.push(Line::from(vec![
2353 Span::styled(marker, Style::default().fg(Color::Yellow)),
2354 Span::styled(format!("{:<14}", name), style),
2355 Span::styled(value, style),
2356 ]));
2357 }
2358 frame.render_widget(Paragraph::new(lines), inner);
2359 if state.strategy_editor_kind_category_selector_open {
2360 render_selector_popup(
2361 frame,
2362 " Select Strategy Category ",
2363 &state.strategy_editor_kind_category_items,
2364 state
2365 .strategy_editor_kind_category_index
2366 .min(state.strategy_editor_kind_category_items.len().saturating_sub(1)),
2367 None,
2368 None,
2369 None,
2370 );
2371 } else if state.strategy_editor_kind_selector_open {
2372 render_selector_popup(
2373 frame,
2374 " Select Strategy Type ",
2375 &state.strategy_editor_kind_popup_items,
2376 state
2377 .strategy_editor_kind_selector_index
2378 .min(state.strategy_editor_kind_popup_items.len().saturating_sub(1)),
2379 None,
2380 None,
2381 None,
2382 );
2383 }
2384}
2385
2386fn render_account_popup(frame: &mut Frame, balances: &HashMap<String, f64>) {
2387 let area = frame.area();
2388 let popup = Rect {
2389 x: area.x + 4,
2390 y: area.y + 2,
2391 width: area.width.saturating_sub(8).max(30),
2392 height: area.height.saturating_sub(4).max(10),
2393 };
2394 frame.render_widget(Clear, popup);
2395 let block = Block::default()
2396 .title(" Account Assets ")
2397 .borders(Borders::ALL)
2398 .border_style(Style::default().fg(Color::Cyan));
2399 let inner = block.inner(popup);
2400 frame.render_widget(block, popup);
2401
2402 let mut assets: Vec<(&String, &f64)> = balances.iter().collect();
2403 assets.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
2404
2405 let mut lines = Vec::with_capacity(assets.len() + 2);
2406 lines.push(Line::from(vec![
2407 Span::styled(
2408 "Asset",
2409 Style::default()
2410 .fg(Color::Cyan)
2411 .add_modifier(Modifier::BOLD),
2412 ),
2413 Span::styled(
2414 " Free",
2415 Style::default()
2416 .fg(Color::Cyan)
2417 .add_modifier(Modifier::BOLD),
2418 ),
2419 ]));
2420 for (asset, qty) in assets {
2421 lines.push(Line::from(vec![
2422 Span::styled(format!("{:<8}", asset), Style::default().fg(Color::White)),
2423 Span::styled(format!("{:>14.8}", qty), Style::default().fg(Color::Yellow)),
2424 ]));
2425 }
2426 if lines.len() == 1 {
2427 lines.push(Line::from(Span::styled(
2428 "No assets",
2429 Style::default().fg(Color::DarkGray),
2430 )));
2431 }
2432
2433 frame.render_widget(Paragraph::new(lines), inner);
2434}
2435
2436fn render_history_popup(frame: &mut Frame, rows: &[String], bucket: order_store::HistoryBucket) {
2437 let area = frame.area();
2438 let popup = Rect {
2439 x: area.x + 2,
2440 y: area.y + 1,
2441 width: area.width.saturating_sub(4).max(40),
2442 height: area.height.saturating_sub(2).max(12),
2443 };
2444 frame.render_widget(Clear, popup);
2445 let block = Block::default()
2446 .title(match bucket {
2447 order_store::HistoryBucket::Day => " History (Day ROI) ",
2448 order_store::HistoryBucket::Hour => " History (Hour ROI) ",
2449 order_store::HistoryBucket::Month => " History (Month ROI) ",
2450 })
2451 .borders(Borders::ALL)
2452 .border_style(Style::default().fg(Color::Cyan));
2453 let inner = block.inner(popup);
2454 frame.render_widget(block, popup);
2455
2456 let max_rows = inner.height.saturating_sub(1) as usize;
2457 let mut visible: Vec<Line> = Vec::new();
2458 for (idx, row) in rows.iter().take(max_rows).enumerate() {
2459 let color = if idx == 0 {
2460 Color::Cyan
2461 } else if row.contains('-') && row.contains('%') {
2462 Color::White
2463 } else {
2464 Color::DarkGray
2465 };
2466 visible.push(Line::from(Span::styled(
2467 row.clone(),
2468 Style::default().fg(color),
2469 )));
2470 }
2471 if visible.is_empty() {
2472 visible.push(Line::from(Span::styled(
2473 "No history rows",
2474 Style::default().fg(Color::DarkGray),
2475 )));
2476 }
2477 frame.render_widget(Paragraph::new(visible), inner);
2478}
2479
2480fn render_selector_popup(
2481 frame: &mut Frame,
2482 title: &str,
2483 items: &[String],
2484 selected: usize,
2485 stats: Option<&HashMap<String, OrderHistoryStats>>,
2486 total_stats: Option<OrderHistoryStats>,
2487 selected_symbol: Option<&str>,
2488) {
2489 let area = frame.area();
2490 let available_width = area.width.saturating_sub(2).max(1);
2491 let width = if stats.is_some() {
2492 let min_width = 44;
2493 let preferred = 84;
2494 preferred
2495 .min(available_width)
2496 .max(min_width.min(available_width))
2497 } else {
2498 let min_width = 24;
2499 let preferred = 48;
2500 preferred
2501 .min(available_width)
2502 .max(min_width.min(available_width))
2503 };
2504 let available_height = area.height.saturating_sub(2).max(1);
2505 let desired_height = if stats.is_some() {
2506 items.len() as u16 + 7
2507 } else {
2508 items.len() as u16 + 4
2509 };
2510 let height = desired_height
2511 .min(available_height)
2512 .max(6.min(available_height));
2513 let popup = Rect {
2514 x: area.x + (area.width.saturating_sub(width)) / 2,
2515 y: area.y + (area.height.saturating_sub(height)) / 2,
2516 width,
2517 height,
2518 };
2519
2520 frame.render_widget(Clear, popup);
2521 let block = Block::default()
2522 .title(title)
2523 .borders(Borders::ALL)
2524 .border_style(Style::default().fg(Color::Cyan));
2525 let inner = block.inner(popup);
2526 frame.render_widget(block, popup);
2527
2528 let mut lines: Vec<Line> = Vec::new();
2529 if stats.is_some() {
2530 if let Some(symbol) = selected_symbol {
2531 lines.push(Line::from(vec![
2532 Span::styled(" Symbol: ", Style::default().fg(Color::DarkGray)),
2533 Span::styled(
2534 symbol,
2535 Style::default()
2536 .fg(Color::Green)
2537 .add_modifier(Modifier::BOLD),
2538 ),
2539 ]));
2540 }
2541 lines.push(Line::from(vec![Span::styled(
2542 " Strategy W L T PnL",
2543 Style::default()
2544 .fg(Color::Cyan)
2545 .add_modifier(Modifier::BOLD),
2546 )]));
2547 }
2548
2549 let mut item_lines: Vec<Line> = items
2550 .iter()
2551 .enumerate()
2552 .map(|(idx, item)| {
2553 let item_text = if let Some(stats_map) = stats {
2554 let symbol = selected_symbol.unwrap_or("-");
2555 if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
2556 format!(
2557 "{:<16} W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2558 item, s.win_count, s.lose_count, s.trade_count, s.realized_pnl
2559 )
2560 } else {
2561 format!("{:<16} W:0 L:0 T:0 PnL:0.0000", item)
2562 }
2563 } else {
2564 item.clone()
2565 };
2566 if idx == selected {
2567 Line::from(vec![
2568 Span::styled("▶ ", Style::default().fg(Color::Yellow)),
2569 Span::styled(
2570 item_text,
2571 Style::default()
2572 .fg(Color::White)
2573 .add_modifier(Modifier::BOLD),
2574 ),
2575 ])
2576 } else {
2577 Line::from(vec![
2578 Span::styled(" ", Style::default()),
2579 Span::styled(item_text, Style::default().fg(Color::DarkGray)),
2580 ])
2581 }
2582 })
2583 .collect();
2584 lines.append(&mut item_lines);
2585 if let (Some(stats_map), Some(t)) = (stats, total_stats.as_ref()) {
2586 let mut strategy_sum = OrderHistoryStats::default();
2587 for item in items {
2588 let symbol = selected_symbol.unwrap_or("-");
2589 if let Some(s) = strategy_stats_for_item(stats_map, item, symbol) {
2590 strategy_sum.trade_count += s.trade_count;
2591 strategy_sum.win_count += s.win_count;
2592 strategy_sum.lose_count += s.lose_count;
2593 strategy_sum.realized_pnl += s.realized_pnl;
2594 }
2595 }
2596 let manual = subtract_stats(t, &strategy_sum);
2597 lines.push(Line::from(vec![Span::styled(
2598 format!(
2599 " MANUAL(rest) W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2600 manual.win_count, manual.lose_count, manual.trade_count, manual.realized_pnl
2601 ),
2602 Style::default().fg(Color::LightBlue),
2603 )]));
2604 }
2605 if let Some(t) = total_stats {
2606 lines.push(Line::from(vec![Span::styled(
2607 format!(
2608 " TOTAL W:{:<3} L:{:<3} T:{:<3} PnL:{:.4}",
2609 t.win_count, t.lose_count, t.trade_count, t.realized_pnl
2610 ),
2611 Style::default()
2612 .fg(Color::Yellow)
2613 .add_modifier(Modifier::BOLD),
2614 )]));
2615 }
2616
2617 frame.render_widget(
2618 Paragraph::new(lines).style(Style::default().fg(Color::White)),
2619 inner,
2620 );
2621}
2622
2623fn strategy_stats_for_item<'a>(
2624 stats_map: &'a HashMap<String, OrderHistoryStats>,
2625 item: &str,
2626 symbol: &str,
2627) -> Option<&'a OrderHistoryStats> {
2628 if let Some(source_tag) = source_tag_for_strategy_item(item) {
2629 let scoped = strategy_stats_scope_key(symbol, &source_tag);
2630 if let Some(s) = stats_map.get(&scoped) {
2631 return Some(s);
2632 }
2633 }
2634 if let Some(s) = stats_map.get(item) {
2635 return Some(s);
2636 }
2637 let source_tag = source_tag_for_strategy_item(item);
2638 source_tag.and_then(|tag| {
2639 stats_map
2640 .get(&tag)
2641 .or_else(|| stats_map.get(&tag.to_ascii_uppercase()))
2642 })
2643}
2644
2645fn strategy_stats_scope_key(symbol: &str, source_tag: &str) -> String {
2646 format!(
2647 "{}::{}",
2648 symbol.trim().to_ascii_uppercase(),
2649 source_tag.trim().to_ascii_lowercase()
2650 )
2651}
2652
2653fn source_tag_for_strategy_item(item: &str) -> Option<String> {
2654 match item {
2655 "MA(Config)" => return Some("cfg".to_string()),
2656 "MA(Fast 5/20)" => return Some("fst".to_string()),
2657 "MA(Slow 20/60)" => return Some("slw".to_string()),
2658 "RSA(RSI 14 30/70)" => return Some("rsa".to_string()),
2659 "DCT(Donchian 20/10)" => return Some("dct".to_string()),
2660 "MRV(SMA 20 -2.00%)" => return Some("mrv".to_string()),
2661 "BBR(BB 20 2.00x)" => return Some("bbr".to_string()),
2662 "STO(Stoch 14 20/80)" => return Some("sto".to_string()),
2663 "VLC(Compression 20 1.20%)" => return Some("vlc".to_string()),
2664 "ORB(Opening 12/8)" => return Some("orb".to_string()),
2665 _ => {}
2666 }
2667 if let Some((_, tail)) = item.rsplit_once('[') {
2668 if let Some(tag) = tail.strip_suffix(']') {
2669 let tag = tag.trim();
2670 if !tag.is_empty() {
2671 return Some(tag.to_ascii_lowercase());
2672 }
2673 }
2674 }
2675 None
2676}
2677
2678fn parse_source_tag_from_client_order_id(client_order_id: &str) -> Option<&str> {
2679 let body = client_order_id.strip_prefix("sq-")?;
2680 let (source_tag, _) = body.split_once('-')?;
2681 if source_tag.is_empty() {
2682 None
2683 } else {
2684 Some(source_tag)
2685 }
2686}
2687
2688fn format_log_record_compact(record: &LogRecord) -> String {
2689 let level = match record.level {
2690 LogLevel::Debug => "DEBUG",
2691 LogLevel::Info => "INFO",
2692 LogLevel::Warn => "WARN",
2693 LogLevel::Error => "ERR",
2694 };
2695 let domain = match record.domain {
2696 LogDomain::Ws => "ws",
2697 LogDomain::Strategy => "strategy",
2698 LogDomain::Risk => "risk",
2699 LogDomain::Order => "order",
2700 LogDomain::Portfolio => "portfolio",
2701 LogDomain::Ui => "ui",
2702 LogDomain::System => "system",
2703 };
2704 let symbol = record.symbol.as_deref().unwrap_or("-");
2705 let strategy = record.strategy_tag.as_deref().unwrap_or("-");
2706 format!(
2707 "[{}] {}.{} {} {} {}",
2708 level, domain, record.event, symbol, strategy, record.msg
2709 )
2710}
2711
2712fn subtract_stats(total: &OrderHistoryStats, used: &OrderHistoryStats) -> OrderHistoryStats {
2713 OrderHistoryStats {
2714 trade_count: total.trade_count.saturating_sub(used.trade_count),
2715 win_count: total.win_count.saturating_sub(used.win_count),
2716 lose_count: total.lose_count.saturating_sub(used.lose_count),
2717 realized_pnl: total.realized_pnl - used.realized_pnl,
2718 }
2719}
2720
2721fn split_symbol_assets(symbol: &str) -> (String, String) {
2722 const QUOTE_SUFFIXES: [&str; 10] = [
2723 "USDT", "USDC", "FDUSD", "BUSD", "TUSD", "TRY", "EUR", "BTC", "ETH", "BNB",
2724 ];
2725 for q in QUOTE_SUFFIXES {
2726 if let Some(base) = symbol.strip_suffix(q) {
2727 if !base.is_empty() {
2728 return (base.to_string(), q.to_string());
2729 }
2730 }
2731 }
2732 (symbol.to_string(), String::new())
2733}
2734
2735fn format_last_applied_fee(symbol: &str, fills: &[Fill]) -> Option<String> {
2736 if fills.is_empty() {
2737 return None;
2738 }
2739 let (base_asset, quote_asset) = split_symbol_assets(symbol);
2740 let mut fee_by_asset: HashMap<String, f64> = HashMap::new();
2741 let mut notional_quote = 0.0;
2742 let mut fee_quote_equiv = 0.0;
2743 let mut quote_convertible = !quote_asset.is_empty();
2744
2745 for f in fills {
2746 if f.qty > 0.0 && f.price > 0.0 {
2747 notional_quote += f.qty * f.price;
2748 }
2749 if f.commission <= 0.0 {
2750 continue;
2751 }
2752 *fee_by_asset
2753 .entry(f.commission_asset.clone())
2754 .or_insert(0.0) += f.commission;
2755 if !quote_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case("e_asset) {
2756 fee_quote_equiv += f.commission;
2757 } else if !base_asset.is_empty() && f.commission_asset.eq_ignore_ascii_case(&base_asset) {
2758 fee_quote_equiv += f.commission * f.price.max(0.0);
2759 } else {
2760 quote_convertible = false;
2761 }
2762 }
2763
2764 if fee_by_asset.is_empty() {
2765 return Some("0".to_string());
2766 }
2767
2768 if quote_convertible && notional_quote > f64::EPSILON {
2769 let fee_pct = fee_quote_equiv / notional_quote * 100.0;
2770 return Some(format!(
2771 "{:.3}% ({:.4} {})",
2772 fee_pct, fee_quote_equiv, quote_asset
2773 ));
2774 }
2775
2776 let mut items: Vec<(String, f64)> = fee_by_asset.into_iter().collect();
2777 items.sort_by(|a, b| a.0.cmp(&b.0));
2778 if items.len() == 1 {
2779 let (asset, amount) = &items[0];
2780 Some(format!("{:.6} {}", amount, asset))
2781 } else {
2782 Some(format!("mixed fees ({})", items.len()))
2783 }
2784}
2785
2786#[cfg(test)]
2787mod tests {
2788 use super::format_last_applied_fee;
2789 use crate::model::order::Fill;
2790
2791 #[test]
2792 fn fee_summary_from_quote_asset_commission() {
2793 let fills = vec![Fill {
2794 price: 2000.0,
2795 qty: 0.5,
2796 commission: 1.0,
2797 commission_asset: "USDT".to_string(),
2798 }];
2799 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
2800 assert_eq!(summary, "0.100% (1.0000 USDT)");
2801 }
2802
2803 #[test]
2804 fn fee_summary_from_base_asset_commission() {
2805 let fills = vec![Fill {
2806 price: 2000.0,
2807 qty: 0.5,
2808 commission: 0.0005,
2809 commission_asset: "ETH".to_string(),
2810 }];
2811 let summary = format_last_applied_fee("ETHUSDT", &fills).unwrap();
2812 assert_eq!(summary, "0.100% (1.0000 USDT)");
2813 }
2814}