1use rust_decimal::Decimal;
4use rust_decimal_macros::dec;
5use std::collections::HashMap;
6
7#[derive(Debug, Clone)]
9pub struct Fill {
10 pub symbol: String,
11 pub side: Side,
12 pub price: Decimal,
13 pub size: Decimal,
14 pub timestamp_ms: i64,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Side {
19 Buy,
20 Sell,
21}
22
23#[derive(Debug, Clone)]
25pub struct SymbolAttribution {
26 pub position: Decimal,
28 pub entry_price: Decimal,
30 pub realized_pnl: Decimal,
32 pub unrealized_pnl: Decimal,
34 pub total_pnl: Decimal,
36}
37
38#[derive(Debug, Clone)]
40pub struct Attribution {
41 pub by_symbol: HashMap<String, SymbolAttribution>,
42 pub total_pnl: Decimal,
43}
44
45#[derive(Debug, Clone)]
47struct PositionState {
48 amount: Decimal,
50 entry_price: Decimal,
52 realized: Decimal,
54}
55
56impl PositionState {
57 fn new() -> Self {
58 Self {
59 amount: dec!(0),
60 entry_price: dec!(0),
61 realized: dec!(0),
62 }
63 }
64
65 fn apply_fill(&mut self, side: Side, price: Decimal, size: Decimal) {
66 let signed_qty = match side {
67 Side::Buy => size,
68 Side::Sell => -size,
69 };
70
71 let is_reducing = (self.amount > dec!(0) && signed_qty < dec!(0))
72 || (self.amount < dec!(0) && signed_qty > dec!(0));
73
74 if is_reducing {
75 let close_qty = size.min(self.amount.abs());
76 let pnl = if self.amount > dec!(0) {
77 (price - self.entry_price) * close_qty
79 } else {
80 (self.entry_price - price) * close_qty
82 };
83 self.realized += pnl;
84
85 let remaining = size - close_qty;
86 self.amount += signed_qty;
87
88 if remaining > dec!(0) {
90 self.entry_price = price;
91 }
93 } else {
95 if self.amount == dec!(0) {
97 self.entry_price = price;
98 } else {
99 let old_cost = self.entry_price * self.amount.abs();
101 let new_cost = price * size;
102 let new_total = self.amount.abs() + size;
103 if new_total > dec!(0) {
104 self.entry_price = (old_cost + new_cost) / new_total;
105 }
106 }
107 self.amount += signed_qty;
108 }
109 }
110
111 fn unrealized_at_mark(&self, mark: Decimal) -> Decimal {
112 if self.amount == dec!(0) {
113 return dec!(0);
114 }
115 if self.amount > dec!(0) {
116 (mark - self.entry_price) * self.amount
117 } else {
118 (self.entry_price - mark) * self.amount.abs()
119 }
120 }
121}
122
123pub struct PositionTracker {
125 positions: HashMap<String, PositionState>,
126}
127
128impl Default for PositionTracker {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134impl PositionTracker {
135 pub fn new() -> Self {
136 Self {
137 positions: HashMap::new(),
138 }
139 }
140
141 pub fn apply_fill(&mut self, fill: &Fill) {
143 let state = self
144 .positions
145 .entry(fill.symbol.clone())
146 .or_insert_with(PositionState::new);
147 state.apply_fill(fill.side, fill.price, fill.size);
148 }
149
150 pub fn attribution(&self, marks: &HashMap<String, Decimal>) -> Attribution {
153 let mut by_symbol = HashMap::new();
154 let mut total_pnl = dec!(0);
155
156 for (symbol, state) in &self.positions {
157 if state.amount == dec!(0) && state.realized == dec!(0) {
158 continue;
159 }
160
161 let mark = marks.get(symbol).copied().unwrap_or(dec!(0));
162 let unrealized = state.unrealized_at_mark(mark);
163 let symbol_total = state.realized + unrealized;
164 total_pnl += symbol_total;
165
166 by_symbol.insert(
167 symbol.clone(),
168 SymbolAttribution {
169 position: state.amount,
170 entry_price: state.entry_price,
171 realized_pnl: state.realized,
172 unrealized_pnl: unrealized,
173 total_pnl: symbol_total,
174 },
175 );
176 }
177
178 Attribution {
179 by_symbol,
180 total_pnl,
181 }
182 }
183
184 pub fn open_symbols(&self) -> Vec<String> {
186 self.positions
187 .iter()
188 .filter(|(_, s)| s.amount != dec!(0))
189 .map(|(sym, _)| sym.clone())
190 .collect()
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 use std::str::FromStr;
199
200 fn d(v: &str) -> Decimal {
201 Decimal::from_str(v).unwrap()
202 }
203
204 fn fill(symbol: &str, side: Side, price: &str, size: &str, ts: i64) -> Fill {
205 Fill {
206 symbol: symbol.to_string(),
207 side,
208 price: d(price),
209 size: d(size),
210 timestamp_ms: ts,
211 }
212 }
213
214 fn marks(entries: &[(&str, &str)]) -> HashMap<String, Decimal> {
215 entries.iter().map(|(s, p)| (s.to_string(), d(p))).collect()
216 }
217
218 #[test]
219 fn empty_tracker_produces_empty_attribution() {
220 let tracker = PositionTracker::new();
221 let attr = tracker.attribution(&HashMap::new());
222 assert!(attr.by_symbol.is_empty());
223 assert_eq!(attr.total_pnl, dec!(0));
224 }
225
226 #[test]
227 fn single_long_position_unrealized_gain() {
228 let mut tracker = PositionTracker::new();
229 tracker.apply_fill(&fill("ETH-CALL", Side::Buy, "1.20", "100", 1000));
230
231 let attr = tracker.attribution(&marks(&[("ETH-CALL", "5")]));
232 let eth = &attr.by_symbol["ETH-CALL"];
233
234 assert_eq!(eth.position, d("100"));
235 assert_eq!(eth.realized_pnl, dec!(0));
236 assert_eq!(eth.unrealized_pnl, d("380"));
237 assert_eq!(eth.total_pnl, d("380"));
238 }
239
240 #[test]
241 fn single_long_position_unrealized_loss() {
242 let mut tracker = PositionTracker::new();
243 tracker.apply_fill(&fill("HYPE-PUT", Side::Buy, "1.20", "100", 1000));
244
245 let attr = tracker.attribution(&marks(&[("HYPE-PUT", "0.50")]));
246 assert_eq!(attr.by_symbol["HYPE-PUT"].unrealized_pnl, d("-70"));
247 }
248
249 #[test]
250 fn buy_then_sell_realizes_pnl() {
251 let mut tracker = PositionTracker::new();
252 tracker.apply_fill(&fill("BTC-CALL", Side::Buy, "100", "10", 1000));
253 tracker.apply_fill(&fill("BTC-CALL", Side::Sell, "150", "10", 2000));
254
255 let attr = tracker.attribution(&HashMap::new());
256 let btc = &attr.by_symbol["BTC-CALL"];
257
258 assert_eq!(btc.position, dec!(0));
259 assert_eq!(btc.realized_pnl, d("500"));
260 assert_eq!(btc.unrealized_pnl, dec!(0));
261 }
262
263 #[test]
264 fn partial_close_splits_realized_and_unrealized() {
265 let mut tracker = PositionTracker::new();
266 tracker.apply_fill(&fill("ETH-CALL", Side::Buy, "10", "100", 1000));
267 tracker.apply_fill(&fill("ETH-CALL", Side::Sell, "15", "40", 2000));
268
269 let attr = tracker.attribution(&marks(&[("ETH-CALL", "20")]));
270 let eth = &attr.by_symbol["ETH-CALL"];
271
272 assert_eq!(eth.position, d("60"));
273 assert_eq!(eth.realized_pnl, d("200"));
274 assert_eq!(eth.unrealized_pnl, d("600"));
275 assert_eq!(eth.total_pnl, d("800"));
276 }
277
278 #[test]
279 fn short_position_pnl() {
280 let mut tracker = PositionTracker::new();
281 tracker.apply_fill(&fill("ETH-PUT", Side::Sell, "5", "100", 1000));
282
283 let attr = tracker.attribution(&marks(&[("ETH-PUT", "3")]));
284 let eth = &attr.by_symbol["ETH-PUT"];
285
286 assert_eq!(eth.position, d("-100"));
287 assert_eq!(eth.unrealized_pnl, d("200"));
288 }
289
290 #[test]
291 fn short_close_realizes_pnl() {
292 let mut tracker = PositionTracker::new();
293 tracker.apply_fill(&fill("ETH-PUT", Side::Sell, "5", "100", 1000));
294 tracker.apply_fill(&fill("ETH-PUT", Side::Buy, "3", "100", 2000));
295
296 let attr = tracker.attribution(&HashMap::new());
297 assert_eq!(attr.by_symbol["ETH-PUT"].position, dec!(0));
298 assert_eq!(attr.by_symbol["ETH-PUT"].realized_pnl, d("200"));
299 }
300
301 #[test]
302 fn multiple_buys_average_entry_price() {
303 let mut tracker = PositionTracker::new();
304 tracker.apply_fill(&fill("BTC-CALL", Side::Buy, "100", "10", 1000));
305 tracker.apply_fill(&fill("BTC-CALL", Side::Buy, "200", "10", 2000));
306
307 let attr = tracker.attribution(&marks(&[("BTC-CALL", "200")]));
308 let btc = &attr.by_symbol["BTC-CALL"];
309
310 assert_eq!(btc.position, d("20"));
311 assert_eq!(btc.entry_price, d("150"));
312 assert_eq!(btc.unrealized_pnl, d("1000"));
313 }
314
315 #[test]
316 fn multi_symbol_attribution() {
317 let mut tracker = PositionTracker::new();
318 tracker.apply_fill(&fill("ETH-CALL", Side::Buy, "1.20", "100", 1000));
319 tracker.apply_fill(&fill("HYPE-PUT", Side::Buy, "1.20", "200", 1000));
320
321 let attr = tracker.attribution(&marks(&[("ETH-CALL", "74.25"), ("HYPE-PUT", "0.71")]));
322
323 assert_eq!(attr.by_symbol["ETH-CALL"].unrealized_pnl, d("7305"));
324 assert_eq!(attr.by_symbol["HYPE-PUT"].unrealized_pnl, d("-98"));
325 assert_eq!(attr.total_pnl, d("7207"));
326 }
327
328 #[test]
329 fn leaderboard_wallet_1_scenario() {
330 let mut tracker = PositionTracker::new();
331 tracker.apply_fill(&fill(
332 "ETH-20260414-2300-C",
333 Side::Buy,
334 "1.20",
335 "12575.8",
336 1000,
337 ));
338 tracker.apply_fill(&fill(
339 "HYPE-20260424-39-P",
340 Side::Buy,
341 "1.20",
342 "25403.6",
343 2000,
344 ));
345
346 let attr = tracker.attribution(&marks(&[
347 ("ETH-20260414-2300-C", "74.25"),
348 ("HYPE-20260424-39-P", "0.71"),
349 ]));
350
351 let eth = &attr.by_symbol["ETH-20260414-2300-C"];
352 let hype = &attr.by_symbol["HYPE-20260424-39-P"];
353
354 assert_eq!(eth.unrealized_pnl, d("918662.190"));
356 assert_eq!(hype.unrealized_pnl, d("-12447.764"));
358 assert_eq!(attr.total_pnl, d("906214.426"));
360 }
361
362 #[test]
363 fn open_symbols_returns_only_non_zero_positions() {
364 let mut tracker = PositionTracker::new();
365 tracker.apply_fill(&fill("OPEN", Side::Buy, "1", "10", 1000));
366 tracker.apply_fill(&fill("CLOSED", Side::Buy, "1", "10", 1000));
367 tracker.apply_fill(&fill("CLOSED", Side::Sell, "2", "10", 2000));
368
369 let mut open = tracker.open_symbols();
370 open.sort();
371 assert_eq!(open, vec!["OPEN"]);
372 }
373
374 #[test]
375 fn missing_mark_defaults_to_zero_unrealized() {
376 let mut tracker = PositionTracker::new();
377 tracker.apply_fill(&fill("NO-MARK", Side::Buy, "10", "5", 1000));
378
379 let attr = tracker.attribution(&HashMap::new());
380 assert_eq!(attr.by_symbol["NO-MARK"].unrealized_pnl, d("-50"));
381 }
382
383 #[test]
384 fn flip_position_from_long_to_short() {
385 let mut tracker = PositionTracker::new();
386 tracker.apply_fill(&fill("SYM", Side::Buy, "10", "100", 1000));
387 tracker.apply_fill(&fill("SYM", Side::Sell, "15", "150", 2000));
388
389 let attr = tracker.attribution(&marks(&[("SYM", "12")]));
390 let sym = &attr.by_symbol["SYM"];
391
392 assert_eq!(sym.realized_pnl, d("500"));
393 assert_eq!(sym.position, d("-50"));
394 assert_eq!(sym.entry_price, d("15"));
395 assert_eq!(sym.unrealized_pnl, d("150"));
396 }
397}