sandbox_quant/ui/
position_ledger.rs1use std::collections::{HashMap, VecDeque};
2
3use crate::order_store::PersistedTrade;
4
5#[derive(Debug, Clone)]
6pub struct OpenOrderPosition {
7 pub symbol: String,
8 pub source_tag: String,
9 pub order_id: u64,
10 pub qty_open: f64,
11 pub entry_price: f64,
12 pub realized_pnl: f64,
13}
14
15fn canonical_source_tag(raw: &str) -> String {
16 let s = raw.trim().to_ascii_lowercase();
17 if s.is_empty() {
18 return "sys".to_string();
19 }
20 if s.starts_with('c') && s[1..].chars().all(|ch| ch.is_ascii_digit()) {
21 return s;
22 }
23 if s == "manual" {
24 return "mnl".to_string();
25 }
26 if s.starts_with("ma(config") {
27 return "cfg".to_string();
28 }
29 if s.starts_with("ma(") && s.contains("fast") {
30 return "fst".to_string();
31 }
32 if s.starts_with("ma(") && s.contains("slow") {
33 return "slw".to_string();
34 }
35 if let Some((head, _)) = s.split_once('(') {
36 let h = head.trim();
37 if !h.is_empty() {
38 return h.to_string();
39 }
40 }
41 s
42}
43
44#[derive(Debug, Clone)]
45struct OpenLot {
46 source_tag: String,
47 order_id: u64,
48 qty_open: f64,
49 qty_total: f64,
50 notional_total: f64,
51 realized_pnl: f64,
52}
53
54impl OpenLot {
55 fn entry_price(&self) -> f64 {
56 if self.qty_total <= f64::EPSILON {
57 0.0
58 } else {
59 self.notional_total / self.qty_total
60 }
61 }
62}
63
64pub fn build_open_order_positions_from_trades(trades: &[PersistedTrade]) -> Vec<OpenOrderPosition> {
65 let mut sorted = trades.to_vec();
66 sorted.sort_by_key(|t| (t.trade.symbol.clone(), t.trade.time, t.trade.id));
67
68 let mut open_by_symbol: HashMap<String, VecDeque<OpenLot>> = HashMap::new();
69 for row in sorted {
70 let symbol = row.trade.symbol.trim().to_ascii_uppercase();
71 let qty = row.trade.qty.max(0.0);
72 if qty <= f64::EPSILON {
73 continue;
74 }
75
76 let lots = open_by_symbol.entry(symbol.clone()).or_default();
77 if row.trade.is_buyer {
78 if let Some(last) = lots.back_mut() {
79 if last.order_id == row.trade.order_id {
80 last.qty_open += qty;
81 last.qty_total += qty;
82 last.notional_total += qty * row.trade.price;
83 continue;
84 }
85 }
86 lots.push_back(OpenLot {
87 source_tag: canonical_source_tag(&row.source),
88 order_id: row.trade.order_id,
89 qty_open: qty,
90 qty_total: qty,
91 notional_total: qty * row.trade.price,
92 realized_pnl: 0.0,
93 });
94 continue;
95 }
96
97 let mut remaining = qty;
98 while remaining > f64::EPSILON {
99 let Some(mut lot) = lots.pop_front() else {
100 break;
101 };
102 let close_qty = remaining.min(lot.qty_open);
103 let entry = lot.entry_price();
104 lot.qty_open -= close_qty;
105 lot.realized_pnl += (row.trade.price - entry) * close_qty;
106 remaining -= close_qty;
107 if lot.qty_open > f64::EPSILON {
108 lots.push_front(lot);
109 break;
110 }
111 }
112 }
113
114 let mut out = Vec::new();
115 for (symbol, lots) in open_by_symbol {
116 for lot in lots {
117 if lot.qty_open <= f64::EPSILON {
118 continue;
119 }
120 let entry_price = lot.entry_price();
121 out.push(OpenOrderPosition {
122 symbol: symbol.clone(),
123 source_tag: lot.source_tag,
124 order_id: lot.order_id,
125 qty_open: lot.qty_open,
126 entry_price,
127 realized_pnl: lot.realized_pnl,
128 });
129 }
130 }
131 out.sort_by(|a, b| {
132 a.symbol
133 .cmp(&b.symbol)
134 .then_with(|| a.source_tag.cmp(&b.source_tag))
135 .then_with(|| a.order_id.cmp(&b.order_id))
136 });
137 out
138}