diff --git a/nautilus_core/Cargo.lock b/nautilus_core/Cargo.lock index b753c1c8aa4..5177fd1bf6a 100644 --- a/nautilus_core/Cargo.lock +++ b/nautilus_core/Cargo.lock @@ -3226,6 +3226,7 @@ dependencies = [ "strum", "thiserror 2.0.3", "ustr", + "uuid", ] [[package]] diff --git a/nautilus_core/analysis/src/python/statistics/long_ratio.rs b/nautilus_core/analysis/src/python/statistics/long_ratio.rs index 7053d477690..c76157bdd95 100644 --- a/nautilus_core/analysis/src/python/statistics/long_ratio.rs +++ b/nautilus_core/analysis/src/python/statistics/long_ratio.rs @@ -31,7 +31,7 @@ impl LongRatio { } #[pyo3(name = "calculate_from_positions")] - fn py_calculate_from_positions(&mut self, positions: Vec) -> Option { + fn py_calculate_from_positions(&mut self, positions: Vec) -> Option { self.calculate_from_positions(&positions) } } diff --git a/nautilus_core/analysis/src/statistics/long_ratio.rs b/nautilus_core/analysis/src/statistics/long_ratio.rs index 2543a7fc44b..12b8756b73d 100644 --- a/nautilus_core/analysis/src/statistics/long_ratio.rs +++ b/nautilus_core/analysis/src/statistics/long_ratio.rs @@ -38,7 +38,7 @@ impl LongRatio { } impl PortfolioStatistic for LongRatio { - type Item = String; + type Item = f64; fn name(&self) -> String { stringify!(LongRatio).to_string() @@ -55,7 +55,9 @@ impl PortfolioStatistic for LongRatio { .collect(); let value = longs.len() as f64 / positions.len() as f64; - Some(format!("{:.1$}", value, self.precision)) + + let scale = 10f64.powi(self.precision as i32); + Some((value * scale).round() / scale) } } @@ -131,7 +133,7 @@ mod tests { let result = long_ratio.calculate_from_positions(&positions); assert!(result.is_some()); - assert_eq!(result.unwrap(), "1.00"); + assert_eq!(result.unwrap(), 1.00); } #[test] @@ -145,7 +147,7 @@ mod tests { let result = long_ratio.calculate_from_positions(&positions); assert!(result.is_some()); - assert_eq!(result.unwrap(), "0.00"); + assert_eq!(result.unwrap(), 0.00); } #[test] @@ -160,7 +162,7 @@ mod tests { let result = long_ratio.calculate_from_positions(&positions); assert!(result.is_some()); - assert_eq!(result.unwrap(), "0.50"); + assert_eq!(result.unwrap(), 0.50); } #[test] @@ -174,7 +176,7 @@ mod tests { let result = long_ratio.calculate_from_positions(&positions); assert!(result.is_some()); - assert_eq!(result.unwrap(), "0.667"); + assert_eq!(result.unwrap(), 0.667); } #[test] @@ -184,7 +186,7 @@ mod tests { let result = long_ratio.calculate_from_positions(&positions); assert!(result.is_some()); - assert_eq!(result.unwrap(), "1.00"); + assert_eq!(result.unwrap(), 1.00); } #[test] @@ -194,7 +196,7 @@ mod tests { let result = long_ratio.calculate_from_positions(&positions); assert!(result.is_some()); - assert_eq!(result.unwrap(), "0.00"); + assert_eq!(result.unwrap(), 0.00); } #[test] @@ -208,7 +210,7 @@ mod tests { let result = long_ratio.calculate_from_positions(&positions); assert!(result.is_some()); - assert_eq!(result.unwrap(), "1"); + assert_eq!(result.unwrap(), 1.00); } #[test] diff --git a/nautilus_core/common/src/cache/mod.rs b/nautilus_core/common/src/cache/mod.rs index 5659127e315..7cfd2c66857 100644 --- a/nautilus_core/common/src/cache/mod.rs +++ b/nautilus_core/common/src/cache/mod.rs @@ -44,7 +44,7 @@ use nautilus_model::{ enums::{AggregationSource, OmsType, OrderSide, PositionSide, PriceType, TriggerType}, identifiers::{ AccountId, ClientId, ClientOrderId, ComponentId, ExecAlgorithmId, InstrumentId, - OrderListId, PositionId, StrategyId, Venue, VenueOrderId, + OrderListId, PositionId, StrategyId, Symbol, Venue, VenueOrderId, }, instruments::{any::InstrumentAny, synthetic::SyntheticInstrument}, orderbook::book::OrderBook, @@ -52,10 +52,13 @@ use nautilus_model::{ position::Position, types::{currency::Currency, price::Price, quantity::Quantity}, }; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use ustr::Ustr; -use crate::{enums::SerializationEncoding, msgbus::database::DatabaseConfig}; +use crate::{ + enums::SerializationEncoding, msgbus::database::DatabaseConfig, xrate::get_exchange_rate, +}; /// Configuration for `Cache` instances. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -2474,6 +2477,78 @@ impl Cache { self.bar_count(bar_type) > 0 } + #[must_use] + pub fn get_xrate( + &self, + venue: Venue, + from_currency: Currency, + to_currency: Currency, + price_type: PriceType, + ) -> Decimal { + if from_currency == to_currency { + return Decimal::ONE; + } + + let (bid_quote, ask_quote) = self.build_quote_table(&venue); + + get_exchange_rate(from_currency, to_currency, price_type, bid_quote, ask_quote) + } + + fn build_quote_table( + &self, + venue: &Venue, + ) -> (HashMap, HashMap) { + let mut bid_quotes = HashMap::new(); + let mut ask_quotes = HashMap::new(); + + for instrument_id in self.instruments.keys() { + if instrument_id.venue != *venue { + continue; + } + + let (bid_price, ask_price) = if let Some(ticks) = self.quotes.get(instrument_id) { + if let Some(tick) = ticks.front() { + (tick.bid_price, tick.ask_price) + } else { + continue; // Empty ticks vector + } + } else { + let bid_bar = self + .bars + .iter() + .find(|(k, _)| { + k.instrument_id() == *instrument_id + && matches!(k.spec().price_type, PriceType::Bid) + }) + .map(|(_, v)| v); + + let ask_bar = self + .bars + .iter() + .find(|(k, _)| { + k.instrument_id() == *instrument_id + && matches!(k.spec().price_type, PriceType::Ask) + }) + .map(|(_, v)| v); + + match (bid_bar, ask_bar) { + (Some(bid), Some(ask)) => { + let bid_price = bid.front().unwrap().close; + let ask_price = ask.front().unwrap().close; + + (bid_price, ask_price) + } + _ => continue, + } + }; + + bid_quotes.insert(instrument_id.symbol, bid_price.as_decimal()); + ask_quotes.insert(instrument_id.symbol, ask_price.as_decimal()); + } + + (bid_quotes, ask_quotes) + } + // -- INSTRUMENT QUERIES ---------------------------------------------------------------------- /// Returns a reference to the instrument for the given `instrument_id` (if found). diff --git a/nautilus_core/common/src/xrate.rs b/nautilus_core/common/src/xrate.rs index 89b5ac99221..960cfcbc018 100644 --- a/nautilus_core/common/src/xrate.rs +++ b/nautilus_core/common/src/xrate.rs @@ -27,21 +27,19 @@ use itertools::Itertools; use nautilus_core::correctness::{check_equal_usize, check_map_not_empty, FAILED}; use nautilus_model::{enums::PriceType, identifiers::Symbol, types::currency::Currency}; use rust_decimal::Decimal; -use rust_decimal_macros::dec; use ustr::Ustr; -const DECIMAL_ONE: Decimal = dec!(1.0); -const DECIMAL_TWO: Decimal = dec!(2.0); - +// TODO: Improve efficiency: Check Top Comment /// Returns the calculated exchange rate for the given price type using the /// given dictionary of bid and ask quotes. +#[must_use] pub fn get_exchange_rate( from_currency: Currency, to_currency: Currency, price_type: PriceType, quotes_bid: HashMap, quotes_ask: HashMap, -) -> anyhow::Result { +) -> Decimal { check_map_not_empty("es_bid, stringify!(quotes_bid)).expect(FAILED); check_map_not_empty("es_ask, stringify!(quotes_ask)).expect(FAILED); check_equal_usize( @@ -53,91 +51,457 @@ pub fn get_exchange_rate( .expect(FAILED); if from_currency == to_currency { - return Ok(DECIMAL_ONE); // No conversion necessary + return Decimal::ONE; } - let calculation_quotes: HashMap = match price_type { + let calculation_quotes = match price_type { PriceType::Bid => quotes_bid, PriceType::Ask => quotes_ask, - PriceType::Mid => { - let mut calculation_quotes = HashMap::new(); - for (symbol, bid_quote) in "es_bid { - if let Some(ask_quote) = quotes_ask.get(symbol) { - calculation_quotes.insert(*symbol, (bid_quote + ask_quote) / DECIMAL_TWO); - } - } - calculation_quotes + PriceType::Mid => quotes_bid + .iter() + .map(|(k, v)| { + let ask = quotes_ask.get(k).unwrap_or(v); + (*k, (v + ask) / Decimal::TWO) + }) + .collect(), + _ => { + panic!("Cannot calculate exchange rate for PriceType: {price_type:?}"); } - _ => panic!("Cannot calculate exchange rate for PriceType {price_type:?}"), }; + let mut codes = HashSet::new(); let mut exchange_rates: HashMap> = HashMap::new(); // Build quote table for (symbol, quote) in &calculation_quotes { + // Split symbol into currency pairs let pieces: Vec<&str> = symbol.as_str().split('/').collect(); let code_lhs = Ustr::from(pieces[0]); let code_rhs = Ustr::from(pieces[1]); - let lhs_rates = exchange_rates.entry(code_lhs).or_default(); - lhs_rates.insert(code_lhs, Decimal::new(1, 0)); - lhs_rates.insert(code_rhs, *quote); + codes.insert(code_lhs); + codes.insert(code_rhs); - let rhs_rates = exchange_rates.entry(code_rhs).or_default(); - rhs_rates.insert(code_lhs, Decimal::new(1, 0)); - rhs_rates.insert(code_rhs, *quote); - } + // Initialize currency dictionaries if they don't exist + exchange_rates.entry(code_lhs).or_default(); + exchange_rates.entry(code_rhs).or_default(); - // Clone exchange_rates to avoid borrowing conflicts - let exchange_rates_cloned = exchange_rates.clone(); + // Add base rates + if let Some(rates_lhs) = exchange_rates.get_mut(&code_lhs) { + rates_lhs.insert(code_lhs, Decimal::ONE); + rates_lhs.insert(code_rhs, *quote); + } + if let Some(rates_rhs) = exchange_rates.get_mut(&code_rhs) { + rates_rhs.insert(code_rhs, Decimal::ONE); + } + } // Generate possible currency pairs from all symbols - let mut codes: HashSet<&Ustr> = HashSet::new(); - for (code_lhs, code_rhs) in exchange_rates_cloned.keys().flat_map(|k| { - exchange_rates_cloned - .keys() - .map(move |code_rhs| (k, code_rhs)) - }) { - codes.insert(code_lhs); - codes.insert(code_rhs); - } - let _code_perms: Vec<(&Ustr, &Ustr)> = codes + let code_perms: Vec<(Ustr, Ustr)> = codes .iter() .cartesian_product(codes.iter()) .filter(|(a, b)| a != b) .map(|(a, b)| (*a, *b)) .collect(); - // TODO: Unable to solve borrowing issues for now (see top comment) // Calculate currency inverses - // for (perm_0, perm_1) in code_perms.iter() { - // let exchange_rates_perm_0 = exchange_rates.entry(**perm_0).or_insert_with(HashMap::new); - // let exchange_rates_perm_1 = exchange_rates.entry(**perm_1).or_insert_with(HashMap::new); - // if !exchange_rates_perm_0.contains_key(perm_1) { - // if let Some(rate) = exchange_rates_perm_0.get(perm_1) { - // exchange_rates_perm_1 - // .entry(**perm_0) - // .or_insert_with(|| Decimal::new(1, 0) / rate); - // } - // } - // if !exchange_rates_perm_1.contains_key(perm_0) { - // if let Some(rate) = exchange_rates_perm_1.get(perm_0) { - // exchange_rates_perm_0 - // .entry(**perm_1) - // .or_insert_with(|| Decimal::new(1, 0) / rate); - // } - // } - // } + for (perm0, perm1) in &code_perms { + // First direction: perm0 -> perm1 + let rate_0_to_1 = exchange_rates + .get(perm0) + .and_then(|rates| rates.get(perm1)) + .copied(); + if let Some(rate) = rate_0_to_1 { + if let Some(xrate_perm1) = exchange_rates.get_mut(perm1) { + if !xrate_perm1.contains_key(perm0) { + xrate_perm1.insert(*perm0, Decimal::ONE / rate); + } + } + } + + // Second direction: perm1 -> perm0 + let rate_1_to_0 = exchange_rates + .get(perm1) + .and_then(|rates| rates.get(perm0)) + .copied(); + + if let Some(rate) = rate_1_to_0 { + if let Some(xrate_perm0) = exchange_rates.get_mut(perm0) { + if !xrate_perm0.contains_key(perm1) { + xrate_perm0.insert(*perm1, Decimal::ONE / rate); + } + } + } + } + + // Check if we already have the rate if let Some(quotes) = exchange_rates.get(&from_currency.code) { - if let Some(xrate) = quotes.get(&to_currency.code) { - return Ok(*xrate); + if let Some(&rate) = quotes.get(&to_currency.code) { + return rate; + } + } + + // Calculate remaining exchange rates through common currencies + for (perm0, perm1) in &code_perms { + // Skip if rate already exists + if exchange_rates + .get(perm1) + .map_or(false, |rates| rates.contains_key(perm0)) + { + continue; + } + + // Search for common currency + for code in &codes { + // First check: rates through common currency + let rates_through_common = { + let rates_perm0 = exchange_rates.get(perm0); + let rates_perm1 = exchange_rates.get(perm1); + + match (rates_perm0, rates_perm1) { + (Some(rates0), Some(rates1)) => { + if let (Some(&rate1), Some(&rate2)) = (rates0.get(code), rates1.get(code)) { + Some((rate1, rate2)) + } else { + None + } + } + _ => None, + } + }; + + // Second check: rates from code's perspective + let rates_from_code = if rates_through_common.is_none() { + if let Some(rates_code) = exchange_rates.get(code) { + if let (Some(&rate1), Some(&rate2)) = + (rates_code.get(perm0), rates_code.get(perm1)) + { + Some((rate1, rate2)) + } else { + None + } + } else { + None + } + } else { + None + }; + + // Apply the found rates if any + if let Some((common_rate1, common_rate2)) = rates_through_common.or(rates_from_code) { + // Insert forward rate + if let Some(rates_perm1) = exchange_rates.get_mut(perm1) { + rates_perm1.insert(*perm0, common_rate2 / common_rate1); + } + + // Insert inverse rate + if let Some(rates_perm0) = exchange_rates.get_mut(perm0) { + if !rates_perm0.contains_key(perm1) { + rates_perm0.insert(*perm1, common_rate1 / common_rate2); + } + } + } + } + } + + let xrate = exchange_rates + .get(&from_currency.code) + .and_then(|quotes| quotes.get(&to_currency.code)) + .copied() + .unwrap_or(Decimal::ZERO); + + xrate +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use rust_decimal::prelude::FromPrimitive; + use rust_decimal_macros::dec; + + use super::*; + + // Helper function to create test quotes + fn setup_test_quotes() -> (HashMap, HashMap) { + let mut quotes_bid = HashMap::new(); + let mut quotes_ask = HashMap::new(); + + // Direct pairs + quotes_bid.insert(Symbol::from_str_unchecked("EUR/USD"), dec!(1.1000)); + quotes_ask.insert(Symbol::from_str_unchecked("EUR/USD"), dec!(1.1002)); + + quotes_bid.insert(Symbol::from_str_unchecked("GBP/USD"), dec!(1.3000)); + quotes_ask.insert(Symbol::from_str_unchecked("GBP/USD"), dec!(1.3002)); + + quotes_bid.insert(Symbol::from_str_unchecked("USD/JPY"), dec!(110.00)); + quotes_ask.insert(Symbol::from_str_unchecked("USD/JPY"), dec!(110.02)); + + quotes_bid.insert(Symbol::from_str_unchecked("AUD/USD"), dec!(0.7500)); + quotes_ask.insert(Symbol::from_str_unchecked("AUD/USD"), dec!(0.7502)); + + (quotes_bid, quotes_ask) + } + + #[test] + /// Test same currency conversion + fn test_same_currency() { + let (quotes_bid, quotes_ask) = setup_test_quotes(); + let rate = get_exchange_rate( + Currency::from_str("USD").unwrap(), + Currency::from_str("USD").unwrap(), + PriceType::Mid, + quotes_bid, + quotes_ask, + ); + assert_eq!(rate, Decimal::ONE); + } + + #[test] + /// Test direct pair conversion + fn test_direct_pair() { + let (quotes_bid, quotes_ask) = setup_test_quotes(); + + // Test bid price + let rate_bid = get_exchange_rate( + Currency::from_str("EUR").unwrap(), + Currency::from_str("USD").unwrap(), + PriceType::Bid, + quotes_bid.clone(), + quotes_ask.clone(), + ); + assert_eq!(rate_bid, dec!(1.1000)); + + // Test ask price + let rate_ask = get_exchange_rate( + Currency::from_str("EUR").unwrap(), + Currency::from_str("USD").unwrap(), + PriceType::Ask, + quotes_bid.clone(), + quotes_ask.clone(), + ); + assert_eq!(rate_ask, dec!(1.1002)); + + // Test mid price + let rate_mid = get_exchange_rate( + Currency::from_str("EUR").unwrap(), + Currency::from_str("USD").unwrap(), + PriceType::Mid, + quotes_bid, + quotes_ask, + ); + assert_eq!(rate_mid, dec!(1.1001)); + } + + #[test] + /// Test inverse pair calculation + fn test_inverse_pair() { + let (quotes_bid, quotes_ask) = setup_test_quotes(); + + let rate = get_exchange_rate( + Currency::from_str("USD").unwrap(), + Currency::from_str("EUR").unwrap(), + PriceType::Mid, + quotes_bid, + quotes_ask, + ); + + // USD/EUR should be approximately 1/1.1001 + let expected = Decimal::ONE / dec!(1.1001); + assert!((rate - expected).abs() < dec!(0.0001)); + } + + #[test] + /// Test cross pair calculation through USD + fn test_cross_pair_through_usd() { + let (quotes_bid, quotes_ask) = setup_test_quotes(); + + let rate = get_exchange_rate( + Currency::from_str("EUR").unwrap(), + Currency::from_str("JPY").unwrap(), + PriceType::Mid, + quotes_bid, + quotes_ask, + ); + + // EUR/JPY should be approximately EUR/USD * USD/JPY + let expected = dec!(1.1001) * dec!(110.01); + assert!((rate - expected).abs() < dec!(0.01)); + } + + #[test] + /// Test cross pair calculation through multiple paths + fn test_multiple_path_cross_pair() { + let (quotes_bid, quotes_ask) = setup_test_quotes(); + + let rate = get_exchange_rate( + Currency::from_str("GBP").unwrap(), + Currency::from_str("AUD").unwrap(), + PriceType::Mid, + quotes_bid, + quotes_ask, + ); + + // GBP/AUD should be calculated through USD + // GBP/USD * (1/AUD/USD) + let expected = dec!(1.3001) / dec!(0.7501); + assert!((rate - expected).abs() < dec!(0.01)); + } + + #[test] + /// Test handling of missing pairs + fn test_missing_pairs() { + let mut quotes_bid = HashMap::new(); + let mut quotes_ask = HashMap::new(); + + // Only adding one pair + quotes_bid.insert(Symbol::from_str_unchecked("EUR/USD"), dec!(1.1000)); + quotes_ask.insert(Symbol::from_str_unchecked("EUR/USD"), dec!(1.1002)); + + let rate = get_exchange_rate( + Currency::from_str("EUR").unwrap(), + Currency::from_str("JPY").unwrap(), + PriceType::Mid, + quotes_bid, + quotes_ask, + ); + + assert_eq!(rate, Decimal::ZERO); // Should return 0 for impossible conversions + } + + #[test] + #[should_panic] + fn test_empty_quotes() { + let quotes_bid = HashMap::new(); + let quotes_ask = HashMap::new(); + + let out_xrate = get_exchange_rate( + Currency::from_str("EUR").unwrap(), + Currency::from_str("USD").unwrap(), + PriceType::Mid, + quotes_bid, + quotes_ask, + ); + + assert_eq!(out_xrate, Decimal::ZERO); + } + + #[test] + #[should_panic] + fn test_unequal_quotes_length() { + let mut quotes_bid = HashMap::new(); + let mut quotes_ask = HashMap::new(); + + quotes_bid.insert(Symbol::from_str_unchecked("EUR/USD"), dec!(1.1000)); + quotes_bid.insert(Symbol::from_str_unchecked("GBP/USD"), dec!(1.3000)); + quotes_ask.insert(Symbol::from_str_unchecked("EUR/USD"), dec!(1.1002)); + + let out_xrate = get_exchange_rate( + Currency::from_str("EUR").unwrap(), + Currency::from_str("USD").unwrap(), + PriceType::Mid, + quotes_bid, + quotes_ask, + ); + + assert_eq!(out_xrate, Decimal::ZERO); + } + + #[test] + #[should_panic] + /// Test invalid price type handling + fn test_invalid_price_type() { + let (quotes_bid, quotes_ask) = setup_test_quotes(); + + let out_xrate = get_exchange_rate( + Currency::from_str("EUR").unwrap(), + Currency::from_str("USD").unwrap(), + PriceType::Last, // Invalid price type + quotes_bid, + quotes_ask, + ); + + assert_eq!(out_xrate, Decimal::ZERO); + } + + #[test] + /// Test extensive cross pairs + fn test_extensive_cross_pairs() { + let mut quotes_bid = HashMap::new(); + let mut quotes_ask = HashMap::new(); + + // Create a complex network of currency pairs + let pairs = vec![ + ("EUR/USD", (1.1000, 1.1002)), + ("GBP/USD", (1.3000, 1.3002)), + ("USD/JPY", (110.00, 110.02)), + ("EUR/GBP", (0.8461, 0.8463)), + ("AUD/USD", (0.7500, 0.7502)), + ("NZD/USD", (0.7000, 0.7002)), + ("USD/CAD", (1.2500, 1.2502)), + ]; + + for (pair, (bid, ask)) in pairs { + quotes_bid.insert( + Symbol::from_str_unchecked(pair), + Decimal::from_f64(bid).unwrap(), + ); + quotes_ask.insert( + Symbol::from_str_unchecked(pair), + Decimal::from_f64(ask).unwrap(), + ); + } + + // Test various cross pairs + let test_pairs = vec![ + ("EUR", "JPY", 121.022), // EUR/USD * USD/JPY + ("GBP", "JPY", 143.024), // GBP/USD * USD/JPY + ("AUD", "JPY", 82.51), // AUD/USD * USD/JPY + ("EUR", "CAD", 1.375), // EUR/USD * USD/CAD + ("NZD", "CAD", 0.875), // NZD/USD * USD/CAD + ("AUD", "NZD", 1.071), // AUD/USD / NZD/USD + ]; + + for (from, to, expected) in test_pairs { + let rate = get_exchange_rate( + Currency::from_str(from).unwrap(), + Currency::from_str(to).unwrap(), + PriceType::Mid, + quotes_bid.clone(), + quotes_ask.clone(), + ); + + let expected_dec = Decimal::from_f64(expected).unwrap(); + assert!( + (rate - expected_dec).abs() < dec!(0.01), + "Failed for pair {from}/{to}: got {rate}, expected {expected_dec}" + ); } } - // TODO: Improve efficiency - let empty: HashMap = HashMap::new(); - let quotes = exchange_rates.get(&from_currency.code).unwrap_or(&empty); + #[test] + /// Test rate consistency + fn test_rate_consistency() { + let (quotes_bid, quotes_ask) = setup_test_quotes(); - Ok(quotes.get(&to_currency.code).copied().unwrap_or(dec!(0.0))) + let rate_eur_usd = get_exchange_rate( + Currency::from_str("EUR").unwrap(), + Currency::from_str("USD").unwrap(), + PriceType::Mid, + quotes_bid.clone(), + quotes_ask.clone(), + ); + + let rate_usd_eur = get_exchange_rate( + Currency::from_str("USD").unwrap(), + Currency::from_str("EUR").unwrap(), + PriceType::Mid, + quotes_bid, + quotes_ask, + ); + + // Check if one rate is the inverse of the other + assert!((rate_eur_usd * rate_usd_eur - Decimal::ONE).abs() < dec!(0.0001)); + } } diff --git a/nautilus_core/model/src/accounts/any.rs b/nautilus_core/model/src/accounts/any.rs index e839d897717..c328fe643fe 100644 --- a/nautilus_core/model/src/accounts/any.rs +++ b/nautilus_core/model/src/accounts/any.rs @@ -13,15 +13,19 @@ // limitations under the License. // ------------------------------------------------------------------------------------------------- +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; use crate::{ accounts::{base::Account, cash::CashAccount, margin::MarginAccount}, enums::AccountType, - events::account::state::AccountState, + events::{account::state::AccountState, order::OrderFilled}, identifiers::AccountId, + instruments::any::InstrumentAny, + position::Position, + types::{balance::AccountBalance, currency::Currency, money::Money}, }; - #[derive(Debug, Clone, Serialize, Deserialize)] pub enum AccountAny { Margin(MarginAccount), @@ -58,6 +62,27 @@ impl AccountAny { } } + pub fn balances(&self) -> HashMap { + match self { + AccountAny::Margin(margin) => margin.balances(), + AccountAny::Cash(cash) => cash.balances(), + } + } + + pub fn balances_locked(&self) -> HashMap { + match self { + AccountAny::Margin(margin) => margin.balances_locked(), + AccountAny::Cash(cash) => cash.balances_locked(), + } + } + + pub fn base_currency(&self) -> Option { + match self { + AccountAny::Margin(margin) => margin.base_currency(), + AccountAny::Cash(cash) => cash.base_currency(), + } + } + pub fn from_events(events: Vec) -> anyhow::Result { if events.is_empty() { anyhow::bail!("No order events provided to create `AccountAny`"); @@ -70,6 +95,18 @@ impl AccountAny { } Ok(account) } + + pub fn calculate_pnls( + &self, + instrument: InstrumentAny, + fill: OrderFilled, + position: Option, + ) -> anyhow::Result> { + match self { + AccountAny::Margin(margin) => margin.calculate_pnls(instrument, fill, position), + AccountAny::Cash(cash) => cash.calculate_pnls(instrument, fill, position), + } + } } impl From for AccountAny { diff --git a/nautilus_core/model/src/accounts/base.rs b/nautilus_core/model/src/accounts/base.rs index fbfe3bcdbbb..bb827921210 100644 --- a/nautilus_core/model/src/accounts/base.rs +++ b/nautilus_core/model/src/accounts/base.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; -use rust_decimal::prelude::ToPrimitive; +use rust_decimal::{prelude::ToPrimitive, Decimal}; use serde::{Deserialize, Serialize}; use crate::{ @@ -135,6 +135,18 @@ impl BaseAccount { } } + pub fn update_commissions(&mut self, commission: Money) { + if commission.as_decimal() == Decimal::ZERO { + return; + } + + let currency = commission.currency; + let total_commissions = self.commissions.get(¤cy).unwrap_or(&0.0); + + self.commissions + .insert(currency, total_commissions + commission.as_f64()); + } + pub fn base_apply(&mut self, event: AccountState) { self.update_balances(event.balances.clone()); self.events.push(event); diff --git a/nautilus_core/model/src/accounts/cash.rs b/nautilus_core/model/src/accounts/cash.rs index adee23b08b6..0b656d476ea 100644 --- a/nautilus_core/model/src/accounts/cash.rs +++ b/nautilus_core/model/src/accounts/cash.rs @@ -19,6 +19,7 @@ use std::{ ops::{Deref, DerefMut}, }; +use rust_decimal::{prelude::ToPrimitive, Decimal}; use serde::{Deserialize, Serialize}; use crate::{ @@ -66,6 +67,36 @@ impl CashAccount { pub const fn is_unleveraged(&self) -> bool { false } + + pub fn recalculate_balance(&mut self, currency: Currency) { + let current_balance = match self.balances.get(¤cy) { + Some(balance) => *balance, + None => { + return; + } + }; + + let total_locked = self + .balances + .values() + .filter(|balance| balance.currency == currency) + .fold(Decimal::ZERO, |acc, balance| { + acc + balance.locked.as_decimal() + }); + + let new_balance = AccountBalance::new( + current_balance.total, + Money::new(total_locked.to_f64().unwrap(), currency), + Money::new( + (current_balance.total.as_decimal() - total_locked) + .to_f64() + .unwrap(), + currency, + ), + ); + + self.balances.insert(currency, new_balance); + } } impl Account for CashAccount { diff --git a/nautilus_core/model/src/events/order/any.rs b/nautilus_core/model/src/events/order/any.rs index 59e02cd0785..e763578d02c 100644 --- a/nautilus_core/model/src/events/order/any.rs +++ b/nautilus_core/model/src/events/order/any.rs @@ -26,7 +26,7 @@ use crate::{ OrderPendingUpdate, OrderRejected, OrderReleased, OrderSubmitted, OrderTriggered, OrderUpdated, }, - identifiers::{ClientOrderId, StrategyId, TraderId}, + identifiers::{AccountId, ClientOrderId, InstrumentId, StrategyId, TraderId}, }; /// Wraps an `OrderEvent` allowing polymorphism. @@ -144,6 +144,52 @@ impl OrderEventAny { } } + #[must_use] + pub fn account_id(&self) -> Option { + match self { + Self::Initialized(event) => event.account_id(), + Self::Denied(event) => event.account_id(), + Self::Emulated(event) => event.account_id(), + Self::Released(event) => event.account_id(), + Self::Submitted(event) => event.account_id(), + Self::Accepted(event) => event.account_id(), + Self::Rejected(event) => event.account_id(), + Self::Canceled(event) => event.account_id(), + Self::Expired(event) => event.account_id(), + Self::Triggered(event) => event.account_id(), + Self::PendingUpdate(event) => event.account_id(), + Self::PendingCancel(event) => event.account_id(), + Self::ModifyRejected(event) => event.account_id(), + Self::CancelRejected(event) => event.account_id(), + Self::Updated(event) => event.account_id(), + Self::PartiallyFilled(event) => event.account_id(), + Self::Filled(event) => event.account_id(), + } + } + + #[must_use] + pub fn instrument_id(&self) -> InstrumentId { + match self { + Self::Initialized(event) => event.instrument_id(), + Self::Denied(event) => event.instrument_id(), + Self::Emulated(event) => event.instrument_id(), + Self::Released(event) => event.instrument_id(), + Self::Submitted(event) => event.instrument_id(), + Self::Accepted(event) => event.instrument_id(), + Self::Rejected(event) => event.instrument_id(), + Self::Canceled(event) => event.instrument_id(), + Self::Expired(event) => event.instrument_id(), + Self::Triggered(event) => event.instrument_id(), + Self::PendingUpdate(event) => event.instrument_id(), + Self::PendingCancel(event) => event.instrument_id(), + Self::ModifyRejected(event) => event.instrument_id(), + Self::CancelRejected(event) => event.instrument_id(), + Self::Updated(event) => event.instrument_id(), + Self::PartiallyFilled(event) => event.instrument_id(), + Self::Filled(event) => event.instrument_id(), + } + } + #[must_use] pub fn strategy_id(&self) -> StrategyId { match self { diff --git a/nautilus_core/model/src/events/position/mod.rs b/nautilus_core/model/src/events/position/mod.rs index 5338a1d1629..a81dd4e0b46 100644 --- a/nautilus_core/model/src/events/position/mod.rs +++ b/nautilus_core/model/src/events/position/mod.rs @@ -13,10 +13,10 @@ // limitations under the License. // ------------------------------------------------------------------------------------------------- -use crate::events::position::{ - changed::PositionChanged, closed::PositionClosed, opened::PositionOpened, +use crate::{ + events::position::{changed::PositionChanged, closed::PositionClosed, opened::PositionOpened}, + identifiers::{AccountId, InstrumentId}, }; - pub mod changed; pub mod closed; pub mod opened; @@ -27,3 +27,21 @@ pub enum PositionEvent { PositionChanged(PositionChanged), PositionClosed(PositionClosed), } + +impl PositionEvent { + pub fn instrument_id(&self) -> InstrumentId { + match self { + PositionEvent::PositionOpened(position) => position.instrument_id, + PositionEvent::PositionChanged(position) => position.instrument_id, + PositionEvent::PositionClosed(position) => position.instrument_id, + } + } + + pub fn account_id(&self) -> AccountId { + match self { + PositionEvent::PositionOpened(position) => position.account_id, + PositionEvent::PositionChanged(position) => position.account_id, + PositionEvent::PositionClosed(position) => position.account_id, + } + } +} diff --git a/nautilus_core/portfolio/Cargo.toml b/nautilus_core/portfolio/Cargo.toml index e1c9743e152..74d16c2eada 100644 --- a/nautilus_core/portfolio/Cargo.toml +++ b/nautilus_core/portfolio/Cargo.toml @@ -28,6 +28,7 @@ serde_json = { workspace = true } strum = { workspace = true } thiserror = { workspace = true } ustr = { workspace = true } +uuid = { workspace = true } [dev-dependencies] criterion = { workspace = true } diff --git a/nautilus_core/portfolio/src/manager.rs b/nautilus_core/portfolio/src/manager.rs index c1edae0f75b..4e7d96b8289 100644 --- a/nautilus_core/portfolio/src/manager.rs +++ b/nautilus_core/portfolio/src/manager.rs @@ -15,31 +15,30 @@ //! Provides account management functionality. -// Under development -#![allow(dead_code)] -#![allow(unused_variables)] - use std::{cell::RefCell, rc::Rc}; use nautilus_common::{cache::Cache, clock::Clock}; -use nautilus_core::nanos::UnixNanos; +use nautilus_core::{ffi::uuid::uuid4_new, nanos::UnixNanos}; use nautilus_model::{ - accounts::{any::AccountAny, cash::CashAccount, margin::MarginAccount}, - enums::OrderSideSpecified, + accounts::{any::AccountAny, base::Account, cash::CashAccount, margin::MarginAccount}, + enums::{AccountType, OrderSide, OrderSideSpecified, PriceType}, events::{account::state::AccountState, order::OrderFilled}, instruments::any::InstrumentAny, orders::any::OrderAny, position::Position, - types::money::Money, + types::{balance::AccountBalance, money::Money}, }; -use rust_decimal::Decimal; - +use rust_decimal::{prelude::ToPrimitive, Decimal}; pub struct AccountsManager { clock: Rc>, cache: Rc>, } impl AccountsManager { + pub fn new(clock: Rc>, cache: Rc>) -> Self { + Self { clock, cache } + } + #[must_use] pub fn update_balances( &self, @@ -47,65 +46,638 @@ impl AccountsManager { instrument: InstrumentAny, fill: OrderFilled, ) -> AccountState { - todo!() + let cache = self.cache.borrow(); + let position_id = if let Some(position_id) = fill.position_id { + position_id + } else { + let positions_open = cache.positions_open(None, Some(&fill.instrument_id), None, None); + positions_open + .first() + .unwrap_or_else(|| panic!("List of Positions is empty")) + .id + }; + + let position = cache.position(&position_id); + + let pnls = account.calculate_pnls(instrument, fill, position.cloned()); + + // Calculate final PnL including commissions + match account.base_currency() { + Some(base_currency) => { + let pnl = pnls.map_or_else( + |_| Money::new(0.0, base_currency), + |pnl_list| { + pnl_list + .first() + .copied() + .unwrap_or_else(|| Money::new(0.0, base_currency)) + }, + ); + + self.update_balance_single_currency(account.clone(), &fill, pnl); + } + None => { + if let Ok(mut pnl_list) = pnls { + self.update_balance_multi_currency(account.clone(), fill, &mut pnl_list); + } + } + } + + // Generate and return account state + self.generate_account_state(account, fill.ts_event) } #[must_use] pub fn update_orders( &self, - account: AccountAny, + account: &AccountAny, instrument: InstrumentAny, - orders_open: &[OrderAny], + orders_open: Vec<&OrderAny>, ts_event: UnixNanos, - ) -> AccountState { - todo!() + ) -> Option<(AccountAny, AccountState)> { + match account.clone() { + AccountAny::Cash(cash_account) => self + .update_balance_locked(&cash_account, instrument, orders_open, ts_event) + .map(|(updated_cash_account, state)| { + (AccountAny::Cash(updated_cash_account), state) + }), + AccountAny::Margin(margin_account) => self + .update_margin_init(&margin_account, instrument, orders_open, ts_event) + .map(|(updated_margin_account, state)| { + (AccountAny::Margin(updated_margin_account), state) + }), + } } #[must_use] pub fn update_positions( &self, - account: MarginAccount, + account: &MarginAccount, instrument: InstrumentAny, - positions: &[Position], + positions: Vec<&Position>, ts_event: UnixNanos, - ) -> AccountState { - todo!() + ) -> Option<(MarginAccount, AccountState)> { + let mut total_margin_maint = Decimal::ZERO; + let mut base_xrate = Decimal::ZERO; + let mut currency = instrument.settlement_currency(); + let mut account = account.clone(); + + for position in positions { + assert_eq!( + position.instrument_id, + instrument.id(), + "Position not for instrument {}", + instrument.id() + ); + + if !position.is_open() { + continue; + } + + let margin_maint = match instrument { + InstrumentAny::Betting(i) => account.calculate_maintenance_margin( + i, + position.quantity, + instrument.make_price(position.avg_px_open), + None, + ), + InstrumentAny::BinaryOption(i) => account.calculate_maintenance_margin( + i, + position.quantity, + instrument.make_price(position.avg_px_open), + None, + ), + InstrumentAny::CryptoFuture(i) => account.calculate_maintenance_margin( + i, + position.quantity, + instrument.make_price(position.avg_px_open), + None, + ), + InstrumentAny::CryptoPerpetual(i) => account.calculate_maintenance_margin( + i, + position.quantity, + instrument.make_price(position.avg_px_open), + None, + ), + InstrumentAny::CurrencyPair(i) => account.calculate_maintenance_margin( + i, + position.quantity, + instrument.make_price(position.avg_px_open), + None, + ), + InstrumentAny::Equity(i) => account.calculate_maintenance_margin( + i, + position.quantity, + instrument.make_price(position.avg_px_open), + None, + ), + InstrumentAny::FuturesContract(i) => account.calculate_maintenance_margin( + i, + position.quantity, + instrument.make_price(position.avg_px_open), + None, + ), + InstrumentAny::FuturesSpread(i) => account.calculate_maintenance_margin( + i, + position.quantity, + instrument.make_price(position.avg_px_open), + None, + ), + InstrumentAny::OptionsContract(i) => account.calculate_maintenance_margin( + i, + position.quantity, + instrument.make_price(position.avg_px_open), + None, + ), + InstrumentAny::OptionsSpread(i) => account.calculate_maintenance_margin( + i, + position.quantity, + instrument.make_price(position.avg_px_open), + None, + ), + }; + + let mut margin_maint = margin_maint.as_decimal(); + + if let Some(base_currency) = account.base_currency { + if base_xrate.is_zero() { + currency = base_currency; + base_xrate = self.calculate_xrate_to_base( + AccountAny::Margin(account.clone()), + instrument.clone(), + position.entry.as_specified(), + ); + + if base_xrate == Decimal::ZERO { + log::debug!("Cannot calculate maintenance (position) margin: insufficient data for {}/{}", instrument.settlement_currency(), base_currency); + return None; + } + } + + margin_maint = (margin_maint * base_xrate).round_dp(currency.precision.into()); + } + + total_margin_maint += margin_maint; + } + + let margin_maint_money = Money::new(total_margin_maint.to_f64()?, currency); + account.update_maintenance_margin(instrument.id(), margin_maint_money); + + log::info!( + "{} margin_maint={}", + instrument.id(), + margin_maint_money.to_string() + ); + + // Generate and return account state + Some(( + account.clone(), + self.generate_account_state(AccountAny::Margin(account), ts_event), + )) } fn update_balance_locked( &self, - account: CashAccount, + account: &CashAccount, instrument: InstrumentAny, - fill: OrderFilled, - ) -> AccountState { - todo!() + orders_open: Vec<&OrderAny>, + ts_event: UnixNanos, + ) -> Option<(CashAccount, AccountState)> { + let mut account = account.clone(); + if orders_open.is_empty() { + let balance = account.balances.remove(&instrument.quote_currency()); + if let Some(balance) = balance { + account.recalculate_balance(balance.currency); + } + return Some(( + account.clone(), + self.generate_account_state(AccountAny::Cash(account), ts_event), + )); + } + + let mut total_locked = Decimal::ZERO; + let mut base_xrate = Decimal::ZERO; + + let mut currency = instrument.settlement_currency(); + + for order in orders_open { + assert_eq!( + order.instrument_id(), + instrument.id(), + "Order not for instrument {}", + instrument.id() + ); + assert!(order.is_open(), "Order is not open"); + + if order.price().is_none() && order.trigger_price().is_none() { + continue; + } + + let price = if order.price().is_some() { + order.price() + } else { + order.trigger_price() + }; + + let mut locked = account + .calculate_balance_locked( + instrument.clone(), + order.order_side(), + order.quantity(), + price?, + None, + ) + .unwrap() + .as_decimal(); + + if let Some(base_curr) = account.base_currency() { + if base_xrate.is_zero() { + currency = base_curr; + base_xrate = self.calculate_xrate_to_base( + AccountAny::Cash(account.clone()), + instrument.clone(), + order.order_side_specified(), + ); + } + + locked = (locked * base_xrate).round_dp(u32::from(currency.precision)); + } + + total_locked += locked; + } + + let locked_money = Money::new(total_locked.to_f64()?, currency); + + if let Some(balance) = account.balances.get_mut(&instrument.quote_currency()) { + balance.locked = locked_money; + let currency = balance.currency; + account.recalculate_balance(currency); + } + + log::info!( + "{} balance_locked={}", + instrument.id(), + locked_money.to_string() + ); + + Some(( + account.clone(), + self.generate_account_state(AccountAny::Cash(account), ts_event), + )) } fn update_margin_init( &self, - account: MarginAccount, + account: &MarginAccount, instrument: InstrumentAny, - orders_open: &[OrderAny], + orders_open: Vec<&OrderAny>, ts_event: UnixNanos, - ) -> AccountState { - todo!() + ) -> Option<(MarginAccount, AccountState)> { + let mut total_margin_init = Decimal::ZERO; + let mut base_xrate = Decimal::ZERO; + let mut currency = instrument.settlement_currency(); + let mut account = account.clone(); + + for order in orders_open { + assert_eq!( + order.instrument_id(), + instrument.id(), + "Order not for instrument {}", + instrument.id() + ); + + if !order.is_open() || (order.price().is_none() && order.trigger_price().is_none()) { + continue; + } + + let price = if order.price().is_some() { + order.price() + } else { + order.trigger_price() + }; + + let margin_init = match instrument { + InstrumentAny::Betting(i) => { + account.calculate_initial_margin(i, order.quantity(), price?, None) + } + InstrumentAny::BinaryOption(i) => { + account.calculate_initial_margin(i, order.quantity(), price?, None) + } + InstrumentAny::CryptoFuture(i) => { + account.calculate_initial_margin(i, order.quantity(), price?, None) + } + InstrumentAny::CryptoPerpetual(i) => { + account.calculate_initial_margin(i, order.quantity(), price?, None) + } + InstrumentAny::CurrencyPair(i) => { + account.calculate_initial_margin(i, order.quantity(), price?, None) + } + InstrumentAny::Equity(i) => { + account.calculate_initial_margin(i, order.quantity(), price?, None) + } + InstrumentAny::FuturesContract(i) => { + account.calculate_initial_margin(i, order.quantity(), price?, None) + } + InstrumentAny::FuturesSpread(i) => { + account.calculate_initial_margin(i, order.quantity(), price?, None) + } + InstrumentAny::OptionsContract(i) => { + account.calculate_initial_margin(i, order.quantity(), price?, None) + } + InstrumentAny::OptionsSpread(i) => { + account.calculate_initial_margin(i, order.quantity(), price?, None) + } + }; + + let mut margin_init = margin_init.as_decimal(); + + if let Some(base_currency) = account.base_currency { + if base_xrate.is_zero() { + currency = base_currency; + base_xrate = self.calculate_xrate_to_base( + AccountAny::Margin(account.clone()), + instrument.clone(), + order.order_side_specified(), + ); + + if base_xrate == Decimal::ZERO { + log::debug!( + "Cannot calculate initial margin: insufficient data for {}/{}", + instrument.settlement_currency(), + base_currency + ); + continue; + } + } + + margin_init = (margin_init * base_xrate).round_dp(currency.precision.into()); + } + + total_margin_init += margin_init; + } + + let money = Money::new(total_margin_init.to_f64().unwrap_or(0.0), currency); + let margin_init_money = { + account.update_initial_margin(instrument.id(), money); + money + }; + + log::info!( + "{} margin_init={}", + instrument.id(), + margin_init_money.to_string() + ); + + Some(( + account.clone(), + self.generate_account_state(AccountAny::Margin(account), ts_event), + )) } - fn update_balance_single_currency(&self, account: AccountAny, fill: OrderFilled, pnl: Money) { - todo!() + fn update_balance_single_currency( + &self, + account: AccountAny, + fill: &OrderFilled, + mut pnl: Money, + ) { + let base_currency = if let Some(currency) = account.base_currency() { + currency + } else { + log::error!("Account has no base currency set"); + return; + }; + + let mut balances = Vec::new(); + let mut commission = fill.commission; + + if let Some(ref mut comm) = commission { + if comm.currency != base_currency { + let xrate = self.cache.borrow().get_xrate( + fill.instrument_id.venue, + comm.currency, + base_currency, + if fill.order_side == OrderSide::Sell { + PriceType::Bid + } else { + PriceType::Ask + }, + ); + + if xrate.is_zero() { + log::error!( + "Cannot calculate account state: insufficient data for {}/{}", + comm.currency, + base_currency + ); + return; + } + + *comm = Money::new((comm.as_decimal() * xrate).to_f64().unwrap(), base_currency); + } + } + + if pnl.currency != base_currency { + let xrate = self.cache.borrow().get_xrate( + fill.instrument_id.venue, + pnl.currency, + base_currency, + if fill.order_side == OrderSide::Sell { + PriceType::Bid + } else { + PriceType::Ask + }, + ); + + if xrate.is_zero() { + log::error!( + "Cannot calculate account state: insufficient data for {}/{}", + pnl.currency, + base_currency + ); + return; + } + + pnl = Money::new((pnl.as_decimal() * xrate).to_f64().unwrap(), base_currency); + } + + if let Some(comm) = commission { + pnl -= comm; + } + + if pnl.is_zero() { + return; + } + + let existing_balances = account.balances(); + let balance = if let Some(b) = existing_balances.get(&pnl.currency) { + b + } else { + log::error!( + "Cannot complete transaction: no balance for {}", + pnl.currency + ); + return; + }; + + let new_balance = + AccountBalance::new(balance.total + pnl, balance.locked, balance.free + pnl); + balances.push(new_balance); + + match account { + AccountAny::Cash(mut cash) => { + cash.update_balances(balances); + if let Some(comm) = commission { + cash.update_commissions(comm); + } + } + AccountAny::Margin(mut margin) => { + margin.update_balances(balances); + if let Some(comm) = commission { + margin.update_commissions(comm); + } + } + } } fn update_balance_multi_currency( &self, account: AccountAny, fill: OrderFilled, - pnls: &[Money], + pnls: &mut [Money], ) { - todo!() + let mut new_balances = Vec::new(); + let commission = fill.commission; + let mut apply_commission = commission.map_or(false, |c| !c.is_zero()); + + for pnl in pnls.iter_mut() { + if apply_commission && pnl.currency == commission.unwrap().currency { + *pnl -= commission.unwrap(); + apply_commission = false; + } + + if pnl.is_zero() { + continue; // No Adjustment + } + + let currency = pnl.currency; + let balances = account.balances(); + + let new_balance = if let Some(balance) = balances.get(¤cy) { + let new_total = balance.total.as_f64() + pnl.as_f64(); + let new_free = balance.free.as_f64() + pnl.as_f64(); + let total = Money::new(new_total, currency); + let free = Money::new(new_free, currency); + + if new_total < 0.0 { + log::error!( + "AccountBalanceNegative: balance = {}, currency = {}", + total.as_decimal(), + currency + ); + return; + } + if new_free < 0.0 { + log::error!( + "AccountMarginExceeded: balance = {}, margin = {}, currency = {}", + total.as_decimal(), + balance.locked.as_decimal(), + currency + ); + return; + } + + AccountBalance::new(total, balance.locked, free) + } else { + if pnl.as_decimal() < Decimal::ZERO { + log::error!( + "Cannot complete transaction: no {} to deduct a {} realized PnL from", + currency, + pnl + ); + return; + } + AccountBalance::new(*pnl, Money::new(0.0, currency), *pnl) + }; + + new_balances.push(new_balance); + } + + if apply_commission { + let commission = commission.unwrap(); + let currency = commission.currency; + let balances = account.balances(); + + let commission_balance = if let Some(balance) = balances.get(¤cy) { + let new_total = balance.total.as_decimal() - commission.as_decimal(); + let new_free = balance.free.as_decimal() - commission.as_decimal(); + AccountBalance::new( + Money::new(new_total.to_f64().unwrap(), currency), + balance.locked, + Money::new(new_free.to_f64().unwrap(), currency), + ) + } else { + if commission.as_decimal() > Decimal::ZERO { + log::error!( + "Cannot complete transaction: no {} balance to deduct a {} commission from", + currency, + commission + ); + return; + } + AccountBalance::new( + Money::new(0.0, currency), + Money::new(0.0, currency), + Money::new(0.0, currency), + ) + }; + new_balances.push(commission_balance); + } + + if new_balances.is_empty() { + return; + } + + match account { + AccountAny::Cash(mut cash) => { + cash.update_balances(new_balances); + if let Some(commission) = commission { + cash.update_commissions(commission); + } + } + AccountAny::Margin(mut margin) => { + margin.update_balances(new_balances); + if let Some(commission) = commission { + margin.update_commissions(commission); + } + } + } } fn generate_account_state(&self, account: AccountAny, ts_event: UnixNanos) -> AccountState { - todo!() + match account { + AccountAny::Cash(cash_account) => AccountState::new( + cash_account.id, + AccountType::Cash, + cash_account.balances.clone().into_values().collect(), + vec![], + false, + uuid4_new(), + ts_event, + self.clock.borrow().timestamp_ns(), + cash_account.base_currency(), + ), + AccountAny::Margin(margin_account) => AccountState::new( + margin_account.id, + AccountType::Cash, + vec![], + margin_account.margins.clone().into_values().collect(), + false, + uuid4_new(), + ts_event, + self.clock.borrow().timestamp_ns(), + margin_account.base_currency(), + ), + } } fn calculate_xrate_to_base( @@ -114,6 +686,17 @@ impl AccountsManager { instrument: InstrumentAny, side: OrderSideSpecified, ) -> Decimal { - todo!() + match account.base_currency() { + None => Decimal::ONE, + Some(base_curr) => self.cache.borrow().get_xrate( + instrument.id().venue, + instrument.settlement_currency(), + base_curr, + match side { + OrderSideSpecified::Sell => PriceType::Bid, + OrderSideSpecified::Buy => PriceType::Ask, + }, + ), + } } } diff --git a/nautilus_core/portfolio/src/portfolio.rs b/nautilus_core/portfolio/src/portfolio.rs index 835e37e7959..05830620e2e 100644 --- a/nautilus_core/portfolio/src/portfolio.rs +++ b/nautilus_core/portfolio/src/portfolio.rs @@ -14,38 +14,137 @@ // ------------------------------------------------------------------------------------------------- //! Provides a generic `Portfolio` for all environments. - -// Under development -#![allow(dead_code)] -#![allow(unused_variables)] - use std::{ + any::Any, cell::RefCell, collections::{HashMap, HashSet}, rc::Rc, + sync::Arc, }; -use nautilus_analysis::analyzer::PortfolioAnalyzer; -use nautilus_common::{cache::Cache, clock::Clock, msgbus::MessageBus}; +use nautilus_analysis::{ + analyzer::PortfolioAnalyzer, + statistics::{ + expectancy::Expectancy, long_ratio::LongRatio, loser_max::MaxLoser, loser_min::MinLoser, + profit_factor::ProfitFactor, returns_avg::ReturnsAverage, + returns_avg_loss::ReturnsAverageLoss, returns_avg_win::ReturnsAverageWin, + returns_volatility::ReturnsVolatility, risk_return_ratio::RiskReturnRatio, + sharpe_ratio::SharpeRatio, sortino_ratio::SortinoRatio, win_rate::WinRate, + winner_avg::AvgWinner, winner_max::MaxWinner, winner_min::MinWinner, + }, +}; +use nautilus_common::{ + cache::Cache, + clock::Clock, + messages::data::DataResponse, + msgbus::{ + handler::{MessageHandler, ShareableMessageHandler}, + MessageBus, + }, +}; use nautilus_model::{ accounts::any::AccountAny, - data::quote::QuoteTick, - enums::OrderSide, + data::{quote::QuoteTick, Data}, + enums::{OrderSide, OrderType, PositionSide, PriceType}, events::{account::state::AccountState, order::OrderEventAny, position::PositionEvent}, identifiers::{InstrumentId, Venue}, instruments::any::InstrumentAny, + orders::any::OrderAny, position::Position, - types::money::Money, + types::{currency::Currency, money::Money, price::Price}, }; -use rust_decimal::Decimal; +use rust_decimal::{ + prelude::{FromPrimitive, ToPrimitive}, + Decimal, +}; +use ustr::Ustr; +use uuid::Uuid; -pub struct Portfolio { - clock: Rc>, - cache: Rc>, - msgbus: Rc>, - accounts: HashMap, +use crate::manager::AccountsManager; + +struct UpdateQuoteTickHandler { + id: Ustr, + callback: Box, +} + +impl MessageHandler for UpdateQuoteTickHandler { + fn id(&self) -> Ustr { + self.id + } + + fn handle(&self, msg: &dyn Any) { + (self.callback)(msg.downcast_ref::<&QuoteTick>().unwrap()); + } + fn handle_response(&self, _resp: DataResponse) {} + fn handle_data(&self, _data: Data) {} + fn as_any(&self) -> &dyn Any { + self + } +} + +struct UpdateOrderHandler { + id: Ustr, + callback: Box, +} + +impl MessageHandler for UpdateOrderHandler { + fn id(&self) -> Ustr { + self.id + } + + fn handle(&self, msg: &dyn Any) { + (self.callback)(msg.downcast_ref::<&OrderEventAny>().unwrap()); + } + fn handle_response(&self, _resp: DataResponse) {} + fn handle_data(&self, _data: Data) {} + fn as_any(&self) -> &dyn Any { + self + } +} + +struct UpdatePositionHandler { + id: Ustr, + callback: Box, +} + +impl MessageHandler for UpdatePositionHandler { + fn id(&self) -> Ustr { + self.id + } + + fn handle(&self, msg: &dyn Any) { + (self.callback)(msg.downcast_ref::<&PositionEvent>().unwrap()); + } + fn handle_response(&self, _resp: DataResponse) {} + fn handle_data(&self, _data: Data) {} + fn as_any(&self) -> &dyn Any { + self + } +} + +struct UpdateAccountHandler { + id: Ustr, + callback: Box, +} + +impl MessageHandler for UpdateAccountHandler { + fn id(&self) -> Ustr { + self.id + } + + fn handle(&self, msg: &dyn Any) { + (self.callback)(msg.downcast_ref::<&AccountState>().unwrap()); + } + fn handle_response(&self, _resp: DataResponse) {} + fn handle_data(&self, _data: Data) {} + fn as_any(&self) -> &dyn Any { + self + } +} + +struct PortfolioState { + accounts: AccountsManager, analyzer: PortfolioAnalyzer, - // venue: Option, // Added for completeness but was meant to be a "temporary hack" unrealized_pnls: HashMap, realized_pnls: HashMap, net_positions: HashMap, @@ -53,156 +152,3154 @@ pub struct Portfolio { initialized: bool, } +impl PortfolioState { + fn new(clock: Rc>, cache: Rc>) -> Self { + let mut analyzer = PortfolioAnalyzer::new(); + analyzer.register_statistic(Arc::new(MaxWinner {})); + analyzer.register_statistic(Arc::new(AvgWinner {})); + analyzer.register_statistic(Arc::new(MinWinner {})); + analyzer.register_statistic(Arc::new(MinLoser {})); + analyzer.register_statistic(Arc::new(MaxLoser {})); + analyzer.register_statistic(Arc::new(Expectancy {})); + analyzer.register_statistic(Arc::new(WinRate {})); + analyzer.register_statistic(Arc::new(ReturnsVolatility::new(None))); + analyzer.register_statistic(Arc::new(ReturnsAverage {})); + analyzer.register_statistic(Arc::new(ReturnsAverageLoss {})); + analyzer.register_statistic(Arc::new(ReturnsAverageWin {})); + analyzer.register_statistic(Arc::new(SharpeRatio::new(None))); + analyzer.register_statistic(Arc::new(SortinoRatio::new(None))); + analyzer.register_statistic(Arc::new(ProfitFactor {})); + analyzer.register_statistic(Arc::new(RiskReturnRatio {})); + analyzer.register_statistic(Arc::new(LongRatio::new(None))); + + Self { + accounts: AccountsManager::new(clock, cache), + analyzer, + unrealized_pnls: HashMap::new(), + realized_pnls: HashMap::new(), + net_positions: HashMap::new(), + pending_calcs: HashSet::new(), + initialized: false, + } + } + + fn reset(&mut self) { + log::debug!("RESETTING"); + self.net_positions.clear(); + self.unrealized_pnls.clear(); + self.realized_pnls.clear(); + self.pending_calcs.clear(); + self.analyzer.reset(); + log::debug!("READY"); + } +} + +pub struct Portfolio { + clock: Rc>, + cache: Rc>, + msgbus: Rc>, + inner: Rc>, +} + impl Portfolio { - // pub fn set_specific_venue(&mut self, venue: Venue) { // Lets try not to use this? - // todo!() - // } + pub fn new( + msgbus: Rc>, + cache: Rc>, + clock: Rc>, + ) -> Self { + let inner = Rc::new(RefCell::new(PortfolioState::new( + clock.clone(), + cache.clone(), + ))); - // -- QUERIES --------------------------------------------------------------------------------- + Self::register_message_handlers( + msgbus.clone(), + cache.clone(), + clock.clone(), + inner.clone(), + ); - #[must_use] - pub const fn is_initialized(&self) -> bool { - self.initialized + Self { + clock, + cache, + msgbus, + inner, + } } - #[must_use] - pub const fn analyzer(&self) -> &PortfolioAnalyzer { - &self.analyzer + fn register_message_handlers( + msgbus: Rc>, + cache: Rc>, + clock: Rc>, + inner: Rc>, + ) { + let update_account_handler = { + let cache = cache.clone(); + ShareableMessageHandler(Rc::new(UpdateAccountHandler { + id: Ustr::from(&Uuid::new_v4().to_string()), + callback: Box::new(move |event: &AccountState| { + update_account(cache.clone(), event); + }), + })) + }; + + let update_position_handler = { + let cache = cache.clone(); + let msgbus = msgbus.clone(); + let clock = clock.clone(); + let inner = inner.clone(); + ShareableMessageHandler(Rc::new(UpdatePositionHandler { + id: Ustr::from(&Uuid::new_v4().to_string()), + callback: Box::new(move |event: &PositionEvent| { + update_position( + cache.clone(), + msgbus.clone(), + clock.clone(), + inner.clone(), + event, + ); + }), + })) + }; + + let update_quote_handler = { + let cache = cache.clone(); + let msgbus = msgbus.clone(); + let clock = clock.clone(); + let inner = inner.clone(); + ShareableMessageHandler(Rc::new(UpdateQuoteTickHandler { + id: Ustr::from(&Uuid::new_v4().to_string()), + callback: Box::new(move |quote: &QuoteTick| { + update_quote_tick( + cache.clone(), + msgbus.clone(), + clock.clone(), + inner.clone(), + quote, + ); + }), + })) + }; + + let update_order_handler = { + let cache = cache; + let msgbus = msgbus.clone(); + let clock = clock.clone(); + let inner = inner; + ShareableMessageHandler(Rc::new(UpdateOrderHandler { + id: Ustr::from(&Uuid::new_v4().to_string()), + callback: Box::new(move |event: &OrderEventAny| { + update_order( + cache.clone(), + msgbus.clone(), + clock.clone(), + inner.clone(), + event, + ); + }), + })) + }; + + let mut borrowed_msgbus = msgbus.borrow_mut(); + borrowed_msgbus.register("Portfolio.update_account", update_account_handler.clone()); + + borrowed_msgbus.subscribe("data.quotes.*", update_quote_handler, Some(10)); + borrowed_msgbus.subscribe("events.order.*", update_order_handler, Some(10)); + borrowed_msgbus.subscribe("events.position.*", update_position_handler, Some(10)); + borrowed_msgbus.subscribe("events.account.*", update_account_handler, Some(10)); + } + + pub fn reset(&mut self) { + log::debug!("RESETTING"); + self.inner.borrow_mut().reset(); + log::debug!("READY"); } + // -- QUERIES --------------------------------------------------------------------------------- + #[must_use] - pub fn account(&self, venue: &Venue) -> Option<&AccountAny> { - self.accounts.get(venue) + pub fn is_initialized(&self) -> bool { + self.inner.borrow().initialized } #[must_use] - pub fn balances_locked(&self, venue: &Venue) -> HashMap { - todo!() + pub fn balances_locked(&self, venue: &Venue) -> HashMap { + self.cache.borrow().account_for_venue(venue).map_or_else( + || { + log::error!( + "Cannot get balances locked: no account generated for {}", + venue + ); + HashMap::new() + }, + nautilus_model::accounts::any::AccountAny::balances_locked, + ) } #[must_use] - pub fn margins_init(&self, venue: &Venue) -> HashMap { - todo!() + pub fn margins_init(&self, venue: &Venue) -> HashMap { + self.cache.borrow().account_for_venue(venue).map_or_else( + || { + log::error!( + "Cannot get initial (order) margins: no account registered for {}", + venue + ); + HashMap::new() + }, + |account| match account { + AccountAny::Margin(margin_account) => margin_account.initial_margins(), + AccountAny::Cash(_) => { + log::warn!("Initial margins not applicable for cash account"); + HashMap::new() + } + }, + ) } #[must_use] - pub fn margins_maint(&self, venue: &Venue) -> HashMap { - todo!() + pub fn margins_maint(&self, venue: &Venue) -> HashMap { + self.cache.borrow().account_for_venue(venue).map_or_else( + || { + log::error!( + "Cannot get maintenance (position) margins: no account registered for {}", + venue + ); + HashMap::new() + }, + |account| match account { + AccountAny::Margin(margin_account) => margin_account.maintenance_margins(), + AccountAny::Cash(_) => { + log::warn!("Maintenance margins not applicable for cash account"); + HashMap::new() + } + }, + ) } #[must_use] - pub fn unrealized_pnls(&self, venue: &Venue) -> HashMap { - todo!() + pub fn unrealized_pnls(&mut self, venue: &Venue) -> HashMap { + let instrument_ids = { + let borrowed_cache = self.cache.borrow(); + let positions = borrowed_cache.positions(Some(venue), None, None, None); + + if positions.is_empty() { + return HashMap::new(); // Nothing to calculate + } + + let instrument_ids: HashSet = + positions.iter().map(|p| p.instrument_id).collect(); + + instrument_ids + }; + + let mut unrealized_pnls: HashMap = HashMap::new(); + + for instrument_id in instrument_ids { + if let Some(&pnl) = self.inner.borrow_mut().unrealized_pnls.get(&instrument_id) { + // PnL already calculated + *unrealized_pnls.entry(pnl.currency).or_insert(0.0) += pnl.as_f64(); + continue; + } + + // Calculate PnL + match self.calculate_unrealized_pnl(&instrument_id) { + Some(pnl) => *unrealized_pnls.entry(pnl.currency).or_insert(0.0) += pnl.as_f64(), + None => continue, + } + } + + unrealized_pnls + .into_iter() + .map(|(currency, amount)| (currency, Money::new(amount, currency))) + .collect() } #[must_use] - pub fn realized_pnls(&self, venue: &Venue) -> HashMap { - todo!() + pub fn realized_pnls(&mut self, venue: &Venue) -> HashMap { + let instrument_ids = { + let borrowed_cache = self.cache.borrow(); + let positions = borrowed_cache.positions(Some(venue), None, None, None); + + if positions.is_empty() { + return HashMap::new(); // Nothing to calculate + } + + let instrument_ids: HashSet = + positions.iter().map(|p| p.instrument_id).collect(); + + instrument_ids + }; + + let mut realized_pnls: HashMap = HashMap::new(); + + for instrument_id in instrument_ids { + if let Some(&pnl) = self.inner.borrow_mut().realized_pnls.get(&instrument_id) { + // PnL already calculated + *realized_pnls.entry(pnl.currency).or_insert(0.0) += pnl.as_f64(); + continue; + } + + // Calculate PnL + match self.calculate_realized_pnl(&instrument_id) { + Some(pnl) => *realized_pnls.entry(pnl.currency).or_insert(0.0) += pnl.as_f64(), + None => continue, + } + } + + realized_pnls + .into_iter() + .map(|(currency, amount)| (currency, Money::new(amount, currency))) + .collect() } #[must_use] - pub fn net_exposures(&self, venue: &Venue) -> HashMap { - todo!() + pub fn net_exposures(&self, venue: &Venue) -> Option> { + let borrowed_cache = self.cache.borrow(); + let account = if let Some(account) = borrowed_cache.account_for_venue(venue) { + account + } else { + log::error!( + "Cannot calculate net exposures: no account registered for {}", + venue + ); + return None; // Cannot calculate + }; + + let positions_open = borrowed_cache.positions_open(Some(venue), None, None, None); + if positions_open.is_empty() { + return Some(HashMap::new()); // Nothing to calculate + } + + let mut net_exposures: HashMap = HashMap::new(); + + for position in positions_open { + let instrument = + if let Some(instrument) = borrowed_cache.instrument(&position.instrument_id) { + instrument + } else { + log::error!( + "Cannot calculate net exposures: no instrument for {}", + position.instrument_id + ); + return None; // Cannot calculate + }; + + if position.side == PositionSide::Flat { + log::error!( + "Cannot calculate net exposures: position is flat for {}", + position.instrument_id + ); + continue; // Nothing to calculate + } + + let last = self.get_last_price(position)?; + let xrate = self.calculate_xrate_to_base(instrument, account, position.entry); + if xrate == 0.0 { + log::error!( + "Cannot calculate net exposures: insufficient data for {}/{:?}", + instrument.settlement_currency(), + account.base_currency() + ); + return None; // Cannot calculate + } + + let settlement_currency = account + .base_currency() + .unwrap_or_else(|| instrument.settlement_currency()); + + let net_exposure = instrument + .calculate_notional_value(position.quantity, last, None) + .as_f64() + * xrate; + + let net_exposure = (net_exposure * 10f64.powi(settlement_currency.precision.into())) + .round() + / 10f64.powi(settlement_currency.precision.into()); + + *net_exposures.entry(settlement_currency).or_insert(0.0) += net_exposure; + } + + Some( + net_exposures + .into_iter() + .map(|(currency, amount)| (currency, Money::new(amount, currency))) + .collect(), + ) } #[must_use] - pub fn unrealized_pnl(&self, instrument_id: &InstrumentId) -> Option { - todo!() + pub fn unrealized_pnl(&mut self, instrument_id: &InstrumentId) -> Option { + if let Some(pnl) = self + .inner + .borrow() + .unrealized_pnls + .get(instrument_id) + .copied() + { + return Some(pnl); + } + + let pnl = self.calculate_unrealized_pnl(instrument_id)?; + self.inner + .borrow_mut() + .unrealized_pnls + .insert(*instrument_id, pnl); + Some(pnl) } #[must_use] - pub fn realized_pnl(&self, instrument_id: &InstrumentId) -> Option { - todo!() + pub fn realized_pnl(&mut self, instrument_id: &InstrumentId) -> Option { + if let Some(pnl) = self + .inner + .borrow() + .realized_pnls + .get(instrument_id) + .copied() + { + return Some(pnl); + } + + let pnl = self.calculate_realized_pnl(instrument_id)?; + self.inner + .borrow_mut() + .realized_pnls + .insert(*instrument_id, pnl); + Some(pnl) } #[must_use] pub fn net_exposure(&self, instrument_id: &InstrumentId) -> Option { - todo!() + let borrowed_cache = self.cache.borrow(); + let account = if let Some(account) = borrowed_cache.account_for_venue(&instrument_id.venue) + { + account + } else { + log::error!( + "Cannot calculate net exposure: no account registered for {}", + instrument_id.venue + ); + return None; + }; + + let instrument = if let Some(instrument) = borrowed_cache.instrument(instrument_id) { + instrument + } else { + log::error!( + "Cannot calculate net exposure: no instrument for {}", + instrument_id + ); + return None; + }; + + let positions_open = borrowed_cache.positions_open( + None, // Faster query filtering + Some(instrument_id), + None, + None, + ); + + if positions_open.is_empty() { + return Some(Money::new(0.0, instrument.settlement_currency())); + } + + let mut net_exposure = 0.0; + + for position in positions_open { + let last = self.get_last_price(position)?; + let xrate = self.calculate_xrate_to_base(instrument, account, position.entry); + if xrate == 0.0 { + log::error!( + "Cannot calculate net exposure: insufficient data for {}/{:?}", + instrument.settlement_currency(), + account.base_currency() + ); + return None; + } + + let notional_value = instrument + .calculate_notional_value(position.quantity, last, None) + .as_f64(); + + net_exposure += notional_value * xrate; + } + + let settlement_currency = account + .base_currency() + .unwrap_or_else(|| instrument.settlement_currency()); + + Some(Money::new(net_exposure, settlement_currency)) } #[must_use] - pub fn net_position(&self, instrument_id: &InstrumentId) -> Option { - todo!() + pub fn net_position(&self, instrument_id: &InstrumentId) -> Decimal { + self.inner + .borrow() + .net_positions + .get(instrument_id) + .copied() + .unwrap_or(Decimal::ZERO) } #[must_use] pub fn is_net_long(&self, instrument_id: &InstrumentId) -> bool { - todo!() + self.inner + .borrow() + .net_positions + .get(instrument_id) + .copied() + .map_or_else(|| false, |net_position| net_position > Decimal::ZERO) } #[must_use] pub fn is_net_short(&self, instrument_id: &InstrumentId) -> bool { - todo!() + self.inner + .borrow() + .net_positions + .get(instrument_id) + .copied() + .map_or_else(|| false, |net_position| net_position < Decimal::ZERO) } #[must_use] pub fn is_flat(&self, instrument_id: &InstrumentId) -> bool { - todo!() + self.inner + .borrow() + .net_positions + .get(instrument_id) + .copied() + .map_or_else(|| true, |net_position| net_position == Decimal::ZERO) } #[must_use] pub fn is_completely_flat(&self) -> bool { - todo!() + for net_position in self.inner.borrow().net_positions.values() { + if *net_position != Decimal::ZERO { + return false; + } + } + true } // -- COMMANDS -------------------------------------------------------------------------------- pub fn initialize_orders(&mut self) { - todo!() + let mut initialized = true; + let orders_and_instruments = { + let borrowed_cache = self.cache.borrow(); + let all_orders_open = borrowed_cache.orders_open(None, None, None, None); + + let mut instruments_with_orders = Vec::new(); + let mut instruments = HashSet::new(); + + for order in &all_orders_open { + instruments.insert(order.instrument_id()); + } + + for instrument_id in instruments { + if let Some(instrument) = borrowed_cache.instrument(&instrument_id) { + let orders = borrowed_cache + .orders_open(None, Some(&instrument_id), None, None) + .into_iter() + .cloned() + .collect::>(); + instruments_with_orders.push((instrument.clone(), orders)); + } else { + log::error!( + "Cannot update initial (order) margin: no instrument found for {}", + instrument_id + ); + initialized = false; + break; + } + } + instruments_with_orders + }; + + for (instrument, orders_open) in &orders_and_instruments { + let mut borrowed_cache = self.cache.borrow_mut(); + let account = + if let Some(account) = borrowed_cache.account_for_venue(&instrument.id().venue) { + account + } else { + log::error!( + "Cannot update initial (order) margin: no account registered for {}", + instrument.id().venue + ); + initialized = false; + break; + }; + + let result = self.inner.borrow_mut().accounts.update_orders( + account, + instrument.clone(), + orders_open.iter().collect(), + self.clock.borrow().timestamp_ns(), + ); + + match result { + Some((updated_account, _)) => { + borrowed_cache.add_account(updated_account).unwrap(); // Temp Fix to update the mutated account + } + None => { + initialized = false; + } + } + } + + let total_orders = orders_and_instruments + .into_iter() + .map(|(_, orders)| orders.len()) + .sum::(); + + log::info!( + "Initialized {} open order{}", + total_orders, + if total_orders == 1 { "" } else { "s" } + ); + + self.inner.borrow_mut().initialized = initialized; } pub fn initialize_positions(&mut self) { - todo!() + self.inner.borrow_mut().unrealized_pnls.clear(); + self.inner.borrow_mut().realized_pnls.clear(); + let all_positions_open: Vec; + let mut instruments = HashSet::new(); + { + let borrowed_cache = self.cache.borrow(); + all_positions_open = borrowed_cache + .positions_open(None, None, None, None) + .into_iter() + .cloned() + .collect(); + for position in &all_positions_open { + instruments.insert(position.instrument_id); + } + } + + let mut initialized = true; + + for instrument_id in instruments { + let positions_open: Vec = { + let borrowed_cache = self.cache.borrow(); + borrowed_cache + .positions_open(None, Some(&instrument_id), None, None) + .into_iter() + .cloned() + .collect() + }; + + self.update_net_position(&instrument_id, positions_open); + + let calculated_unrealized_pnl = self + .calculate_unrealized_pnl(&instrument_id) + .expect("Failed to calculate unrealized PnL"); + let calculated_realized_pnl = self + .calculate_realized_pnl(&instrument_id) + .expect("Failed to calculate realized PnL"); + + self.inner + .borrow_mut() + .unrealized_pnls + .insert(instrument_id, calculated_unrealized_pnl); + self.inner + .borrow_mut() + .realized_pnls + .insert(instrument_id, calculated_realized_pnl); + + let borrowed_cache = self.cache.borrow(); + let account = + if let Some(account) = borrowed_cache.account_for_venue(&instrument_id.venue) { + account + } else { + log::error!( + "Cannot update maintenance (position) margin: no account registered for {}", + instrument_id.venue + ); + initialized = false; + break; + }; + + let account = match account { + AccountAny::Cash(_) => continue, + AccountAny::Margin(margin_account) => margin_account, + }; + + let mut borrowed_cache = self.cache.borrow_mut(); + let instrument = if let Some(instrument) = borrowed_cache.instrument(&instrument_id) { + instrument + } else { + log::error!( + "Cannot update maintenance (position) margin: no instrument found for {}", + instrument_id + ); + initialized = false; + break; + }; + + let result = self.inner.borrow_mut().accounts.update_positions( + account, + instrument.clone(), + self.cache + .borrow() + .positions_open(None, Some(&instrument_id), None, None), + self.clock.borrow().timestamp_ns(), + ); + + match result { + Some((updated_account, _)) => { + borrowed_cache + .add_account(AccountAny::Margin(updated_account)) // Temp Fix to update the mutated account + .unwrap(); + } + None => { + initialized = false; + } + } + } + + let open_count = all_positions_open.len(); + self.inner.borrow_mut().initialized = initialized; + log::info!( + "Initialized {} open position{}", + open_count, + if open_count == 1 { "" } else { "s" } + ); } pub fn update_quote_tick(&mut self, quote: &QuoteTick) { - todo!() + update_quote_tick( + self.cache.clone(), + self.msgbus.clone(), + self.clock.clone(), + self.inner.clone(), + quote, + ); } pub fn update_account(&mut self, event: &AccountState) { - todo!() + update_account(self.cache.clone(), event); } pub fn update_order(&mut self, event: &OrderEventAny) { - todo!() + update_order( + self.cache.clone(), + self.msgbus.clone(), + self.clock.clone(), + self.inner.clone(), + event, + ); } pub fn update_position(&mut self, event: &PositionEvent) { - todo!() + update_position( + self.cache.clone(), + self.msgbus.clone(), + self.clock.clone(), + self.inner.clone(), + event, + ); } // -- INTERNAL -------------------------------------------------------------------------------- - // fn net_position(&self, instrument_id: &InstrumentId) -> Decimal { // Same as above? - // todo!() - // } + fn update_net_position(&mut self, instrument_id: &InstrumentId, positions_open: Vec) { + let mut net_position = Decimal::ZERO; - fn update_net_position( - &self, - instrument_id: &InstrumentId, - positions_open: Vec<&Position>, - ) -> Decimal { - todo!() + for open_position in positions_open { + log::debug!("open_position: {}", open_position); + net_position += Decimal::from_f64(open_position.signed_qty).unwrap_or(Decimal::ZERO); + } + + let existing_position = self.net_position(instrument_id); + if existing_position != net_position { + self.inner + .borrow_mut() + .net_positions + .insert(*instrument_id, net_position); + log::info!("{} net_position={}", instrument_id, net_position); + } } - fn calculate_unrealized_pnl(&self, instrument_id: &InstrumentId) -> Money { - todo!() + fn calculate_unrealized_pnl(&mut self, instrument_id: &InstrumentId) -> Option { + let borrowed_cache = self.cache.borrow(); + + let account = if let Some(account) = borrowed_cache.account_for_venue(&instrument_id.venue) + { + account + } else { + log::error!( + "Cannot calculate unrealized PnL: no account registered for {}", + instrument_id.venue + ); + return None; + }; + + let instrument = if let Some(instrument) = borrowed_cache.instrument(instrument_id) { + instrument + } else { + log::error!( + "Cannot calculate unrealized PnL: no instrument for {}", + instrument_id + ); + return None; + }; + + let currency = account + .base_currency() + .unwrap_or_else(|| instrument.settlement_currency()); + + let positions_open = borrowed_cache.positions_open( + None, // Faster query filtering + Some(instrument_id), + None, + None, + ); + + if positions_open.is_empty() { + return Some(Money::new(0.0, currency)); + } + + let mut total_pnl = 0.0; + + for position in positions_open { + if position.instrument_id != *instrument_id { + continue; // Nothing to calculate + } + + if position.side == PositionSide::Flat { + continue; // Nothing to calculate + } + + let last = if let Some(price) = self.get_last_price(position) { + price + } else { + log::debug!( + "Cannot calculate unrealized PnL: no prices for {}", + instrument_id + ); + self.inner.borrow_mut().pending_calcs.insert(*instrument_id); + return None; // Cannot calculate + }; + + let mut pnl = position.unrealized_pnl(last).as_f64(); + + if let Some(base_currency) = account.base_currency() { + let xrate = self.calculate_xrate_to_base(instrument, account, position.entry); + + if xrate == 0.0 { + log::debug!( + "Cannot calculate unrealized PnL: insufficient data for {}/{}", + instrument.settlement_currency(), + base_currency + ); + self.inner.borrow_mut().pending_calcs.insert(*instrument_id); + return None; + } + + let scale = 10f64.powi(currency.precision.into()); + pnl = ((pnl * xrate) * scale).round() / scale; + } + + total_pnl += pnl; + } + + Some(Money::new(total_pnl, currency)) } - fn calculate_realized_pnl(&self, instrument_id: &InstrumentId) -> Money { - todo!() + fn calculate_realized_pnl(&mut self, instrument_id: &InstrumentId) -> Option { + let borrowed_cache = self.cache.borrow(); + + let account = if let Some(account) = borrowed_cache.account_for_venue(&instrument_id.venue) + { + account + } else { + log::error!( + "Cannot calculate realized PnL: no account registered for {}", + instrument_id.venue + ); + return None; + }; + + let instrument = if let Some(instrument) = borrowed_cache.instrument(instrument_id) { + instrument + } else { + log::error!( + "Cannot calculate realized PnL: no instrument for {}", + instrument_id + ); + return None; + }; + + let currency = account + .base_currency() + .unwrap_or_else(|| instrument.settlement_currency()); + + let positions = borrowed_cache.positions( + None, // Faster query filtering + Some(instrument_id), + None, + None, + ); + + if positions.is_empty() { + return Some(Money::new(0.0, currency)); + } + + let mut total_pnl = 0.0; + + for position in positions { + if position.instrument_id != *instrument_id { + continue; // Nothing to calculate + } + + if position.side == PositionSide::Flat { + continue; // Nothing to calculate + } + + let mut pnl = position.realized_pnl?.as_f64(); + + if let Some(base_currency) = account.base_currency() { + let xrate = self.calculate_xrate_to_base(instrument, account, position.entry); + + if xrate == 0.0 { + log::debug!( + "Cannot calculate realized PnL: insufficient data for {}/{}", + instrument.settlement_currency(), + base_currency + ); + self.inner.borrow_mut().pending_calcs.insert(*instrument_id); + return None; // Cannot calculate + } + + let scale = 10f64.powi(currency.precision.into()); + pnl = ((pnl * xrate) * scale).round() / scale; + } + + total_pnl += pnl; + } + + Some(Money::new(total_pnl, currency)) } - fn get_last_price(&self, position: &Position) -> Money { - todo!() + fn get_last_price(&self, position: &Position) -> Option { + let price_type = match position.side { + PositionSide::Long => PriceType::Bid, + PositionSide::Short => PriceType::Ask, + _ => panic!("invalid `PositionSide`, was {}", position.side), + }; + + let borrowed_cache = self.cache.borrow(); + + borrowed_cache + .price(&position.instrument_id, price_type) + .or_else(|| borrowed_cache.price(&position.instrument_id, PriceType::Last)) } fn calculate_xrate_to_base( &self, - account: &AccountAny, instrument: &InstrumentAny, + account: &AccountAny, side: OrderSide, ) -> f64 { - todo!() + match account.base_currency() { + Some(base_currency) => { + let price_type = if side == OrderSide::Buy { + PriceType::Bid + } else { + PriceType::Ask + }; + + self.cache + .borrow() + .get_xrate( + instrument.id().venue, + instrument.settlement_currency(), + base_currency, + price_type, + ) + .to_f64() + .unwrap_or_else(|| { + log::error!( + "Failed to get/convert xrate for instrument {} from {} to {}", + instrument.id(), + instrument.settlement_currency(), + base_currency + ); + 1.0 + }) + } + None => 1.0, // No conversion needed + } + } +} + +// Helper functions +fn update_quote_tick( + cache: Rc>, + msgbus: Rc>, + clock: Rc>, + inner: Rc>, + quote: &QuoteTick, +) { + inner + .borrow_mut() + .unrealized_pnls + .remove("e.instrument_id); + + if inner.borrow().initialized || !inner.borrow().pending_calcs.contains("e.instrument_id) { + return; + } + + let result_init; + let mut result_maint = None; + + let account = { + let borrowed_cache = cache.borrow(); + let account = + if let Some(account) = borrowed_cache.account_for_venue("e.instrument_id.venue) { + account + } else { + log::error!( + "Cannot update tick: no account registered for {}", + quote.instrument_id.venue + ); + return; + }; + + let mut borrowed_cache = cache.borrow_mut(); + let instrument = if let Some(instrument) = borrowed_cache.instrument("e.instrument_id) { + instrument.clone() + } else { + log::error!( + "Cannot update tick: no instrument found for {}", + quote.instrument_id + ); + return; + }; + + // Clone the orders and positions to own the data + let orders_open: Vec = borrowed_cache + .orders_open(None, Some("e.instrument_id), None, None) + .iter() + .map(|o| (*o).clone()) + .collect(); + + let positions_open: Vec = borrowed_cache + .positions_open(None, Some("e.instrument_id), None, None) + .iter() + .map(|p| (*p).clone()) + .collect(); + + result_init = inner.borrow().accounts.update_orders( + account, + instrument.clone(), + orders_open.iter().collect(), + clock.borrow().timestamp_ns(), + ); + + if let AccountAny::Margin(margin_account) = account { + result_maint = inner.borrow().accounts.update_positions( + margin_account, + instrument, + positions_open.iter().collect(), + clock.borrow().timestamp_ns(), + ); + } + + if let Some((ref updated_account, _)) = result_init { + borrowed_cache.add_account(updated_account.clone()).unwrap(); // Temp Fix to update the mutated account + } + account.clone() + }; + + let mut portfolio_clone = Portfolio { + clock: clock.clone(), + cache, + msgbus, + inner: inner.clone(), + }; + + let result_unrealized_pnl: Option = + portfolio_clone.calculate_unrealized_pnl("e.instrument_id); + + if result_init.is_some() + && (matches!(account, AccountAny::Cash(_)) + || (result_maint.is_some() && result_unrealized_pnl.is_some())) + { + inner + .borrow_mut() + .pending_calcs + .remove("e.instrument_id); + if inner.borrow().pending_calcs.is_empty() { + inner.borrow_mut().initialized = true; + } + } +} + +fn update_order( + cache: Rc>, + msgbus: Rc>, + clock: Rc>, + inner: Rc>, + event: &OrderEventAny, +) { + let borrowed_cache = cache.borrow(); + let account_id = match event.account_id() { + Some(account_id) => account_id, + None => { + return; // No Account Assigned + } + }; + + let account = if let Some(account) = borrowed_cache.account(&account_id) { + account + } else { + log::error!( + "Cannot update order: no account registered for {}", + account_id + ); + return; + }; + + match account { + AccountAny::Cash(cash_account) => { + if !cash_account.base.calculate_account_state { + return; + } + } + AccountAny::Margin(margin_account) => { + if !margin_account.base.calculate_account_state { + return; + } + } + } + + match event { + OrderEventAny::Accepted(_) + | OrderEventAny::Canceled(_) + | OrderEventAny::Rejected(_) + | OrderEventAny::Updated(_) + | OrderEventAny::Filled(_) => {} + _ => { + return; + } + } + + let borrowed_cache = cache.borrow(); + let order = if let Some(order) = borrowed_cache.order(&event.client_order_id()) { + order + } else { + log::error!( + "Cannot update order: {} not found in the cache", + event.client_order_id() + ); + return; // No Order Found + }; + + if matches!(event, OrderEventAny::Rejected(_)) && order.order_type() != OrderType::StopLimit { + return; // No change to account state + } + + let instrument = if let Some(instrument_id) = borrowed_cache.instrument(&event.instrument_id()) + { + instrument_id + } else { + log::error!( + "Cannot update order: no instrument found for {}", + event.instrument_id() + ); + return; + }; + + if let OrderEventAny::Filled(order_filled) = event { + let _ = inner.borrow().accounts.update_balances( + account.clone(), + instrument.clone(), + *order_filled, + ); + + let mut portfolio_clone = Portfolio { + clock: clock.clone(), + cache: cache.clone(), + msgbus: msgbus.clone(), + inner: inner.clone(), + }; + + match portfolio_clone.calculate_unrealized_pnl(&order_filled.instrument_id) { + Some(unrealized_pnl) => { + inner + .borrow_mut() + .unrealized_pnls + .insert(event.instrument_id(), unrealized_pnl); + } + None => { + log::error!( + "Failed to calculate unrealized PnL for instrument {}", + event.instrument_id() + ); + } + } + } + + let orders_open = borrowed_cache.orders_open(None, Some(&event.instrument_id()), None, None); + + let account_state = inner.borrow_mut().accounts.update_orders( + account, + instrument.clone(), + orders_open, + clock.borrow().timestamp_ns(), + ); + + let mut borrowed_cache = cache.borrow_mut(); + borrowed_cache.update_account(account.clone()).unwrap(); + + if let Some(account_state) = account_state { + msgbus.borrow().publish( + &Ustr::from(&format!("events.account.{}", account.id())), + &account_state, + ); + } else { + log::debug!("Added pending calculation for {}", instrument.id()); + inner.borrow_mut().pending_calcs.insert(instrument.id()); + } + + log::debug!("Updated {}", event); +} + +fn update_position( + cache: Rc>, + msgbus: Rc>, + clock: Rc>, + inner: Rc>, + event: &PositionEvent, +) { + let instrument_id = event.instrument_id(); + + let positions_open: Vec = { + let borrowed_cache = cache.borrow(); + + borrowed_cache + .positions_open(None, Some(&instrument_id), None, None) + .iter() + .map(|o| (*o).clone()) + .collect() + }; + + log::debug!("postion fresh from cache -> {:?}", positions_open); + + let mut portfolio_clone = Portfolio { + clock: clock.clone(), + cache: cache.clone(), + msgbus, + inner: inner.clone(), + }; + + portfolio_clone.update_net_position(&instrument_id, positions_open.clone()); + + let calculated_unrealized_pnl = portfolio_clone + .calculate_unrealized_pnl(&instrument_id) + .expect("Failed to calculate unrealized PnL"); + let calculated_realized_pnl = portfolio_clone + .calculate_realized_pnl(&instrument_id) + .expect("Failed to calculate realized PnL"); + + inner + .borrow_mut() + .unrealized_pnls + .insert(event.instrument_id(), calculated_unrealized_pnl); + inner + .borrow_mut() + .realized_pnls + .insert(event.instrument_id(), calculated_realized_pnl); + + let borrowed_cache = cache.borrow(); + let account = borrowed_cache.account(&event.account_id()); + + if let Some(AccountAny::Margin(margin_account)) = account { + if !margin_account.calculate_account_state { + return; // Nothing to calculate + }; + + let borrowed_cache = cache.borrow(); + let instrument = if let Some(instrument) = borrowed_cache.instrument(&instrument_id) { + instrument + } else { + log::error!( + "Cannot update position: no instrument found for {}", + instrument_id + ); + return; + }; + + let result = inner.borrow_mut().accounts.update_positions( + margin_account, + instrument.clone(), + positions_open.iter().collect(), + clock.borrow().timestamp_ns(), + ); + let mut borrowed_cache = cache.borrow_mut(); + if let Some((margin_account, _)) = result { + borrowed_cache + .add_account(AccountAny::Margin(margin_account)) // Temp Fix to update the mutated account + .unwrap(); + } + } else if account.is_none() { + log::error!( + "Cannot update position: no account registered for {}", + event.account_id() + ); + } +} + +pub fn update_account(cache: Rc>, event: &AccountState) { + let mut borrowed_cache = cache.borrow_mut(); + + if let Some(existing) = borrowed_cache.account(&event.account_id) { + let mut account = existing.clone(); + account.apply(event.clone()); + + if let Err(e) = borrowed_cache.update_account(account.clone()) { + log::error!("Failed to update account: {}", e); + return; + } + } else { + let account = match AccountAny::from_events(vec![event.clone()]) { + Ok(account) => account, + Err(e) => { + log::error!("Failed to create account: {}", e); + return; + } + }; + + if let Err(e) = borrowed_cache.add_account(account) { + log::error!("Failed to add account: {}", e); + return; + } + } + + log::info!("Updated {}", event); +} + +//////////////////////////////////////////////////////////////////////////////// +// Tests +//////////////////////////////////////////////////////////////////////////////// +#[cfg(test)] +mod tests { + use std::{cell::RefCell, rc::Rc}; + + use nautilus_common::{cache::Cache, clock::TestClock, msgbus::MessageBus}; + use nautilus_core::nanos::UnixNanos; + use nautilus_model::{ + data::quote::QuoteTick, + enums::{AccountType, LiquiditySide, OmsType, OrderSide, OrderType}, + events::{ + account::{state::AccountState, stubs::cash_account_state}, + order::{ + stubs::{order_accepted, order_filled, order_submitted}, + OrderAccepted, OrderEventAny, OrderFilled, OrderSubmitted, + }, + position::{ + changed::PositionChanged, closed::PositionClosed, opened::PositionOpened, + PositionEvent, + }, + }, + identifiers::{ + stubs::{account_id, uuid4}, + AccountId, ClientOrderId, PositionId, StrategyId, Symbol, TradeId, VenueOrderId, + }, + instruments::{ + any::InstrumentAny, + crypto_perpetual::CryptoPerpetual, + currency_pair::CurrencyPair, + stubs::{audusd_sim, currency_pair_btcusdt, default_fx_ccy, ethusdt_bitmex}, + }, + orders::{any::OrderAny, builder::OrderTestBuilder}, + position::Position, + types::{ + balance::AccountBalance, currency::Currency, money::Money, price::Price, + quantity::Quantity, + }, + }; + use rstest::{fixture, rstest}; + use rust_decimal::{prelude::FromPrimitive, Decimal}; + + use super::Portfolio; + + #[fixture] + fn msgbus() -> MessageBus { + MessageBus::default() + } + + #[fixture] + fn simple_cache() -> Cache { + Cache::new(None, None) + } + + #[fixture] + fn clock() -> TestClock { + TestClock::new() + } + + #[fixture] + fn venue() -> Venue { + Venue::new("SIM") + } + + #[fixture] + fn instrument_audusd(audusd_sim: CurrencyPair) -> InstrumentAny { + InstrumentAny::CurrencyPair(audusd_sim) + } + + #[fixture] + fn instrument_gbpusd() -> InstrumentAny { + InstrumentAny::CurrencyPair(default_fx_ccy( + Symbol::from("GBP/USD"), + Some(Venue::from("SIM")), + )) + } + + #[fixture] + fn instrument_btcusdt(currency_pair_btcusdt: CurrencyPair) -> InstrumentAny { + InstrumentAny::CurrencyPair(currency_pair_btcusdt) + } + + #[fixture] + fn instrument_ethusdt(ethusdt_bitmex: CryptoPerpetual) -> InstrumentAny { + InstrumentAny::CryptoPerpetual(ethusdt_bitmex) + } + + #[fixture] + fn portfolio( + msgbus: MessageBus, + mut simple_cache: Cache, + clock: TestClock, + instrument_audusd: InstrumentAny, + instrument_gbpusd: InstrumentAny, + instrument_btcusdt: InstrumentAny, + instrument_ethusdt: InstrumentAny, + ) -> Portfolio { + simple_cache.add_instrument(instrument_audusd).unwrap(); + simple_cache.add_instrument(instrument_gbpusd).unwrap(); + simple_cache.add_instrument(instrument_btcusdt).unwrap(); + simple_cache.add_instrument(instrument_ethusdt).unwrap(); + + Portfolio::new( + Rc::new(RefCell::new(msgbus)), + Rc::new(RefCell::new(simple_cache)), + Rc::new(RefCell::new(clock)), + ) + } + + use std::collections::HashMap; + + use nautilus_model::identifiers::Venue; + + // Helpers + fn get_cash_account(accountid: Option<&str>) -> AccountState { + AccountState::new( + match accountid { + Some(account_id_str) => AccountId::new(account_id_str), + None => account_id(), + }, + AccountType::Cash, + vec![ + AccountBalance::new( + Money::new(10.00000000, Currency::BTC()), + Money::new(0.00000000, Currency::BTC()), + Money::new(10.00000000, Currency::BTC()), + ), + AccountBalance::new( + Money::new(10.000, Currency::USD()), + Money::new(0.000, Currency::USD()), + Money::new(10.000, Currency::USD()), + ), + AccountBalance::new( + Money::new(100000.000, Currency::USDT()), + Money::new(0.000, Currency::USDT()), + Money::new(100000.000, Currency::USDT()), + ), + AccountBalance::new( + Money::new(20.000, Currency::ETH()), + Money::new(0.000, Currency::ETH()), + Money::new(20.000, Currency::ETH()), + ), + ], + vec![], + true, + uuid4(), + 0.into(), + 0.into(), + None, + ) + } + + fn get_margin_account(accountid: Option<&str>) -> AccountState { + AccountState::new( + match accountid { + Some(account_id_str) => AccountId::new(account_id_str), + None => account_id(), + }, + AccountType::Margin, + vec![ + AccountBalance::new( + Money::new(10.000, Currency::BTC()), + Money::new(0.000, Currency::BTC()), + Money::new(10.000, Currency::BTC()), + ), + AccountBalance::new( + Money::new(20.000, Currency::ETH()), + Money::new(0.000, Currency::ETH()), + Money::new(20.000, Currency::ETH()), + ), + AccountBalance::new( + Money::new(100000.000, Currency::USDT()), + Money::new(0.000, Currency::USDT()), + Money::new(100000.000, Currency::USDT()), + ), + AccountBalance::new( + Money::new(10.000, Currency::USD()), + Money::new(0.000, Currency::USD()), + Money::new(10.000, Currency::USD()), + ), + AccountBalance::new( + Money::new(10.000, Currency::GBP()), + Money::new(0.000, Currency::GBP()), + Money::new(10.000, Currency::GBP()), + ), + ], + Vec::new(), + true, + uuid4(), + 0.into(), + 0.into(), + None, + ) + } + + fn get_quote_tick( + instrument: &InstrumentAny, + bid: f64, + ask: f64, + bid_size: f64, + ask_size: f64, + ) -> QuoteTick { + QuoteTick::new( + instrument.id(), + Price::new(bid, 0), + Price::new(ask, 0), + Quantity::new(bid_size, 0), + Quantity::new(ask_size, 0), + 0.into(), + 0.into(), + ) + } + + fn submit_order(order: &OrderAny) -> OrderSubmitted { + order_submitted( + order.trader_id(), + order.strategy_id(), + order.instrument_id(), + order.client_order_id(), + account_id(), + uuid4(), + ) + } + + fn accept_order(order: &OrderAny) -> OrderAccepted { + order_accepted( + order.trader_id(), + order.strategy_id(), + order.instrument_id(), + order.client_order_id(), + account_id(), + order.venue_order_id().unwrap_or(VenueOrderId::new("1")), + uuid4(), + ) + } + + fn fill_order(order: &OrderAny) -> OrderFilled { + order_filled( + order.trader_id(), + order.strategy_id(), + order.instrument_id(), + order.client_order_id(), + uuid4(), + ) + } + + fn get_open_position(position: &Position) -> PositionOpened { + PositionOpened { + trader_id: position.trader_id, + strategy_id: position.strategy_id, + instrument_id: position.instrument_id, + position_id: position.id, + account_id: position.account_id, + opening_order_id: position.opening_order_id, + entry: position.entry, + side: position.side, + signed_qty: position.signed_qty, + quantity: position.quantity, + last_qty: position.quantity, + last_px: Price::new(position.avg_px_open, 0), + currency: position.settlement_currency, + avg_px_open: position.avg_px_open, + ts_event: 0.into(), + ts_init: 0.into(), + } + } + + fn get_changed_position(position: &Position) -> PositionChanged { + PositionChanged { + trader_id: position.trader_id, + strategy_id: position.strategy_id, + instrument_id: position.instrument_id, + position_id: position.id, + account_id: position.account_id, + opening_order_id: position.opening_order_id, + entry: position.entry, + side: position.side, + signed_qty: position.signed_qty, + quantity: position.quantity, + last_qty: position.quantity, + last_px: Price::new(position.avg_px_open, 0), + currency: position.settlement_currency, + avg_px_open: position.avg_px_open, + ts_event: 0.into(), + ts_init: 0.into(), + peak_quantity: position.quantity, + avg_px_close: position.avg_px_open, + realized_return: position.avg_px_open, + realized_pnl: Money::new(10.0, Currency::USD()), + unrealized_pnl: Money::new(10.0, Currency::USD()), + ts_opened: 0.into(), + } + } + + fn get_close_position(position: &Position) -> PositionClosed { + PositionClosed { + trader_id: position.trader_id, + strategy_id: position.strategy_id, + instrument_id: position.instrument_id, + position_id: position.id, + account_id: position.account_id, + opening_order_id: position.opening_order_id, + entry: position.entry, + side: position.side, + signed_qty: position.signed_qty, + quantity: position.quantity, + last_qty: position.quantity, + last_px: Price::new(position.avg_px_open, 0), + currency: position.settlement_currency, + avg_px_open: position.avg_px_open, + ts_event: 0.into(), + ts_init: 0.into(), + peak_quantity: position.quantity, + avg_px_close: position.avg_px_open, + realized_return: position.avg_px_open, + realized_pnl: Money::new(10.0, Currency::USD()), + unrealized_pnl: Money::new(10.0, Currency::USD()), + ts_opened: 0.into(), + closing_order_id: ClientOrderId::new("SSD"), + duration: 0, + ts_closed: 0.into(), + } + } + + // Tests + #[rstest] + fn test_account_when_account_returns_the_account_facade(mut portfolio: Portfolio) { + let account_id = "BINANCE-1513111"; + let state = get_cash_account(Some(account_id)); + + portfolio.update_account(&state); + + let borrowed_cache = portfolio.cache.borrow_mut(); + let account = borrowed_cache.account(&AccountId::new(account_id)).unwrap(); + assert_eq!(account.id().get_issuer(), "BINANCE".into()); + assert_eq!(account.id().get_issuers_id(), "1513111"); + } + + #[rstest] + fn test_balances_locked_when_no_account_for_venue_returns_none( + portfolio: Portfolio, + venue: Venue, + ) { + let result = portfolio.balances_locked(&venue); + assert_eq!(result, HashMap::new()); + } + + #[rstest] + fn test_margins_init_when_no_account_for_venue_returns_none( + portfolio: Portfolio, + venue: Venue, + ) { + let result = portfolio.margins_init(&venue); + assert_eq!(result, HashMap::new()); + } + + #[rstest] + fn test_margins_maint_when_no_account_for_venue_returns_none( + portfolio: Portfolio, + venue: Venue, + ) { + let result = portfolio.margins_maint(&venue); + assert_eq!(result, HashMap::new()); + } + + #[rstest] + fn test_unrealized_pnl_for_instrument_when_no_instrument_returns_none( + mut portfolio: Portfolio, + instrument_audusd: InstrumentAny, + ) { + let result = portfolio.unrealized_pnl(&instrument_audusd.id()); + assert!(result.is_none()); + } + + #[rstest] + fn test_unrealized_pnl_for_venue_when_no_account_returns_empty_dict( + mut portfolio: Portfolio, + venue: Venue, + ) { + let result = portfolio.unrealized_pnls(&venue); + assert_eq!(result, HashMap::new()); + } + + #[rstest] + fn test_realized_pnl_for_instrument_when_no_instrument_returns_none( + mut portfolio: Portfolio, + instrument_audusd: InstrumentAny, + ) { + let result = portfolio.realized_pnl(&instrument_audusd.id()); + assert!(result.is_none()); + } + + #[rstest] + fn test_realized_pnl_for_venue_when_no_account_returns_empty_dict( + mut portfolio: Portfolio, + venue: Venue, + ) { + let result = portfolio.realized_pnls(&venue); + assert_eq!(result, HashMap::new()); + } + + #[rstest] + fn test_net_position_when_no_positions_returns_zero( + portfolio: Portfolio, + instrument_audusd: InstrumentAny, + ) { + let result = portfolio.net_position(&instrument_audusd.id()); + assert_eq!(result, Decimal::ZERO); + } + + #[rstest] + fn test_net_exposures_when_no_positions_returns_none(portfolio: Portfolio, venue: Venue) { + let result = portfolio.net_exposures(&venue); + assert!(result.is_none()); + } + + #[rstest] + fn test_is_net_long_when_no_positions_returns_false( + portfolio: Portfolio, + instrument_audusd: InstrumentAny, + ) { + let result = portfolio.is_net_long(&instrument_audusd.id()); + assert!(!result); + } + + #[rstest] + fn test_is_net_short_when_no_positions_returns_false( + portfolio: Portfolio, + instrument_audusd: InstrumentAny, + ) { + let result = portfolio.is_net_short(&instrument_audusd.id()); + assert!(!result); + } + + #[rstest] + fn test_is_flat_when_no_positions_returns_true( + portfolio: Portfolio, + instrument_audusd: InstrumentAny, + ) { + let result = portfolio.is_flat(&instrument_audusd.id()); + assert!(result); + } + + #[rstest] + fn test_is_completely_flat_when_no_positions_returns_true(portfolio: Portfolio) { + let result = portfolio.is_completely_flat(); + assert!(result); + } + + #[rstest] + fn test_open_value_when_no_account_returns_none(portfolio: Portfolio, venue: Venue) { + let result = portfolio.net_exposures(&venue); + assert!(result.is_none()); + } + + #[rstest] + fn test_update_tick(mut portfolio: Portfolio, instrument_audusd: InstrumentAny) { + let tick = get_quote_tick(&instrument_audusd, 1.25, 1.251, 1.0, 1.0); + portfolio.update_quote_tick(&tick); + assert!(portfolio.unrealized_pnl(&instrument_audusd.id()).is_none()); + } + + //TODO: FIX: It should return an error + #[rstest] + fn test_exceed_free_balance_single_currency_raises_account_balance_negative_exception( + mut portfolio: Portfolio, + cash_account_state: AccountState, + instrument_audusd: InstrumentAny, + ) { + portfolio.update_account(&cash_account_state); + + let mut order = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("1000000")) + .build(); + + portfolio + .cache + .borrow_mut() + .add_order(order.clone(), None, None, false) + .unwrap(); + + let order_submitted = submit_order(&order); + order + .apply(OrderEventAny::Submitted(order_submitted)) + .unwrap(); + + portfolio.update_order(&OrderEventAny::Submitted(order_submitted)); + + let order_filled = fill_order(&order); + order.apply(OrderEventAny::Filled(order_filled)).unwrap(); + portfolio.update_order(&OrderEventAny::Filled(order_filled)); + } + + // TODO: It should return an error + #[rstest] + fn test_exceed_free_balance_multi_currency_raises_account_balance_negative_exception( + mut portfolio: Portfolio, + cash_account_state: AccountState, + instrument_audusd: InstrumentAny, + ) { + portfolio.update_account(&cash_account_state); + + let account = portfolio + .cache + .borrow_mut() + .account_for_venue(&Venue::from("SIM")) + .unwrap() + .clone(); + + // Create Order + let mut order = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("3.0")) + .build(); + + portfolio + .cache + .borrow_mut() + .add_order(order.clone(), None, None, false) + .unwrap(); + + let order_submitted = submit_order(&order); + order + .apply(OrderEventAny::Submitted(order_submitted)) + .unwrap(); + portfolio.update_order(&OrderEventAny::Submitted(order_submitted)); + + // Assert + assert_eq!( + account.balances().iter().next().unwrap().1.total.as_f64(), + 1525000.00 + ); + } + + #[rstest] + fn test_update_orders_open_cash_account( + mut portfolio: Portfolio, + cash_account_state: AccountState, + instrument_audusd: InstrumentAny, + ) { + portfolio.update_account(&cash_account_state); + + // Create Order + let mut order = OrderTestBuilder::new(OrderType::Limit) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("1.0")) + .price(Price::new(50000.0, 0)) + .build(); + + portfolio + .cache + .borrow_mut() + .add_order(order.clone(), None, None, false) + .unwrap(); + + let order_submitted = submit_order(&order); + order + .apply(OrderEventAny::Submitted(order_submitted)) + .unwrap(); + portfolio.update_order(&OrderEventAny::Submitted(order_submitted)); + + // ACCEPTED + let order_accepted = accept_order(&order); + order + .apply(OrderEventAny::Accepted(order_accepted)) + .unwrap(); + portfolio.update_order(&OrderEventAny::Accepted(order_accepted)); + + assert_eq!( + portfolio + .balances_locked(&Venue::from("SIM")) + .get(&Currency::USD()) + .unwrap() + .as_f64(), + 25000.0 + ); + } + + #[rstest] + fn test_update_orders_open_margin_account( + mut portfolio: Portfolio, + instrument_btcusdt: InstrumentAny, + ) { + let account_state = get_margin_account(Some("BINANCE-01234")); + portfolio.update_account(&account_state); + + // Create Order + let mut order1 = OrderTestBuilder::new(OrderType::StopMarket) + .instrument_id(instrument_btcusdt.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("100.0")) + .price(Price::new(55.0, 1)) + .trigger_price(Price::new(35.0, 1)) + .build(); + + let order2 = OrderTestBuilder::new(OrderType::StopMarket) + .instrument_id(instrument_btcusdt.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("1000.0")) + .price(Price::new(45.0, 1)) + .trigger_price(Price::new(30.0, 1)) + .build(); + + portfolio + .cache + .borrow_mut() + .add_order(order1.clone(), None, None, true) + .unwrap(); + + portfolio + .cache + .borrow_mut() + .add_order(order2, None, None, true) + .unwrap(); + + let order_submitted = submit_order(&order1); + order1 + .apply(OrderEventAny::Submitted(order_submitted)) + .unwrap(); + portfolio.cache.borrow_mut().update_order(&order1).unwrap(); + + // Push status to Accepted + let order_accepted = accept_order(&order1); + order1 + .apply(OrderEventAny::Accepted(order_accepted)) + .unwrap(); + portfolio.cache.borrow_mut().update_order(&order1).unwrap(); + + // TODO: Replace with Execution Engine once implemented. + portfolio + .cache + .borrow_mut() + .add_order(order1.clone(), None, None, true) + .unwrap(); + + let order_filled1 = fill_order(&order1); + order1.apply(OrderEventAny::Filled(order_filled1)).unwrap(); + + // Act + let last = get_quote_tick(&instrument_btcusdt, 25001.0, 25002.0, 15.0, 12.0); + portfolio.update_quote_tick(&last); + portfolio.initialize_orders(); + + // Assert + assert_eq!( + portfolio + .margins_init(&Venue::from("BINANCE")) + .get(&instrument_btcusdt.id()) + .unwrap() + .as_f64(), + 10.5 + ); + } + + #[rstest] + fn test_order_accept_updates_margin_init( + mut portfolio: Portfolio, + instrument_btcusdt: InstrumentAny, + ) { + let account_state = get_margin_account(Some("BINANCE-01234")); + portfolio.update_account(&account_state); + + // Create Order + let mut order = OrderTestBuilder::new(OrderType::Limit) + .client_order_id(ClientOrderId::new("55")) + .instrument_id(instrument_btcusdt.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("100.0")) + .price(Price::new(5.0, 0)) + .build(); + + portfolio + .cache + .borrow_mut() + .add_order(order.clone(), None, None, true) + .unwrap(); + + let order_submitted = submit_order(&order); + order + .apply(OrderEventAny::Submitted(order_submitted)) + .unwrap(); + portfolio.cache.borrow_mut().update_order(&order).unwrap(); + + let order_accepted = accept_order(&order); + order + .apply(OrderEventAny::Accepted(order_accepted)) + .unwrap(); + portfolio.cache.borrow_mut().update_order(&order).unwrap(); + + // TODO: Replace with Execution Engine once implemented. + portfolio + .cache + .borrow_mut() + .add_order(order.clone(), None, None, true) + .unwrap(); + + // Act + portfolio.initialize_orders(); + + // Assert + assert_eq!( + portfolio + .margins_init(&Venue::from("BINANCE")) + .get(&instrument_btcusdt.id()) + .unwrap() + .as_f64(), + 1.5 + ); + } + + #[rstest] + fn test_update_positions(mut portfolio: Portfolio, instrument_audusd: InstrumentAny) { + let account_state = get_cash_account(None); + portfolio.update_account(&account_state); + + // Create Order + let mut order1 = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("10.50")) + .build(); + + let order2 = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Sell) + .quantity(Quantity::from("10.50")) + .build(); + + portfolio + .cache + .borrow_mut() + .add_order(order1.clone(), None, None, true) + .unwrap(); + portfolio + .cache + .borrow_mut() + .add_order(order2.clone(), None, None, true) + .unwrap(); + + let order1_submitted = submit_order(&order1); + order1 + .apply(OrderEventAny::Submitted(order1_submitted)) + .unwrap(); + portfolio.update_order(&OrderEventAny::Submitted(order1_submitted)); + + // ACCEPTED + let order1_accepted = accept_order(&order1); + order1 + .apply(OrderEventAny::Accepted(order1_accepted)) + .unwrap(); + portfolio.update_order(&OrderEventAny::Accepted(order1_accepted)); + + let mut fill1 = fill_order(&order1); + fill1.position_id = Some(PositionId::new("SSD")); + + let mut fill2 = fill_order(&order2); + fill2.trade_id = TradeId::new("2"); + + let mut position1 = Position::new(&instrument_audusd, fill1); + position1.apply(&fill2); + + let order3 = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Sell) + .quantity(Quantity::from("10.00")) + .build(); + + let mut fill3 = fill_order(&order3); + fill3.position_id = Some(PositionId::new("SSsD")); + + let position2 = Position::new(&instrument_audusd, fill3); + + // Update the last quote + let last = get_quote_tick(&instrument_audusd, 250001.0, 250002.0, 1.0, 1.0); + + // Act + portfolio + .cache + .borrow_mut() + .add_position(position1, OmsType::Hedging) + .unwrap(); + portfolio + .cache + .borrow_mut() + .add_position(position2, OmsType::Hedging) + .unwrap(); + portfolio.cache.borrow_mut().add_quote(last).unwrap(); + portfolio.initialize_positions(); + portfolio.update_quote_tick(&last); + + // Assert + assert!(portfolio.is_net_long(&instrument_audusd.id())); + } + + #[rstest] + fn test_opening_one_long_position_updates_portfolio( + mut portfolio: Portfolio, + instrument_audusd: InstrumentAny, + ) { + let account_state = get_margin_account(None); + portfolio.update_account(&account_state); + + // Create Order + let order = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("10.00")) + .build(); + + let mut fill = fill_order(&order); + fill.position_id = Some(PositionId::new("SSD")); + + // Update the last quote + let last = get_quote_tick(&instrument_audusd, 10510.0, 10511.0, 1.0, 1.0); + portfolio.cache.borrow_mut().add_quote(last).unwrap(); + portfolio.update_quote_tick(&last); + + let position = Position::new(&instrument_audusd, fill); + + // Act + portfolio + .cache + .borrow_mut() + .add_position(position.clone(), OmsType::Hedging) + .unwrap(); + + let position_opened = get_open_position(&position); + portfolio.update_position(&PositionEvent::PositionOpened(position_opened)); + + // Assert + assert_eq!( + portfolio + .net_exposures(&Venue::from("SIM")) + .unwrap() + .get(&Currency::USD()) + .unwrap() + .as_f64(), + 10510.0 + ); + assert_eq!( + portfolio + .unrealized_pnls(&Venue::from("SIM")) + .get(&Currency::USD()) + .unwrap() + .as_f64(), + -6445.89 + ); + assert_eq!( + portfolio + .realized_pnls(&Venue::from("SIM")) + .get(&Currency::USD()) + .unwrap() + .as_f64(), + 0.0 + ); + assert_eq!( + portfolio + .net_exposure(&instrument_audusd.id()) + .unwrap() + .as_f64(), + 10510.0 + ); + assert_eq!( + portfolio + .unrealized_pnl(&instrument_audusd.id()) + .unwrap() + .as_f64(), + -6445.89 + ); + assert_eq!( + portfolio + .realized_pnl(&instrument_audusd.id()) + .unwrap() + .as_f64(), + 0.0 + ); + assert_eq!( + portfolio.net_position(&instrument_audusd.id()), + Decimal::new(561, 3) + ); + assert!(portfolio.is_net_long(&instrument_audusd.id())); + assert!(!portfolio.is_net_short(&instrument_audusd.id())); + assert!(!portfolio.is_flat(&instrument_audusd.id())); + assert!(!portfolio.is_completely_flat()); + } + + #[rstest] + fn test_opening_one_short_position_updates_portfolio( + mut portfolio: Portfolio, + instrument_audusd: InstrumentAny, + ) { + let account_state = get_margin_account(None); + portfolio.update_account(&account_state); + + // Create Order + let order = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Sell) + .quantity(Quantity::from("2")) + .build(); + + let fill = OrderFilled::new( + order.trader_id(), + order.strategy_id(), + order.instrument_id(), + order.client_order_id(), + VenueOrderId::new("123456"), + AccountId::new("SIM-001"), + TradeId::new("1"), + order.order_side(), + order.order_type(), + order.quantity(), + Price::new(10.0, 0), + Currency::USD(), + LiquiditySide::Taker, + uuid4(), + UnixNanos::default(), + UnixNanos::default(), + false, + Some(PositionId::new("SSD")), + Some(Money::from("12.2 USD")), + ); + + // Update the last quote + let last = get_quote_tick(&instrument_audusd, 15510.15, 15510.25, 13.0, 4.0); + + portfolio.cache.borrow_mut().add_quote(last).unwrap(); + portfolio.update_quote_tick(&last); + + let position = Position::new(&instrument_audusd, fill); + + // Act + portfolio + .cache + .borrow_mut() + .add_position(position.clone(), OmsType::Hedging) + .unwrap(); + + let position_opened = get_open_position(&position); + portfolio.update_position(&PositionEvent::PositionOpened(position_opened)); + + // Assert + assert_eq!( + portfolio + .net_exposures(&Venue::from("SIM")) + .unwrap() + .get(&Currency::USD()) + .unwrap() + .as_f64(), + 31020.0 + ); + assert_eq!( + portfolio + .unrealized_pnls(&Venue::from("SIM")) + .get(&Currency::USD()) + .unwrap() + .as_f64(), + -31000.0 + ); + assert_eq!( + portfolio + .realized_pnls(&Venue::from("SIM")) + .get(&Currency::USD()) + .unwrap() + .as_f64(), + -12.2 + ); + assert_eq!( + portfolio + .net_exposure(&instrument_audusd.id()) + .unwrap() + .as_f64(), + 31020.0 + ); + assert_eq!( + portfolio + .unrealized_pnl(&instrument_audusd.id()) + .unwrap() + .as_f64(), + -31000.0 + ); + assert_eq!( + portfolio + .realized_pnl(&instrument_audusd.id()) + .unwrap() + .as_f64(), + -12.2 + ); + assert_eq!( + portfolio.net_position(&instrument_audusd.id()), + Decimal::new(-2, 0) + ); + + assert!(!portfolio.is_net_long(&instrument_audusd.id())); + assert!(portfolio.is_net_short(&instrument_audusd.id())); + assert!(!portfolio.is_flat(&instrument_audusd.id())); + assert!(!portfolio.is_completely_flat()); + } + + #[rstest] + fn test_opening_positions_with_multi_asset_account( + mut portfolio: Portfolio, + instrument_btcusdt: InstrumentAny, + instrument_ethusdt: InstrumentAny, + ) { + let account_state = get_margin_account(Some("BITMEX-01234")); + portfolio.update_account(&account_state); + + let last_ethusd = get_quote_tick(&instrument_ethusdt, 376.05, 377.10, 16.0, 25.0); + let last_btcusd = get_quote_tick(&instrument_btcusdt, 10500.05, 10501.51, 2.54, 0.91); + + portfolio.cache.borrow_mut().add_quote(last_ethusd).unwrap(); + portfolio.cache.borrow_mut().add_quote(last_btcusd).unwrap(); + portfolio.update_quote_tick(&last_ethusd); + portfolio.update_quote_tick(&last_btcusd); + + // Create Order + let order = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_ethusdt.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("10000")) + .build(); + + let fill = OrderFilled::new( + order.trader_id(), + order.strategy_id(), + order.instrument_id(), + order.client_order_id(), + VenueOrderId::new("123456"), + AccountId::new("SIM-001"), + TradeId::new("1"), + order.order_side(), + order.order_type(), + order.quantity(), + Price::new(376.0, 0), + Currency::USD(), + LiquiditySide::Taker, + uuid4(), + UnixNanos::default(), + UnixNanos::default(), + false, + Some(PositionId::new("SSD")), + Some(Money::from("12.2 USD")), + ); + + let position = Position::new(&instrument_ethusdt, fill); + + // Act + portfolio + .cache + .borrow_mut() + .add_position(position.clone(), OmsType::Hedging) + .unwrap(); + + let position_opened = get_open_position(&position); + portfolio.update_position(&PositionEvent::PositionOpened(position_opened)); + + // Assert + assert_eq!( + portfolio + .net_exposures(&Venue::from("BITMEX")) + .unwrap() + .get(&Currency::ETH()) + .unwrap() + .as_f64(), + 26.59574468 + ); + assert_eq!( + portfolio + .unrealized_pnls(&Venue::from("BITMEX")) + .get(&Currency::ETH()) + .unwrap() + .as_f64(), + 0.0 + ); + // TODO: fix + // assert_eq!( + // portfolio + // .margins_maint(&Venue::from("SIM")) + // .get(&instrument_audusd.id()) + // .unwrap() + // .as_f64(), + // 0.0 + // ); + assert_eq!( + portfolio + .net_exposure(&instrument_ethusdt.id()) + .unwrap() + .as_f64(), + 26.59574468 + ); + } + + #[rstest] + fn test_market_value_when_insufficient_data_for_xrate_returns_none( + mut portfolio: Portfolio, + instrument_btcusdt: InstrumentAny, + instrument_ethusdt: InstrumentAny, + ) { + let account_state = get_margin_account(Some("BITMEX-01234")); + portfolio.update_account(&account_state); + + // Create Order + let order = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_ethusdt.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("100")) + .build(); + + let fill = OrderFilled::new( + order.trader_id(), + order.strategy_id(), + order.instrument_id(), + order.client_order_id(), + VenueOrderId::new("123456"), + AccountId::new("SIM-001"), + TradeId::new("1"), + order.order_side(), + order.order_type(), + order.quantity(), + Price::new(376.05, 0), + Currency::USD(), + LiquiditySide::Taker, + uuid4(), + UnixNanos::default(), + UnixNanos::default(), + false, + Some(PositionId::new("SSD")), + Some(Money::from("12.2 USD")), + ); + + let last_ethusd = get_quote_tick(&instrument_ethusdt, 376.05, 377.10, 16.0, 25.0); + let last_xbtusd = get_quote_tick(&instrument_btcusdt, 50000.00, 50000.00, 1.0, 1.0); + + let position = Position::new(&instrument_ethusdt, fill); + let position_opened = get_open_position(&position); + + // Act + portfolio.update_position(&PositionEvent::PositionOpened(position_opened)); + portfolio + .cache + .borrow_mut() + .add_position(position, OmsType::Hedging) + .unwrap(); + portfolio.cache.borrow_mut().add_quote(last_ethusd).unwrap(); + portfolio.cache.borrow_mut().add_quote(last_xbtusd).unwrap(); + portfolio.update_quote_tick(&last_ethusd); + portfolio.update_quote_tick(&last_xbtusd); + + // Assert + assert_eq!( + portfolio + .net_exposures(&Venue::from("BITMEX")) + .unwrap() + .get(&Currency::ETH()) + .unwrap() + .as_f64(), + 0.26595745 + ); + } + + #[rstest] + fn test_opening_several_positions_updates_portfolio( + mut portfolio: Portfolio, + instrument_audusd: InstrumentAny, + instrument_gbpusd: InstrumentAny, + ) { + let account_state = get_margin_account(None); + portfolio.update_account(&account_state); + + let last_audusd = get_quote_tick(&instrument_audusd, 0.80501, 0.80505, 1.0, 1.0); + let last_gbpusd = get_quote_tick(&instrument_gbpusd, 1.30315, 1.30317, 1.0, 1.0); + + portfolio.cache.borrow_mut().add_quote(last_audusd).unwrap(); + portfolio.cache.borrow_mut().add_quote(last_gbpusd).unwrap(); + portfolio.update_quote_tick(&last_audusd); + portfolio.update_quote_tick(&last_gbpusd); + + // Create Order + let order1 = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("100000")) + .build(); + + let order2 = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_gbpusd.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("100000")) + .build(); + + portfolio + .cache + .borrow_mut() + .add_order(order1.clone(), None, None, true) + .unwrap(); + portfolio + .cache + .borrow_mut() + .add_order(order2.clone(), None, None, true) + .unwrap(); + + let fill1 = OrderFilled::new( + order1.trader_id(), + order1.strategy_id(), + order1.instrument_id(), + order1.client_order_id(), + VenueOrderId::new("123456"), + AccountId::new("SIM-001"), + TradeId::new("1"), + order1.order_side(), + order1.order_type(), + order1.quantity(), + Price::new(376.05, 0), + Currency::USD(), + LiquiditySide::Taker, + uuid4(), + UnixNanos::default(), + UnixNanos::default(), + false, + Some(PositionId::new("SSD")), + Some(Money::from("12.2 USD")), + ); + let fill2 = OrderFilled::new( + order2.trader_id(), + order2.strategy_id(), + order2.instrument_id(), + order2.client_order_id(), + VenueOrderId::new("123456"), + AccountId::new("SIM-001"), + TradeId::new("1"), + order2.order_side(), + order2.order_type(), + order2.quantity(), + Price::new(376.05, 0), + Currency::USD(), + LiquiditySide::Taker, + uuid4(), + UnixNanos::default(), + UnixNanos::default(), + false, + Some(PositionId::new("SSD")), + Some(Money::from("12.2 USD")), + ); + + portfolio.cache.borrow_mut().update_order(&order1).unwrap(); + portfolio.cache.borrow_mut().update_order(&order2).unwrap(); + + let position1 = Position::new(&instrument_audusd, fill1); + let position2 = Position::new(&instrument_gbpusd, fill2); + + let position_opened1 = get_open_position(&position1); + let position_opened2 = get_open_position(&position2); + + // Act + portfolio + .cache + .borrow_mut() + .add_position(position1, OmsType::Hedging) + .unwrap(); + portfolio + .cache + .borrow_mut() + .add_position(position2, OmsType::Hedging) + .unwrap(); + portfolio.update_position(&PositionEvent::PositionOpened(position_opened1)); + portfolio.update_position(&PositionEvent::PositionOpened(position_opened2)); + + // Assert + assert_eq!( + portfolio + .net_exposures(&Venue::from("SIM")) + .unwrap() + .get(&Currency::USD()) + .unwrap() + .as_f64(), + 100000.0 + ); + + assert_eq!( + portfolio + .unrealized_pnls(&Venue::from("SIM")) + .get(&Currency::USD()) + .unwrap() + .as_f64(), + -37500000.0 + ); + + assert_eq!( + portfolio + .realized_pnls(&Venue::from("SIM")) + .get(&Currency::USD()) + .unwrap() + .as_f64(), + -12.2 + ); + // FIX: TODO: should not be empty + assert_eq!(portfolio.margins_maint(&Venue::from("SIM")), HashMap::new()); + assert_eq!( + portfolio + .net_exposure(&instrument_audusd.id()) + .unwrap() + .as_f64(), + 100000.0 + ); + assert_eq!( + portfolio + .net_exposure(&instrument_gbpusd.id()) + .unwrap() + .as_f64(), + 100000.0 + ); + assert_eq!( + portfolio + .unrealized_pnl(&instrument_audusd.id()) + .unwrap() + .as_f64(), + 0.0 + ); + assert_eq!( + portfolio + .unrealized_pnl(&instrument_gbpusd.id()) + .unwrap() + .as_f64(), + -37500000.0 + ); + assert_eq!( + portfolio + .realized_pnl(&instrument_audusd.id()) + .unwrap() + .as_f64(), + 0.0 + ); + assert_eq!( + portfolio + .realized_pnl(&instrument_gbpusd.id()) + .unwrap() + .as_f64(), + -12.2 + ); + assert_eq!( + portfolio.net_position(&instrument_audusd.id()), + Decimal::from_f64(100000.0).unwrap() + ); + assert_eq!( + portfolio.net_position(&instrument_gbpusd.id()), + Decimal::from_f64(100000.0).unwrap() + ); + assert!(portfolio.is_net_long(&instrument_audusd.id())); + assert!(!portfolio.is_net_short(&instrument_audusd.id())); + assert!(!portfolio.is_flat(&instrument_audusd.id())); + assert!(!portfolio.is_completely_flat()); + } + + #[rstest] + fn test_modifying_position_updates_portfolio( + mut portfolio: Portfolio, + instrument_audusd: InstrumentAny, + ) { + let account_state = get_margin_account(None); + portfolio.update_account(&account_state); + + let last_audusd = get_quote_tick(&instrument_audusd, 0.80501, 0.80505, 1.0, 1.0); + portfolio.cache.borrow_mut().add_quote(last_audusd).unwrap(); + portfolio.update_quote_tick(&last_audusd); + + // Create Order + let order1 = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("100000")) + .build(); + + let fill1 = OrderFilled::new( + order1.trader_id(), + order1.strategy_id(), + order1.instrument_id(), + order1.client_order_id(), + VenueOrderId::new("123456"), + AccountId::new("SIM-001"), + TradeId::new("1"), + order1.order_side(), + order1.order_type(), + order1.quantity(), + Price::new(376.05, 0), + Currency::USD(), + LiquiditySide::Taker, + uuid4(), + UnixNanos::default(), + UnixNanos::default(), + false, + Some(PositionId::new("SSD")), + Some(Money::from("12.2 USD")), + ); + + let mut position1 = Position::new(&instrument_audusd, fill1); + portfolio + .cache + .borrow_mut() + .add_position(position1.clone(), OmsType::Hedging) + .unwrap(); + let position_opened1 = get_open_position(&position1); + portfolio.update_position(&PositionEvent::PositionOpened(position_opened1)); + + let order2 = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Sell) + .quantity(Quantity::from("50000")) + .build(); + + let fill2 = OrderFilled::new( + order2.trader_id(), + order2.strategy_id(), + order2.instrument_id(), + order2.client_order_id(), + VenueOrderId::new("123456"), + AccountId::new("SIM-001"), + TradeId::new("2"), + order2.order_side(), + order2.order_type(), + order2.quantity(), + Price::new(1.00, 0), + Currency::USD(), + LiquiditySide::Taker, + uuid4(), + UnixNanos::default(), + UnixNanos::default(), + false, + Some(PositionId::new("SSD")), + Some(Money::from("1.2 USD")), + ); + + position1.apply(&fill2); + let position1_changed = get_changed_position(&position1); + + // Act + portfolio.update_position(&PositionEvent::PositionChanged(position1_changed)); + + // Assert + assert_eq!( + portfolio + .net_exposures(&Venue::from("SIM")) + .unwrap() + .get(&Currency::USD()) + .unwrap() + .as_f64(), + 100000.0 + ); + + assert_eq!( + portfolio + .unrealized_pnls(&Venue::from("SIM")) + .get(&Currency::USD()) + .unwrap() + .as_f64(), + -37500000.0 + ); + + assert_eq!( + portfolio + .realized_pnls(&Venue::from("SIM")) + .get(&Currency::USD()) + .unwrap() + .as_f64(), + -12.2 + ); + // FIX: TODO: should not be empty + assert_eq!(portfolio.margins_maint(&Venue::from("SIM")), HashMap::new()); + assert_eq!( + portfolio + .net_exposure(&instrument_audusd.id()) + .unwrap() + .as_f64(), + 100000.0 + ); + assert_eq!( + portfolio + .unrealized_pnl(&instrument_audusd.id()) + .unwrap() + .as_f64(), + -37500000.0 + ); + assert_eq!( + portfolio + .realized_pnl(&instrument_audusd.id()) + .unwrap() + .as_f64(), + -12.2 + ); + assert_eq!( + portfolio.net_position(&instrument_audusd.id()), + Decimal::from_f64(100000.0).unwrap() + ); + assert!(portfolio.is_net_long(&instrument_audusd.id())); + assert!(!portfolio.is_net_short(&instrument_audusd.id())); + assert!(!portfolio.is_flat(&instrument_audusd.id())); + assert!(!portfolio.is_completely_flat()); + assert_eq!( + portfolio.unrealized_pnls(&Venue::from("BINANCE")), + HashMap::new() + ); + assert_eq!( + portfolio.realized_pnls(&Venue::from("BINANCE")), + HashMap::new() + ); + assert_eq!(portfolio.net_exposures(&Venue::from("BINANCE")), None); + } + + #[rstest] + fn test_closing_position_updates_portfolio( + mut portfolio: Portfolio, + instrument_audusd: InstrumentAny, + ) { + let account_state = get_margin_account(None); + portfolio.update_account(&account_state); + + let last_audusd = get_quote_tick(&instrument_audusd, 0.80501, 0.80505, 1.0, 1.0); + portfolio.cache.borrow_mut().add_quote(last_audusd).unwrap(); + portfolio.update_quote_tick(&last_audusd); + + // Create Order + let order1 = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("100000")) + .build(); + + let fill1 = OrderFilled::new( + order1.trader_id(), + order1.strategy_id(), + order1.instrument_id(), + order1.client_order_id(), + VenueOrderId::new("123456"), + AccountId::new("SIM-001"), + TradeId::new("1"), + order1.order_side(), + order1.order_type(), + order1.quantity(), + Price::new(376.05, 0), + Currency::USD(), + LiquiditySide::Taker, + uuid4(), + UnixNanos::default(), + UnixNanos::default(), + false, + Some(PositionId::new("SSD")), + Some(Money::from("12.2 USD")), + ); + + let mut position1 = Position::new(&instrument_audusd, fill1); + portfolio + .cache + .borrow_mut() + .add_position(position1.clone(), OmsType::Hedging) + .unwrap(); + let position_opened1 = get_open_position(&position1); + portfolio.update_position(&PositionEvent::PositionOpened(position_opened1)); + + let order2 = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Sell) + .quantity(Quantity::from("50000")) + .build(); + + let fill2 = OrderFilled::new( + order2.trader_id(), + order2.strategy_id(), + order2.instrument_id(), + order2.client_order_id(), + VenueOrderId::new("123456"), + AccountId::new("SIM-001"), + TradeId::new("2"), + order2.order_side(), + order2.order_type(), + order2.quantity(), + Price::new(1.00, 0), + Currency::USD(), + LiquiditySide::Taker, + uuid4(), + UnixNanos::default(), + UnixNanos::default(), + false, + Some(PositionId::new("SSD")), + Some(Money::from("1.2 USD")), + ); + + position1.apply(&fill2); + portfolio + .cache + .borrow_mut() + .update_position(&position1) + .unwrap(); + + // Act + let position1_closed = get_close_position(&position1); + portfolio.update_position(&PositionEvent::PositionClosed(position1_closed)); + + // Assert + assert_eq!( + portfolio + .net_exposures(&Venue::from("SIM")) + .unwrap() + .get(&Currency::USD()) + .unwrap() + .as_f64(), + 100000.00 + ); + assert_eq!( + portfolio + .unrealized_pnls(&Venue::from("SIM")) + .get(&Currency::USD()) + .unwrap() + .as_f64(), + -37500000.00 + ); + assert_eq!( + portfolio + .realized_pnls(&Venue::from("SIM")) + .get(&Currency::USD()) + .unwrap() + .as_f64(), + -12.2 + ); + assert_eq!(portfolio.margins_maint(&Venue::from("SIM")), HashMap::new()); + } + + #[rstest] + fn test_several_positions_with_different_instruments_updates_portfolio( + mut portfolio: Portfolio, + instrument_audusd: InstrumentAny, + instrument_gbpusd: InstrumentAny, + ) { + let account_state = get_margin_account(None); + portfolio.update_account(&account_state); + + // Create Order + let order1 = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("100000")) + .build(); + let order2 = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_audusd.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("100000")) + .build(); + let order3 = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_gbpusd.id()) + .side(OrderSide::Buy) + .quantity(Quantity::from("100000")) + .build(); + let order4 = OrderTestBuilder::new(OrderType::Market) + .instrument_id(instrument_gbpusd.id()) + .side(OrderSide::Sell) + .quantity(Quantity::from("100000")) + .build(); + + let fill1 = OrderFilled::new( + order1.trader_id(), + StrategyId::new("S-1"), + order1.instrument_id(), + order1.client_order_id(), + VenueOrderId::new("123456"), + AccountId::new("SIM-001"), + TradeId::new("1"), + order1.order_side(), + order1.order_type(), + order1.quantity(), + Price::new(1.0, 0), + Currency::USD(), + LiquiditySide::Taker, + uuid4(), + UnixNanos::default(), + UnixNanos::default(), + false, + Some(PositionId::new("P-1")), + None, + ); + let fill2 = OrderFilled::new( + order2.trader_id(), + StrategyId::new("S-1"), + order2.instrument_id(), + order2.client_order_id(), + VenueOrderId::new("123456"), + AccountId::new("SIM-001"), + TradeId::new("2"), + order2.order_side(), + order2.order_type(), + order2.quantity(), + Price::new(1.0, 0), + Currency::USD(), + LiquiditySide::Taker, + uuid4(), + UnixNanos::default(), + UnixNanos::default(), + false, + Some(PositionId::new("P-2")), + None, + ); + let fill3 = OrderFilled::new( + order3.trader_id(), + StrategyId::new("S-1"), + order3.instrument_id(), + order3.client_order_id(), + VenueOrderId::new("123456"), + AccountId::new("SIM-001"), + TradeId::new("3"), + order3.order_side(), + order3.order_type(), + order3.quantity(), + Price::new(1.0, 0), + Currency::USD(), + LiquiditySide::Taker, + uuid4(), + UnixNanos::default(), + UnixNanos::default(), + false, + Some(PositionId::new("P-3")), + None, + ); + let fill4 = OrderFilled::new( + order4.trader_id(), + StrategyId::new("S-1"), + order4.instrument_id(), + order4.client_order_id(), + VenueOrderId::new("123456"), + AccountId::new("SIM-001"), + TradeId::new("4"), + order4.order_side(), + order4.order_type(), + order4.quantity(), + Price::new(1.0, 0), + Currency::USD(), + LiquiditySide::Taker, + uuid4(), + UnixNanos::default(), + UnixNanos::default(), + false, + Some(PositionId::new("P-4")), + None, + ); + + let position1 = Position::new(&instrument_audusd, fill1); + let position2 = Position::new(&instrument_audusd, fill2); + let mut position3 = Position::new(&instrument_gbpusd, fill3); + + let last_audusd = get_quote_tick(&instrument_audusd, 0.80501, 0.80505, 1.0, 1.0); + let last_gbpusd = get_quote_tick(&instrument_gbpusd, 1.30315, 1.30317, 1.0, 1.0); + + portfolio.cache.borrow_mut().add_quote(last_audusd).unwrap(); + portfolio.cache.borrow_mut().add_quote(last_gbpusd).unwrap(); + portfolio.update_quote_tick(&last_audusd); + portfolio.update_quote_tick(&last_gbpusd); + + portfolio + .cache + .borrow_mut() + .add_position(position1.clone(), OmsType::Hedging) + .unwrap(); + portfolio + .cache + .borrow_mut() + .add_position(position2.clone(), OmsType::Hedging) + .unwrap(); + portfolio + .cache + .borrow_mut() + .add_position(position3.clone(), OmsType::Hedging) + .unwrap(); + + let position_opened1 = get_open_position(&position1); + let position_opened2 = get_open_position(&position2); + let position_opened3 = get_open_position(&position3); + + portfolio.update_position(&PositionEvent::PositionOpened(position_opened1)); + portfolio.update_position(&PositionEvent::PositionOpened(position_opened2)); + portfolio.update_position(&PositionEvent::PositionOpened(position_opened3)); + + let position_closed3 = get_close_position(&position3); + position3.apply(&fill4); + portfolio + .cache + .borrow_mut() + .add_position(position3.clone(), OmsType::Hedging) + .unwrap(); + portfolio.update_position(&PositionEvent::PositionClosed(position_closed3)); + + // Assert + assert_eq!( + portfolio + .net_exposures(&Venue::from("SIM")) + .unwrap() + .get(&Currency::USD()) + .unwrap() + .as_f64(), + 200000.00 + ); + assert_eq!( + portfolio + .unrealized_pnls(&Venue::from("SIM")) + .get(&Currency::USD()) + .unwrap() + .as_f64(), + 0.0 + ); + assert_eq!( + portfolio + .realized_pnls(&Venue::from("SIM")) + .get(&Currency::USD()) + .unwrap() + .as_f64(), + 0.0 + ); + // FIX: TODO: should not be empty + assert_eq!(portfolio.margins_maint(&Venue::from("SIM")), HashMap::new()); } } diff --git a/nautilus_core/risk/src/engine/config.rs b/nautilus_core/risk/src/engine/config.rs index 1ef521e487c..51ee77e43df 100644 --- a/nautilus_core/risk/src/engine/config.rs +++ b/nautilus_core/risk/src/engine/config.rs @@ -13,7 +13,7 @@ // limitations under the License. // ------------------------------------------------------------------------------------------------- -//! Provides a configuration for `ExecutionEngine` instances. +//! Provides a configuration for `RiskEngine` instances. use std::collections::HashMap; diff --git a/nautilus_core/risk/src/engine/mod.rs b/nautilus_core/risk/src/engine/mod.rs index 5f009fc2787..e3ade98e741 100644 --- a/nautilus_core/risk/src/engine/mod.rs +++ b/nautilus_core/risk/src/engine/mod.rs @@ -193,9 +193,9 @@ impl RiskEngine { } fn handle_submit_order_cache(cache: &Rc>, submit_order: &SubmitOrder) { - let mut burrowed_cache = cache.borrow_mut(); - if !burrowed_cache.order_exists(&submit_order.client_order_id) { - burrowed_cache + let mut borrowed_cache = cache.borrow_mut(); + if !borrowed_cache.order_exists(&submit_order.client_order_id) { + borrowed_cache .add_order(submit_order.order.clone(), None, None, false) .map_err(|e| { log::error!("Cannot add order to cache: {e}"); @@ -205,8 +205,8 @@ impl RiskEngine { } fn get_existing_order(cache: &Rc>, order: &ModifyOrder) -> Option { - let burrowed_cache = cache.borrow(); - if let Some(order) = burrowed_cache.order(&order.client_order_id) { + let borrowed_cache = cache.borrow(); + if let Some(order) = borrowed_cache.order(&order.client_order_id) { Some(order.clone()) } else { log::error!( @@ -424,8 +424,8 @@ impl RiskEngine { // VALIDATE COMMAND //////////////////////////////////////////////////////////////////////////////// let order_exists = { - let burrowed_cache = self.cache.borrow(); - burrowed_cache.order(&command.client_order_id).cloned() + let borrowed_cache = self.cache.borrow(); + borrowed_cache.order(&command.client_order_id).cloned() }; let order = if let Some(order) = order_exists { @@ -633,16 +633,16 @@ impl RiskEngine { last_px = match order { OrderAny::Market(_) | OrderAny::MarketToLimit(_) => { if last_px.is_none() { - let burrowed_cache = self.cache.borrow(); - if let Some(last_quote) = burrowed_cache.quote(&instrument.id()) { + let borrowed_cache = self.cache.borrow(); + if let Some(last_quote) = borrowed_cache.quote(&instrument.id()) { match order.order_side() { OrderSide::Buy => Some(last_quote.ask_price), OrderSide::Sell => Some(last_quote.bid_price), _ => panic!("Invalid order side"), } } else { - let burrowed_cache = self.cache.borrow(); - let last_trade = burrowed_cache.trade(&instrument.id()); + let borrowed_cache = self.cache.borrow(); + let last_trade = borrowed_cache.trade(&instrument.id()); if let Some(last_trade) = last_trade { Some(last_trade.price) @@ -937,9 +937,9 @@ impl RiskEngine { return; } - let mut burrowed_cache = self.cache.borrow_mut(); - if !burrowed_cache.order_exists(&order.client_order_id()) { - burrowed_cache + let mut borrowed_cache = self.cache.borrow_mut(); + if !borrowed_cache.order_exists(&order.client_order_id()) { + borrowed_cache .add_order(order.clone(), None, None, false) .map_err(|e| { log::error!("Cannot add order to cache: {e}");