1use crate::error::PositionError;
39use crate::model::decimal::{d_div, d_mul, d_sub};
40use crate::model::leg::traits::LegAble;
41use crate::model::types::Side;
42use chrono::{DateTime, Utc};
43use positive::Positive;
44use rust_decimal::Decimal;
45use serde::{Deserialize, Serialize};
46
47#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
63#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
64pub struct SpotPosition {
65 pub symbol: String,
67
68 pub quantity: Positive,
70
71 pub cost_basis: Positive,
73
74 pub side: Side,
76
77 pub date: DateTime<Utc>,
79
80 pub open_fee: Positive,
82
83 pub close_fee: Positive,
85}
86
87impl SpotPosition {
88 #[must_use]
104 pub fn new(
105 symbol: String,
106 quantity: Positive,
107 cost_basis: Positive,
108 side: Side,
109 date: DateTime<Utc>,
110 open_fee: Positive,
111 close_fee: Positive,
112 ) -> Self {
113 Self {
114 symbol,
115 quantity,
116 cost_basis,
117 side,
118 date,
119 open_fee,
120 close_fee,
121 }
122 }
123
124 #[must_use]
136 pub fn long(symbol: String, quantity: Positive, cost_basis: Positive) -> Self {
137 Self::new(
138 symbol,
139 quantity,
140 cost_basis,
141 Side::Long,
142 Utc::now(),
143 Positive::ZERO,
144 Positive::ZERO,
145 )
146 }
147
148 #[must_use]
160 pub fn short(symbol: String, quantity: Positive, cost_basis: Positive) -> Self {
161 Self::new(
162 symbol,
163 quantity,
164 cost_basis,
165 Side::Short,
166 Utc::now(),
167 Positive::ZERO,
168 Positive::ZERO,
169 )
170 }
171
172 pub fn initial_value(&self) -> Result<Positive, PositionError> {
181 Ok(self.quantity.checked_mul(&self.cost_basis)?)
182 }
183
184 pub fn market_value(&self, current_price: Positive) -> Result<Positive, PositionError> {
195 Ok(self.quantity.checked_mul(¤t_price)?)
196 }
197
198 pub fn percentage_return(&self, current_price: Positive) -> Result<Decimal, PositionError> {
214 if self.cost_basis == Positive::ZERO {
215 return Ok(Decimal::ZERO);
216 }
217
218 let price_change = d_sub(
219 current_price.to_dec(),
220 self.cost_basis.to_dec(),
221 "SpotPosition::percentage_return/price_change",
222 )?;
223 let return_pct = d_div(
224 price_change,
225 self.cost_basis.to_dec(),
226 "SpotPosition::percentage_return",
227 )?;
228
229 Ok(match self.side {
230 Side::Long => return_pct,
231 Side::Short => -return_pct,
232 })
233 }
234
235 pub fn break_even_price(&self) -> Result<Positive, PositionError> {
248 if self.quantity == Positive::ZERO {
249 return Ok(self.cost_basis);
250 }
251
252 let fee_per_unit = self
253 .open_fee
254 .checked_add(&self.close_fee)?
255 .checked_div(&self.quantity)?;
256
257 Ok(match self.side {
258 Side::Long => self.cost_basis.checked_add(&fee_per_unit)?,
259 Side::Short => {
260 if self.cost_basis > fee_per_unit {
261 self.cost_basis.checked_sub(&fee_per_unit)?
262 } else {
263 Positive::ZERO
264 }
265 }
266 })
267 }
268}
269
270impl LegAble for SpotPosition {
271 fn get_symbol(&self) -> &str {
272 &self.symbol
273 }
274
275 fn get_quantity(&self) -> Positive {
276 self.quantity
277 }
278
279 fn get_side(&self) -> Side {
280 self.side
281 }
282
283 fn pnl_at_price(&self, price: Positive) -> Result<Decimal, PositionError> {
284 let price_change = d_sub(
285 price.to_dec(),
286 self.cost_basis.to_dec(),
287 "SpotPosition::pnl_at_price/price_change",
288 )?;
289 let gross_pnl = d_mul(
290 price_change,
291 self.quantity.to_dec(),
292 "SpotPosition::pnl_at_price/gross",
293 )?;
294 let total_fees = self.open_fee.checked_add(&self.close_fee)?;
295
296 let signed_gross = match self.side {
297 Side::Long => gross_pnl,
298 Side::Short => -gross_pnl,
301 };
302 Ok(d_sub(
303 signed_gross,
304 total_fees.to_dec(),
305 "SpotPosition::pnl_at_price/net",
306 )?)
307 }
308
309 fn total_cost(&self) -> Result<Positive, PositionError> {
310 Ok(match self.side {
311 Side::Long => self
312 .quantity
313 .checked_mul(&self.cost_basis)?
314 .checked_add(&self.open_fee)?
315 .checked_add(&self.close_fee)?,
316 Side::Short => self.open_fee.checked_add(&self.close_fee)?,
317 })
318 }
319
320 fn fees(&self) -> Result<Positive, PositionError> {
321 Ok(self.open_fee.checked_add(&self.close_fee)?)
322 }
323}
324
325impl std::fmt::Display for SpotPosition {
326 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327 write!(
328 f,
329 "{} {} {} @ {} (fees: ",
330 self.side, self.quantity, self.symbol, self.cost_basis,
331 )?;
332 match self.open_fee.checked_add(&self.close_fee) {
335 Ok(total) => write!(f, "{total})"),
336 Err(_) => write!(f, "{} + {})", self.open_fee, self.close_fee),
337 }
338 }
339}
340
341impl Default for SpotPosition {
342 fn default() -> Self {
343 Self {
344 symbol: String::new(),
345 quantity: Positive::ZERO,
346 cost_basis: Positive::ZERO,
347 side: Side::Long,
348 date: Utc::now(),
349 open_fee: Positive::ZERO,
350 close_fee: Positive::ZERO,
351 }
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use positive::pos_or_panic;
359
360 #[test]
361 fn test_spot_position_new() {
362 let spot = SpotPosition::new(
363 "AAPL".to_string(),
364 Positive::HUNDRED,
365 pos_or_panic!(150.0),
366 Side::Long,
367 Utc::now(),
368 Positive::ONE,
369 Positive::ONE,
370 );
371
372 assert_eq!(spot.symbol, "AAPL");
373 assert_eq!(spot.quantity, Positive::HUNDRED);
374 assert_eq!(spot.cost_basis, pos_or_panic!(150.0));
375 assert_eq!(spot.side, Side::Long);
376 assert_eq!(spot.open_fee, Positive::ONE);
377 assert_eq!(spot.close_fee, Positive::ONE);
378 }
379
380 #[test]
381 fn test_spot_position_long_convenience() {
382 let spot = SpotPosition::long("BTC".to_string(), Positive::ONE, pos_or_panic!(50000.0));
383
384 assert_eq!(spot.symbol, "BTC");
385 assert_eq!(spot.quantity, Positive::ONE);
386 assert_eq!(spot.cost_basis, pos_or_panic!(50000.0));
387 assert_eq!(spot.side, Side::Long);
388 assert_eq!(spot.open_fee, Positive::ZERO);
389 }
390
391 #[test]
392 fn test_spot_position_short_convenience() {
393 let spot = SpotPosition::short(
394 "ETH".to_string(),
395 pos_or_panic!(10.0),
396 pos_or_panic!(3000.0),
397 );
398
399 assert_eq!(spot.symbol, "ETH");
400 assert_eq!(spot.quantity, pos_or_panic!(10.0));
401 assert_eq!(spot.cost_basis, pos_or_panic!(3000.0));
402 assert_eq!(spot.side, Side::Short);
403 }
404
405 #[test]
406 fn test_initial_value() {
407 let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
408 assert_eq!(spot.initial_value().ok(), Some(pos_or_panic!(15000.0)));
409 }
410
411 #[test]
412 fn test_market_value() {
413 let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
414 assert_eq!(
415 spot.market_value(pos_or_panic!(160.0)).ok(),
416 Some(pos_or_panic!(16000.0))
417 );
418 }
419
420 #[test]
421 fn test_long_pnl_profit() {
422 let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
423 let pnl = spot.pnl_at_price(pos_or_panic!(160.0));
424 assert_eq!(pnl.ok(), Some(Decimal::from(1000))); }
426
427 #[test]
428 fn test_long_pnl_loss() {
429 let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
430 let pnl = spot.pnl_at_price(pos_or_panic!(140.0));
431 assert_eq!(pnl.ok(), Some(Decimal::from(-1000))); }
433
434 #[test]
435 fn test_short_pnl_profit() {
436 let spot = SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
437 let pnl = spot.pnl_at_price(pos_or_panic!(140.0));
438 assert_eq!(pnl.ok(), Some(Decimal::from(1000))); }
440
441 #[test]
442 fn test_short_pnl_loss() {
443 let spot = SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
444 let pnl = spot.pnl_at_price(pos_or_panic!(160.0));
445 assert_eq!(pnl.ok(), Some(Decimal::from(-1000))); }
447
448 #[test]
449 fn test_pnl_with_fees() {
450 let spot = SpotPosition::new(
451 "AAPL".to_string(),
452 Positive::HUNDRED,
453 pos_or_panic!(150.0),
454 Side::Long,
455 Utc::now(),
456 pos_or_panic!(10.0),
457 pos_or_panic!(10.0),
458 );
459 let pnl = spot.pnl_at_price(pos_or_panic!(160.0));
460 assert_eq!(pnl.ok(), Some(Decimal::from(980))); }
462
463 #[test]
468 fn test_spot_position_pnl_at_price_overflowing_notional_is_reported() {
469 let mut spot =
470 SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
471 spot.quantity = Positive::MAX;
472
473 assert!(spot.pnl_at_price(Positive::MAX).is_err());
474 }
475
476 #[test]
479 fn test_spot_position_total_cost_overflowing_notional_is_reported() {
480 let mut spot = SpotPosition::long("AAPL".to_string(), Positive::MAX, Positive::MAX);
481 spot.open_fee = Positive::ONE;
482
483 assert!(spot.total_cost().is_err());
484 }
485
486 #[test]
488 fn test_spot_position_total_cost_overflowing_fees_is_reported() {
489 let mut spot =
490 SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
491 spot.open_fee = Positive::MAX;
492 spot.close_fee = Positive::MAX;
493
494 assert!(spot.total_cost().is_err());
495 assert!(spot.fees().is_err());
496 }
497
498 #[test]
499 fn test_total_cost_long() {
500 let spot = SpotPosition::new(
501 "AAPL".to_string(),
502 Positive::HUNDRED,
503 pos_or_panic!(150.0),
504 Side::Long,
505 Utc::now(),
506 pos_or_panic!(10.0),
507 pos_or_panic!(10.0),
508 );
509 assert_eq!(spot.total_cost().ok(), Some(pos_or_panic!(15020.0))); }
511
512 #[test]
513 fn test_total_cost_short() {
514 let spot = SpotPosition::new(
515 "AAPL".to_string(),
516 Positive::HUNDRED,
517 pos_or_panic!(150.0),
518 Side::Short,
519 Utc::now(),
520 pos_or_panic!(10.0),
521 pos_or_panic!(10.0),
522 );
523 assert_eq!(spot.total_cost().ok(), Some(pos_or_panic!(20.0))); }
525
526 #[test]
527 fn test_percentage_return_long() {
528 let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, Positive::HUNDRED);
529 let return_pct = spot.percentage_return(pos_or_panic!(110.0));
530 assert_eq!(return_pct.ok(), Some(Decimal::new(1, 1))); }
532
533 #[test]
534 fn test_percentage_return_short() {
535 let spot = SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, Positive::HUNDRED);
536 let return_pct = spot.percentage_return(pos_or_panic!(90.0));
537 assert_eq!(return_pct.ok(), Some(Decimal::new(1, 1))); }
539
540 #[test]
541 fn test_break_even_long() {
542 let spot = SpotPosition::new(
543 "AAPL".to_string(),
544 Positive::HUNDRED,
545 pos_or_panic!(150.0),
546 Side::Long,
547 Utc::now(),
548 pos_or_panic!(50.0),
549 pos_or_panic!(50.0),
550 );
551 assert_eq!(spot.break_even_price().ok(), Some(pos_or_panic!(151.0))); }
553
554 #[test]
555 fn test_break_even_short() {
556 let spot = SpotPosition::new(
557 "AAPL".to_string(),
558 Positive::HUNDRED,
559 pos_or_panic!(150.0),
560 Side::Short,
561 Utc::now(),
562 pos_or_panic!(50.0),
563 pos_or_panic!(50.0),
564 );
565 assert_eq!(spot.break_even_price().ok(), Some(pos_or_panic!(149.0))); }
567
568 #[test]
573 fn test_extreme_values_are_errors_not_aborts() {
574 let wide = SpotPosition::long("X".to_string(), Positive::MAX, Positive::TWO);
575 assert!(wide.initial_value().is_err());
576 assert!(wide.market_value(Positive::TWO).is_err());
577
578 let tiny_basis = SpotPosition::long("X".to_string(), Positive::ONE, pos_or_panic!(1e-27));
579 assert!(matches!(
580 tiny_basis.percentage_return(Positive::MAX),
581 Err(PositionError::DecimalError(_))
582 ));
583
584 let mut tiny_quantity =
585 SpotPosition::long("X".to_string(), pos_or_panic!(1e-27), Positive::ONE);
586 tiny_quantity.open_fee = Positive::MAX;
587 assert!(tiny_quantity.break_even_price().is_err());
588
589 tiny_quantity.close_fee = Positive::MAX;
590 let shown = tiny_quantity.to_string();
591 assert!(shown.ends_with(&format!("(fees: {} + {})", Positive::MAX, Positive::MAX)));
592 }
593
594 #[test]
595 fn test_display() {
596 let spot = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
597 let display = format!("{}", spot);
598 assert!(display.contains("Long"));
599 assert!(display.contains("AAPL"));
600 assert!(display.contains("100"));
601 }
602
603 #[test]
604 fn test_is_long_short() {
605 let long = SpotPosition::long("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
606 let short =
607 SpotPosition::short("AAPL".to_string(), Positive::HUNDRED, pos_or_panic!(150.0));
608
609 assert!(long.is_long());
610 assert!(!long.is_short());
611 assert!(!short.is_long());
612 assert!(short.is_short());
613 }
614}