1use std::{
6 collections::{BTreeMap, BTreeSet},
7 convert::Infallible,
8 fmt,
9 str::FromStr,
10};
11
12use crate::account::order::Cloid;
13use crate::markets::outcomes::coin::Coin;
14use crate::markets::outcomes::px::{Px, Qty, SignedQty};
15use crate::trade::Side;
16use crate::utils::{Dec, Round};
17
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub enum Direction {
21 Buy,
22 Sell,
23 SplitOutcome,
24 MergeOutcome,
25 NegateOutcome,
26 MergeQuestion,
27 Settlement,
28 Other(String),
30}
31
32impl Direction {
33 #[must_use]
34 pub fn read(text: &str) -> Direction {
35 match text.trim() {
36 "Buy" => Direction::Buy,
37 "Sell" => Direction::Sell,
38 "Split Outcome" => Direction::SplitOutcome,
39 "Merge Outcome" => Direction::MergeOutcome,
40 "Negate Outcome" => Direction::NegateOutcome,
41 "Merge Question" => Direction::MergeQuestion,
42 "Settlement" => Direction::Settlement,
43 other => Direction::Other(other.to_owned()),
44 }
45 }
46
47 #[must_use]
48 pub fn slug(&self) -> &str {
49 match self {
50 Direction::Buy => "buy",
51 Direction::Sell => "sell",
52 Direction::SplitOutcome => "split_outcome",
53 Direction::MergeOutcome => "merge_outcome",
54 Direction::NegateOutcome => "negate_outcome",
55 Direction::MergeQuestion => "merge_question",
56 Direction::Settlement => "settlement",
57 Direction::Other(raw) => raw,
58 }
59 }
60
61 #[must_use]
63 pub const fn is_trade(&self) -> bool {
64 matches!(self, Direction::Buy | Direction::Sell)
65 }
66
67 #[must_use]
69 pub const fn is_structural(&self) -> bool {
70 matches!(
71 self,
72 Direction::SplitOutcome | Direction::MergeOutcome | Direction::NegateOutcome | Direction::MergeQuestion
73 )
74 }
75
76 #[must_use]
78 pub const fn is_settlement(&self) -> bool {
79 matches!(self, Direction::Settlement)
80 }
81}
82
83impl FromStr for Direction {
84 type Err = Infallible;
85
86 fn from_str(text: &str) -> Result<Self, Self::Err> {
87 Ok(Direction::read(text))
88 }
89}
90
91impl fmt::Display for Direction {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 f.write_str(self.slug())
94 }
95}
96
97pub type RawFill = crate::account::fill::Fill;
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Fill {
104 pub coin: Coin,
105 pub side: Side,
106 pub direction: Direction,
107 pub price: Dec,
109 pub size: Qty,
110 pub at_ms: u64,
111 pub start_position: SignedQty,
112 pub position: SignedQty,
114 pub closed_pnl: Dec,
115 pub fee: Dec,
117 pub fee_token: String,
118 pub builder_fee: Option<Dec>,
119 pub crossed: bool,
121 pub oid: u64,
122 pub tid: u64,
123 pub hash: String,
124 pub cloid: Option<String>,
125}
126
127impl Fill {
128 #[must_use]
130 pub fn read(raw: &RawFill) -> Option<Fill> {
131 if !raw.coin.starts_with('#') {
132 return None;
133 }
134 let coin = Coin::parse(&raw.coin)?;
135 let size = Qty::from_dec(raw.sz).ok()?;
136 let start_position = SignedQty::from_dec(raw.start_position).ok()?;
137 let moved = match raw.side {
138 Side::Buy => SignedQty::long(size),
139 Side::Sell => SignedQty::short(size),
140 };
141 Some(Fill {
142 coin,
143 side: raw.side,
144 direction: Direction::read(&raw.dir),
145 price: raw.px,
146 size,
147 at_ms: raw.time,
148 start_position,
149 position: start_position.saturating_add(moved),
150 closed_pnl: raw.closed_pnl,
151 fee: raw.fee,
152 fee_token: raw.fee_token.clone(),
153 builder_fee: raw.builder_fee,
154 crossed: raw.crossed,
155 oid: raw.oid,
156 tid: raw.tid,
157 hash: raw.hash.clone(),
158 cloid: raw.cloid.clone(),
159 })
160 }
161
162 #[must_use]
164 pub fn notional(&self) -> Dec {
165 self.size.to_dec().checked_mul(self.price).unwrap_or(Dec::ZERO)
166 }
167
168 #[must_use]
170 pub fn venue_fee(&self) -> Option<Dec> {
171 self.fee.checked_sub(self.builder_fee.unwrap_or(Dec::ZERO))
172 }
173}
174
175pub type RawOrder = crate::account::order::OpenOrder;
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct OpenOrder {
181 pub coin: Coin,
182 pub side: Side,
183 pub price: Px,
184 pub size: Qty,
186 pub original_size: Qty,
187 pub placed_ms: u64,
188 pub oid: u64,
189 pub cloid: Option<Cloid>,
190}
191
192impl OpenOrder {
193 #[must_use]
195 pub fn read(raw: &RawOrder) -> Option<OpenOrder> {
196 if !raw.coin.starts_with('#') {
197 return None;
198 }
199 Some(OpenOrder {
200 coin: Coin::parse(&raw.coin)?,
201 side: raw.side,
202 price: Px::from_dec(raw.limit_px).ok()?,
203 size: Qty::from_dec(raw.sz).ok()?,
204 original_size: Qty::from_dec(raw.orig_sz).ok()?,
205 placed_ms: raw.timestamp,
206 oid: raw.oid,
207 cloid: raw.cloid,
208 })
209 }
210
211 #[must_use]
213 pub const fn filled(&self) -> Qty {
214 self.original_size.saturating_sub(self.size)
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct Position {
221 pub coin: Coin,
222 pub size: SignedQty,
224 pub fixed_at_ms: u64,
226 pub realized_pnl: Dec,
227 pub fees: Dec,
228 pub fills: usize,
229}
230
231#[must_use]
234pub fn positions(fills: &[Fill]) -> Vec<Position> {
235 let mut newest_first: Vec<&Fill> = fills.iter().collect();
236 newest_first.sort_by_key(|fill| std::cmp::Reverse(fill.at_ms));
237 let mut found: BTreeMap<Coin, Position> = BTreeMap::new();
238 for fill in newest_first {
239 let position = found.entry(fill.coin).or_insert_with(|| Position {
240 coin: fill.coin,
241 size: fill.position,
242 fixed_at_ms: fill.at_ms,
243 realized_pnl: Dec::ZERO,
244 fees: Dec::ZERO,
245 fills: 0,
246 });
247 position.realized_pnl = position.realized_pnl.checked_add(fill.closed_pnl).unwrap_or(position.realized_pnl);
248 position.fees = position.fees.checked_add(fill.fee).unwrap_or(position.fees);
249 position.fills += 1;
250 }
251 let mut positions: Vec<Position> = found.into_values().collect();
252 positions.sort_by(|a, b| b.size.abs().cmp(&a.size.abs()).then_with(|| a.coin.cmp(&b.coin)));
253 positions
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct Summary {
260 pub fills: usize,
261 pub markets: usize,
263 pub trades: usize,
264 pub traded_volume: Dec,
265 pub structural_volume: Dec,
266 pub settled_volume: Dec,
267 pub realized_pnl: Dec,
268 pub fees: Dec,
269 pub made: usize,
271 pub taken: usize,
273 pub by_direction: BTreeMap<String, usize>,
274 pub first_ms: Option<u64>,
275 pub last_ms: Option<u64>,
276}
277
278#[must_use]
279pub fn summarize(fills: &[Fill]) -> Summary {
280 let add = |sum: Dec, more: Dec| sum.checked_add(more).unwrap_or(sum);
281 let mut summary = Summary {
282 fills: fills.len(),
283 markets: 0,
284 trades: 0,
285 traded_volume: Dec::ZERO,
286 structural_volume: Dec::ZERO,
287 settled_volume: Dec::ZERO,
288 realized_pnl: Dec::ZERO,
289 fees: Dec::ZERO,
290 made: 0,
291 taken: 0,
292 by_direction: BTreeMap::new(),
293 first_ms: None,
294 last_ms: None,
295 };
296 let mut outcomes = BTreeSet::new();
297 for fill in fills {
298 outcomes.insert(fill.coin.outcome());
299 summary.realized_pnl = add(summary.realized_pnl, fill.closed_pnl);
300 summary.fees = add(summary.fees, fill.fee);
301 if fill.direction.is_trade() {
302 summary.trades += 1;
303 summary.traded_volume = add(summary.traded_volume, fill.notional());
304 match fill.crossed {
305 true => summary.taken += 1,
306 false => summary.made += 1,
307 }
308 } else if fill.direction.is_structural() {
309 summary.structural_volume = add(summary.structural_volume, fill.notional());
310 } else if fill.direction.is_settlement() {
311 summary.settled_volume = add(summary.settled_volume, fill.notional());
312 }
313 *summary.by_direction.entry(fill.direction.slug().to_owned()).or_default() += 1;
314 summary.first_ms = Some(summary.first_ms.map_or(fill.at_ms, |first| first.min(fill.at_ms)));
315 summary.last_ms = Some(summary.last_ms.map_or(fill.at_ms, |last| last.max(fill.at_ms)));
316 }
317 summary.markets = outcomes.len();
318 summary
319}
320
321pub type SpotBalances = crate::account::clearinghouse::SpotState;
324
325pub type SpotRow = crate::account::clearinghouse::SpotBalance;
326
327#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct Holding {
330 pub coin: Coin,
331 pub total: Dec,
333 pub hold: Dec,
335 pub entry_ntl: Dec,
337}
338
339impl Holding {
340 #[must_use]
342 pub fn free(&self) -> Dec {
343 self.total.checked_sub(self.hold).unwrap_or(Dec::ZERO).max(Dec::ZERO)
344 }
345
346 #[must_use]
348 pub fn average(&self) -> Option<Dec> {
349 match self.total.is_positive() {
350 true => self.entry_ntl.checked_div(self.total, 8, Round::Nearest),
351 false => None,
352 }
353 }
354}
355
356#[must_use]
359pub fn holdings(state: &SpotBalances) -> Vec<Holding> {
360 state
361 .balances
362 .iter()
363 .filter(|row| !(row.total.is_zero() && row.hold.is_zero()))
364 .filter_map(|row| {
365 Some(Holding { coin: Coin::parse(&row.coin)?, total: row.total, hold: row.hold, entry_ntl: row.entry_ntl })
366 })
367 .collect()
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum Check {
373 Agrees,
374 Disagrees {
377 from_fills: SignedQty,
378 },
379 Unseen,
381}
382
383#[must_use]
385pub fn corroborate<'a>(holdings: &'a [Holding], positions: &[Position]) -> Vec<(&'a Holding, Check)> {
386 holdings
387 .iter()
388 .map(|holding| {
389 let check = match positions.iter().find(|position| position.coin == holding.coin) {
390 None => Check::Unseen,
391 Some(position) if !position.size.is_short() && position.size.abs().to_dec() == holding.total => {
392 Check::Agrees
393 }
394 Some(position) => Check::Disagrees { from_fills: position.size },
395 };
396 (holding, check)
397 })
398 .collect()
399}
400
401#[cfg(test)]
402#[path = "tests/outcomes.rs"]
403mod tests;