Skip to main content

sandbox_quant/ui/
position_ledger.rs

1use 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}