1use futures::future::join_all;
2use std::collections::BTreeMap;
3use std::sync::Arc;
4use std::vec::Vec;
5use thiserror::Error;
6
7use chrono::offset::TimeZone;
8use chrono::{DateTime, Local, NaiveDate};
9use serde::{Deserialize, Serialize};
10
11use crate::datatypes::{
12 Asset, AssetHandler, Currency, DataError, QuoteHandler, Transaction, TransactionType,
13};
14
15use crate::period_date::PeriodDateError;
16use crate::Market;
17
18#[derive(Error, Debug)]
20pub enum PositionError {
21 #[error("Failed to fetch position data")]
22 PositionDataError(#[from] DataError),
23 #[error("Failed to parse foreign currency")]
24 ForeignCurrency,
25 #[error("Invalid start or end date")]
26 DateError(#[from] PeriodDateError),
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Position {
32 pub asset_id: Option<i32>,
33 pub name: String,
34 pub position: f64,
35 pub purchase_value: f64,
36 pub trading_pnl: f64,
38 pub interest: f64,
39 pub dividend: f64,
40 pub fees: f64,
41 pub tax: f64,
42 pub currency: Currency,
43 pub last_quote: Option<f64>,
44 pub last_quote_time: Option<DateTime<Local>>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
49pub struct PositionTotals {
50 pub value: f64,
51 trading_pnl: f64,
52 unrealized_pnl: f64,
53 dividend: f64,
54 interest: f64,
55 tax: f64,
56 fees: f64,
57}
58
59impl Position {
60 pub fn new(asset_id: Option<i32>, currency: Currency) -> Position {
61 Position {
62 asset_id,
63 name: String::new(),
64 position: 0.0,
65 purchase_value: 0.0,
66 trading_pnl: 0.0,
67 currency,
68 interest: 0.0,
69 dividend: 0.0,
70 fees: 0.0,
71 tax: 0.0,
72 last_quote: None,
73 last_quote_time: None,
74 }
75 }
76
77 fn quote_from_purchase(&self) -> Option<f64> {
78 if self.position == 0.0 {
79 None
80 } else {
81 Some(-self.purchase_value / self.position)
82 }
83 }
84
85 pub async fn add_quote(&mut self, time: DateTime<Local>, market: &Market) {
89 if let Some(asset_id) = self.asset_id {
90 if let Ok(price) = market.get_asset_price(asset_id, self.currency, time).await {
91 self.last_quote = Some(price);
92 self.last_quote_time = Some(time);
93 } else {
94 self.last_quote = self.quote_from_purchase();
96 self.last_quote_time = None;
97 }
98 } else {
99 self.last_quote = Some(1.0);
101 self.last_quote_time = Some(Local::now());
102 };
103 }
104}
105
106#[derive(Debug, Serialize, Deserialize, Clone)]
107pub struct PortfolioPosition {
108 pub cash: Position,
109 pub assets: BTreeMap<i32, Position>,
110}
111
112impl PortfolioPosition {
113 pub fn new(base_currency: Currency) -> PortfolioPosition {
114 PortfolioPosition {
115 cash: Position::new(None, base_currency),
116 assets: BTreeMap::new(),
117 }
118 }
119
120 pub async fn get_asset_names(
121 &mut self,
122 db: Arc<dyn AssetHandler + Send + Sync>,
123 ) -> Result<(), DataError> {
124 for (id, mut pos) in &mut self.assets {
125 let asset = db.get_asset_by_id(*id).await?;
126 pos.name = match asset {
127 Asset::Currency(c) => c.iso_code.to_string(),
128 Asset::Stock(s) => s.name.clone(),
129 };
130 }
131 Ok(())
132 }
133
134 pub async fn add_quote(&mut self, time: DateTime<Local>, market: &Market) {
135 let mut get_quote_futures = Vec::new();
136 for pos in self.assets.values_mut() {
137 get_quote_futures.push(pos.add_quote(time, market));
138 }
139 let _ = join_all(get_quote_futures).await;
140 }
141
142 pub fn calc_totals(&mut self) -> PositionTotals {
143 let mut totals = PositionTotals {
144 value: self.cash.position,
145 trading_pnl: self.cash.trading_pnl,
146 unrealized_pnl: 0.0,
147 dividend: self.cash.dividend,
148 interest: self.cash.interest,
149 tax: self.cash.tax,
150 fees: self.cash.fees,
151 };
152 for pos in self.assets.values() {
153 let pos_value = if let Some(quote) = pos.last_quote {
154 pos.position * quote
155 } else {
156 -pos.purchase_value
157 };
158 totals.value += pos_value;
159 totals.trading_pnl += pos.trading_pnl;
160 totals.unrealized_pnl += pos_value + pos.purchase_value;
161 totals.dividend += pos.dividend;
162 totals.interest += pos.interest;
163 totals.tax += pos.tax;
164 totals.fees += pos.fees;
165 }
166 totals
167 }
168
169 fn reset_pnl(&mut self) {
172 self.remove_zero_positions();
173 self.cash.trading_pnl = 0.0;
174 self.cash.dividend = 0.0;
175 self.cash.interest = 0.0;
176 self.cash.fees = 0.0;
177 self.cash.tax = 0.0;
178 for mut pos in self.assets.iter_mut() {
179 pos.1.trading_pnl = 0.0;
180 pos.1.dividend = 0.0;
181 pos.1.interest = 0.0;
182 pos.1.fees = 0.0;
183 pos.1.tax = 0.0;
184 pos.1.purchase_value = -pos.1.position * pos.1.last_quote.unwrap_or(0.0);
185 }
186 }
187
188 fn remove_zero_positions(&mut self) {
189 let mut zero_positions = Vec::new();
190 for pos in self.assets.iter() {
191 if pos.1.position == 0.0 {
192 zero_positions.push(*pos.0);
193 }
194 }
195 for key in zero_positions {
196 self.assets.remove(&key);
197 }
198 }
199}
200
201fn get_asset_id(transactions: &[Transaction], trans_ref: Option<i32>) -> Option<i32> {
203 trans_ref?;
204 for trans in transactions {
205 if trans.id == trans_ref {
206 return match trans.transaction_type {
207 TransactionType::Asset {
208 asset_id,
209 position: _,
210 } => Some(asset_id),
211 TransactionType::Dividend { asset_id } => Some(asset_id),
212 TransactionType::Interest { asset_id } => Some(asset_id),
213 _ => None,
214 };
215 }
216 }
217 None
218}
219
220pub fn calc_position(
222 base_currency: Currency,
223 transactions: &[Transaction],
224 date: Option<NaiveDate>,
225) -> Result<PortfolioPosition, PositionError> {
226 let mut positions = PortfolioPosition::new(base_currency);
227 calc_delta_position(&mut positions, transactions, None, date)?;
228 Ok(positions)
229}
230
231pub fn calc_delta_position(
233 positions: &mut PortfolioPosition,
234 transactions: &[Transaction],
235 start: Option<NaiveDate>,
236 end: Option<NaiveDate>,
237) -> Result<(), PositionError> {
238 let base_currency = positions.cash.currency;
239 for trans in transactions {
240 if start.is_some() && trans.cash_flow.date < start.unwrap() {
241 continue;
242 }
243 if end.is_some() && trans.cash_flow.date >= end.unwrap() {
244 continue;
245 }
246 if trans.cash_flow.amount.currency != base_currency {
248 return Err(PositionError::ForeignCurrency);
249 }
250 positions.cash.position += trans.cash_flow.amount.amount;
252
253 match trans.transaction_type {
254 TransactionType::Cash => {
255 }
257 TransactionType::Asset { asset_id, position } => {
258 match positions.assets.get_mut(&asset_id) {
259 None => {
260 let mut new_pos = Position::new(Some(asset_id), base_currency);
261 new_pos.position = position;
262 new_pos.purchase_value = trans.cash_flow.amount.amount;
263 positions.assets.insert(asset_id, new_pos);
264 }
265 Some(pos) => {
266 let amount = trans.cash_flow.amount.amount;
267 if pos.position * position >= 0.0 {
268 pos.position += position;
270 pos.purchase_value += amount;
271 } else {
272 let eff_price = -pos.purchase_value / pos.position;
274 let sell_price = -amount / position;
275 let pnl = -position * (sell_price - eff_price);
276 pos.trading_pnl += pnl;
277 pos.position += position;
278 pos.purchase_value += amount - pnl;
279 }
280 }
281 };
282 }
283 TransactionType::Interest { asset_id } => {
284 match positions.assets.get_mut(&asset_id) {
285 None => {
286 let mut new_pos = Position::new(Some(asset_id), base_currency);
287 new_pos.interest = trans.cash_flow.amount.amount;
288 positions.assets.insert(asset_id, new_pos);
289 }
290 Some(pos) => {
291 pos.interest += trans.cash_flow.amount.amount;
292 }
293 };
294 }
295 TransactionType::Dividend { asset_id } => {
296 match positions.assets.get_mut(&asset_id) {
297 None => {
298 let mut new_pos = Position::new(Some(asset_id), base_currency);
299 new_pos.dividend = trans.cash_flow.amount.amount;
300 positions.assets.insert(asset_id, new_pos);
301 }
302 Some(pos) => {
303 pos.dividend += trans.cash_flow.amount.amount;
304 }
305 };
306 }
307 TransactionType::Fee { transaction_ref } => {
308 let asset_id = get_asset_id(transactions, transaction_ref);
309 if let Some(asset_id) = asset_id {
310 match positions.assets.get_mut(&asset_id) {
311 None => {
312 let mut new_pos = Position::new(Some(asset_id), base_currency);
313 new_pos.fees = trans.cash_flow.amount.amount;
314 positions.assets.insert(asset_id, new_pos);
315 }
316 Some(pos) => {
317 pos.fees += trans.cash_flow.amount.amount;
318 }
319 };
320 } else {
321 positions.cash.fees += trans.cash_flow.amount.amount;
322 }
323 }
324 TransactionType::Tax { transaction_ref } => {
325 let asset_id = get_asset_id(transactions, transaction_ref);
326 if let Some(asset_id) = asset_id {
327 match positions.assets.get_mut(&asset_id) {
328 None => {
329 let mut new_pos = Position::new(Some(asset_id), base_currency);
330 new_pos.tax = trans.cash_flow.amount.amount;
331 positions.assets.insert(asset_id, new_pos);
332 }
333 Some(pos) => {
334 pos.tax += trans.cash_flow.amount.amount;
335 }
336 };
337 } else {
338 positions.cash.tax += trans.cash_flow.amount.amount;
339 }
340 }
341 }
342 }
343 Ok(())
344}
345
346pub async fn calculate_position_and_pnl(
350 currency: Currency,
351 transactions: &[Transaction],
352 date: Option<NaiveDate>,
353 db: Arc<dyn QuoteHandler + Send + Sync>,
354) -> Result<(PortfolioPosition, PositionTotals), PositionError> {
355 let mut position = calc_position(currency, transactions, date)?;
356 position
357 .get_asset_names(db.clone().into_arc_dispatch())
358 .await?;
359 let date_time: DateTime<Local> = if let Some(date) = date {
360 Local.from_local_datetime(&date.and_hms(0, 0, 0)).unwrap()
361 } else {
362 Local::now()
363 };
364 let market = Market::new(db).await;
365 position.add_quote(date_time, &market).await;
366 let totals = position.calc_totals();
367 Ok((position, totals))
368}
369
370pub async fn calculate_position_for_period(
377 currency: Currency,
378 transactions: &[Transaction],
379 start: NaiveDate,
380 end: NaiveDate,
381 db: Arc<dyn QuoteHandler + Send + Sync>,
382) -> Result<(PortfolioPosition, PositionTotals), PositionError> {
383 let (mut position, _) =
384 calculate_position_and_pnl(currency, transactions, Some(start), db.clone()).await?;
385 position.reset_pnl();
386 calc_delta_position(&mut position, transactions, Some(start), Some(end))?;
387 position
388 .get_asset_names(db.clone().into_arc_dispatch())
389 .await?;
390 let end_date_time: DateTime<Local> = Local
391 .from_local_datetime(&end.succ().and_hms(0, 0, 0))
392 .unwrap();
393 let quote_handler = db as Arc<dyn QuoteHandler + Send + Sync>;
394 let market = Market::new(quote_handler).await;
395 position.add_quote(end_date_time, &market).await;
396 let totals = position.calc_totals();
397 Ok((position, totals))
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 use std::str::FromStr;
405
406 use chrono::NaiveDate;
407
408 use crate::assert_fuzzy_eq;
409 use crate::datatypes::{
410 date_time_helper::make_time, Asset, AssetHandler, CashAmount, CashFlow, Currency,
411 CurrencyISOCode, Quote, Stock, Ticker,
412 };
413 use crate::postgres::PostgresDB;
414
415 #[test]
416 fn test_portfolio_position() {
417 let tol = 1e-4;
418 let eur = Currency::from_str("EUR").unwrap();
419
420 let mut transactions = Vec::new();
421 let positions = calc_position(eur, &transactions, None).unwrap();
422 assert_fuzzy_eq!(positions.cash.position, 0.0, tol);
423
424 transactions.push(Transaction {
425 id: Some(1),
426 transaction_type: TransactionType::Cash,
427 cash_flow: CashFlow {
428 amount: CashAmount {
429 amount: 10000.0,
430 currency: eur,
431 },
432 date: NaiveDate::from_ymd(2020, 1, 1),
433 },
434 note: None,
435 });
436 let positions = calc_position(eur, &transactions, None).unwrap();
437 assert_fuzzy_eq!(positions.cash.position, 10000.0, tol);
438 assert_eq!(positions.assets.len(), 0);
439
440 transactions.push(Transaction {
441 id: Some(2),
442 transaction_type: TransactionType::Asset {
443 asset_id: 1,
444 position: 100.0,
445 },
446 cash_flow: CashFlow {
447 amount: CashAmount {
448 amount: -104.0,
449 currency: eur,
450 },
451 date: NaiveDate::from_ymd(2020, 1, 2),
452 },
453 note: None,
454 });
455 transactions.push(Transaction {
456 id: Some(3),
457 transaction_type: TransactionType::Fee {
458 transaction_ref: Some(2),
459 },
460 cash_flow: CashFlow {
461 amount: CashAmount {
462 amount: -5.0,
463 currency: eur,
464 },
465 date: NaiveDate::from_ymd(2020, 1, 2),
466 },
467 note: None,
468 });
469 let positions = calc_position(eur, &transactions, None).unwrap();
470 assert_fuzzy_eq!(positions.cash.position, 10000.0 - 104.0 - 5.0, tol);
471 assert_eq!(positions.assets.len(), 1);
472 let asset_pos_1 = positions.assets.get(&1).unwrap();
473 assert_fuzzy_eq!(asset_pos_1.purchase_value, -104.0, tol);
474 assert_fuzzy_eq!(asset_pos_1.position, 100.0, tol);
475 assert_fuzzy_eq!(asset_pos_1.fees, -5.0, tol);
476 assert_eq!(asset_pos_1.currency, eur);
477
478 transactions.push(Transaction {
479 id: Some(4),
480 transaction_type: TransactionType::Asset {
481 asset_id: 1,
482 position: -50.0,
483 },
484 cash_flow: CashFlow {
485 amount: CashAmount {
486 amount: 60.0,
487 currency: eur,
488 },
489 date: NaiveDate::from_ymd(2020, 1, 31),
490 },
491 note: None,
492 });
493 transactions.push(Transaction {
494 id: Some(5),
495 transaction_type: TransactionType::Fee {
496 transaction_ref: Some(4),
497 },
498 cash_flow: CashFlow {
499 amount: CashAmount {
500 amount: -3.0,
501 currency: eur,
502 },
503 date: NaiveDate::from_ymd(2020, 1, 31),
504 },
505 note: None,
506 });
507 transactions.push(Transaction {
508 id: Some(6),
509 transaction_type: TransactionType::Tax {
510 transaction_ref: Some(4),
511 },
512 cash_flow: CashFlow {
513 amount: CashAmount {
514 amount: -2.0,
515 currency: eur,
516 },
517 date: NaiveDate::from_ymd(2020, 1, 31),
518 },
519 note: None,
520 });
521 let positions = calc_position(eur, &transactions, None).unwrap();
522 assert_fuzzy_eq!(
523 positions.cash.position,
524 10000.0 - 104.0 - 5.0 + 60.0 - 2.0 - 3.0,
525 tol
526 );
527 assert_eq!(positions.assets.len(), 1);
528 let asset_pos_1 = positions.assets.get(&1).unwrap();
529 assert_fuzzy_eq!(asset_pos_1.purchase_value, -52.0, tol);
530 assert_fuzzy_eq!(asset_pos_1.position, 50.0, tol);
531 assert_fuzzy_eq!(asset_pos_1.fees, -8.0, tol);
532 assert_fuzzy_eq!(asset_pos_1.trading_pnl, 8.0, tol);
533 assert_eq!(asset_pos_1.currency, eur);
534
535 transactions.push(Transaction {
536 id: Some(7),
537 transaction_type: TransactionType::Asset {
538 asset_id: 1,
539 position: 150.0,
540 },
541 cash_flow: CashFlow {
542 amount: CashAmount {
543 amount: -140.0,
544 currency: eur,
545 },
546 date: NaiveDate::from_ymd(2020, 2, 15),
547 },
548 note: None,
549 });
550 transactions.push(Transaction {
551 id: Some(8),
552 transaction_type: TransactionType::Fee {
553 transaction_ref: None,
554 },
555 cash_flow: CashFlow {
556 amount: CashAmount {
557 amount: -7.0,
558 currency: eur,
559 },
560 date: NaiveDate::from_ymd(2020, 2, 25),
561 },
562 note: None,
563 });
564 transactions.push(Transaction {
565 id: Some(9),
566 transaction_type: TransactionType::Tax {
567 transaction_ref: None,
568 },
569 cash_flow: CashFlow {
570 amount: CashAmount {
571 amount: -4.5,
572 currency: eur,
573 },
574 date: NaiveDate::from_ymd(2020, 2, 26),
575 },
576 note: None,
577 });
578 transactions.push(Transaction {
579 id: Some(10),
580 transaction_type: TransactionType::Dividend { asset_id: 2 },
581 cash_flow: CashFlow {
582 amount: CashAmount {
583 amount: 13.0,
584 currency: eur,
585 },
586 date: NaiveDate::from_ymd(2020, 2, 27),
587 },
588 note: None,
589 });
590 transactions.push(Transaction {
591 id: Some(11),
592 transaction_type: TransactionType::Interest { asset_id: 3 },
593 cash_flow: CashFlow {
594 amount: CashAmount {
595 amount: 6.6,
596 currency: eur,
597 },
598 date: NaiveDate::from_ymd(2020, 2, 28),
599 },
600 note: None,
601 });
602 let positions = calc_position(eur, &transactions, None).unwrap();
603 assert_fuzzy_eq!(
604 positions.cash.position,
605 10000.0 - 104.0 - 5.0 + 60.0 - 2.0 - 3.0 - 140.0 - 7.0 - 4.5 + 13.0 + 6.6,
606 tol
607 );
608 assert_eq!(positions.assets.len(), 3);
609 let asset_pos_1 = positions.assets.get(&1).unwrap();
610 assert_fuzzy_eq!(asset_pos_1.purchase_value, -192.0, tol);
611 assert_fuzzy_eq!(asset_pos_1.position, 200.0, tol);
612 assert_fuzzy_eq!(asset_pos_1.fees, -8.0, tol);
613 assert_fuzzy_eq!(asset_pos_1.trading_pnl, 8.0, tol);
614
615 assert_fuzzy_eq!(positions.cash.fees, -7.0, tol);
617 assert_fuzzy_eq!(positions.cash.tax, -4.5, tol);
618
619 let asset_pos_2 = positions.assets.get(&2).unwrap();
621 assert_fuzzy_eq!(asset_pos_2.dividend, 13.0, tol);
622 let asset_pos_3 = positions.assets.get(&3).unwrap();
623 assert_fuzzy_eq!(asset_pos_3.interest, 6.6, tol);
624 }
625
626 #[tokio::test]
627 async fn test_add_quote_to_position() {
628 use crate::datatypes::DataItem;
629
630 let tol = 1e-4;
631 let db_url = std::env::var("FINQL_TEST_DATABASE_URL");
633 assert!(
634 db_url.is_ok(),
635 "environment variable $FINQL_TEST_DATABASE_URL is not set"
636 );
637 let db = PostgresDB::new(&db_url.unwrap()).await.unwrap();
638 db.clean().await.unwrap();
639
640 let eur_stock_id = db
642 .insert_asset(&Asset::Stock(Stock::new(
643 None,
644 "EUR Stock".to_string(),
645 Some("EURS".to_string()),
646 None,
647 None,
648 )))
649 .await
650 .unwrap();
651 let us_stock_id = db
652 .insert_asset(&Asset::Stock(Stock::new(
653 None,
654 "USD Stock".to_string(),
655 Some("USDS".to_string()),
656 None,
657 None,
658 )))
659 .await
660 .unwrap();
661 let mut eur = Currency::new(None, CurrencyISOCode::new("EUR").unwrap(), Some(2));
662 let eur_id = db.insert_asset(&Asset::Currency(eur)).await.unwrap();
663 eur.set_id(eur_id).unwrap();
664
665 let mut usd = Currency::new(None, CurrencyISOCode::new("USD").unwrap(), Some(2));
666 let usd_id = db.insert_asset(&Asset::Currency(usd)).await.unwrap();
667 usd.set_id(usd_id).unwrap();
668
669 let eur_ticker_id = db
671 .insert_ticker(&Ticker {
672 id: None,
673 name: "EUR_STOCK.DE".to_string(),
674 asset: eur_stock_id,
675 priority: 10,
676 currency: eur,
677 source: "manual".to_string(),
678 factor: 1.0,
679 tz: None,
680 cal: None,
681 })
682 .await
683 .unwrap();
684 let us_ticker_id = db
685 .insert_ticker(&Ticker {
686 id: None,
687 name: "US_STOCK.DE".to_string(),
688 asset: us_stock_id,
689 priority: 10,
690 currency: usd,
691 source: "manual".to_string(),
692 factor: 1.0,
693 tz: None,
694 cal: None,
695 })
696 .await
697 .unwrap();
698 let time = make_time(2019, 12, 30, 10, 0, 0).unwrap();
700 let _ = db
701 .insert_quote(&Quote {
702 id: None,
703 ticker: eur_ticker_id,
704 price: 12.34,
705 time,
706 volume: None,
707 })
708 .await
709 .unwrap();
710 let _ = db
711 .insert_quote(&Quote {
712 id: None,
713 ticker: us_ticker_id,
714 price: 43.21,
715 time,
716 volume: None,
717 })
718 .await
719 .unwrap();
720 let mut eur_position = Position::new(Some(eur_stock_id), eur);
721 eur_position.name = "EUR Stock".to_string();
722 eur_position.position = 1000.0;
723
724 let mut usd_position = Position::new(Some(us_stock_id), eur);
725 usd_position.name = "US Stock".to_string();
726 usd_position.position = 1000.0;
727
728 let qh: Arc<dyn QuoteHandler + Sync + Send> = Arc::new(db);
729 crate::fx_rates::insert_fx_quote(1.2, eur, usd, time, qh.clone())
730 .await
731 .unwrap();
732 let time = make_time(2019, 12, 30, 10, 0, 0).unwrap();
733 let market = Market::new(qh.clone()).await;
734
735 eur_position.add_quote(time, &market).await;
736 assert_fuzzy_eq!(eur_position.last_quote.unwrap(), 12.34, tol);
737 assert_eq!(
738 eur_position
739 .last_quote_time
740 .unwrap()
741 .format("%F %H:%M:%S")
742 .to_string(),
743 "2019-12-30 10:00:00"
744 );
745
746 usd_position.add_quote(time, &market).await;
747 assert_fuzzy_eq!(usd_position.last_quote.unwrap(), 36.0083, tol);
748 assert_eq!(
749 usd_position
750 .last_quote_time
751 .unwrap()
752 .format("%F %H:%M:%S")
753 .to_string(),
754 "2019-12-30 10:00:00"
755 );
756 }
757}