1use crate::error::PositionError;
41use crate::model::decimal::{d_div, d_mul, d_sub};
42use crate::model::leg::traits::LegAble;
43use crate::model::types::Side;
44#[cfg(feature = "clock")]
45use crate::utils::time::wall_clock;
46use chrono::{DateTime, Utc};
47use positive::Positive;
48use rust_decimal::Decimal;
49use serde::{Deserialize, Serialize};
50
51#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
67#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
68pub struct SpotPosition {
69 pub symbol: String,
71
72 pub quantity: Positive,
74
75 pub cost_basis: Positive,
77
78 pub side: Side,
80
81 pub date: DateTime<Utc>,
83
84 pub open_fee: Positive,
86
87 pub close_fee: Positive,
89}
90
91impl SpotPosition {
92 #[must_use]
108 pub fn new(
109 symbol: String,
110 quantity: Positive,
111 cost_basis: Positive,
112 side: Side,
113 date: DateTime<Utc>,
114 open_fee: Positive,
115 close_fee: Positive,
116 ) -> Self {
117 Self {
118 symbol,
119 quantity,
120 cost_basis,
121 side,
122 date,
123 open_fee,
124 close_fee,
125 }
126 }
127
128 #[cfg(feature = "clock")]
140 #[must_use]
141 pub fn long(symbol: String, quantity: Positive, cost_basis: Positive) -> Self {
142 Self::new(
143 symbol,
144 quantity,
145 cost_basis,
146 Side::Long,
147 wall_clock(),
148 Positive::ZERO,
149 Positive::ZERO,
150 )
151 }
152
153 #[cfg(feature = "clock")]
165 #[must_use]
166 pub fn short(symbol: String, quantity: Positive, cost_basis: Positive) -> Self {
167 Self::new(
168 symbol,
169 quantity,
170 cost_basis,
171 Side::Short,
172 wall_clock(),
173 Positive::ZERO,
174 Positive::ZERO,
175 )
176 }
177
178 pub fn initial_value(&self) -> Result<Positive, PositionError> {
187 Ok(self.quantity.checked_mul(&self.cost_basis)?)
188 }
189
190 pub fn market_value(&self, current_price: Positive) -> Result<Positive, PositionError> {
201 Ok(self.quantity.checked_mul(¤t_price)?)
202 }
203
204 pub fn percentage_return(&self, current_price: Positive) -> Result<Decimal, PositionError> {
220 if self.cost_basis == Positive::ZERO {
221 return Ok(Decimal::ZERO);
222 }
223
224 let price_change = d_sub(
225 current_price.to_dec(),
226 self.cost_basis.to_dec(),
227 "SpotPosition::percentage_return/price_change",
228 )?;
229 let return_pct = d_div(
230 price_change,
231 self.cost_basis.to_dec(),
232 "SpotPosition::percentage_return",
233 )?;
234
235 Ok(match self.side {
236 Side::Long => return_pct,
237 Side::Short => -return_pct,
238 })
239 }
240
241 pub fn break_even_price(&self) -> Result<Positive, PositionError> {
254 if self.quantity == Positive::ZERO {
255 return Ok(self.cost_basis);
256 }
257
258 let fee_per_unit = self
259 .open_fee
260 .checked_add(&self.close_fee)?
261 .checked_div(&self.quantity)?;
262
263 Ok(match self.side {
264 Side::Long => self.cost_basis.checked_add(&fee_per_unit)?,
265 Side::Short => {
266 if self.cost_basis > fee_per_unit {
267 self.cost_basis.checked_sub(&fee_per_unit)?
268 } else {
269 Positive::ZERO
270 }
271 }
272 })
273 }
274}
275
276impl LegAble for SpotPosition {
277 fn get_symbol(&self) -> &str {
278 &self.symbol
279 }
280
281 fn get_quantity(&self) -> Positive {
282 self.quantity
283 }
284
285 fn get_side(&self) -> Side {
286 self.side
287 }
288
289 fn pnl_at_price(&self, price: Positive) -> Result<Decimal, PositionError> {
290 let price_change = d_sub(
291 price.to_dec(),
292 self.cost_basis.to_dec(),
293 "SpotPosition::pnl_at_price/price_change",
294 )?;
295 let gross_pnl = d_mul(
296 price_change,
297 self.quantity.to_dec(),
298 "SpotPosition::pnl_at_price/gross",
299 )?;
300 let total_fees = self.open_fee.checked_add(&self.close_fee)?;
301
302 let signed_gross = match self.side {
303 Side::Long => gross_pnl,
304 Side::Short => -gross_pnl,
307 };
308 Ok(d_sub(
309 signed_gross,
310 total_fees.to_dec(),
311 "SpotPosition::pnl_at_price/net",
312 )?)
313 }
314
315 fn total_cost(&self) -> Result<Positive, PositionError> {
316 Ok(match self.side {
317 Side::Long => self
318 .quantity
319 .checked_mul(&self.cost_basis)?
320 .checked_add(&self.open_fee)?
321 .checked_add(&self.close_fee)?,
322 Side::Short => self.open_fee.checked_add(&self.close_fee)?,
323 })
324 }
325
326 fn fees(&self) -> Result<Positive, PositionError> {
327 Ok(self.open_fee.checked_add(&self.close_fee)?)
328 }
329}
330
331impl std::fmt::Display for SpotPosition {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 write!(
334 f,
335 "{} {} {} @ {} (fees: ",
336 self.side, self.quantity, self.symbol, self.cost_basis,
337 )?;
338 match self.open_fee.checked_add(&self.close_fee) {
341 Ok(total) => write!(f, "{total})"),
342 Err(_) => write!(f, "{} + {})", self.open_fee, self.close_fee),
343 }
344 }
345}
346
347#[cfg(feature = "clock")]
348impl Default for SpotPosition {
349 fn default() -> Self {
350 Self {
351 symbol: String::new(),
352 quantity: Positive::ZERO,
353 cost_basis: Positive::ZERO,
354 side: Side::Long,
355 date: wall_clock(),
356 open_fee: Positive::ZERO,
357 close_fee: Positive::ZERO,
358 }
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 #[cfg(feature = "clock")]
365 use super::*;
366 #[cfg(feature = "clock")]
367 use positive::pos_or_panic;
368
369 #[cfg(feature = "clock")]
370 #[test]
371 fn test_spot_position_new() {
372 let spot = SpotPosition::new(
373 "AAPL".to_string(),
374 Positive::HUNDRED,
375 pos_or_panic!(150.0),
376 Side::Long,
377 wall_clock(),
378 Positive::ONE,
379 Positive::ONE,
380 );
381
382 assert_eq!(spot.symbol, "AAPL");
383 assert_eq!(spot.quantity, Positive::HUNDRED);
384 assert_eq!(spot.cost_basis, pos_or_panic!(150.0));
385 assert_eq!(spot.side, Side::Long);
386 assert_eq!(spot.open_fee, Positive::ONE);
387 assert_eq!(spot.close_fee, Positive::ONE);
388 }
389
390 #[cfg(feature = "clock")]
391 #[test]
392 fn test_spot_position_long_convenience() {
393 let spot = SpotPosition::long("BTC".to_string(), Positive::ONE, pos_or_panic!(50000.0));
394
395 assert_eq!(spot.symbol, "BTC");
396 assert_eq!(spot.quantity, Positive::ONE);
397 assert_eq!(spot.cost_basis, pos_or_panic!(50000.0));
398 assert_eq!(spot.side, Side::Long);
399 assert_eq!(spot.open_fee, Positive::ZERO);
400 }
401
402 #[cfg(feature = "clock")]
403 #[test]
404 fn test_spot_position_short_convenience() {
405 let spot = SpotPosition::short(
406 "ETH".to_string(),
407 pos_or_panic!(10.0),
408 pos_or_panic!(3000.0),
409 );
410
411 assert_eq!(spot.symbol, "ETH");
412 assert_eq!(spot.quantity, pos_or_panic!(10.0));
413 assert_eq!(spot.cost_basis, pos_or_panic!(3000.0));
414 assert_eq!(spot.side, Side::Short);
415 }
416
417 #[cfg(feature = "clock")]
418 #[test]
419 fn test_initial_value() {
420 let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
421 assert_eq!(spot.initial_value().ok(), Some(pos_or_panic!(15000.0)));
422 }
423
424 #[cfg(feature = "clock")]
425 #[test]
426 fn test_market_value() {
427 let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
428 assert_eq!(
429 spot.market_value(pos_or_panic!(160.0)).ok(),
430 Some(pos_or_panic!(16000.0))
431 );
432 }
433
434 #[cfg(feature = "clock")]
435 #[test]
436 fn test_long_pnl_profit() {
437 let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
438 let pnl = spot.pnl_at_price(pos_or_panic!(160.0));
439 assert_eq!(pnl.ok(), Some(Decimal::from(1000))); }
441
442 #[cfg(feature = "clock")]
443 #[test]
444 fn test_long_pnl_loss() {
445 let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
446 let pnl = spot.pnl_at_price(pos_or_panic!(140.0));
447 assert_eq!(pnl.ok(), Some(Decimal::from(-1000))); }
449
450 #[cfg(feature = "clock")]
451 #[test]
452 fn test_short_pnl_profit() {
453 let spot = SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
454 let pnl = spot.pnl_at_price(pos_or_panic!(140.0));
455 assert_eq!(pnl.ok(), Some(Decimal::from(1000))); }
457
458 #[cfg(feature = "clock")]
459 #[test]
460 fn test_short_pnl_loss() {
461 let spot = SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
462 let pnl = spot.pnl_at_price(pos_or_panic!(160.0));
463 assert_eq!(pnl.ok(), Some(Decimal::from(-1000))); }
465
466 #[cfg(feature = "clock")]
467 #[test]
468 fn test_pnl_with_fees() {
469 let spot = SpotPosition::new(
470 "AAPL".to_string(),
471 Positive::HUNDRED,
472 pos_or_panic!(150.0),
473 Side::Long,
474 wall_clock(),
475 pos_or_panic!(10.0),
476 pos_or_panic!(10.0),
477 );
478 let pnl = spot.pnl_at_price(pos_or_panic!(160.0));
479 assert_eq!(pnl.ok(), Some(Decimal::from(980))); }
481
482 #[cfg(feature = "clock")]
487 #[test]
488 fn test_spot_position_pnl_at_price_overflowing_notional_is_reported() {
489 let mut spot =
490 SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
491 spot.quantity = Positive::MAX;
492
493 assert!(spot.pnl_at_price(Positive::MAX).is_err());
494 }
495
496 #[cfg(feature = "clock")]
499 #[test]
500 fn test_spot_position_total_cost_overflowing_notional_is_reported() {
501 let mut spot = SpotPosition::long("AAPL".to_string(), Positive::MAX, Positive::MAX);
502 spot.open_fee = Positive::ONE;
503
504 assert!(spot.total_cost().is_err());
505 }
506
507 #[cfg(feature = "clock")]
509 #[test]
510 fn test_spot_position_total_cost_overflowing_fees_is_reported() {
511 let mut spot =
512 SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
513 spot.open_fee = Positive::MAX;
514 spot.close_fee = Positive::MAX;
515
516 assert!(spot.total_cost().is_err());
517 assert!(spot.fees().is_err());
518 }
519
520 #[cfg(feature = "clock")]
521 #[test]
522 fn test_total_cost_long() {
523 let spot = SpotPosition::new(
524 "AAPL".to_string(),
525 Positive::HUNDRED,
526 pos_or_panic!(150.0),
527 Side::Long,
528 wall_clock(),
529 pos_or_panic!(10.0),
530 pos_or_panic!(10.0),
531 );
532 assert_eq!(spot.total_cost().ok(), Some(pos_or_panic!(15020.0))); }
534
535 #[cfg(feature = "clock")]
536 #[test]
537 fn test_total_cost_short() {
538 let spot = SpotPosition::new(
539 "AAPL".to_string(),
540 Positive::HUNDRED,
541 pos_or_panic!(150.0),
542 Side::Short,
543 wall_clock(),
544 pos_or_panic!(10.0),
545 pos_or_panic!(10.0),
546 );
547 assert_eq!(spot.total_cost().ok(), Some(pos_or_panic!(20.0))); }
549
550 #[cfg(feature = "clock")]
551 #[test]
552 fn test_percentage_return_long() {
553 let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, Positive::HUNDRED);
554 let return_pct = spot.percentage_return(pos_or_panic!(110.0));
555 assert_eq!(return_pct.ok(), Some(Decimal::new(1, 1))); }
557
558 #[cfg(feature = "clock")]
559 #[test]
560 fn test_percentage_return_short() {
561 let spot = SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, Positive::HUNDRED);
562 let return_pct = spot.percentage_return(pos_or_panic!(90.0));
563 assert_eq!(return_pct.ok(), Some(Decimal::new(1, 1))); }
565
566 #[cfg(feature = "clock")]
567 #[test]
568 fn test_break_even_long() {
569 let spot = SpotPosition::new(
570 "AAPL".to_string(),
571 Positive::HUNDRED,
572 pos_or_panic!(150.0),
573 Side::Long,
574 wall_clock(),
575 pos_or_panic!(50.0),
576 pos_or_panic!(50.0),
577 );
578 assert_eq!(spot.break_even_price().ok(), Some(pos_or_panic!(151.0))); }
580
581 #[cfg(feature = "clock")]
582 #[test]
583 fn test_break_even_short() {
584 let spot = SpotPosition::new(
585 "AAPL".to_string(),
586 Positive::HUNDRED,
587 pos_or_panic!(150.0),
588 Side::Short,
589 wall_clock(),
590 pos_or_panic!(50.0),
591 pos_or_panic!(50.0),
592 );
593 assert_eq!(spot.break_even_price().ok(), Some(pos_or_panic!(149.0))); }
595
596 #[cfg(feature = "clock")]
601 #[test]
602 fn test_extreme_values_are_errors_not_aborts() {
603 let wide = SpotPosition::long("X".to_string(), Positive::MAX, Positive::TWO);
604 assert!(wide.initial_value().is_err());
605 assert!(wide.market_value(Positive::TWO).is_err());
606
607 let tiny_basis = SpotPosition::long("X".to_string(), Positive::ONE, pos_or_panic!(1e-27));
608 assert!(matches!(
609 tiny_basis.percentage_return(Positive::MAX),
610 Err(PositionError::DecimalError(_))
611 ));
612
613 let mut tiny_quantity =
614 SpotPosition::long("X".to_string(), pos_or_panic!(1e-27), Positive::ONE);
615 tiny_quantity.open_fee = Positive::MAX;
616 assert!(tiny_quantity.break_even_price().is_err());
617
618 tiny_quantity.close_fee = Positive::MAX;
619 let shown = tiny_quantity.to_string();
620 assert!(shown.ends_with(&format!("(fees: {} + {})", Positive::MAX, Positive::MAX)));
621 }
622
623 #[cfg(feature = "clock")]
624 #[test]
625 fn test_display() {
626 let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
627 let display = format!("{}", spot);
628 assert!(display.contains("Long"));
629 assert!(display.contains("AAPL"));
630 assert!(display.contains("100"));
631 }
632
633 #[cfg(feature = "clock")]
634 #[test]
635 fn test_is_long_short() {
636 let long = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
637 let short =
638 SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
639
640 assert!(long.is_long());
641 assert!(!long.is_short());
642 assert!(!short.is_long());
643 assert!(short.is_short());
644 }
645}