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