Skip to main content

finql/
portfolio.rs

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/// Errors related to position calculation
19#[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/// Calculate the total position as of a given date by applying a specified set of filters
30#[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    // realized p&l from buying/selling assets
37    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/// Calculate the total position as of a given date by applying a specified set of filters
48#[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    /// Add quote information to position
86    /// If no quote is available (or no conversion to position currency), calculate
87    /// from purchase value.
88    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                // No price found
95                self.last_quote = self.quote_from_purchase();
96                self.last_quote_time = None;
97            }
98        } else {
99            // No asset ID, must be some technical account, set price to 1.0
100            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    /// Reset all pnl relevant figures, i.e. set purchase value to position * price and
170    /// realized p&l, dividends, interest, tax, fee to 0 and eliminate 0 positions
171    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
201/// Search for transaction referred to by transaction_ref and return associated asset_id
202fn 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
220/// Calculate the total position since inception caused by a given set of transactions.
221pub 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
231/// Given a PortfolioPosition, calculate changes to position by a given set of transactions.
232pub 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        // currently, we assume that all cash flows are in the same currency
247        if trans.cash_flow.amount.currency != base_currency {
248            return Err(PositionError::ForeignCurrency);
249        }
250        // adjust cash balance
251        positions.cash.position += trans.cash_flow.amount.amount;
252
253        match trans.transaction_type {
254            TransactionType::Cash => {
255                // Do nothing, cash position has already been updated
256            }
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                            // Increase position
269                            pos.position += position;
270                            pos.purchase_value += amount;
271                        } else {
272                            // Reduce position, calculate realized p&l part
273                            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
346/// Calculate position and P&L since for list of transactions.
347/// All transaction with cash flow dates before the given date are taken into account and valued
348/// using the latest available quote before midnight of that date.
349pub 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
370/// Calculate position and P&L changes for a given range of dates.
371/// The date range is inclusive, i.e. all transactions with cash flow dates on or after `start`
372/// and on or before `end` a taken into account. The initial positions at `start` are valued
373/// with the latest quotes before that date, the final position is valued with the latest
374/// quotes before the date after `end`. With this method, P&L is additive, i.e. adding the
375/// P&L figures of directly succeeding date periods should sum up to the P&L of the joined period.
376pub 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        // fees and taxes not associated to any transaction
616        assert_fuzzy_eq!(positions.cash.fees, -7.0, tol);
617        assert_fuzzy_eq!(positions.cash.tax, -4.5, tol);
618
619        // standalone dividends/interest
620        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        // Setup database connection
632        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        // first add some assets and currencies
641        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        // add ticker
670        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        // add quotes
699        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}