From 6eb9d7725f61f66c1f6b148ecccddef8bc0095ae Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 15 Sep 2024 15:19:09 -0400 Subject: [PATCH 01/45] use database connection pool --- src-core/Cargo.lock | 21 ++ src-core/Cargo.toml | 4 +- src-core/src/account/account_service.rs | 89 +++--- src-core/src/activity/activity_repository.rs | 13 + src-core/src/activity/activity_service.rs | 91 +++--- src-core/src/app_state.rs | 7 - src-core/src/asset/asset_service.rs | 277 ++++-------------- src-core/src/lib.rs | 3 +- .../src/market_data/market_data_service.rs | 150 ++++++++++ src-core/src/market_data/mod.rs | 1 + src-core/src/portfolio/portfolio_service.rs | 65 ++-- src-core/src/providers/yahoo_provider.rs | 3 - src-tauri/Cargo.lock | 22 ++ src-tauri/Cargo.toml | 1 + src-tauri/src/commands/account.rs | 30 +- src-tauri/src/commands/activity.rs | 42 +-- src-tauri/src/commands/asset.rs | 40 --- src-tauri/src/commands/goal.rs | 36 ++- src-tauri/src/commands/market_data.rs | 33 +++ src-tauri/src/commands/mod.rs | 2 +- src-tauri/src/commands/portfolio.rs | 30 +- src-tauri/src/commands/settings.rs | 18 +- src-tauri/src/main.rs | 128 ++++---- src/commands/{symbol.ts => market-data.ts} | 2 +- .../activity/components/ticker-search.tsx | 15 +- .../activity/import/activity-import-page.tsx | 2 +- src/pages/asset/asset-profile-page.tsx | 2 +- src/pages/dashboard/accounts.tsx | 2 +- src/pages/dashboard/dashboard-page.tsx | 6 +- src/pages/dashboard/goals.tsx | 74 +++-- .../holdings/components/income-dashboard.tsx | 1 - 31 files changed, 613 insertions(+), 597 deletions(-) delete mode 100644 src-core/src/app_state.rs create mode 100644 src-core/src/market_data/market_data_service.rs create mode 100644 src-core/src/market_data/mod.rs delete mode 100644 src-tauri/src/commands/asset.rs create mode 100644 src-tauri/src/commands/market_data.rs rename src/commands/{symbol.ts => market-data.ts} (92%) diff --git a/src-core/Cargo.lock b/src-core/Cargo.lock index dedf621..d231a8d 100644 --- a/src-core/Cargo.lock +++ b/src-core/Cargo.lock @@ -315,6 +315,7 @@ dependencies = [ "num-bigint", "num-integer", "num-traits", + "r2d2", "time", ] @@ -1228,6 +1229,17 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + [[package]] name = "rand" version = "0.8.5" @@ -1476,6 +1488,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + [[package]] name = "scopeguard" version = "1.2.0" diff --git a/src-core/Cargo.toml b/src-core/Cargo.toml index b70a6a6..76336ac 100644 --- a/src-core/Cargo.toml +++ b/src-core/Cargo.toml @@ -3,7 +3,7 @@ name = "wealthfolio_core" version = "1.0.11" description = "Portfolio tracker" authors = ["Aziz Fadil"] -license = "MIT" +license = "LGPL-3.0" repository = "" edition = "2021" @@ -12,7 +12,7 @@ edition = "2021" [dependencies] serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" -diesel = { version = "2.2.4", features = ["sqlite", "chrono", "numeric", "returning_clauses_for_sqlite_3_35"] } +diesel = { version = "2.2.4", features = ["sqlite", "chrono","r2d2", "numeric", "returning_clauses_for_sqlite_3_35"] } chrono = { version = "0.4.38", features = ["serde"] } uuid = { version = "1.10.0", features = ["v4"] } rusqlite = { version = "0.32.1", features = ["bundled"] } diff --git a/src-core/src/account/account_service.rs b/src-core/src/account/account_service.rs index 2326d4b..f2423ed 100644 --- a/src-core/src/account/account_service.rs +++ b/src-core/src/account/account_service.rs @@ -2,62 +2,50 @@ use crate::account::AccountRepository; use crate::asset::asset_service::AssetService; use crate::models::{Account, AccountUpdate, NewAccount}; use crate::settings::SettingsService; -use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::Connection; use diesel::SqliteConnection; pub struct AccountService { account_repo: AccountRepository, - asset_service: AssetService, + pool: Pool>, } impl AccountService { - pub fn new() -> Self { + pub fn new(pool: Pool>) -> Self { AccountService { account_repo: AccountRepository::new(), - asset_service: AssetService::new(), + pool, } } - pub fn get_accounts( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { - self.account_repo.load_accounts(conn) + pub fn get_accounts(&self) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.account_repo.load_accounts(&mut conn) } - //get account by id - pub fn get_account_by_id( - &self, - conn: &mut SqliteConnection, - account_id: &str, - ) -> Result { - self.account_repo.load_account_by_id(conn, account_id) + pub fn get_account_by_id(&self, account_id: &str) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.account_repo.load_account_by_id(&mut conn, account_id) } pub fn create_account( &self, - conn: &mut SqliteConnection, new_account: NewAccount, - ) -> Result { - //get base currency + ) -> Result> { + let mut conn = self.pool.get()?; + let asset_service = AssetService::new(self.pool.clone()); let settings_service = SettingsService::new(); - let settings = settings_service.get_settings(conn)?; - let base_currency = settings.base_currency.clone(); conn.transaction(|conn| { - //if the account currency is not the same as the base currency, then create the exchange rate asset so that we can track the exchange rate + let settings = settings_service.get_settings(conn)?; + let base_currency = settings.base_currency; + + // Create exchange rate asset if necessary if new_account.currency != base_currency { - // Create the $EXCHANGE_RATE asset let asset_id = format!("{}{}=X", base_currency, new_account.currency); - - //load the asset profile from the database or create it if not found - let _asset_profile = self - .asset_service - .get_asset_by_id(conn, &asset_id) - .unwrap_or_default(); - - if _asset_profile.id.is_empty() { - let _asset_profile = self.asset_service.create_rate_exchange_asset( + if asset_service.get_asset_by_id(&asset_id).is_err() { + asset_service.create_rate_exchange_asset( conn, &base_currency, &new_account.currency, @@ -65,41 +53,34 @@ impl AccountService { } } - // Create the $CASH-CURRENCY asset - let asset_id = format!("$CASH-{}", new_account.currency); - - //load the asset profile from the database or create it if not found - let _asset_profile = self - .asset_service - .get_asset_by_id(conn, &asset_id) - .unwrap_or_default(); - - if _asset_profile.id.is_empty() { - let _asset_profile = self - .asset_service - .create_cash_asset(conn, &new_account.currency)?; + // Create cash ($CASH-CURRENCY) asset if necessary + let cash_asset_id = format!("$CASH-{}", new_account.currency); + if asset_service.get_asset_by_id(&cash_asset_id).is_err() { + asset_service.create_cash_asset(conn, &new_account.currency)?; } - drop(_asset_profile); - let account = self.account_repo.insert_new_account(conn, new_account)?; - - Ok(account) + // Insert new account + self.account_repo + .insert_new_account(conn, new_account) + .map_err(|e| Box::new(e) as Box) }) } pub fn update_account( &self, - conn: &mut SqliteConnection, updated_account_data: AccountUpdate, ) -> Result { - self.account_repo.update_account(conn, updated_account_data) + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.account_repo + .update_account(&mut conn, updated_account_data) } pub fn delete_account( &self, - conn: &mut SqliteConnection, - account_id_to_delete: String, // ID of the account to delete + account_id_to_delete: String, ) -> Result { - self.account_repo.delete_account(conn, account_id_to_delete) + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.account_repo + .delete_account(&mut conn, account_id_to_delete) } } diff --git a/src-core/src/activity/activity_repository.rs b/src-core/src/activity/activity_repository.rs index 5e2e376..155999e 100644 --- a/src-core/src/activity/activity_repository.rs +++ b/src-core/src/activity/activity_repository.rs @@ -28,6 +28,19 @@ impl ActivityRepository { .load::(conn) } + pub fn get_income_activities( + &self, + conn: &mut SqliteConnection, + ) -> Result, diesel::result::Error> { + activities::table + .inner_join(accounts::table.on(accounts::id.eq(activities::account_id))) + .filter(accounts::is_active.eq(true)) + .filter(activities::activity_type.eq_any(vec!["DIVIDEND", "INTEREST"])) + .select(activities::all_columns) + .order(activities::activity_date.asc()) + .load::(conn) + } + pub fn get_activities( &self, conn: &mut SqliteConnection, diff --git a/src-core/src/activity/activity_service.rs b/src-core/src/activity/activity_service.rs index c02ed45..674256d 100644 --- a/src-core/src/activity/activity_service.rs +++ b/src-core/src/activity/activity_service.rs @@ -4,56 +4,57 @@ use crate::account::AccountService; use crate::activity::ActivityRepository; use crate::asset::asset_service::AssetService; use crate::models::{ - Activity, ActivityImport, ActivitySearchResponse, ActivityUpdate, NewActivity, Sort, + Activity, ActivityImport, ActivitySearchResponse, ActivityUpdate, IncomeData, NewActivity, Sort, }; use crate::schema::activities; use csv::ReaderBuilder; use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; use uuid::Uuid; pub struct ActivityService { repo: ActivityRepository, - asset_service: AssetService, - account_service: AccountService, + pool: Pool>, } impl ActivityService { - pub fn new() -> Self { + pub fn new(pool: Pool>) -> Self { ActivityService { repo: ActivityRepository::new(), - asset_service: AssetService::new(), - account_service: AccountService::new(), + pool, } } - // delete an activity - pub fn delete_activity( - &self, - conn: &mut SqliteConnection, - activity_id: String, - ) -> Result { - self.repo.delete_activity(conn, activity_id) + //load all activities + pub fn get_activities(&self) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.repo.get_activities(&mut conn) } - //load all activities - pub fn get_activities( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { - self.repo.get_activities(conn) + pub fn get_trading_activities(&self) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.repo.get_trading_activities(&mut conn) } - pub fn get_trading_activities( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { - self.repo.get_trading_activities(conn) + pub fn get_income_data(&self) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.repo.get_income_activities(&mut conn).map(|results| { + results + .into_iter() + .map(|activity| IncomeData { + date: activity.activity_date, + income_type: activity.activity_type, + symbol: activity.asset_id, + amount: activity.quantity * activity.unit_price, + currency: activity.currency, + }) + .collect() + }) } pub fn search_activities( &self, - conn: &mut SqliteConnection, page: i64, // Page number, 1-based page_size: i64, // Number of items per page account_id_filter: Option>, // Optional account_id filter @@ -61,8 +62,9 @@ impl ActivityService { asset_id_keyword: Option, // Optional asset_id keyword for search sort: Option, // Optional sort ) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); self.repo.search_activities( - conn, + &mut conn, page, page_size, account_id_filter, @@ -75,17 +77,13 @@ impl ActivityService { //create a new activity and fetch related the asset profile pub async fn create_activity( &self, - conn: &mut SqliteConnection, mut activity: NewActivity, ) -> Result { - // Clone asset_id to avoid moving it + let mut conn = self.pool.get().expect("Couldn't get db connection"); let asset_id = activity.asset_id.clone(); + let asset_service = AssetService::new(self.pool.clone()); - // fetch the asset profile from the database or create it if not found - let _asset_profile = self - .asset_service - .get_asset_profile(conn, &asset_id) - .await?; + let _asset_profile = asset_service.get_asset_profile(&asset_id).await?; // Adjust unit price based on activity type if ["DEPOSIT", "WITHDRAWAL", "INTEREST", "FEE", "DIVIDEND"] @@ -95,19 +93,19 @@ impl ActivityService { } // Insert the new activity into the database - self.repo.insert_new_activity(conn, activity) + self.repo.insert_new_activity(&mut conn, activity) } // verify the activities import from csv file pub async fn check_activities_import( &self, - conn: &mut SqliteConnection, _account_id: String, file_path: String, ) -> Result, String> { - let account = self - .account_service - .get_account_by_id(conn, &_account_id) + let asset_service = AssetService::new(self.pool.clone()); + let account_service = AccountService::new(self.pool.clone()); + let account = account_service + .get_account_by_id(&_account_id) .map_err(|e| e.to_string())?; let file = File::open(&file_path).map_err(|e| e.to_string())?; @@ -122,9 +120,8 @@ impl ActivityService { let mut activity_import: ActivityImport = result.map_err(|e| e.to_string())?; // Load the symbol profile here, now awaiting the async call - let symbol_profile_result = self - .asset_service - .get_asset_profile(conn, &activity_import.symbol) + let symbol_profile_result = asset_service + .get_asset_profile(&activity_import.symbol) .await; // Check if symbol profile is valid @@ -159,9 +156,9 @@ impl ActivityService { // create activities used after the import is verified pub fn create_activities( &self, - conn: &mut SqliteConnection, activities: Vec, ) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); conn.transaction(|conn| { let mut insert_count = 0; for new_activity in activities { @@ -178,9 +175,15 @@ impl ActivityService { // update an activity pub fn update_activity( &self, - conn: &mut SqliteConnection, activity: ActivityUpdate, ) -> Result { - self.repo.update_activity(conn, activity) + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.repo.update_activity(&mut conn, activity) + } + + // delete an activity + pub fn delete_activity(&self, activity_id: String) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.repo.delete_activity(&mut conn, activity_id) } } diff --git a/src-core/src/app_state.rs b/src-core/src/app_state.rs deleted file mode 100644 index d081116..0000000 --- a/src-core/src/app_state.rs +++ /dev/null @@ -1,7 +0,0 @@ -use diesel::sqlite::SqliteConnection; -use std::sync::Mutex; - -pub struct AppState { - pub conn: Mutex, - pub db_path: String, -} diff --git a/src-core/src/asset/asset_service.rs b/src-core/src/asset/asset_service.rs index 2e76191..cce8211 100644 --- a/src-core/src/asset/asset_service.rs +++ b/src-core/src/asset/asset_service.rs @@ -1,29 +1,28 @@ +use crate::market_data::market_data_service::MarketDataService; use crate::models::{Asset, AssetProfile, NewAsset, Quote, QuoteSummary}; -use crate::providers::yahoo_provider::YahooProvider; -use std::time::SystemTime; - -use crate::schema::{activities, assets, quotes}; -use chrono::{NaiveDateTime, TimeZone, Utc}; +use crate::schema::{assets, quotes}; use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; use std::collections::HashMap; pub struct AssetService { - provider: YahooProvider, + market_data_service: MarketDataService, + pool: Pool>, } impl From for Quote { fn from(external_quote: yahoo_finance_api::Quote) -> Self { Quote { - id: uuid::Uuid::new_v4().to_string(), // Generate a new UUID for the id - created_at: chrono::Utc::now().naive_utc(), // Use the current time for created_at - data_source: String::from("Yahoo"), // Replace with actual data source if available - date: chrono::Utc::now().naive_utc(), // Adjust based on your requirements - symbol: String::new(), // Placeholder, needs actual symbol + id: uuid::Uuid::new_v4().to_string(), + created_at: chrono::Utc::now().naive_utc(), + data_source: String::from("Yahoo"), + date: chrono::Utc::now().naive_utc(), + symbol: String::new(), open: external_quote.open, high: external_quote.high, low: external_quote.low, - volume: external_quote.volume as f64, // Convert from u64 to f64 + volume: external_quote.volume as f64, close: external_quote.close, adjclose: external_quote.adjclose, } @@ -31,43 +30,34 @@ impl From for Quote { } impl AssetService { - pub fn new() -> Self { + pub fn new(pool: Pool>) -> Self { AssetService { - provider: YahooProvider::new().unwrap(), + market_data_service: MarketDataService::new(pool.clone()), + pool, } } - pub fn get_assets( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { - assets::table.load::(conn) + pub fn get_assets(&self) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + assets::table.load::(&mut conn) } - // get asset by id - pub fn get_asset_by_id( - &self, - conn: &mut SqliteConnection, - asset_id: &str, - ) -> Result { - assets::table.find(asset_id).first::(conn) + pub fn get_asset_by_id(&self, asset_id: &str) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + assets::table.find(asset_id).first::(&mut conn) } - pub fn get_asset_data( - &self, - conn: &mut SqliteConnection, - asset_id: &str, - ) -> Result { - // Load Asset data + pub fn get_asset_data(&self, asset_id: &str) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + let asset = assets::table .filter(assets::id.eq(asset_id)) - .first::(conn)?; + .first::(&mut conn)?; - // Load Quote history for the Asset let quote_history = quotes::table .filter(quotes::symbol.eq(&asset.symbol)) .order(quotes::date.desc()) - .load::(conn)?; + .load::(&mut conn)?; Ok(AssetProfile { asset, @@ -77,33 +67,32 @@ impl AssetService { pub fn load_currency_assets( &self, - conn: &mut SqliteConnection, base_currency: &str, ) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); use crate::schema::assets::dsl::*; assets .filter(asset_type.eq("Currency")) .filter(symbol.like(format!("{}%", base_currency))) - .load::(conn) + .load::(&mut conn) } pub fn load_exchange_rates( &self, - conn: &mut SqliteConnection, base_currency: &str, ) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); use crate::schema::quotes::dsl::{date, quotes, symbol}; let mut exchange_rates = HashMap::new(); - - let currency_assets = self.load_currency_assets(conn, base_currency)?; + let currency_assets = self.load_currency_assets(base_currency)?; for asset in currency_assets { let latest_quote = quotes .filter(symbol.eq(&asset.symbol)) .order(date.desc()) - .first::(conn) + .first::(&mut conn) .ok(); if let Some(quote) = latest_quote { @@ -114,7 +103,6 @@ impl AssetService { Ok(exchange_rates) } - // create CASH asset pub fn create_cash_asset( &self, conn: &mut SqliteConnection, @@ -126,7 +114,7 @@ impl AssetService { id: asset_id.to_string(), isin: None, name: None, - asset_type: None, + asset_type: Some("Cash".to_string()), symbol: asset_id.to_string(), symbol_mapping: None, asset_class: Some("CASH".to_string()), @@ -144,10 +132,9 @@ impl AssetService { diesel::insert_into(assets::table) .values(&new_asset) - .get_result::(conn) // This line changed + .get_result::(conn) } - // create Rate exchange asset pub fn create_rate_exchange_asset( &self, conn: &mut SqliteConnection, @@ -178,210 +165,58 @@ impl AssetService { diesel::insert_into(assets::table) .values(&new_asset) - .get_result::(conn) // This line changed + .get_result::(conn) } - // pub async fn fetch_quote(&self, symbol: &str) -> Result { - // self.provider - // .get_latest_quote(symbol) - // .await - // .map_err(|e| e.to_string()) - // .map(|external_quote| Quote::from(external_quote)) // Converts ExternalQuote to Quote - // } - - pub fn get_latest_quote( - &self, - conn: &mut SqliteConnection, - symbol_query: &str, - ) -> QueryResult { - use crate::schema::quotes::dsl::*; - - quotes - .filter(symbol.eq(symbol_query)) - .order(date.desc()) // Order by date descending to get the latest quote in the table - .first::(conn) + pub fn get_latest_quote(&self, symbol_query: &str) -> QueryResult { + self.market_data_service.get_latest_quote(symbol_query) } - pub fn get_history_quotes( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { - quotes::table.load::(conn) + pub fn get_history_quotes(&self) -> Result, diesel::result::Error> { + self.market_data_service.get_history_quotes() } pub async fn search_ticker(&self, query: &str) -> Result, String> { - self.provider - .search_ticker(query) - .await - .map_err(|e| e.to_string()) + self.market_data_service.search_symbol(query).await } pub async fn initialize_crumb_data(&self) -> Result<(), String> { - match self.provider.set_crumb().await { - Ok(_) => { - println!("Crumb data initialized successfully."); - Ok(()) - } - Err(e) => { - let error_message = format!("Failed to initialize crumb data: {}", e); - eprintln!("{}", &error_message); - Err(error_message) - } - } + self.market_data_service.initialize_crumb_data().await } - pub async fn get_asset_profile( - &self, - conn: &mut SqliteConnection, - asset_id: &str, - ) -> Result { + pub async fn get_asset_profile(&self, asset_id: &str) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); use crate::schema::assets::dsl::*; - // Try to load the Asset from the database - match assets.find(asset_id).first::(conn) { + + match assets.find(asset_id).first::(&mut conn) { Ok(existing_profile) => Ok(existing_profile), Err(diesel::NotFound) => { - // If not found, fetch one and save it to the database let fetched_profile = self - .provider - .fetch_quote_summary(asset_id) + .market_data_service + .fetch_symbol_summary(asset_id) .await .map_err(|_e| diesel::result::Error::NotFound)?; - // Insert the new profile into the database diesel::insert_into(assets) .values(&fetched_profile) .returning(Asset::as_returning()) - .get_result(conn) + .get_result(&mut conn) } Err(e) => Err(e), } } - fn get_last_quote_sync_date( - &self, - conn: &mut SqliteConnection, - ticker: &str, - ) -> Result, diesel::result::Error> { - // Try to get the latest quote date for the given ticker - let latest_quote_date = quotes::table - .filter(quotes::symbol.eq(ticker)) - .select(diesel::dsl::max(quotes::date)) - .first::>(conn)?; - - // Check if latest_quote_date is Some and return early - if let Some(date) = latest_quote_date { - return Ok(Some(date)); - } - - // The code reaches here only if latest_quote_date is None - let earliest_activity_date = activities::table - .filter(activities::asset_id.eq(ticker)) - .select(diesel::dsl::min(activities::activity_date)) - .first::>(conn)?; - - Ok(earliest_activity_date) - } - - pub async fn sync_history_quotes_for_all_assets( - &self, - conn: &mut SqliteConnection, - ) -> Result<(), String> { - println!("Syncing history quotes for all assets..."); - - // 1. Query all assets - let asset_list = Self::get_assets(self, conn).map_err(|e| e.to_string())?; - - // 2. Determine your end date for fetching historical quotes (e.g., current time) - let end_date = SystemTime::now(); - - // 3. Create a Vec to store quotes for all assets - let mut all_quotes_to_insert = Vec::new(); - - for asset in asset_list { - let symbol = asset.symbol.as_str(); - // Get the last quote sync date for this asset - let last_sync_date_naive = match self.get_last_quote_sync_date(conn, symbol) { - Ok(date) => date.unwrap_or_else(|| { - chrono::Utc::now().naive_utc() - chrono::Duration::days(3 * 365) - }), - Err(e) => { - eprintln!( - "Error getting last sync date for {}: {}. Skipping.", - symbol, e - ); - continue; - } - }; - - // Convert NaiveDateTime to DateTime - let start_datetime_utc = Utc.from_utc_datetime(&last_sync_date_naive); - - // Convert DateTime to SystemTime - let start_date: std::time::SystemTime = start_datetime_utc.into(); - - // Fetch quotes for the asset and append them to the all_quotes_to_insert Vec - match self - .provider - .fetch_stock_history(symbol, start_date, end_date) - .await - { - Ok(quotes_history) => { - for yahoo_quote in quotes_history { - let timestamp = yahoo_quote.timestamp as i64; - match chrono::DateTime::from_timestamp(timestamp, 0) { - Some(datetime) => { - let naive_datetime = datetime.naive_utc(); - let new_quote = Quote { - id: uuid::Uuid::new_v4().to_string(), - created_at: naive_datetime, - data_source: "YAHOO".to_string(), - date: naive_datetime, - symbol: symbol.to_string(), - open: yahoo_quote.open, - high: yahoo_quote.high, - low: yahoo_quote.low, - volume: yahoo_quote.volume as f64, - close: yahoo_quote.close, - adjclose: yahoo_quote.adjclose, - }; - all_quotes_to_insert.push(new_quote); - } - None => eprintln!( - "Invalid timestamp {} for {}. Skipping quote.", - timestamp, symbol - ), - } - } - } - Err(e) => eprintln!("Error fetching history for {}: {}. Skipping.", symbol, e), - } - } - - // 4. Use Diesel's batch insert to insert all quotes in a single operation - diesel::replace_into(quotes::table) - .values(&all_quotes_to_insert) - .execute(conn) - .map_err(|e| e.to_string())?; - - Ok(()) + pub async fn sync_history_quotes_for_all_assets(&self) -> Result<(), String> { + let asset_list = self.get_assets().map_err(|e| e.to_string())?; + self.market_data_service + .sync_history_quotes_for_all_assets(&asset_list) + .await } - pub async fn initialize_and_sync_quotes( - &self, - conn: &mut SqliteConnection, - ) -> Result<(), String> { - // Initialize crumb data - if let Err(e) = self.initialize_crumb_data().await { - return Err(format!("Failed to initialize crumb data: {}", e)); - } - - // Synchronize history quotes - if let Err(e) = self.sync_history_quotes_for_all_assets(conn).await { - return Err(format!("Failed to sync history quotes: {}", e)); - } - - Ok(()) + pub async fn initialize_and_sync_quotes(&self) -> Result<(), String> { + let asset_list = self.get_assets().map_err(|e| e.to_string())?; + self.market_data_service + .initialize_and_sync_quotes(&asset_list) + .await } } - -// } diff --git a/src-core/src/lib.rs b/src-core/src/lib.rs index 2ceb355..664ba18 100644 --- a/src-core/src/lib.rs +++ b/src-core/src/lib.rs @@ -4,10 +4,9 @@ pub mod account; pub mod activity; pub mod asset; pub mod goal; +pub mod market_data; pub mod models; pub mod portfolio; pub mod providers; pub mod schema; pub mod settings; - -pub mod app_state; diff --git a/src-core/src/market_data/market_data_service.rs b/src-core/src/market_data/market_data_service.rs new file mode 100644 index 0000000..8f169ab --- /dev/null +++ b/src-core/src/market_data/market_data_service.rs @@ -0,0 +1,150 @@ +use crate::models::{Asset, NewAsset, Quote, QuoteSummary}; +use crate::providers::yahoo_provider::YahooProvider; +use crate::schema::{activities, quotes}; +use chrono::{Duration, NaiveDateTime, TimeZone, Utc}; +use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::SqliteConnection; +use std::time::SystemTime; +use uuid::Uuid; + +pub struct MarketDataService { + provider: YahooProvider, + pool: Pool>, +} + +impl MarketDataService { + pub fn new(pool: Pool>) -> Self { + MarketDataService { + provider: YahooProvider::new().expect("Failed to initialize YahooProvider"), + pool, + } + } + + pub async fn search_symbol(&self, query: &str) -> Result, String> { + self.provider + .search_ticker(query) + .await + .map_err(|e| e.to_string()) + } + + pub fn get_latest_quote(&self, symbol: &str) -> QueryResult { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + quotes::table + .filter(quotes::symbol.eq(symbol)) + .order(quotes::date.desc()) + .first::(&mut conn) + } + + pub fn get_history_quotes(&self) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + quotes::table.load::(&mut conn) + } + + pub async fn initialize_crumb_data(&self) -> Result<(), String> { + self.provider.set_crumb().await.map_err(|e| { + let error_message = format!("Failed to initialize crumb data: {}", e); + eprintln!("{}", &error_message); + error_message + }) + } + + fn get_last_quote_sync_date( + &self, + ticker: &str, + ) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + + quotes::table + .filter(quotes::symbol.eq(ticker)) + .select(diesel::dsl::max(quotes::date)) + .first::>(&mut conn) + .or_else(|_| { + activities::table + .filter(activities::asset_id.eq(ticker)) + .select(diesel::dsl::min(activities::activity_date)) + .first::>(&mut conn) + }) + } + + pub async fn sync_history_quotes_for_all_assets( + &self, + asset_list: &[Asset], + ) -> Result<(), String> { + println!("Syncing history quotes for all assets..."); + + let end_date = SystemTime::now(); + let mut all_quotes_to_insert = Vec::new(); + + for asset in asset_list { + let symbol = asset.symbol.as_str(); + let last_sync_date = self + .get_last_quote_sync_date(symbol) + .map_err(|e| format!("Error getting last sync date for {}: {}", symbol, e))? + .unwrap_or_else(|| Utc::now().naive_utc() - Duration::days(3 * 365)); + + let start_date: SystemTime = Utc.from_utc_datetime(&last_sync_date).into(); + + match self + .provider + .fetch_stock_history(symbol, start_date, end_date) + .await + { + Ok(quotes_history) => { + for yahoo_quote in quotes_history { + if let Some(new_quote) = self.create_quote_from_yahoo(yahoo_quote, symbol) { + all_quotes_to_insert.push(new_quote); + } + } + } + Err(e) => eprintln!("Error fetching history for {}: {}. Skipping.", symbol, e), + } + } + + self.insert_quotes(&all_quotes_to_insert) + } + + fn create_quote_from_yahoo( + &self, + yahoo_quote: yahoo_finance_api::Quote, + symbol: &str, + ) -> Option { + chrono::DateTime::from_timestamp(yahoo_quote.timestamp as i64, 0).map(|datetime| { + let naive_datetime = datetime.naive_utc(); + Quote { + id: Uuid::new_v4().to_string(), + created_at: naive_datetime, + data_source: "YAHOO".to_string(), + date: naive_datetime, + symbol: symbol.to_string(), + open: yahoo_quote.open, + high: yahoo_quote.high, + low: yahoo_quote.low, + volume: yahoo_quote.volume as f64, + close: yahoo_quote.close, + adjclose: yahoo_quote.adjclose, + } + }) + } + + fn insert_quotes(&self, quotes: &[Quote]) -> Result<(), String> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + diesel::replace_into(quotes::table) + .values(quotes) + .execute(&mut conn) + .map_err(|e| format!("Failed to insert quotes: {}", e))?; + Ok(()) + } + + pub async fn initialize_and_sync_quotes(&self, asset_list: &[Asset]) -> Result<(), String> { + self.initialize_crumb_data().await?; + self.sync_history_quotes_for_all_assets(asset_list).await + } + + pub async fn fetch_symbol_summary(&self, symbol: &str) -> Result { + self.provider + .fetch_quote_summary(symbol) + .await + .map_err(|e| e.to_string()) + } +} diff --git a/src-core/src/market_data/mod.rs b/src-core/src/market_data/mod.rs new file mode 100644 index 0000000..a30c25d --- /dev/null +++ b/src-core/src/market_data/mod.rs @@ -0,0 +1 @@ +pub mod market_data_service; diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 14f4983..667de3a 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -12,6 +12,7 @@ use std::collections::{HashMap, HashSet}; use chrono::Datelike; use chrono::{Duration, NaiveDate, Utc}; +use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; pub struct PortfolioService { @@ -20,6 +21,7 @@ pub struct PortfolioService { asset_service: AssetService, base_currency: String, exchange_rates: HashMap, + pool: Pool>, } /// This module contains the implementation of the `PortfolioService` struct. @@ -29,28 +31,29 @@ pub struct PortfolioService { /// and getting dates between two given dates. impl PortfolioService { - pub fn new(conn: &mut SqliteConnection) -> Result> { + pub fn new( + pool: Pool>, + ) -> Result> { let mut service = PortfolioService { - account_service: AccountService::new(), - activity_service: ActivityService::new(), - asset_service: AssetService::new(), + account_service: AccountService::new(pool.clone()), + activity_service: ActivityService::new(pool.clone()), + asset_service: AssetService::new(pool.clone()), base_currency: String::new(), exchange_rates: HashMap::new(), + pool: pool, }; - service.initialize(conn)?; + service.initialize()?; Ok(service) } - fn initialize( - &mut self, - conn: &mut SqliteConnection, - ) -> Result<(), Box> { + fn initialize(&mut self) -> Result<(), Box> { + let mut conn = self.pool.get()?; let settings_service = SettingsService::new(); - let settings = settings_service.get_settings(conn)?; + let settings = settings_service.get_settings(&mut conn)?; self.base_currency.clone_from(&settings.base_currency); self.exchange_rates = self .asset_service - .load_exchange_rates(conn, &settings.base_currency)?; + .load_exchange_rates(&settings.base_currency)?; Ok(()) } @@ -75,14 +78,11 @@ impl PortfolioService { } } - pub fn compute_holdings( - &self, - conn: &mut SqliteConnection, - ) -> Result, Box> { + pub fn compute_holdings(&self) -> Result, Box> { let mut holdings: HashMap = HashMap::new(); - let accounts = self.account_service.get_accounts(conn)?; - let activities = self.activity_service.get_trading_activities(conn)?; - let assets = self.asset_service.get_assets(conn)?; + let accounts = self.account_service.get_accounts()?; + let activities = self.activity_service.get_trading_activities()?; + let assets = self.asset_service.get_assets()?; for activity in activities { //find asset by id @@ -159,7 +159,7 @@ impl PortfolioService { // Fetch quotes for each symbol asynchronously let mut quotes = HashMap::new(); for symbol in symbols { - match self.asset_service.get_latest_quote(conn, &symbol) { + match self.asset_service.get_latest_quote(&symbol) { Ok(quote) => { quotes.insert(symbol, quote); } @@ -215,23 +215,20 @@ impl PortfolioService { fn fetch_data( &self, - conn: &mut SqliteConnection, ) -> Result<(Vec, Vec, Vec), Box> { - let accounts = self.account_service.get_accounts(conn)?; - let activities = self.activity_service.get_activities(conn)?; - let market_data = self.asset_service.get_history_quotes(conn)?; - //let assets = self.asset_service.get_assets(conn)?; + let accounts = self.account_service.get_accounts()?; + let activities = self.activity_service.get_activities()?; + let market_data = self.asset_service.get_history_quotes()?; Ok((accounts, activities, market_data)) } pub fn calculate_historical_portfolio_values( &self, - conn: &mut SqliteConnection, ) -> Result, Box> { let strt_time = std::time::Instant::now(); - let (accounts, activities, market_data) = self.fetch_data(conn)?; + let (accounts, activities, market_data) = self.fetch_data()?; // Use Rayon's par_iter to process each account in parallel let results: Vec = accounts @@ -489,13 +486,10 @@ impl PortfolioService { results } - pub fn get_income_data( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { + pub fn get_income_data(&self) -> Result, diesel::result::Error> { use crate::schema::activities; use diesel::prelude::*; - + let mut conn = self.pool.get().expect("Couldn't get db connection"); activities::table .filter(activities::activity_type.eq_any(vec!["DIVIDEND", "INTEREST"])) .select(( @@ -505,7 +499,7 @@ impl PortfolioService { activities::quantity * activities::unit_price, activities::currency, )) - .load::<(NaiveDateTime, String, String, f64, String)>(conn) + .load::<(NaiveDateTime, String, String, f64, String)>(&mut conn) .map(|results| { results .into_iter() @@ -520,11 +514,8 @@ impl PortfolioService { }) } - pub fn get_income_summary( - &self, - conn: &mut SqliteConnection, - ) -> Result { - let income_data = self.get_income_data(conn)?; + pub fn get_income_summary(&self) -> Result { + let income_data = self.get_income_data()?; let mut by_month: HashMap = HashMap::new(); let mut by_type: HashMap = HashMap::new(); diff --git a/src-core/src/providers/yahoo_provider.rs b/src-core/src/providers/yahoo_provider.rs index b64a048..87db56d 100644 --- a/src-core/src/providers/yahoo_provider.rs +++ b/src-core/src/providers/yahoo_provider.rs @@ -306,9 +306,6 @@ impl YahooProvider { .await .map_err(|err| YahooError::FetchFailed(err.to_string()))?; - // Print the raw JSON response - println!("Raw JSON Response: {}", response_text); - // Deserialize the JSON response into your struct let deserialized: YahooResult = serde_json::from_str(&response_text).map_err(|err| { println!("JSON Deserialization Error: {}", err); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0b16659..dac78f6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -673,6 +673,7 @@ dependencies = [ "num-bigint", "num-integer", "num-traits", + "r2d2", "time", ] @@ -2709,6 +2710,17 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + [[package]] name = "rand" version = "0.7.3" @@ -3139,6 +3151,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4416,6 +4437,7 @@ dependencies = [ name = "wealthfolio-app" version = "1.0.13" dependencies = [ + "diesel", "dotenvy", "tauri", "tauri-build", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 340a567..c2a4621 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,6 +15,7 @@ tauri-build = { version = "1.5.4", features = [] } [dependencies] wealthfolio_core = { path = "../src-core" } tauri = { version = "1.7.2", features = [ "updater", "dialog-open", "fs-all", "path-all", "window-start-dragging", "shell-open"] } +diesel = { version = "2.2.4", features = ["sqlite", "chrono", "r2d2", "numeric", "returning_clauses_for_sqlite_3_35"] } dotenvy = "0.15.7" [features] diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 2b3dd57..3599d75 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -1,44 +1,40 @@ -use crate::account::account_service; +use crate::account::account_service::AccountService; use crate::models::{Account, AccountUpdate, NewAccount}; use crate::AppState; use tauri::State; #[tauri::command] pub fn get_accounts(state: State) -> Result, String> { - println!("Fetching active accounts..."); // Log message - let mut conn = state.conn.lock().unwrap(); - let service = account_service::AccountService::new(); + println!("Fetching active accounts..."); + let service = AccountService::new((*state.pool).clone()); service - .get_accounts(&mut conn) + .get_accounts() .map_err(|e| format!("Failed to load accounts: {}", e)) } #[tauri::command] pub fn create_account(account: NewAccount, state: State) -> Result { - println!("Adding new account..."); // Log message - let mut conn = state.conn.lock().unwrap(); - let service = account_service::AccountService::new(); + println!("Adding new account..."); + let service = AccountService::new((*state.pool).clone()); service - .create_account(&mut conn, account) + .create_account(account) .map_err(|e| format!("Failed to add new account: {}", e)) } #[tauri::command] pub fn update_account(account: AccountUpdate, state: State) -> Result { - println!("Updating account..."); // Log message - let mut conn = state.conn.lock().unwrap(); - let service = account_service::AccountService::new(); + println!("Updating account..."); + let service = AccountService::new((*state.pool).clone()); service - .update_account(&mut conn, account) + .update_account(account) .map_err(|e| format!("Failed to update account: {}", e)) } #[tauri::command] pub fn delete_account(account_id: String, state: State) -> Result { - println!("Deleting account..."); // Log message - let mut conn = state.conn.lock().unwrap(); - let service = account_service::AccountService::new(); + println!("Deleting account..."); + let service = AccountService::new((*state.pool).clone()); service - .delete_account(&mut conn, account_id) + .delete_account(account_id) .map_err(|e| format!("Failed to delete account: {}", e)) } diff --git a/src-tauri/src/commands/activity.rs b/src-tauri/src/commands/activity.rs index 80db4ac..ed52fc9 100644 --- a/src-tauri/src/commands/activity.rs +++ b/src-tauri/src/commands/activity.rs @@ -5,7 +5,6 @@ use crate::models::{ use crate::AppState; use tauri::State; - #[tauri::command] pub fn search_activities( page: i64, // Page number, 1-based @@ -17,12 +16,10 @@ pub fn search_activities( state: State, ) -> Result { println!("Search activities... {}, {}", page, page_size); - let mut conn = state.conn.lock().unwrap(); - let service = activity_service::ActivityService::new(); + let service = activity_service::ActivityService::new((*state.pool).clone()); service .search_activities( - &mut conn, page, page_size, account_id_filter, @@ -30,17 +27,15 @@ pub fn search_activities( asset_id_keyword, sort, ) - .map_err(|e| format!("Seach activities: {}", e)) + .map_err(|e| format!("Search activities: {}", e)) } #[tauri::command] pub fn create_activity(activity: NewActivity, state: State) -> Result { println!("Adding new activity..."); - let result = tauri::async_runtime::block_on(async { - let mut conn = state.conn.lock().unwrap(); - let service = activity_service::ActivityService::new(); - service.create_activity(&mut conn, activity).await + let service = activity_service::ActivityService::new((*state.pool).clone()); + service.create_activity(activity).await }); result.map_err(|e| format!("Failed to add new activity: {}", e)) @@ -58,11 +53,8 @@ pub fn check_activities_import( ); let result = tauri::async_runtime::block_on(async { - let mut conn = state.conn.lock().unwrap(); - let service = activity_service::ActivityService::new(); - service - .check_activities_import(&mut conn, account_id, file_path) - .await + let service = activity_service::ActivityService::new((*state.pool).clone()); + service.check_activities_import(account_id, file_path).await }); result.map_err(|e| e.to_string()) @@ -73,14 +65,11 @@ pub fn create_activities( activities: Vec, state: State, ) -> Result { - // Return a Result with the count or an error message println!("Importing activities..."); - let mut conn = state.conn.lock().unwrap(); - let service = activity_service::ActivityService::new(); + let service = activity_service::ActivityService::new((*state.pool).clone()); service - .create_activities(&mut conn, activities) + .create_activities(activities) .map_err(|err| format!("Failed to import activities: {}", err)) - .map(|count| count) // You can directly return the count here } #[tauri::command] @@ -88,21 +77,18 @@ pub fn update_activity( activity: ActivityUpdate, state: State, ) -> Result { - println!("Updating activity..."); - let mut conn = state.conn.lock().unwrap(); - let service = activity_service::ActivityService::new(); + println!("Updating activity..."); + let service = activity_service::ActivityService::new((*state.pool).clone()); service - .update_activity(&mut conn, activity) + .update_activity(activity) .map_err(|e| format!("Failed to update activity: {}", e)) } - #[tauri::command] pub fn delete_activity(activity_id: String, state: State) -> Result { - println!("Deleting activity..."); - let mut conn = state.conn.lock().unwrap(); - let service = activity_service::ActivityService::new(); + println!("Deleting activity..."); + let service = activity_service::ActivityService::new((*state.pool).clone()); service - .delete_activity(&mut conn, activity_id) + .delete_activity(activity_id) .map_err(|e| format!("Failed to delete activity: {}", e)) } diff --git a/src-tauri/src/commands/asset.rs b/src-tauri/src/commands/asset.rs deleted file mode 100644 index fe9e522..0000000 --- a/src-tauri/src/commands/asset.rs +++ /dev/null @@ -1,40 +0,0 @@ -use crate::asset::asset_service; -use crate::db; -use crate::models::{AssetProfile, QuoteSummary}; -use crate::AppState; -use tauri::State; - -#[tauri::command] -pub async fn search_ticker(query: String) -> Result, String> { - println!("Searching for ticker symbol: {}", query); - let service = asset_service::AssetService::new(); - - service - .search_ticker(&query) - .await - .map_err(|e| format!("Failed to search ticker: {}", e)) -} - -#[tauri::command] -pub fn get_asset_data(asset_id: String, state: State) -> Result { - let mut conn = state.conn.lock().unwrap(); - let service = asset_service::AssetService::new(); - service - .get_asset_data(&mut conn, &asset_id) - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn synch_quotes(state: State<'_, AppState>) -> Result<(), String> { - println!("Synching quotes history"); - let service = asset_service::AssetService::new(); - - // Get the database path from the AppState - let db_path = state.db_path.clone(); - - println!("Sync B Path: {}", db_path); - // Create a new connection - let mut new_conn = db::establish_connection(&db_path); - - service.initialize_and_sync_quotes(&mut new_conn).await -} diff --git a/src-tauri/src/commands/goal.rs b/src-tauri/src/commands/goal.rs index 920e070..b39204e 100644 --- a/src-tauri/src/commands/goal.rs +++ b/src-tauri/src/commands/goal.rs @@ -1,12 +1,24 @@ use crate::goal::goal_service; use crate::models::{Goal, GoalsAllocation, NewGoal}; use crate::AppState; +use diesel::r2d2::ConnectionManager; +use diesel::SqliteConnection; use tauri::State; +fn get_connection( + state: &State, +) -> Result>, String> { + state + .pool + .clone() + .get() + .map_err(|e| format!("Failed to get database connection: {}", e)) +} + #[tauri::command] pub fn get_goals(state: State) -> Result, String> { - println!("Fetching active goals..."); // Log message - let mut conn = state.conn.lock().unwrap(); + println!("Fetching active goals..."); + let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); service .get_goals(&mut conn) @@ -15,8 +27,8 @@ pub fn get_goals(state: State) -> Result, String> { #[tauri::command] pub fn create_goal(goal: NewGoal, state: State) -> Result { - println!("Adding new goal..."); // Log message - let mut conn = state.conn.lock().unwrap(); + println!("Adding new goal..."); + let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); service .create_goal(&mut conn, goal) @@ -25,8 +37,8 @@ pub fn create_goal(goal: NewGoal, state: State) -> Result) -> Result { - println!("Updating goal..."); // Log message - let mut conn = state.conn.lock().unwrap(); + println!("Updating goal..."); + let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); service .update_goal(&mut conn, goal) @@ -35,8 +47,8 @@ pub fn update_goal(goal: Goal, state: State) -> Result { #[tauri::command] pub fn delete_goal(goal_id: String, state: State) -> Result { - println!("Deleting goal..."); // Log message - let mut conn = state.conn.lock().unwrap(); + println!("Deleting goal..."); + let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); service .delete_goal(&mut conn, goal_id) @@ -48,8 +60,8 @@ pub fn update_goal_allocations( allocations: Vec, state: State, ) -> Result { - print!("Get goals allocations..."); - let mut conn = state.conn.lock().unwrap(); + println!("Updating goal allocations..."); + let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); service .upsert_goal_allocations(&mut conn, allocations) @@ -58,8 +70,8 @@ pub fn update_goal_allocations( #[tauri::command] pub fn load_goals_allocations(state: State) -> Result, String> { - print!("Upserting goal allocations..."); - let mut conn = state.conn.lock().unwrap(); + println!("Loading goal allocations..."); + let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); service .load_goals_allocations(&mut conn) diff --git a/src-tauri/src/commands/market_data.rs b/src-tauri/src/commands/market_data.rs new file mode 100644 index 0000000..cbc50fe --- /dev/null +++ b/src-tauri/src/commands/market_data.rs @@ -0,0 +1,33 @@ +use crate::asset::asset_service::AssetService; +use crate::market_data::market_data_service::MarketDataService; + +use crate::models::{AssetProfile, QuoteSummary}; +use crate::AppState; +use tauri::State; + +#[tauri::command] +pub async fn search_symbol( + query: String, + state: State<'_, AppState>, +) -> Result, String> { + println!("Searching for ticker symbol: {}", query); + let service = MarketDataService::new((*state.pool).clone()); + + service + .search_symbol(&query) + .await + .map_err(|e| format!("Failed to search ticker: {}", e)) +} + +#[tauri::command] +pub fn get_asset_data(asset_id: String, state: State) -> Result { + let service = AssetService::new((*state.pool).clone()); + service.get_asset_data(&asset_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn synch_quotes(state: State<'_, AppState>) -> Result<(), String> { + println!("Synching quotes history"); + let service = AssetService::new((*state.pool).clone()); + service.initialize_and_sync_quotes().await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a14c7b2..c3a86aa 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,6 @@ pub mod account; pub mod activity; -pub mod asset; pub mod goal; +pub mod market_data; pub mod portfolio; pub mod settings; diff --git a/src-tauri/src/commands/portfolio.rs b/src-tauri/src/commands/portfolio.rs index a035ca2..b38b0ab 100644 --- a/src-tauri/src/commands/portfolio.rs +++ b/src-tauri/src/commands/portfolio.rs @@ -1,49 +1,39 @@ use crate::models::{FinancialHistory, Holding, IncomeSummary}; use crate::portfolio::portfolio_service; use crate::AppState; +use tauri::State; #[tauri::command] -pub async fn get_historical( - state: tauri::State<'_, AppState>, -) -> Result, String> { +pub async fn get_historical(state: State<'_, AppState>) -> Result, String> { println!("Fetching portfolio historical..."); - let mut conn = state.conn.lock().unwrap(); - - let service = portfolio_service::PortfolioService::new(&mut *conn) + let service = portfolio_service::PortfolioService::new((*state.pool).clone()) .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; service - .calculate_historical_portfolio_values(&mut *conn) + .calculate_historical_portfolio_values() .map_err(|e| format!("Failed to fetch activities: {}", e)) } #[tauri::command] -pub async fn compute_holdings(state: tauri::State<'_, AppState>) -> Result, String> { +pub async fn compute_holdings(state: State<'_, AppState>) -> Result, String> { println!("Compute holdings..."); - let mut conn = state.conn.lock().unwrap(); - - let service = portfolio_service::PortfolioService::new(&mut *conn) + let service = portfolio_service::PortfolioService::new((*state.pool).clone()) .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; service - .compute_holdings(&mut *conn) + .compute_holdings() .map_err(|e| format!("Failed to fetch activities: {}", e)) } #[tauri::command] -pub async fn get_income_summary( - state: tauri::State<'_, AppState>, -) -> Result { +pub async fn get_income_summary(state: State<'_, AppState>) -> Result { println!("Fetching income summary..."); - - let mut conn = state.conn.lock().unwrap(); - - let service = portfolio_service::PortfolioService::new(&mut *conn) + let service = portfolio_service::PortfolioService::new((*state.pool).clone()) .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; service - .get_income_summary(&mut *conn) + .get_income_summary() .map_err(|e| format!("Failed to fetch income summary: {}", e)) } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index f6d74ca..3a46865 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1,12 +1,24 @@ use crate::models::{NewSettings, Settings}; use crate::settings::settings_service; use crate::AppState; +use diesel::r2d2::ConnectionManager; +use diesel::SqliteConnection; use tauri::State; +fn get_connection( + state: &State, +) -> Result>, String> { + state + .pool + .clone() + .get() + .map_err(|e| format!("Failed to get database connection: {}", e)) +} + #[tauri::command] pub fn get_settings(state: State) -> Result { println!("Fetching active settings..."); - let mut conn = state.conn.lock().unwrap(); + let mut conn = get_connection(&state)?; let service = settings_service::SettingsService::new(); service .get_settings(&mut conn) @@ -16,7 +28,7 @@ pub fn get_settings(state: State) -> Result { #[tauri::command] pub fn update_settings(settings: NewSettings, state: State) -> Result { println!("Updating settings..."); // Log message - let mut conn = state.conn.lock().unwrap(); + let mut conn = get_connection(&state)?; let service = settings_service::SettingsService::new(); service .update_settings(&mut conn, &settings) @@ -29,7 +41,7 @@ pub fn update_settings(settings: NewSettings, state: State) -> Result< #[tauri::command] pub fn update_currency(currency: String, state: State) -> Result { println!("Updating base currency..."); // Log message - let mut conn = state.conn.lock().unwrap(); + let mut conn = get_connection(&state)?; let service = settings_service::SettingsService::new(); service .update_base_currency(&mut conn, ¤cy) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2793149..6b3f224 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,101 +8,70 @@ use commands::activity::{ check_activities_import, create_activities, create_activity, delete_activity, search_activities, update_activity, }; -use commands::asset::{get_asset_data, search_ticker, synch_quotes}; use commands::goal::{ create_goal, delete_goal, get_goals, load_goals_allocations, update_goal, update_goal_allocations, }; + +use commands::market_data::{get_asset_data, search_symbol, synch_quotes}; use commands::portfolio::{compute_holdings, get_historical, get_income_summary}; use commands::settings::{get_settings, update_currency, update_settings}; use wealthfolio_core::db; +use wealthfolio_core::models; use wealthfolio_core::account; use wealthfolio_core::activity; use wealthfolio_core::asset; use wealthfolio_core::goal; -use wealthfolio_core::models; +use wealthfolio_core::market_data; + use wealthfolio_core::portfolio; use wealthfolio_core::settings; -use wealthfolio_core::app_state; - -use app_state::AppState; -use asset::asset_service; - use dotenvy::dotenv; use std::env; use std::path::Path; -use std::sync::Mutex; +use std::sync::Arc; +use diesel::r2d2::{self, ConnectionManager}; +use diesel::SqliteConnection; use tauri::async_runtime::spawn; use tauri::{api::dialog, CustomMenuItem, Manager, Menu, Submenu}; -// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command +type DbPool = r2d2::Pool>; + +// AppState +struct AppState { + pool: Arc, +} fn main() { dotenv().ok(); // Load environment variables from .env file if available let context = tauri::generate_context!(); - // Customize the menu - let report_issue_menu_item = CustomMenuItem::new("report_issue".to_string(), "Report Issue"); - let menu = tauri::Menu::os_default(&context.package_info().name).add_submenu(Submenu::new( - "Help", - Menu::new().add_item(report_issue_menu_item), - )); + let menu = create_menu(&context); - // Clone the AppHandle let app = tauri::Builder::default() .menu(menu) - .on_menu_event(|event| match event.menu_item_id() { - "report_issue" => { - dialog::message( - Some(event.window()), - "Contact Support", - "If you encounter any issues, please email us at wealthfolio@teymz.com", - ); - } - - _ => {} - }) + .on_menu_event(handle_menu_event) .setup(|app| { let app_handle = app.handle(); let db_path = get_db_path(&app_handle); db::init(&db_path); - // Initialize state and connection - let state = AppState { - conn: Mutex::new(db::establish_connection(&db_path)), - db_path: db_path.to_string(), - }; - app.manage(state); + // Create connection pool + let manager = ConnectionManager::::new(&db_path); + let pool = r2d2::Pool::builder() + .build(manager) + .expect("Failed to create database connection pool"); + let pool = Arc::new(pool); - spawn(async move { - let asset_service = asset_service::AssetService::new(); - app_handle - .emit_all("QUOTES_SYNC_START", ()) - .expect("Failed to emit event"); + // Initialize state + let state = AppState { pool: pool.clone() }; + app.manage(state); - let mut new_conn = db::establish_connection(&db_path); - let result = asset_service - .initialize_and_sync_quotes(&mut new_conn) - .await; - - match result { - Ok(_) => { - app_handle - .emit_all("QUOTES_SYNC_COMPLETE", ()) - .expect("Failed to emit event"); - } - Err(e) => { - eprintln!("Failed to sync history quotes: {}", e); - app_handle - .emit_all("QUOTES_SYNC_ERROR", ()) - .expect("Failed to emit event"); - } - } - }); + spawn_quote_sync(app_handle, pool); Ok(()) }) @@ -115,7 +84,7 @@ fn main() { create_activity, update_activity, delete_activity, - search_ticker, + search_symbol, check_activities_import, create_activities, get_historical, @@ -141,6 +110,49 @@ fn main() { }); } +fn create_menu(context: &tauri::Context) -> Menu { + let report_issue_menu_item = CustomMenuItem::new("report_issue".to_string(), "Report Issue"); + tauri::Menu::os_default(&context.package_info().name).add_submenu(Submenu::new( + "Help", + Menu::new().add_item(report_issue_menu_item), + )) +} + +fn handle_menu_event(event: tauri::WindowMenuEvent) { + if event.menu_item_id() == "report_issue" { + dialog::message( + Some(&event.window()), + "Contact Support", + "If you encounter any issues, please email us at wealthfolio@teymz.com", + ); + } +} + +fn spawn_quote_sync(app_handle: tauri::AppHandle, pool: Arc) { + spawn(async move { + let asset_service = asset::asset_service::AssetService::new((*pool).clone()); + app_handle + .emit_all("QUOTES_SYNC_START", ()) + .expect("Failed to emit event"); + + let result = asset_service.initialize_and_sync_quotes().await; + + match result { + Ok(_) => { + app_handle + .emit_all("QUOTES_SYNC_COMPLETE", ()) + .expect("Failed to emit event"); + } + Err(e) => { + eprintln!("Failed to sync history quotes: {}", e); + app_handle + .emit_all("QUOTES_SYNC_ERROR", ()) + .expect("Failed to emit event"); + } + } + }); +} + fn get_db_path(app_handle: &tauri::AppHandle) -> String { // Try to get the database URL from the environment variable match env::var("DATABASE_URL") { diff --git a/src/commands/symbol.ts b/src/commands/market-data.ts similarity index 92% rename from src/commands/symbol.ts rename to src/commands/market-data.ts index ae6826d..0fd1a95 100644 --- a/src/commands/symbol.ts +++ b/src/commands/market-data.ts @@ -3,7 +3,7 @@ import { AssetData, QuoteSummary } from '@/lib/types'; export const searchTicker = async (query: string): Promise => { try { - const searchResult = await invoke('search_ticker', { query }); + const searchResult = await invoke('search_symbol', { query }); return searchResult as QuoteSummary[]; } catch (error) { console.error('Error searching for ticker:', error); diff --git a/src/pages/activity/components/ticker-search.tsx b/src/pages/activity/components/ticker-search.tsx index 731f7fc..072a59e 100644 --- a/src/pages/activity/components/ticker-search.tsx +++ b/src/pages/activity/components/ticker-search.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { Command as CommandPrimitive } from 'cmdk'; import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; -import { searchTicker } from '@/commands/symbol'; +import { searchTicker } from '@/commands/market-data'; import { Check } from 'lucide-react'; import { cn } from '@/lib/utils'; import { QuoteSummary } from '@/lib/types'; @@ -44,13 +44,12 @@ function TickerSearchInput({ selectedResult, defaultValue, onSelectResult }: Sea const tickers = data?.sort((a, b) => b.score - a.score); return ( - 0) } - )} - > + 0), + })} + > { diff --git a/src/pages/asset/asset-profile-page.tsx b/src/pages/asset/asset-profile-page.tsx index ab7dd25..a6e881b 100644 --- a/src/pages/asset/asset-profile-page.tsx +++ b/src/pages/asset/asset-profile-page.tsx @@ -5,7 +5,7 @@ import { useLocation, useParams } from 'react-router-dom'; import SymbolCard from './symbol-card'; import SymbolHoldingCard from './symbol-holding'; import { AssetData, Holding } from '@/lib/types'; -import { getAssetData } from '@/commands/symbol'; +import { getAssetData } from '@/commands/market-data'; import { useQuery } from '@tanstack/react-query'; export const AssetProfilePage = () => { diff --git a/src/pages/dashboard/accounts.tsx b/src/pages/dashboard/accounts.tsx index 1675229..ac62a5a 100644 --- a/src/pages/dashboard/accounts.tsx +++ b/src/pages/dashboard/accounts.tsx @@ -150,7 +150,7 @@ export function Accounts({ category: string; accountsInCategory: AccountTotal[]; }) => { - if (!category) { + if (accountsInCategory.length === 1) { return ( diff --git a/src/pages/dashboard/dashboard-page.tsx b/src/pages/dashboard/dashboard-page.tsx index a82d48a..ebad6c7 100644 --- a/src/pages/dashboard/dashboard-page.tsx +++ b/src/pages/dashboard/dashboard-page.tsx @@ -13,11 +13,11 @@ import { formatAccountsData } from '@/lib/portfolio-helper'; // filter function DashboardSkeleton() { return ( -
+
-
+
@@ -53,7 +53,7 @@ export default function DashboardPage() { />
@@ -33,38 +33,48 @@ export function SavingGoals({ accounts }: { accounts?: AccountTotal[] }) { - {goalsProgess?.map((goal: GoalProgress, index) => ( - - -
- - {goal.name} - {goal.progress >= 100 ? ( - - ) : null} - + {goalsProgress && goalsProgress.length > 0 ? ( + goalsProgress.map((goal: GoalProgress, index) => ( + + +
+ + {goal.name} + {goal.progress >= 100 ? ( + + ) : null} + - -
-
- -

{goal.name}

-
    -
  • - Progress: {formatPercent(goal.progress)} -
  • -
  • - Current Value:{' '} - {formatAmount(goal.currentValue, goal.currency, false)} -
  • -
  • - Target Value:{' '} - {formatAmount(goal.targetValue, goal.currency, false)} -
  • -
-
-
- ))} + +
+
+ +

{goal.name}

+
    +
  • + Progress: {formatPercent(goal.progress)} +
  • +
  • + Current Value:{' '} + {formatAmount(goal.currentValue, goal.currency, false)} +
  • +
  • + Target Value:{' '} + {formatAmount(goal.targetValue, goal.currency, false)} +
  • +
+
+
+ )) + ) : ( +
+ +

No saving goals set

+

+ Create a goal to start tracking your progress +

+
+ )}
diff --git a/src/pages/holdings/components/income-dashboard.tsx b/src/pages/holdings/components/income-dashboard.tsx index 9d72e74..59b72c4 100644 --- a/src/pages/holdings/components/income-dashboard.tsx +++ b/src/pages/holdings/components/income-dashboard.tsx @@ -31,7 +31,6 @@ export function IncomeDashboard() { return
Failed to load income summary: {error?.message || 'Unknown error'}
; } - console.log(incomeSummary); const totalIncome = incomeSummary.total_income; const dividendIncome = incomeSummary.by_type['DIVIDEND'] || 0; const interestIncome = incomeSummary.by_type['INTEREST'] || 0; From b041eb7d10312fac1b9015a1c9044435bf72adc8 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 15 Sep 2024 16:45:04 -0400 Subject: [PATCH 02/45] Fix #39 Activity can be displayed with date of a previous day --- src-core/src/models.rs | 6 +- src/lib/schemas.ts | 2 +- src/lib/utils.ts | 28 ++++ .../activity/components/activity-table.tsx | 21 ++- .../import/imported-activity-table.tsx | 158 ++++++++---------- 5 files changed, 120 insertions(+), 95 deletions(-) diff --git a/src-core/src/models.rs b/src-core/src/models.rs index e2f51c5..6dbe97c 100644 --- a/src-core/src/models.rs +++ b/src-core/src/models.rs @@ -237,15 +237,15 @@ pub struct ActivityDetails { pub account_id: String, pub asset_id: String, pub activity_type: String, - pub date: chrono::NaiveDateTime, + pub date: String, pub quantity: f64, pub unit_price: f64, pub currency: String, pub fee: f64, pub is_draft: bool, pub comment: Option, - pub created_at: chrono::NaiveDateTime, - pub updated_at: chrono::NaiveDateTime, + pub created_at: String, + pub updated_at: String, pub account_name: String, pub account_currency: String, pub asset_symbol: String, diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 263a08a..7212ac6 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -35,7 +35,7 @@ export const newGoalSchema = z.object({ export const newActivitySchema = z.object({ id: z.string().uuid().optional(), accountId: z.string().min(1, { message: 'Please select an account.' }), - activityDate: z.date(), + activityDate: z.union([z.date(), z.string().datetime()]).optional(), currency: z.string().min(1, { message: 'Currency is required' }), fee: z.coerce .number({ diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 7e7e4cd..4a408db 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -17,6 +17,34 @@ export function formatDate(input: string | number): string { return format(date, 'MMM d, yyyy'); } +export const formatDateTime = (date: string | Date, timezone?: string) => { + if (!date) return { date: '-', time: '-' }; + + const dateOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: timezone || 'UTC', + }; + + const timeOptions: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + timeZone: timezone || 'UTC', + }; + + const dateFormatter = new Intl.DateTimeFormat('en-US', dateOptions); + const timeFormatter = new Intl.DateTimeFormat('en-US', timeOptions); + + const dateObj = new Date(date); + + return { + date: dateFormatter.format(dateObj), + time: timeFormatter.format(dateObj), + }; +}; + export function formatAmount(amount: number, currency: string, displayCurrency = true) { return new Intl.NumberFormat('en-US', { style: displayCurrency ? 'currency' : undefined, diff --git a/src/pages/activity/components/activity-table.tsx b/src/pages/activity/components/activity-table.tsx index c3eba2c..6aac162 100644 --- a/src/pages/activity/components/activity-table.tsx +++ b/src/pages/activity/components/activity-table.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { debounce } from 'lodash'; import { DataTableColumnHeader } from '@/components/ui/data-table/data-table-column-header'; -import { formatDate, formatAmount } from '@/lib/utils'; +import { formatDateTime, formatAmount } from '@/lib/utils'; import { Badge } from '@/components/ui/badge'; import { Account, ActivityDetails, ActivitySearchResponse } from '@/lib/types'; import { ActivityOperations } from './activity-operations'; @@ -65,9 +65,16 @@ export const ActivityTable = ({ accessorKey: 'date', enableHiding: false, header: ({ column }) => , - cell: ({ row }) => ( -
{formatDate(row.getValue('date')) || '-'}
- ), + cell: ({ row }) => { + const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const formattedDate = formatDateTime(row.getValue('date'), userTimezone); + return ( +
+ {formattedDate.date} + {formattedDate.time} +
+ ); + }, }, { id: 'activityType', @@ -109,7 +116,7 @@ export const ActivityTable = ({ symbol = symbol.split('-')[0]; } return ( -
+
{symbol} @@ -149,7 +156,7 @@ export const ActivityTable = ({ cell: ({ row }) => { const unitPrice = row.getValue('unitPrice') as number; const currency = (row.getValue('currency') as string) || 'USD'; - return
{formatAmount(unitPrice, currency)}
; + return
{formatAmount(unitPrice, currency)}
; }, }, { @@ -179,7 +186,7 @@ export const ActivityTable = ({ const currency = (row.getValue('currency') as string) || 'USD'; return ( -
{formatAmount(unitPrice * quantity, currency)}
+
{formatAmount(unitPrice * quantity, currency)}
); }, }, diff --git a/src/pages/activity/import/imported-activity-table.tsx b/src/pages/activity/import/imported-activity-table.tsx index 4c69b62..8a74ec9 100644 --- a/src/pages/activity/import/imported-activity-table.tsx +++ b/src/pages/activity/import/imported-activity-table.tsx @@ -6,12 +6,12 @@ import { Badge } from '@/components/ui/badge'; import { DataTableColumnHeader } from '@/components/ui/data-table/data-table-column-header'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import type { Account, ActivityImport } from '@/lib/types'; -import { formatAmount, toPascalCase } from '@/lib/utils'; +import { formatAmount, formatDateTime, toPascalCase } from '@/lib/utils'; import type { ColumnDef, SortingState } from '@tanstack/react-table'; export const ImportedActivitiesTable = ({ - activities, - }: { + activities, +}: { accounts: Account[]; activities: ActivityImport[]; editModalVisible: boolean; @@ -20,16 +20,16 @@ export const ImportedActivitiesTable = ({ const activitiesType = useMemo(() => { const uniqueTypesSet = new Set(); return activities.reduce( - (result, activity) => { - //@ts-ignore - const type = activity?.activityType; - if (type && !uniqueTypesSet.has(type)) { - uniqueTypesSet.add(type); - result.push({ label: toPascalCase(type), value: type }); - } - return result; - }, - [] as Array<{ label: string; value: string }>, + (result, activity) => { + //@ts-ignore + const type = activity?.activityType; + if (type && !uniqueTypesSet.has(type)) { + uniqueTypesSet.add(type); + result.push({ label: toPascalCase(type), value: type }); + } + return result; + }, + [] as Array<{ label: string; value: string }>, ); }, [activities]); @@ -57,41 +57,26 @@ export const ImportedActivitiesTable = ({ ]; return ( -
- -
+
+ +
); }; export default ImportedActivitiesTable; -// Update formatDate function to accept a timezone argument -export const formatDate = (date: string | Date, timezone?: string) => { - if (!date) return '-'; - const options: Intl.DateTimeFormatOptions = { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - timeZone: timezone || 'UTC', // Default to UTC if timezone is not provided - }; - return new Intl.DateTimeFormat('en-US', options).format(new Date(date)); -}; - export const columns: ColumnDef[] = [ { id: 'isValid', @@ -103,23 +88,23 @@ export const columns: ColumnDef[] = [ const lineNumber = row.getValue('lineNumber') as number; return ( -
- {isValid === 'true' ? ( - - ) : ( - - - - - - -

{error}

-
-
-
- )} -
{lineNumber}
-
+
+ {isValid === 'true' ? ( + + ) : ( + + + + + + +

{error}

+
+
+
+ )} +
{lineNumber}
+
); }, filterFn: (row, id, value: string) => { @@ -137,8 +122,14 @@ export const columns: ColumnDef[] = [ accessorKey: 'date', header: ({ column }) => , cell: ({ row }) => { - const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; // Get user timezone - return
{formatDate(row.getValue('date'), userTimezone) || '-'}
; + const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const formattedDate = formatDateTime(row.getValue('date'), userTimezone); + return ( +
+ {formattedDate.date} + {formattedDate.time} +
+ ); }, }, { @@ -149,9 +140,9 @@ export const columns: ColumnDef[] = [ const type = row.getValue('activityType') as string; const badgeVariant = type === 'BUY' ? 'success' : 'error'; return ( -
- {type} -
+
+ {type} +
); }, filterFn: (row, id, value: string) => { @@ -180,12 +171,12 @@ export const columns: ColumnDef[] = [ header: ({ column }) => , cell: ({ row }) => { return ( -
- - {row.getValue('symbol')} - - {row.getValue('symbolName')} -
+
+ + {row.getValue('symbol')} + + {row.getValue('symbolName')} +
); }, sortingFn: (rowA, rowB, id) => { @@ -201,7 +192,7 @@ export const columns: ColumnDef[] = [ accessorKey: 'quantity', enableHiding: false, header: ({ column }) => ( - + ), cell: ({ row }) =>
{row.getValue('quantity')}
, }, @@ -211,11 +202,11 @@ export const columns: ColumnDef[] = [ enableHiding: false, enableSorting: false, header: ({ column }) => ( - + ), cell: ({ row }) => { const unitPrice = row.getValue('unitPrice') as number; @@ -229,7 +220,7 @@ export const columns: ColumnDef[] = [ enableHiding: false, enableSorting: false, header: ({ column }) => ( - + ), cell: ({ row }) => { const fee = row.getValue('fee') as number; @@ -241,7 +232,7 @@ export const columns: ColumnDef[] = [ id: 'value', accessorKey: 'value', header: ({ column }) => ( - + ), cell: ({ row }) => { const unitPrice = row.getValue('unitPrice') as number; @@ -263,10 +254,10 @@ export const columns: ColumnDef[] = [ header: ({ column }) => , cell: ({ row }) => { return ( -
- {row.getValue('accountName')} - {row.getValue('currency')} -
+
+ {row.getValue('accountName')} + {row.getValue('currency')} +
); }, }, @@ -277,4 +268,3 @@ export const columns: ColumnDef[] = [ }, }, ]; - From a60b1cec3588ccc7bf32d2c08d06de934a5c38ba Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 15 Sep 2024 21:34:24 -0400 Subject: [PATCH 03/45] Refactor currency exchange --- src-core/src/fx/fx_service.rs | 121 ++++++++++++++++++++ src-core/src/fx/mod.rs | 1 + src-core/src/lib.rs | 1 + src-core/src/portfolio/portfolio_service.rs | 68 +++++------ 4 files changed, 153 insertions(+), 38 deletions(-) create mode 100644 src-core/src/fx/fx_service.rs create mode 100644 src-core/src/fx/mod.rs diff --git a/src-core/src/fx/fx_service.rs b/src-core/src/fx/fx_service.rs new file mode 100644 index 0000000..b47b437 --- /dev/null +++ b/src-core/src/fx/fx_service.rs @@ -0,0 +1,121 @@ +use crate::models::Quote; +use crate::schema::quotes; +use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::SqliteConnection; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +pub struct CurrencyExchangeService { + exchange_rates: Arc>>, + pool: Pool>, + is_loading: Arc, +} + +impl CurrencyExchangeService { + pub fn new(pool: Pool>) -> Self { + Self { + exchange_rates: Arc::new(Mutex::new(HashMap::new())), + pool, + is_loading: Arc::new(AtomicBool::new(false)), + } + } + + fn load_exchange_rates( + &self, + from_currency: &str, + to_currency: &str, + ) -> Result> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + let direct_symbol = format!("{}{}=X", from_currency, to_currency); + let inverse_symbol = format!("{}{}=X", to_currency, from_currency); + + let latest_quote: Option = quotes::table + .filter( + quotes::symbol + .eq(&direct_symbol) + .or(quotes::symbol.eq(&inverse_symbol)), + ) + .order(quotes::date.desc()) + .first(&mut *conn) + .optional()?; + + if let Some(quote) = latest_quote { + let rate = if quote.symbol == direct_symbol { + quote.close + } else { + 1.0 / quote.close + }; + + let mut exchange_rates = self + .exchange_rates + .lock() + .map_err(|_| "Failed to acquire lock")?; + exchange_rates.insert(direct_symbol.clone(), rate); + exchange_rates.insert(inverse_symbol, 1.0 / rate); + + Ok(rate) + } else { + Err(format!( + "No exchange rate found for {} to {}", + from_currency, to_currency + ) + .into()) + } + } + + pub fn convert_currency( + &self, + amount: f64, + from_currency: &str, + to_currency: &str, + ) -> Result> { + if from_currency == to_currency { + return Ok(amount); + } + + let rate = self.load_exchange_rates(from_currency, to_currency)?; + Ok(amount * rate) + } + + pub fn get_exchange_rate( + &self, + from_currency: &str, + to_currency: &str, + ) -> Result> { + if from_currency == to_currency { + return Ok(1.0); + } + + let direct_key = format!("{}{}=X", from_currency, to_currency); + let inverse_key = format!("{}{}=X", to_currency, from_currency); + + { + let exchange_rates = self + .exchange_rates + .lock() + .map_err(|_| "Failed to acquire lock")?; + if let Some(&rate) = exchange_rates.get(&direct_key) { + return Ok(rate); + } else if let Some(&rate) = exchange_rates.get(&inverse_key) { + return Ok(1.0 / rate); + } + } + + // Use atomic flag to prevent multiple threads from loading the same rate simultaneously + if self + .is_loading + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_ok() + { + let result = self.load_exchange_rates(from_currency, to_currency); + self.is_loading.store(false, Ordering::Release); + result + } else { + // Another thread is loading, wait and retry + std::thread::yield_now(); + self.get_exchange_rate(from_currency, to_currency) + } + } +} diff --git a/src-core/src/fx/mod.rs b/src-core/src/fx/mod.rs new file mode 100644 index 0000000..c036ee6 --- /dev/null +++ b/src-core/src/fx/mod.rs @@ -0,0 +1 @@ +pub mod fx_service; diff --git a/src-core/src/lib.rs b/src-core/src/lib.rs index 664ba18..f01c0c2 100644 --- a/src-core/src/lib.rs +++ b/src-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod db; pub mod account; pub mod activity; pub mod asset; +pub mod fx; pub mod goal; pub mod market_data; pub mod models; diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 667de3a..47a69e3 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -1,6 +1,7 @@ use crate::account::account_service::AccountService; use crate::activity::activity_service::ActivityService; use crate::asset::asset_service::AssetService; +use crate::fx::fx_service::CurrencyExchangeService; use crate::models::{ Account, Activity, FinancialHistory, FinancialSnapshot, Holding, IncomeData, IncomeSummary, Performance, Quote, @@ -19,8 +20,8 @@ pub struct PortfolioService { account_service: AccountService, activity_service: ActivityService, asset_service: AssetService, + fx_service: CurrencyExchangeService, base_currency: String, - exchange_rates: HashMap, pool: Pool>, } @@ -38,8 +39,8 @@ impl PortfolioService { account_service: AccountService::new(pool.clone()), activity_service: ActivityService::new(pool.clone()), asset_service: AssetService::new(pool.clone()), + fx_service: CurrencyExchangeService::new(pool.clone()), base_currency: String::new(), - exchange_rates: HashMap::new(), pool: pool, }; service.initialize()?; @@ -51,33 +52,9 @@ impl PortfolioService { let settings_service = SettingsService::new(); let settings = settings_service.get_settings(&mut conn)?; self.base_currency.clone_from(&settings.base_currency); - self.exchange_rates = self - .asset_service - .load_exchange_rates(&settings.base_currency)?; Ok(()) } - fn convert_to_base_currency(&self, amount: f64, currency: &str) -> f64 { - if currency == self.base_currency { - amount - } else { - let rate = self.get_exchange_rate(currency); - amount * rate - } - } - - fn get_exchange_rate(&self, currency: &str) -> f64 { - if currency == self.base_currency { - 1.0 - } else { - let currency_key = format!("{}{}=X", self.base_currency, currency); - 1.0 / *self - .exchange_rates - .get(¤cy_key.to_string()) - .unwrap_or(&1.0) - } - } - pub fn compute_holdings(&self) -> Result, Box> { let mut holdings: HashMap = HashMap::new(); let accounts = self.account_service.get_accounts()?; @@ -141,9 +118,6 @@ impl PortfolioService { holding.quantity -= activity.quantity; holding.book_value -= activity.quantity * activity.unit_price + activity.fee; } - "SPLIT" => { - // TODO:: Handle the split logic here - } _ => {} } } @@ -178,10 +152,16 @@ impl PortfolioService { } holding.average_cost = Some(holding.book_value / holding.quantity); holding.market_value = holding.quantity * holding.market_price.unwrap_or(0.0); - holding.market_value_converted = - self.convert_to_base_currency(holding.market_value, &holding.currency); - holding.book_value_converted = - self.convert_to_base_currency(holding.book_value, &holding.currency); + holding.market_value_converted = self.fx_service.convert_currency( + holding.market_value, + &holding.currency, + &self.base_currency, + )?; + holding.book_value_converted = self.fx_service.convert_currency( + holding.book_value, + &holding.currency, + &self.base_currency, + )?; // Calculate performance metrics holding.performance.total_gain_amount = holding.market_value - holding.book_value; @@ -190,8 +170,11 @@ impl PortfolioService { } else { 0.0 }; - holding.performance.total_gain_amount_converted = self - .convert_to_base_currency(holding.performance.total_gain_amount, &holding.currency); + holding.performance.total_gain_amount_converted = self.fx_service.convert_currency( + holding.performance.total_gain_amount, + &holding.currency, + &self.base_currency, + )?; } holdings @@ -320,7 +303,10 @@ impl PortfolioService { exchange_rate: Some(1.0), // Default exchange rate for base currency }); - let exchange_rate = snapshot.exchange_rate.unwrap_or(1.0); + let exchange_rate = self + .fx_service + .get_exchange_rate(&snapshot.currency, &self.base_currency) + .unwrap_or(1.0); // Convert values to base currency before aggregating entry.total_value += snapshot.total_value * exchange_rate; @@ -463,7 +449,10 @@ impl PortfolioService { 0.0 }; - let exchange_rate = self.get_exchange_rate(currency); + let exchange_rate = self + .fx_service + .get_exchange_rate(currency, &self.base_currency) + .unwrap_or(1.0); results.push(FinancialSnapshot { date: date.format("%Y-%m-%d").to_string(), @@ -527,7 +516,10 @@ impl PortfolioService { for data in income_data { let month = data.date.format("%Y-%m").to_string(); - let converted_amount = self.convert_to_base_currency(data.amount, &data.currency); + let converted_amount = self + .fx_service + .convert_currency(data.amount, &data.currency, &self.base_currency) + .unwrap_or(data.amount); *by_month.entry(month).or_insert(0.0) += converted_amount; *by_type.entry(data.income_type).or_insert(0.0) += converted_amount; From 0101e1f18d97ab77114d71cd9aa9a9e86d574c0f Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 15 Sep 2024 21:42:37 -0400 Subject: [PATCH 04/45] refactor to extract income service --- src-core/src/portfolio/income_service.rs | 92 +++++++++++++++++++++ src-core/src/portfolio/mod.rs | 1 + src-core/src/portfolio/portfolio_service.rs | 77 ++++------------- 3 files changed, 109 insertions(+), 61 deletions(-) create mode 100644 src-core/src/portfolio/income_service.rs diff --git a/src-core/src/portfolio/income_service.rs b/src-core/src/portfolio/income_service.rs new file mode 100644 index 0000000..5255b9b --- /dev/null +++ b/src-core/src/portfolio/income_service.rs @@ -0,0 +1,92 @@ +use crate::fx::fx_service::CurrencyExchangeService; +use crate::models::{IncomeData, IncomeSummary}; +use chrono::{Datelike, NaiveDateTime}; +use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::SqliteConnection; +use std::collections::HashMap; + +pub struct IncomeService { + fx_service: CurrencyExchangeService, + base_currency: String, + pool: Pool>, +} + +impl IncomeService { + pub fn new( + pool: Pool>, + fx_service: CurrencyExchangeService, + base_currency: String, + ) -> Self { + IncomeService { + fx_service, + base_currency, + pool, + } + } + + pub fn get_income_data(&self) -> Result, diesel::result::Error> { + use crate::schema::activities; + let mut conn = self.pool.get().expect("Couldn't get db connection"); + activities::table + .filter(activities::activity_type.eq_any(vec!["DIVIDEND", "INTEREST"])) + .select(( + activities::activity_date, + activities::activity_type, + activities::asset_id, + activities::quantity * activities::unit_price, + activities::currency, + )) + .load::<(NaiveDateTime, String, String, f64, String)>(&mut conn) + .map(|results| { + results + .into_iter() + .map(|(date, income_type, symbol, amount, currency)| IncomeData { + date, + income_type, + symbol, + amount, + currency, + }) + .collect() + }) + } + + pub fn get_income_summary(&self) -> Result { + let income_data = self.get_income_data()?; + + let mut by_month: HashMap = HashMap::new(); + let mut by_type: HashMap = HashMap::new(); + let mut by_symbol: HashMap = HashMap::new(); + let mut total_income = 0.0; + let mut total_income_ytd = 0.0; + + let current_year = chrono::Local::now().year(); + + for data in income_data { + let month = data.date.format("%Y-%m").to_string(); + let converted_amount = self + .fx_service + .convert_currency(data.amount, &data.currency, &self.base_currency) + .unwrap_or(data.amount); + + *by_month.entry(month).or_insert(0.0) += converted_amount; + *by_type.entry(data.income_type).or_insert(0.0) += converted_amount; + *by_symbol.entry(data.symbol).or_insert(0.0) += converted_amount; + total_income += converted_amount; + + if data.date.year() == current_year { + total_income_ytd += converted_amount; + } + } + + Ok(IncomeSummary { + by_month, + by_type, + by_symbol, + total_income, + total_income_ytd, + currency: self.base_currency.clone(), + }) + } +} diff --git a/src-core/src/portfolio/mod.rs b/src-core/src/portfolio/mod.rs index 76d5b6d..bc4d5c9 100644 --- a/src-core/src/portfolio/mod.rs +++ b/src-core/src/portfolio/mod.rs @@ -1 +1,2 @@ pub mod portfolio_service; +pub mod income_service; diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 47a69e3..89a07e5 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -16,6 +16,8 @@ use chrono::{Duration, NaiveDate, Utc}; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; +use crate::portfolio::income_service::IncomeService; + pub struct PortfolioService { account_service: AccountService, activity_service: ActivityService, @@ -23,6 +25,7 @@ pub struct PortfolioService { fx_service: CurrencyExchangeService, base_currency: String, pool: Pool>, + income_service: IncomeService, } /// This module contains the implementation of the `PortfolioService` struct. @@ -41,7 +44,12 @@ impl PortfolioService { asset_service: AssetService::new(pool.clone()), fx_service: CurrencyExchangeService::new(pool.clone()), base_currency: String::new(), - pool: pool, + pool: pool.clone(), + income_service: IncomeService::new( + pool.clone(), + CurrencyExchangeService::new(pool.clone()), + String::new(), + ), }; service.initialize()?; Ok(service) @@ -52,6 +60,11 @@ impl PortfolioService { let settings_service = SettingsService::new(); let settings = settings_service.get_settings(&mut conn)?; self.base_currency.clone_from(&settings.base_currency); + self.income_service = IncomeService::new( + self.pool.clone(), + CurrencyExchangeService::new(self.pool.clone()), + self.base_currency.clone(), + ); Ok(()) } @@ -476,68 +489,10 @@ impl PortfolioService { } pub fn get_income_data(&self) -> Result, diesel::result::Error> { - use crate::schema::activities; - use diesel::prelude::*; - let mut conn = self.pool.get().expect("Couldn't get db connection"); - activities::table - .filter(activities::activity_type.eq_any(vec!["DIVIDEND", "INTEREST"])) - .select(( - activities::activity_date, - activities::activity_type, - activities::asset_id, - activities::quantity * activities::unit_price, - activities::currency, - )) - .load::<(NaiveDateTime, String, String, f64, String)>(&mut conn) - .map(|results| { - results - .into_iter() - .map(|(date, income_type, symbol, amount, currency)| IncomeData { - date, - income_type, - symbol, - amount, - currency, - }) - .collect() - }) + self.income_service.get_income_data() } pub fn get_income_summary(&self) -> Result { - let income_data = self.get_income_data()?; - - let mut by_month: HashMap = HashMap::new(); - let mut by_type: HashMap = HashMap::new(); - let mut by_symbol: HashMap = HashMap::new(); - let mut total_income = 0.0; - let mut total_income_ytd = 0.0; - - let current_year = chrono::Local::now().year(); - - for data in income_data { - let month = data.date.format("%Y-%m").to_string(); - let converted_amount = self - .fx_service - .convert_currency(data.amount, &data.currency, &self.base_currency) - .unwrap_or(data.amount); - - *by_month.entry(month).or_insert(0.0) += converted_amount; - *by_type.entry(data.income_type).or_insert(0.0) += converted_amount; - *by_symbol.entry(data.symbol).or_insert(0.0) += converted_amount; - total_income += converted_amount; - - if data.date.year() == current_year { - total_income_ytd += converted_amount; - } - } - - Ok(IncomeSummary { - by_month, - by_type, - by_symbol, - total_income, - total_income_ytd, - currency: self.base_currency.clone(), - }) + self.income_service.get_income_summary() } } From 54fea3a1fc2e6f586167ba24815fc0c65155498e Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 15 Sep 2024 21:51:00 -0400 Subject: [PATCH 05/45] Extract holdings calculation to new service --- src-core/src/portfolio/holdings_service.rs | 159 ++++++++++++++++++++ src-core/src/portfolio/mod.rs | 1 + src-core/src/portfolio/portfolio_service.rs | 131 +--------------- 3 files changed, 165 insertions(+), 126 deletions(-) create mode 100644 src-core/src/portfolio/holdings_service.rs diff --git a/src-core/src/portfolio/holdings_service.rs b/src-core/src/portfolio/holdings_service.rs new file mode 100644 index 0000000..a7c0e0f --- /dev/null +++ b/src-core/src/portfolio/holdings_service.rs @@ -0,0 +1,159 @@ +use crate::account::account_service::AccountService; +use crate::activity::activity_service::ActivityService; +use crate::asset::asset_service::AssetService; +use crate::fx::fx_service::CurrencyExchangeService; +use crate::models::{Holding, Performance}; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::SqliteConnection; +use std::collections::{HashMap, HashSet}; + +pub struct HoldingsService { + account_service: AccountService, + activity_service: ActivityService, + asset_service: AssetService, + fx_service: CurrencyExchangeService, + base_currency: String, + pool: Pool>, +} + +impl HoldingsService { + pub fn new(pool: Pool>, base_currency: String) -> Self { + HoldingsService { + account_service: AccountService::new(pool.clone()), + activity_service: ActivityService::new(pool.clone()), + asset_service: AssetService::new(pool.clone()), + fx_service: CurrencyExchangeService::new(pool.clone()), + base_currency, + pool, + } + } + + pub fn compute_holdings(&self) -> Result, Box> { + let mut holdings: HashMap = HashMap::new(); + let accounts = self.account_service.get_accounts()?; + let activities = self.activity_service.get_trading_activities()?; + let assets = self.asset_service.get_assets()?; + + for activity in activities { + //find asset by id + let asset = match assets.iter().find(|a| a.id == activity.asset_id) { + Some(found_asset) => found_asset, + None => { + println!("Asset not found for id: {}", activity.asset_id); + continue; // Skip this iteration if the asset is not found + } + }; + + //find account by id + let account = accounts + .iter() + .find(|a| a.id == activity.account_id) + .unwrap(); + + let key = format!("{}-{}", activity.account_id, activity.asset_id); + let holding = holdings.entry(key.clone()).or_insert_with(|| Holding { + id: key, + symbol: activity.asset_id.clone(), + symbol_name: asset.name.clone(), + holding_type: asset.asset_type.clone().unwrap_or_default(), + quantity: 0.0, + currency: activity.currency.clone(), + base_currency: "CAD".to_string(), + market_price: None, // You need to provide market price + average_cost: None, // Will be calculated + market_value: 0.0, // Will be calculated + book_value: 0.0, // Will be calculated + market_value_converted: 0.0, // Will be calculated + book_value_converted: 0.0, // Will be calculated + performance: Performance { + total_gain_percent: 0.0, + total_gain_amount: 0.0, + total_gain_amount_converted: 0.0, + day_gain_percent: Some(0.0), + day_gain_amount: Some(0.0), + day_gain_amount_converted: Some(0.0), + }, + account: Some(account.clone()), + asset_class: asset.asset_class.clone(), + asset_sub_class: asset.asset_sub_class.clone(), + sectors: asset + .sectors + .clone() + .map(|s| serde_json::from_str(&s).unwrap_or_default()), + }); + + match activity.activity_type.as_str() { + "BUY" => { + holding.quantity += activity.quantity; + holding.book_value += activity.quantity * activity.unit_price + activity.fee; + } + "SELL" => { + holding.quantity -= activity.quantity; + holding.book_value -= activity.quantity * activity.unit_price + activity.fee; + } + _ => {} + } + } + + // Collect all unique symbols from holdings + let unique_symbols: HashSet = holdings + .values() + .map(|holding| holding.symbol.clone()) + .collect(); + + let symbols: Vec = unique_symbols.into_iter().collect(); + + // Fetch quotes for each symbol asynchronously + let mut quotes = HashMap::new(); + for symbol in symbols { + match self.asset_service.get_latest_quote(&symbol) { + Ok(quote) => { + quotes.insert(symbol, quote); + } + Err(e) => { + println!("Error fetching quote for symbol {}: {}", symbol, e); + // Handle the error as per your logic, e.g., continue, return an error, etc. + } + } + } + + // Post-processing for each holding + for holding in holdings.values_mut() { + if let Some(quote) = quotes.get(&holding.symbol) { + //prinln!("Quote: {:?}", quote); + holding.market_price = Some(quote.close); // Assuming you want to use the 'close' value as market price + } + holding.average_cost = Some(holding.book_value / holding.quantity); + holding.market_value = holding.quantity * holding.market_price.unwrap_or(0.0); + holding.market_value_converted = self.fx_service.convert_currency( + holding.market_value, + &holding.currency, + &self.base_currency, + )?; + holding.book_value_converted = self.fx_service.convert_currency( + holding.book_value, + &holding.currency, + &self.base_currency, + )?; + + // Calculate performance metrics + holding.performance.total_gain_amount = holding.market_value - holding.book_value; + holding.performance.total_gain_percent = if holding.book_value != 0.0 { + holding.performance.total_gain_amount / holding.book_value * 100.0 + } else { + 0.0 + }; + holding.performance.total_gain_amount_converted = self.fx_service.convert_currency( + holding.performance.total_gain_amount, + &holding.currency, + &self.base_currency, + )?; + } + + holdings + .into_values() + .filter(|holding| holding.quantity > 0.0) + .map(Ok) + .collect::, _>>() + } +} diff --git a/src-core/src/portfolio/mod.rs b/src-core/src/portfolio/mod.rs index bc4d5c9..0a48746 100644 --- a/src-core/src/portfolio/mod.rs +++ b/src-core/src/portfolio/mod.rs @@ -1,2 +1,3 @@ pub mod portfolio_service; pub mod income_service; +pub mod holdings_service; diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 89a07e5..f1e53b7 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -16,6 +16,7 @@ use chrono::{Duration, NaiveDate, Utc}; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; +use crate::portfolio::holdings_service::HoldingsService; use crate::portfolio::income_service::IncomeService; pub struct PortfolioService { @@ -26,6 +27,7 @@ pub struct PortfolioService { base_currency: String, pool: Pool>, income_service: IncomeService, + holdings_service: HoldingsService, } /// This module contains the implementation of the `PortfolioService` struct. @@ -50,6 +52,7 @@ impl PortfolioService { CurrencyExchangeService::new(pool.clone()), String::new(), ), + holdings_service: HoldingsService::new(pool.clone(), String::new()), }; service.initialize()?; Ok(service) @@ -65,136 +68,12 @@ impl PortfolioService { CurrencyExchangeService::new(self.pool.clone()), self.base_currency.clone(), ); + self.holdings_service = HoldingsService::new(self.pool.clone(), self.base_currency.clone()); Ok(()) } pub fn compute_holdings(&self) -> Result, Box> { - let mut holdings: HashMap = HashMap::new(); - let accounts = self.account_service.get_accounts()?; - let activities = self.activity_service.get_trading_activities()?; - let assets = self.asset_service.get_assets()?; - - for activity in activities { - //find asset by id - let asset = match assets.iter().find(|a| a.id == activity.asset_id) { - Some(found_asset) => found_asset, - None => { - println!("Asset not found for id: {}", activity.asset_id); - continue; // Skip this iteration if the asset is not found - } - }; - - //find account by id - let account = accounts - .iter() - .find(|a| a.id == activity.account_id) - .unwrap(); - - let key = format!("{}-{}", activity.account_id, activity.asset_id); - let holding = holdings.entry(key.clone()).or_insert_with(|| Holding { - id: key, - symbol: activity.asset_id.clone(), - symbol_name: asset.name.clone(), - holding_type: asset.asset_type.clone().unwrap_or_default(), - quantity: 0.0, - currency: activity.currency.clone(), - base_currency: "CAD".to_string(), - market_price: None, // You need to provide market price - average_cost: None, // Will be calculated - market_value: 0.0, // Will be calculated - book_value: 0.0, // Will be calculated - market_value_converted: 0.0, // Will be calculated - book_value_converted: 0.0, // Will be calculated - performance: Performance { - total_gain_percent: 0.0, - total_gain_amount: 0.0, - total_gain_amount_converted: 0.0, - day_gain_percent: Some(0.0), - day_gain_amount: Some(0.0), - day_gain_amount_converted: Some(0.0), - }, - account: Some(account.clone()), - asset_class: asset.asset_class.clone(), - asset_sub_class: asset.asset_sub_class.clone(), - sectors: asset - .sectors - .clone() - .map(|s| serde_json::from_str(&s).unwrap_or_default()), - }); - - match activity.activity_type.as_str() { - "BUY" => { - holding.quantity += activity.quantity; - holding.book_value += activity.quantity * activity.unit_price + activity.fee; - } - "SELL" => { - holding.quantity -= activity.quantity; - holding.book_value -= activity.quantity * activity.unit_price + activity.fee; - } - _ => {} - } - } - - // Collect all unique symbols from holdings - let unique_symbols: HashSet = holdings - .values() - .map(|holding| holding.symbol.clone()) - .collect(); - - let symbols: Vec = unique_symbols.into_iter().collect(); - - // Fetch quotes for each symbol asynchronously - let mut quotes = HashMap::new(); - for symbol in symbols { - match self.asset_service.get_latest_quote(&symbol) { - Ok(quote) => { - quotes.insert(symbol, quote); - } - Err(e) => { - println!("Error fetching quote for symbol {}: {}", symbol, e); - // Handle the error as per your logic, e.g., continue, return an error, etc. - } - } - } - - // Post-processing for each holding - for holding in holdings.values_mut() { - if let Some(quote) = quotes.get(&holding.symbol) { - //prinln!("Quote: {:?}", quote); - holding.market_price = Some(quote.close); // Assuming you want to use the 'close' value as market price - } - holding.average_cost = Some(holding.book_value / holding.quantity); - holding.market_value = holding.quantity * holding.market_price.unwrap_or(0.0); - holding.market_value_converted = self.fx_service.convert_currency( - holding.market_value, - &holding.currency, - &self.base_currency, - )?; - holding.book_value_converted = self.fx_service.convert_currency( - holding.book_value, - &holding.currency, - &self.base_currency, - )?; - - // Calculate performance metrics - holding.performance.total_gain_amount = holding.market_value - holding.book_value; - holding.performance.total_gain_percent = if holding.book_value != 0.0 { - holding.performance.total_gain_amount / holding.book_value * 100.0 - } else { - 0.0 - }; - holding.performance.total_gain_amount_converted = self.fx_service.convert_currency( - holding.performance.total_gain_amount, - &holding.currency, - &self.base_currency, - )?; - } - - holdings - .into_values() - .filter(|holding| holding.quantity > 0.0) - .map(Ok) - .collect::, _>>() + self.holdings_service.compute_holdings() } fn get_dates_between(start: NaiveDate, end: NaiveDate) -> Vec { From feafc92e90773353792947933a44bcd0df90c599 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 15 Sep 2024 21:55:33 -0400 Subject: [PATCH 06/45] Extract History calculation to new service --- src-core/src/portfolio/history_service.rs | 289 ++++++++++++++++++++ src-core/src/portfolio/mod.rs | 9 +- src-core/src/portfolio/portfolio_service.rs | 282 ++----------------- 3 files changed, 311 insertions(+), 269 deletions(-) create mode 100644 src-core/src/portfolio/history_service.rs diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs new file mode 100644 index 0000000..138640e --- /dev/null +++ b/src-core/src/portfolio/history_service.rs @@ -0,0 +1,289 @@ +use crate::fx::fx_service::CurrencyExchangeService; +use crate::models::{Account, Activity, FinancialHistory, FinancialSnapshot, Quote}; +use chrono::{Duration, NaiveDate, Utc}; +use rayon::prelude::*; +use std::collections::HashMap; + +pub struct HistoryService { + fx_service: CurrencyExchangeService, + base_currency: String, +} + +impl HistoryService { + pub fn new(fx_service: CurrencyExchangeService, base_currency: String) -> Self { + Self { + fx_service, + base_currency, + } + } + + pub fn calculate_historical_portfolio_values( + &self, + accounts: &[Account], + activities: &[Activity], + market_data: &[Quote], + ) -> Vec { + // Use Rayon's par_iter to process each account in parallel + let results: Vec = accounts + .par_iter() + .filter_map(|account| { + let account_activities: Vec<_> = activities + .iter() + .filter(|a| a.account_id == account.id) + .cloned() + .collect(); + + if account_activities.is_empty() { + None + } else { + let history = self.calculate_historical_value(&account_activities, market_data); + Some(FinancialHistory { + account: account.clone(), + history, + }) + } + }) + .collect(); + + // Calculate the total value of the portfolio + let portfolio_total_value = results + .iter() + .map(|fh| fh.history.last().map_or(0.0, |s| s.total_value)) + .sum::(); + + // Calculate the percentage of each account + let mut results_with_percentage = results + .into_iter() + .map(|mut fh| { + let account_total: f64 = fh.history.last().map_or(0.0, |s| s.total_value); + let percentage = account_total / portfolio_total_value * 100.0; + if let Some(last_snapshot) = fh.history.last_mut() { + last_snapshot.allocation_percentage = Some(percentage); + } + fh + }) + .collect::>(); + + // Aggregate historical data from all accounts + let mut aggregated_history: HashMap = HashMap::new(); + for financial_history in &results_with_percentage { + self.aggregate_account_history(&mut aggregated_history, &financial_history.history); + } + + let mut total_history: Vec<_> = aggregated_history.into_values().collect(); + total_history.sort_by(|a, b| a.date.cmp(&b.date)); + + let total_account = self.create_total_account(); + results_with_percentage.push(FinancialHistory { + account: total_account, + history: total_history, + }); + + results_with_percentage + } + + fn aggregate_account_history( + &self, + aggregated_history: &mut HashMap, + history: &[FinancialSnapshot], + ) { + for snapshot in history { + let entry = aggregated_history + .entry(snapshot.date.clone()) + .or_insert_with(|| FinancialSnapshot { + date: snapshot.date.clone(), + total_value: 0.0, + market_value: 0.0, + book_cost: 0.0, + available_cash: 0.0, + net_deposit: 0.0, + currency: snapshot.currency.to_string(), + base_currency: self.base_currency.to_string(), + total_gain_value: 0.0, + total_gain_percentage: 0.0, + day_gain_percentage: 0.0, + day_gain_value: 0.0, + allocation_percentage: None, + exchange_rate: Some(1.0), // Default exchange rate for base currency + }); + + let exchange_rate = self + .fx_service + .get_exchange_rate(&snapshot.currency, &self.base_currency) + .unwrap_or(1.0); + + // Convert values to base currency before aggregating + entry.total_value += snapshot.total_value * exchange_rate; + entry.market_value += snapshot.market_value * exchange_rate; + entry.book_cost += snapshot.book_cost * exchange_rate; + entry.available_cash += snapshot.available_cash * exchange_rate; + entry.net_deposit += snapshot.net_deposit * exchange_rate; + entry.total_gain_value += snapshot.total_gain_value * exchange_rate; + + // Recalculate percentage values based on aggregated totals + entry.total_gain_percentage = if entry.book_cost != 0.0 { + entry.total_gain_value / entry.book_cost * 100.0 + } else { + 0.0 + }; + + // Assuming day gain values are already in base currency or need similar conversion + entry.day_gain_percentage += snapshot.day_gain_percentage; + entry.day_gain_value += snapshot.day_gain_value * exchange_rate; + } + } + + fn create_total_account(&self) -> Account { + Account { + id: "TOTAL".to_string(), + name: "Total".to_string(), + account_type: "TOTAL".to_string(), + group: Some("TOTAL".to_string()), + is_default: true, + is_active: true, + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), + platform_id: None, + currency: self.base_currency.to_string(), + } + } + + fn calculate_historical_value( + &self, + activities: &[Activity], + quotes: &[Quote], + ) -> Vec { + let first_activity = activities[0].clone(); + + let start_date = first_activity.activity_date.date(); + let end_date = Utc::now().naive_utc().date(); + let all_dates = Self::get_dates_between(start_date, end_date); + + let mut currency = self.base_currency.as_str(); + let mut cumulative_cash = 0.0; + let mut holdings: HashMap = HashMap::new(); + + let mut results = Vec::new(); + let mut _initial_investment = 0.0; + let mut net_deposit = 0.0; + let mut book_cost = 0.0; + + // HashMap to keep the last available quote for each symbol + let mut last_available_quotes: HashMap = HashMap::new(); + + for date in all_dates { + for activity in activities.iter().filter(|a| a.activity_date.date() == date) { + currency = activity.currency.as_str(); + let activity_amount = activity.quantity; + let activity_fee = activity.fee; + + match activity.activity_type.as_str() { + "BUY" => { + let entry = holdings.entry(activity.asset_id.clone()).or_insert(0.0); + *entry += activity_amount; + let buy_cost = activity_amount * activity.unit_price + activity_fee; + cumulative_cash -= buy_cost; + _initial_investment += activity_amount * activity.unit_price; + book_cost += buy_cost; + } + "SELL" => { + let entry = holdings.entry(activity.asset_id.clone()).or_insert(0.0); + *entry -= activity_amount; + let sell_profit = activity_amount * activity.unit_price - activity_fee; + cumulative_cash += sell_profit; + _initial_investment -= activity_amount * activity.unit_price; + book_cost -= activity_amount * activity.unit_price + activity_fee; + } + "DEPOSIT" | "TRANSFER_IN" | "CONVERSION_IN" => { + cumulative_cash += activity_amount * activity.unit_price - activity_fee; + net_deposit += activity_amount * activity.unit_price; + } + "DIVIDEND" | "INTEREST" => { + cumulative_cash += activity_amount * activity.unit_price - activity_fee; + } + "WITHDRAWAL" | "TRANSFER_OUT" | "CONVERSION_OUT" => { + cumulative_cash -= activity_amount * activity.unit_price + activity_fee; + net_deposit -= activity_amount * activity.unit_price; + } + "FEE" | "TAX" => { + cumulative_cash -= activity_fee; + } + _ => {} + } + } + + let mut holdings_value = 0.0; + let mut day_gain_value = 0.0; + + for (symbol, &holding_amount) in &holdings { + let quote = quotes + .iter() + .find(|q| q.date.date() == date && q.symbol == *symbol) + .or_else(|| last_available_quotes.get(symbol).cloned()); + + if let Some(quote) = quote { + let holding_value_for_symbol = holding_amount * quote.close; + let daily_change_percent = ((quote.close - quote.open) / quote.open) * 100.0; + let day_gain_for_symbol = + (daily_change_percent / 100.0) * holding_value_for_symbol; + + holdings_value += holding_value_for_symbol; + day_gain_value += day_gain_for_symbol; + + // Update the last available quote for the symbol + last_available_quotes.insert(symbol.clone(), quote); + } + } + + let day_gain_percentage = if holdings_value != 0.0 { + (day_gain_value / holdings_value) * 100.0 + } else { + 0.0 + }; + + let total_value = cumulative_cash + holdings_value; + let total_gain_value = holdings_value - book_cost; + let total_gain_percentage = if book_cost != 0.0 { + (total_gain_value / book_cost) * 100.0 + } else { + 0.0 + }; + + let exchange_rate = self + .fx_service + .get_exchange_rate(currency, &self.base_currency) + .unwrap_or(1.0); + + results.push(FinancialSnapshot { + date: date.format("%Y-%m-%d").to_string(), + total_value, + market_value: holdings_value, + book_cost, + available_cash: cumulative_cash, + net_deposit, + currency: currency.to_string(), + base_currency: self.base_currency.to_string(), + total_gain_value: holdings_value - book_cost, + total_gain_percentage, + day_gain_percentage, + day_gain_value, + allocation_percentage: None, // to Calculate later + exchange_rate: Some(exchange_rate), + }); + } + + results + } + + fn get_dates_between(start: NaiveDate, end: NaiveDate) -> Vec { + let mut dates = Vec::new(); + let mut current = start; + + while current <= end { + dates.push(current); + current = current.checked_add_signed(Duration::days(1)).unwrap(); + } + + dates + } +} diff --git a/src-core/src/portfolio/mod.rs b/src-core/src/portfolio/mod.rs index 0a48746..40cd01a 100644 --- a/src-core/src/portfolio/mod.rs +++ b/src-core/src/portfolio/mod.rs @@ -1,3 +1,6 @@ -pub mod portfolio_service; -pub mod income_service; -pub mod holdings_service; +mod holdings_service; +mod income_service; +mod portfolio_service; +mod history_service; + +pub use portfolio_service::PortfolioService; diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index f1e53b7..1e552ee 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -16,6 +16,7 @@ use chrono::{Duration, NaiveDate, Utc}; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; +use crate::portfolio::history_service::HistoryService; use crate::portfolio::holdings_service::HoldingsService; use crate::portfolio::income_service::IncomeService; @@ -28,6 +29,7 @@ pub struct PortfolioService { pool: Pool>, income_service: IncomeService, holdings_service: HoldingsService, + history_service: HistoryService, } /// This module contains the implementation of the `PortfolioService` struct. @@ -53,6 +55,10 @@ impl PortfolioService { String::new(), ), holdings_service: HoldingsService::new(pool.clone(), String::new()), + history_service: HistoryService::new( + CurrencyExchangeService::new(pool.clone()), + String::new(), + ), }; service.initialize()?; Ok(service) @@ -69,6 +75,10 @@ impl PortfolioService { self.base_currency.clone(), ); self.holdings_service = HoldingsService::new(self.pool.clone(), self.base_currency.clone()); + self.history_service = HistoryService::new( + CurrencyExchangeService::new(self.pool.clone()), + self.base_currency.clone(), + ); Ok(()) } @@ -76,18 +86,6 @@ impl PortfolioService { self.holdings_service.compute_holdings() } - fn get_dates_between(start: NaiveDate, end: NaiveDate) -> Vec { - let mut dates = Vec::new(); - let mut current = start; - - while current <= end { - dates.push(current); - current = current.checked_add_signed(Duration::days(1)).unwrap(); - } - - dates - } - fn fetch_data( &self, ) -> Result<(Vec, Vec, Vec), Box> { @@ -105,266 +103,18 @@ impl PortfolioService { let (accounts, activities, market_data) = self.fetch_data()?; - // Use Rayon's par_iter to process each account in parallel - let results: Vec = accounts - .par_iter() - .filter_map(|account| { - let account_activities: Vec<_> = activities - .iter() - .filter(|a| a.account_id == account.id) - .cloned() - .collect(); - - if account_activities.is_empty() { - None - } else { - let history = - self.calculate_historical_value(&account_activities, &market_data); - Some(FinancialHistory { - account: account.clone(), - history, - }) - } - }) - .collect(); - - // Calculate the total value of the portfolio - let portfolio_total_value = results - .iter() - .map(|fh| fh.history.last().map_or(0.0, |s| s.total_value)) - .sum::(); - - // Calculate the percentage of each account - let mut results_with_percentage = results - .into_iter() - .map(|mut fh| { - let account_total: f64 = fh.history.last().map_or(0.0, |s| s.total_value); - let percentage = account_total / portfolio_total_value * 100.0; - if let Some(last_snapshot) = fh.history.last_mut() { - last_snapshot.allocation_percentage = Some(percentage); - } - fh - }) - .collect::>(); - - // Aggregate historical data from all accounts - let mut aggregated_history: HashMap = HashMap::new(); - for financial_history in &results_with_percentage { - self.aggregate_account_history(&mut aggregated_history, &financial_history.history); - } - - let mut total_history: Vec<_> = aggregated_history.into_values().collect(); - total_history.sort_by(|a, b| a.date.cmp(&b.date)); - - let total_account = self.create_total_account(); - results_with_percentage.push(FinancialHistory { - account: total_account, - history: total_history, - }); + let results = self.history_service.calculate_historical_portfolio_values( + &accounts, + &activities, + &market_data, + ); println!( "Calculating historical portfolio values took: {:?}", std::time::Instant::now() - strt_time ); - Ok(results_with_percentage) - } - - fn aggregate_account_history( - &self, - aggregated_history: &mut HashMap, - history: &[FinancialSnapshot], - ) { - for snapshot in history { - let entry = aggregated_history - .entry(snapshot.date.clone()) - .or_insert_with(|| FinancialSnapshot { - date: snapshot.date.clone(), - total_value: 0.0, - market_value: 0.0, - book_cost: 0.0, - available_cash: 0.0, - net_deposit: 0.0, - currency: snapshot.currency.to_string(), - base_currency: self.base_currency.to_string(), - total_gain_value: 0.0, - total_gain_percentage: 0.0, - day_gain_percentage: 0.0, - day_gain_value: 0.0, - allocation_percentage: None, - exchange_rate: Some(1.0), // Default exchange rate for base currency - }); - - let exchange_rate = self - .fx_service - .get_exchange_rate(&snapshot.currency, &self.base_currency) - .unwrap_or(1.0); - - // Convert values to base currency before aggregating - entry.total_value += snapshot.total_value * exchange_rate; - entry.market_value += snapshot.market_value * exchange_rate; - entry.book_cost += snapshot.book_cost * exchange_rate; - entry.available_cash += snapshot.available_cash * exchange_rate; - entry.net_deposit += snapshot.net_deposit * exchange_rate; - entry.total_gain_value += snapshot.total_gain_value * exchange_rate; - - // Recalculate percentage values based on aggregated totals - entry.total_gain_percentage = if entry.book_cost != 0.0 { - entry.total_gain_value / entry.book_cost * 100.0 - } else { - 0.0 - }; - - // Assuming day gain values are already in base currency or need similar conversion - entry.day_gain_percentage += snapshot.day_gain_percentage; - entry.day_gain_value += snapshot.day_gain_value * exchange_rate; - } - } - - fn create_total_account(&self) -> Account { - Account { - id: "TOTAL".to_string(), - name: "Total".to_string(), - account_type: "TOTAL".to_string(), - group: Some("TOTAL".to_string()), - is_default: true, - is_active: true, - created_at: Utc::now().naive_utc(), - updated_at: Utc::now().naive_utc(), - platform_id: None, - currency: self.base_currency.to_string(), - } - } - - fn calculate_historical_value( - &self, - activities: &[Activity], - quotes: &[Quote], - ) -> Vec { - let first_activity = activities[0].clone(); - - let start_date = first_activity.activity_date.date(); - - let end_date = Utc::now().naive_utc().date(); - let all_dates = Self::get_dates_between(start_date, end_date); - - let mut currency = self.base_currency.as_str(); - let mut cumulative_cash = 0.0; - let mut holdings: HashMap = HashMap::new(); - - let mut results = Vec::new(); - let mut _initial_investment = 0.0; - let mut net_deposit = 0.0; - let mut book_cost = 0.0; - - // HashMap to keep the last available quote for each symbol - let mut last_available_quotes: HashMap = HashMap::new(); - - for date in all_dates { - for activity in activities.iter().filter(|a| a.activity_date.date() == date) { - currency = activity.currency.as_str(); - let activity_amount = activity.quantity; - let activity_fee = activity.fee; - - match activity.activity_type.as_str() { - "BUY" => { - let entry = holdings.entry(activity.asset_id.clone()).or_insert(0.0); - *entry += activity_amount; - let buy_cost = activity_amount * activity.unit_price + activity_fee; - cumulative_cash -= buy_cost; - _initial_investment += activity_amount * activity.unit_price; - book_cost += buy_cost; - } - "SELL" => { - let entry = holdings.entry(activity.asset_id.clone()).or_insert(0.0); - *entry -= activity_amount; - let sell_profit = activity_amount * activity.unit_price - activity_fee; - cumulative_cash += sell_profit; - _initial_investment -= activity_amount * activity.unit_price; - book_cost -= activity_amount * activity.unit_price + activity_fee; - } - "DEPOSIT" | "TRANSFER_IN" | "CONVERSION_IN" => { - cumulative_cash += activity_amount * activity.unit_price - activity_fee; - net_deposit += activity_amount * activity.unit_price; - } - "DIVIDEND" | "INTEREST" => { - cumulative_cash += activity_amount * activity.unit_price - activity_fee; - } - "WITHDRAWAL" | "TRANSFER_OUT" | "CONVERSION_OUT" => { - cumulative_cash -= activity_amount * activity.unit_price + activity_fee; - net_deposit -= activity_amount * activity.unit_price; - } - "FEE" | "TAX" => { - cumulative_cash -= activity_fee; - } - _ => {} - } - } - - let mut holdings_value = 0.0; - let mut day_gain_value = 0.0; - - // println!("{:?}", &holdings); - - for (symbol, &holding_amount) in &holdings { - let quote = quotes - .iter() - .find(|q| q.date.date() == date && q.symbol == *symbol) - .or_else(|| last_available_quotes.get(symbol).cloned()) // Copy the reference to the quote - ; // Copy the reference to the quote - - if let Some(quote) = quote { - let holding_value_for_symbol = holding_amount * quote.close; - let daily_change_percent = ((quote.close - quote.open) / quote.open) * 100.0; - let day_gain_for_symbol = - (daily_change_percent / 100.0) * holding_value_for_symbol; - - holdings_value += holding_value_for_symbol; - day_gain_value += day_gain_for_symbol; - - // Update the last available quote for the symbol - last_available_quotes.insert(symbol.clone(), quote); - } - } - - let day_gain_percentage = if holdings_value != 0.0 { - (day_gain_value / holdings_value) * 100.0 - } else { - 0.0 - }; - - let total_value = cumulative_cash + holdings_value; - let total_gain_value = holdings_value - book_cost; - let total_gain_percentage = if book_cost != 0.0 { - (total_gain_value / book_cost) * 100.0 - } else { - 0.0 - }; - - let exchange_rate = self - .fx_service - .get_exchange_rate(currency, &self.base_currency) - .unwrap_or(1.0); - - results.push(FinancialSnapshot { - date: date.format("%Y-%m-%d").to_string(), - total_value, - market_value: holdings_value, - book_cost, - available_cash: cumulative_cash, - net_deposit, - currency: currency.to_string(), - base_currency: self.base_currency.to_string(), - total_gain_value: holdings_value - book_cost, - total_gain_percentage, - day_gain_percentage, - day_gain_value, - allocation_percentage: None, // to Calculate later - exchange_rate: Some(exchange_rate), - }); - } - - results + Ok(results) } pub fn get_income_data(&self) -> Result, diesel::result::Error> { From 8094ec7e213b031eb41405dedf1bbc9561386196 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 15 Sep 2024 22:32:48 -0400 Subject: [PATCH 07/45] refactor holdings calculation --- src-core/Cargo.lock | 13 ++++ src-core/Cargo.toml | 1 + src-core/src/error.rs | 15 ++++ src-core/src/lib.rs | 1 + src-core/src/portfolio/holdings_service.rs | 78 ++++++++++----------- src-core/src/portfolio/mod.rs | 4 +- src-core/src/portfolio/portfolio_service.rs | 4 +- src-tauri/Cargo.lock | 1 + src-tauri/src/commands/portfolio.rs | 8 +-- 9 files changed, 78 insertions(+), 47 deletions(-) create mode 100644 src-core/src/error.rs diff --git a/src-core/Cargo.lock b/src-core/Cargo.lock index d231a8d..d5e68ed 100644 --- a/src-core/Cargo.lock +++ b/src-core/Cargo.lock @@ -1930,9 +1930,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.76", +] + [[package]] name = "tracing-core" version = "0.1.32" @@ -2111,6 +2123,7 @@ dependencies = [ "serde", "serde_json", "thiserror", + "tracing", "uuid", "yahoo_finance_api", ] diff --git a/src-core/Cargo.toml b/src-core/Cargo.toml index 76336ac..b6d8743 100644 --- a/src-core/Cargo.toml +++ b/src-core/Cargo.toml @@ -24,3 +24,4 @@ thiserror = "1.0.63" lazy_static = "1.5.0" diesel_migrations = { version = "2.2.0", features = ["sqlite" ] } rayon = "1.10.0" +tracing = "0.1.40" diff --git a/src-core/src/error.rs b/src-core/src/error.rs new file mode 100644 index 0000000..214c69b --- /dev/null +++ b/src-core/src/error.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum PortfolioError { + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), + #[error("Currency conversion error: {0}")] + CurrencyConversionError(String), + #[error("Asset not found: {0}")] + AssetNotFoundError(String), + #[error("Invalid data: {0}")] + InvalidDataError(String), +} + +pub type Result = std::result::Result; diff --git a/src-core/src/lib.rs b/src-core/src/lib.rs index f01c0c2..41ae734 100644 --- a/src-core/src/lib.rs +++ b/src-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod db; pub mod account; pub mod activity; pub mod asset; +pub mod error; pub mod fx; pub mod goal; pub mod market_data; diff --git a/src-core/src/portfolio/holdings_service.rs b/src-core/src/portfolio/holdings_service.rs index a7c0e0f..f3390a4 100644 --- a/src-core/src/portfolio/holdings_service.rs +++ b/src-core/src/portfolio/holdings_service.rs @@ -1,11 +1,13 @@ use crate::account::account_service::AccountService; use crate::activity::activity_service::ActivityService; use crate::asset::asset_service::AssetService; +use crate::error::{PortfolioError, Result}; use crate::fx::fx_service::CurrencyExchangeService; use crate::models::{Holding, Performance}; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; use std::collections::{HashMap, HashSet}; +use tracing::{info, warn}; pub struct HoldingsService { account_service: AccountService, @@ -28,27 +30,23 @@ impl HoldingsService { } } - pub fn compute_holdings(&self) -> Result, Box> { + pub fn compute_holdings(&self) -> Result> { + info!("Computing holdings"); let mut holdings: HashMap = HashMap::new(); let accounts = self.account_service.get_accounts()?; let activities = self.activity_service.get_trading_activities()?; let assets = self.asset_service.get_assets()?; for activity in activities { - //find asset by id - let asset = match assets.iter().find(|a| a.id == activity.asset_id) { - Some(found_asset) => found_asset, - None => { - println!("Asset not found for id: {}", activity.asset_id); - continue; // Skip this iteration if the asset is not found - } - }; + let asset = assets + .iter() + .find(|a| a.id == activity.asset_id) + .ok_or_else(|| PortfolioError::AssetNotFoundError(activity.asset_id.clone()))?; - //find account by id let account = accounts .iter() .find(|a| a.id == activity.account_id) - .unwrap(); + .ok_or_else(|| PortfolioError::InvalidDataError("Account not found".to_string()))?; let key = format!("{}-{}", activity.account_id, activity.asset_id); let holding = holdings.entry(key.clone()).or_insert_with(|| Holding { @@ -58,13 +56,13 @@ impl HoldingsService { holding_type: asset.asset_type.clone().unwrap_or_default(), quantity: 0.0, currency: activity.currency.clone(), - base_currency: "CAD".to_string(), - market_price: None, // You need to provide market price - average_cost: None, // Will be calculated - market_value: 0.0, // Will be calculated - book_value: 0.0, // Will be calculated - market_value_converted: 0.0, // Will be calculated - book_value_converted: 0.0, // Will be calculated + base_currency: self.base_currency.clone(), + market_price: None, + average_cost: None, + market_value: 0.0, + book_value: 0.0, + market_value_converted: 0.0, + book_value_converted: 0.0, performance: Performance { total_gain_percent: 0.0, total_gain_amount: 0.0, @@ -91,7 +89,7 @@ impl HoldingsService { holding.quantity -= activity.quantity; holding.book_value -= activity.quantity * activity.unit_price + activity.fee; } - _ => {} + _ => warn!("Unhandled activity type: {}", activity.activity_type), } } @@ -111,7 +109,7 @@ impl HoldingsService { quotes.insert(symbol, quote); } Err(e) => { - println!("Error fetching quote for symbol {}: {}", symbol, e); + warn!("Error fetching quote for symbol {}: {}", symbol, e); // Handle the error as per your logic, e.g., continue, return an error, etc. } } @@ -120,21 +118,19 @@ impl HoldingsService { // Post-processing for each holding for holding in holdings.values_mut() { if let Some(quote) = quotes.get(&holding.symbol) { - //prinln!("Quote: {:?}", quote); - holding.market_price = Some(quote.close); // Assuming you want to use the 'close' value as market price + holding.market_price = Some(quote.close); } holding.average_cost = Some(holding.book_value / holding.quantity); holding.market_value = holding.quantity * holding.market_price.unwrap_or(0.0); - holding.market_value_converted = self.fx_service.convert_currency( - holding.market_value, - &holding.currency, - &self.base_currency, - )?; - holding.book_value_converted = self.fx_service.convert_currency( - holding.book_value, - &holding.currency, - &self.base_currency, - )?; + holding.market_value_converted = self + .fx_service + .convert_currency(holding.market_value, &holding.currency, &self.base_currency) + .map_err(|e| PortfolioError::CurrencyConversionError(e.to_string()))?; + + holding.book_value_converted = self + .fx_service + .convert_currency(holding.book_value, &holding.currency, &self.base_currency) + .map_err(|e| PortfolioError::CurrencyConversionError(e.to_string()))?; // Calculate performance metrics holding.performance.total_gain_amount = holding.market_value - holding.book_value; @@ -143,17 +139,19 @@ impl HoldingsService { } else { 0.0 }; - holding.performance.total_gain_amount_converted = self.fx_service.convert_currency( - holding.performance.total_gain_amount, - &holding.currency, - &self.base_currency, - )?; + holding.performance.total_gain_amount_converted = self + .fx_service + .convert_currency( + holding.performance.total_gain_amount, + &holding.currency, + &self.base_currency, + ) + .map_err(|e| PortfolioError::CurrencyConversionError(e.to_string()))?; } - holdings + Ok(holdings .into_values() .filter(|holding| holding.quantity > 0.0) - .map(Ok) - .collect::, _>>() + .collect()) } } diff --git a/src-core/src/portfolio/mod.rs b/src-core/src/portfolio/mod.rs index 40cd01a..af2b956 100644 --- a/src-core/src/portfolio/mod.rs +++ b/src-core/src/portfolio/mod.rs @@ -1,6 +1,6 @@ +mod history_service; mod holdings_service; mod income_service; -mod portfolio_service; -mod history_service; +pub mod portfolio_service; pub use portfolio_service::PortfolioService; diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 1e552ee..69a9f83 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -83,7 +83,9 @@ impl PortfolioService { } pub fn compute_holdings(&self) -> Result, Box> { - self.holdings_service.compute_holdings() + self.holdings_service + .compute_holdings() + .map_err(|e| Box::new(e) as Box) } fn fetch_data( diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dac78f6..674a4bc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4460,6 +4460,7 @@ dependencies = [ "serde", "serde_json", "thiserror", + "tracing", "uuid", "yahoo_finance_api", ] diff --git a/src-tauri/src/commands/portfolio.rs b/src-tauri/src/commands/portfolio.rs index b38b0ab..e94e936 100644 --- a/src-tauri/src/commands/portfolio.rs +++ b/src-tauri/src/commands/portfolio.rs @@ -1,5 +1,5 @@ use crate::models::{FinancialHistory, Holding, IncomeSummary}; -use crate::portfolio::portfolio_service; +use crate::portfolio::portfolio_service::PortfolioService; use crate::AppState; use tauri::State; @@ -7,7 +7,7 @@ use tauri::State; pub async fn get_historical(state: State<'_, AppState>) -> Result, String> { println!("Fetching portfolio historical..."); - let service = portfolio_service::PortfolioService::new((*state.pool).clone()) + let service = PortfolioService::new((*state.pool).clone()) .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; service @@ -19,7 +19,7 @@ pub async fn get_historical(state: State<'_, AppState>) -> Result) -> Result, String> { println!("Compute holdings..."); - let service = portfolio_service::PortfolioService::new((*state.pool).clone()) + let service = PortfolioService::new((*state.pool).clone()) .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; service @@ -30,7 +30,7 @@ pub async fn compute_holdings(state: State<'_, AppState>) -> Result #[tauri::command] pub async fn get_income_summary(state: State<'_, AppState>) -> Result { println!("Fetching income summary..."); - let service = portfolio_service::PortfolioService::new((*state.pool).clone()) + let service = PortfolioService::new((*state.pool).clone()) .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; service From 8e61466437431627ce0e891bd09654f2fb020de6 Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 13 Sep 2024 10:16:15 +1000 Subject: [PATCH 08/45] rebase remoote branch --- src/adapters/index.ts | 30 +++++++++++++ src/adapters/tauri.ts | 38 ++++++++++++++++ src/commands/account.ts | 38 +++++++++++----- src/commands/activity.ts | 80 ++++++++++++++++++++++----------- src/commands/file.ts | 11 +++-- src/commands/goal.ts | 52 ++++++++++++++++----- src/commands/import-listener.ts | 24 ++++++++-- src/commands/market-data.ts | 26 ++++++++--- src/commands/portfolio.ts | 26 ++++++++--- src/commands/quote-listener.ts | 17 +++++-- src/commands/setting.ts | 18 +++++--- 11 files changed, 285 insertions(+), 75 deletions(-) create mode 100644 src/adapters/index.ts create mode 100644 src/adapters/tauri.ts diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 0000000..9ebf937 --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,30 @@ +export enum RUN_ENV { + DESKTOP = 'desktop', + BROWSER = 'browser', + UNSUPPORTED = 'unsupported', +}; + +export const getRunEnv = () => { + if (typeof window !== 'undefined' && window.__TAURI__) { + return RUN_ENV.DESKTOP; + } + if (typeof window !== 'undefined' && window.indexedDB) { + return RUN_ENV.BROWSER; + } + return RUN_ENV.UNSUPPORTED; +} + +export type { + EventCallback, + UnlistenFn, +} from './tauri'; + +export { + invokeTauri, + openCsvFileDialogTauri, + listenFileDropHoverTauri, + listenFileDropTauri, + listenFileDropCancelledTauri, + listenQuotesSyncStartTauri, + listenQuotesSyncCompleteTauri, +} from './tauri'; diff --git a/src/adapters/tauri.ts b/src/adapters/tauri.ts new file mode 100644 index 0000000..0bffbf6 --- /dev/null +++ b/src/adapters/tauri.ts @@ -0,0 +1,38 @@ +import type { EventCallback, UnlistenFn } from '@tauri-apps/api/event'; + +export type { EventCallback, UnlistenFn }; + +export const invokeTauri = async (command: string, payload?: Record) => { + const invoke = await import('@tauri-apps/api').then((mod) => mod.invoke); + return await invoke(command, payload); +} + +export const openCsvFileDialogTauri = async (): Promise => { + const open = await import('@tauri-apps/api/dialog').then((mod) => mod.open); + return open({ filters: [{ name: 'CSV', extensions: ['csv'] }] }); +} + +export const listenFileDropHoverTauri = async (handler: EventCallback): Promise => { + const { listen } = await import('@tauri-apps/api/event'); + return listen('tauri://file-drop-hover', handler); +} + +export const listenFileDropTauri = async (handler: EventCallback): Promise => { + const { listen } = await import('@tauri-apps/api/event'); + return listen('tauri://file-drop', handler); +} + +export const listenFileDropCancelledTauri = async (handler: EventCallback): Promise => { + const { listen } = await import('@tauri-apps/api/event'); + return listen('tauri://file-drop-cancelled', handler); +} + +export const listenQuotesSyncStartTauri = async (handler: EventCallback): Promise => { + const { listen } = await import('@tauri-apps/api/event'); + return listen('QUOTES_SYNC_START', handler); +} + +export const listenQuotesSyncCompleteTauri = async (handler: EventCallback): Promise => { + const { listen } = await import('@tauri-apps/api/event'); + return listen('QUOTES_SYNC_COMPLETE', handler); +} diff --git a/src/commands/account.ts b/src/commands/account.ts index 3e8c67a..63ce4ee 100644 --- a/src/commands/account.ts +++ b/src/commands/account.ts @@ -1,14 +1,18 @@ -import { invoke } from '@tauri-apps/api'; -import * as z from 'zod'; +import z from 'zod'; import { Account } from '@/lib/types'; import { newAccountSchema } from '@/lib/schemas'; +import { getRunEnv, RUN_ENV, invokeTauri } from '@/adapters'; type NewAccount = z.infer; export const getAccounts = async (): Promise => { try { - const accounts = await invoke('get_accounts'); - return accounts as Account[]; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('get_accounts'); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error fetching accounts:', error); throw error; @@ -18,8 +22,12 @@ export const getAccounts = async (): Promise => { // createAccount export const createAccount = async (account: NewAccount): Promise => { try { - const createdAccount = await invoke('create_account', { account }); - return createdAccount as Account; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('create_account', { account }); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error creating account:', error); throw error; @@ -29,9 +37,13 @@ export const createAccount = async (account: NewAccount): Promise => { // updateAccount export const updateAccount = async (account: NewAccount): Promise => { try { - const { currency, ...updatedAccountData } = account; - const updatedAccount = await invoke('update_account', { account: updatedAccountData }); - return updatedAccount as Account; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + const { currency, ...updatedAccountData } = account; + return invokeTauri('update_account', { account: updatedAccountData }); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error updating account:', error); throw error; @@ -41,7 +53,13 @@ export const updateAccount = async (account: NewAccount): Promise => { // deleteAccount export const deleteAccount = async (accountId: string): Promise => { try { - await invoke('delete_account', { accountId }); + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + await invokeTauri('delete_account', { accountId }); + return; + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error deleting account:', error); throw error; diff --git a/src/commands/activity.ts b/src/commands/activity.ts index 59866e3..ae1385b 100644 --- a/src/commands/activity.ts +++ b/src/commands/activity.ts @@ -1,7 +1,7 @@ -import { invoke } from '@tauri-apps/api'; -import * as z from 'zod'; +import z from 'zod'; import { Activity, ActivityDetails, ActivityImport, ActivitySearchResponse } from '@/lib/types'; import { newActivitySchema } from '@/lib/schemas'; +import { getRunEnv, RUN_ENV, invokeTauri } from '@/adapters'; export type NewActivity = z.infer; @@ -18,8 +18,12 @@ interface Sort { export const getActivities = async (): Promise => { try { - const activities = await invoke('get_activities'); - return activities as ActivityDetails[]; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('get_activities'); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error fetching activities:', error); throw error; @@ -34,15 +38,19 @@ export const searchActivities = async ( sort: Sort, ): Promise => { try { - const result = await invoke('search_activities', { - page, - pageSize, - accountIdFilter: filters?.accountId, - activityTypeFilter: filters?.activityType, - assetIdKeyword: searchKeyword, - sort, - }); - return result as ActivitySearchResponse; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('search_activities', { + page, + pageSize, + accountIdFilter: filters?.accountId, + activityTypeFilter: filters?.activityType, + assetIdKeyword: searchKeyword, + sort, + }); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error fetching activities:', error); throw error; @@ -52,8 +60,12 @@ export const searchActivities = async ( // createActivity export const createActivity = async (activity: NewActivity): Promise => { try { - const newActivity = await invoke('create_activity', { activity }); - return newActivity as Activity; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('create_activity', { activity }); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error creating activity:', error); throw error; @@ -63,8 +75,12 @@ export const createActivity = async (activity: NewActivity): Promise = // updateActivity export const updateActivity = async (activity: NewActivity): Promise => { try { - const updatedActivity = await invoke('update_activity', { activity }); - return updatedActivity as Activity; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('update_activity', { activity }); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error updating activity:', error); throw error; @@ -74,7 +90,13 @@ export const updateActivity = async (activity: NewActivity): Promise = // deleteActivity export const deleteActivity = async (activityId: string): Promise => { try { - await invoke('delete_activity', { activityId }); + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + await invokeTauri('delete_activity', { activityId }); + return; + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error deleting activity:', error); throw error; @@ -90,11 +112,15 @@ export const checkActivitiesImport = async ({ file_path: string; }): Promise => { try { - const result: ActivityImport[] = await invoke('check_activities_import', { - accountId: account_id, - filePath: file_path, - }); - return result; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('check_activities_import', { + accountId: account_id, + filePath: file_path, + }); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error checking activities import:', error); throw error; @@ -104,8 +130,12 @@ export const checkActivitiesImport = async ({ // importActivities export const createActivities = async (activities: NewActivity[]): Promise => { try { - const importResult: Number = await invoke('create_activities', { activities }); - return importResult; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('create_activities', { activities }); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error importing activities:', error); throw error; diff --git a/src/commands/file.ts b/src/commands/file.ts index 63d0660..7868642 100644 --- a/src/commands/file.ts +++ b/src/commands/file.ts @@ -1,9 +1,14 @@ -import { open } from '@tauri-apps/api/dialog'; +import { getRunEnv, openCsvFileDialogTauri, RUN_ENV } from '@/adapters'; -// openCsvFile +// openCsvFileDialog export const openCsvFileDialog = async (): Promise => { try { - return open({ filters: [{ name: 'CSV', extensions: ['csv'] }] }); + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return openCsvFileDialogTauri(); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error open csv file', error); throw error; diff --git a/src/commands/goal.ts b/src/commands/goal.ts index 9fd67dc..11720dc 100644 --- a/src/commands/goal.ts +++ b/src/commands/goal.ts @@ -1,14 +1,18 @@ -import { invoke } from '@tauri-apps/api'; -import * as z from 'zod'; +import z from 'zod'; import { Goal, GoalAllocation } from '@/lib/types'; import { newGoalSchema } from '@/lib/schemas'; +import { getRunEnv, RUN_ENV, invokeTauri } from '@/adapters'; type NewGoal = z.infer; export const getGoals = async (): Promise => { try { - const goals = await invoke('get_goals'); - return goals as Goal[]; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('get_goals'); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error fetching goals:', error); throw error; @@ -23,8 +27,12 @@ export const createGoal = async (goal: NewGoal): Promise => { isAchieved: false, }; try { - const createdGoal = await invoke('create_goal', { goal: newGoal }); - return createdGoal as Goal; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('create_goal', { goal: newGoal }); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error creating goal:', error); throw error; @@ -33,8 +41,12 @@ export const createGoal = async (goal: NewGoal): Promise => { export const updateGoal = async (goal: Goal): Promise => { try { - const updatedGoal = await invoke('update_goal', { goal }); - return updatedGoal as Goal; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('update_goal', { goal }); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error updating goal:', error); throw error; @@ -43,7 +55,13 @@ export const updateGoal = async (goal: Goal): Promise => { export const deleteGoal = async (goalId: string): Promise => { try { - await invoke('delete_goal', { goalId }); + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + await invokeTauri('delete_goal', { goalId }); + return; + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error deleting goal:', error); throw error; @@ -52,7 +70,13 @@ export const deleteGoal = async (goalId: string): Promise => { export const updateGoalsAllocations = async (allocations: GoalAllocation[]): Promise => { try { - await invoke('update_goal_allocations', { allocations }); + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + await invokeTauri('update_goal_allocations', { allocations }); + return; + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error saving goals allocations:', error); throw error; @@ -61,8 +85,12 @@ export const updateGoalsAllocations = async (allocations: GoalAllocation[]): Pro export const getGoalsAllocation = async (): Promise => { try { - const allocations = await invoke('load_goals_allocations'); - return allocations as GoalAllocation[]; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('load_goals_allocations'); + default: + throw new Error(`Unsupported`); + }; } catch (error) { console.error('Error fetching goals allocations:', error); throw error; diff --git a/src/commands/import-listener.ts b/src/commands/import-listener.ts index 579fac0..1e806f2 100644 --- a/src/commands/import-listener.ts +++ b/src/commands/import-listener.ts @@ -1,9 +1,15 @@ -import { listen, EventCallback, UnlistenFn } from '@tauri-apps/api/event'; +import type { EventCallback, UnlistenFn } from '@/adapters'; +import { getRunEnv, RUN_ENV, listenFileDropCancelledTauri, listenFileDropHoverTauri, listenFileDropTauri } from "@/adapters"; // listenImportFileDropHover export const listenImportFileDropHover = async (handler: EventCallback): Promise => { try { - return listen('tauri://file-drop-hover', handler); + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return listenFileDropHoverTauri(handler); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error listen tauri://file-drop-hover:', error); throw error; @@ -13,7 +19,12 @@ export const listenImportFileDropHover = async (handler: EventCallback): P // listenImportFileDrop export const listenImportFileDrop = async (handler: EventCallback): Promise => { try { - return listen('tauri://file-drop', handler); + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return listenFileDropTauri(handler); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error listen tauri://file-drop:', error); throw error; @@ -23,7 +34,12 @@ export const listenImportFileDrop = async (handler: EventCallback): Promis // listenImportFileDropCancelled export const listenImportFileDropCancelled = async (handler: EventCallback): Promise => { try { - return listen('tauri://file-drop-cancelled', handler); + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return listenFileDropCancelledTauri(handler); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error listen tauri://file-drop-cancelled:', error); throw error; diff --git a/src/commands/market-data.ts b/src/commands/market-data.ts index 0fd1a95..162af98 100644 --- a/src/commands/market-data.ts +++ b/src/commands/market-data.ts @@ -1,10 +1,14 @@ -import { invoke } from '@tauri-apps/api'; import { AssetData, QuoteSummary } from '@/lib/types'; +import { getRunEnv, RUN_ENV, invokeTauri } from '@/adapters'; export const searchTicker = async (query: string): Promise => { try { - const searchResult = await invoke('search_symbol', { query }); - return searchResult as QuoteSummary[]; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('search_ticker', { query }); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error searching for ticker:', error); throw error; @@ -13,7 +17,13 @@ export const searchTicker = async (query: string): Promise => { export const syncHistoryQuotes = async (): Promise => { try { - await invoke('synch_quotes'); + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + await invokeTauri('synch_quotes'); + return; + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error syncing history quotes:', error); throw error; @@ -22,8 +32,12 @@ export const syncHistoryQuotes = async (): Promise => { export const getAssetData = async (assetId: string): Promise => { try { - const result = await invoke('get_asset_data', { assetId }); - return result as AssetData; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('get_asset_data', { assetId }); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error loading asset data:', error); throw error; diff --git a/src/commands/portfolio.ts b/src/commands/portfolio.ts index ea572ab..43e3061 100644 --- a/src/commands/portfolio.ts +++ b/src/commands/portfolio.ts @@ -1,10 +1,14 @@ -import { invoke } from '@tauri-apps/api'; import { FinancialHistory, Holding, IncomeSummary } from '@/lib/types'; +import { getRunEnv, RUN_ENV, invokeTauri } from '@/adapters'; export const getHistorical = async (): Promise => { try { - const result = await invoke('get_historical'); - return result as FinancialHistory[]; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('get_historical'); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error fetching accounts:', error); throw error; @@ -13,8 +17,12 @@ export const getHistorical = async (): Promise => { export const computeHoldings = async (): Promise => { try { - const result = await invoke('compute_holdings'); - return result as Holding[]; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('compute_holdings'); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error computing holdings:', error); throw error; @@ -23,8 +31,12 @@ export const computeHoldings = async (): Promise => { export const getIncomeSummary = async (): Promise => { try { - const result = await invoke('get_income_summary'); - return result as IncomeSummary; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('get_income_summary'); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error fetching income summary:', error); throw error; diff --git a/src/commands/quote-listener.ts b/src/commands/quote-listener.ts index b29b3ab..b4b4ab6 100644 --- a/src/commands/quote-listener.ts +++ b/src/commands/quote-listener.ts @@ -1,9 +1,15 @@ -import { listen, EventCallback, UnlistenFn } from '@tauri-apps/api/event'; +import type { EventCallback, UnlistenFn } from '@/adapters'; +import { getRunEnv, RUN_ENV, listenQuotesSyncStartTauri, listenQuotesSyncCompleteTauri } from "@/adapters"; // listenQuotesSyncStart export const listenQuotesSyncStart = async (handler: EventCallback): Promise => { try { - return listen('QUOTES_SYNC_START', handler); + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return listenQuotesSyncStartTauri(handler); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error listen QUOTES_SYNC_START:', error); throw error; @@ -13,7 +19,12 @@ export const listenQuotesSyncStart = async (handler: EventCallback): Promi // listenQuotesSyncComplete export const listenQuotesSyncComplete = async (handler: EventCallback): Promise => { try { - return listen('QUOTES_SYNC_COMPLETE', handler); + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return listenQuotesSyncCompleteTauri(handler); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error listen QUOTES_SYNC_COMPLETE:', error); throw error; diff --git a/src/commands/setting.ts b/src/commands/setting.ts index 9e96115..572aa55 100644 --- a/src/commands/setting.ts +++ b/src/commands/setting.ts @@ -1,11 +1,15 @@ -import { invoke } from '@tauri-apps/api'; import { Settings } from '@/lib/types'; +import { getRunEnv, RUN_ENV, invokeTauri } from '@/adapters'; // getSettings export const getSettings = async (): Promise => { try { - const settings = await invoke('get_settings'); - return settings as Settings; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('get_settings'); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error fetching settings:', error); return {} as Settings; @@ -15,8 +19,12 @@ export const getSettings = async (): Promise => { // saveSettings export const saveSettings = async (settings: Settings): Promise => { try { - const updatedSettings = await invoke('update_settings', { settings }); - return updatedSettings as Settings; + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('update_settings', { settings }); + default: + throw new Error(`Unsupported`); + } } catch (error) { console.error('Error updating settings:', error); throw error; From 1fdc8c4b2a095e534df6f39d9439b87b27059da3 Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 13 Sep 2024 10:41:40 +1000 Subject: [PATCH 09/45] mobile --- src/adapters/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 9ebf937..062ef17 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -1,5 +1,6 @@ export enum RUN_ENV { DESKTOP = 'desktop', + MOBILE = 'mobile', BROWSER = 'browser', UNSUPPORTED = 'unsupported', }; From 17913b80dd791f5b1b03d8b2e55d9cd159764741 Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 13 Sep 2024 10:43:24 +1000 Subject: [PATCH 10/45] run-env --- src/adapters/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 062ef17..417d3f8 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -5,7 +5,7 @@ export enum RUN_ENV { UNSUPPORTED = 'unsupported', }; -export const getRunEnv = () => { +export const getRunEnv = (): RUN_ENV => { if (typeof window !== 'undefined' && window.__TAURI__) { return RUN_ENV.DESKTOP; } From 40311f7cafb4b650ec1a7dd18972dc7f5248f143 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 15 Sep 2024 15:19:09 -0400 Subject: [PATCH 11/45] use database connection pool rebase main --- src-core/src/portfolio/portfolio_service.rs | 141 ++++++++++++-------- src-tauri/src/commands/portfolio.rs | 41 +++++- src/commands/market-data.ts | 2 +- 3 files changed, 125 insertions(+), 59 deletions(-) diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 69a9f83..10616d9 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -3,16 +3,11 @@ use crate::activity::activity_service::ActivityService; use crate::asset::asset_service::AssetService; use crate::fx::fx_service::CurrencyExchangeService; use crate::models::{ - Account, Activity, FinancialHistory, FinancialSnapshot, Holding, IncomeData, IncomeSummary, - Performance, Quote, + Account, AccountSummary, Activity, HistorySummary, Holding, IncomeData, IncomeSummary, + PortfolioHistory, }; use crate::settings::SettingsService; -use chrono::NaiveDateTime; -use rayon::prelude::*; -use std::collections::{HashMap, HashSet}; -use chrono::Datelike; -use chrono::{Duration, NaiveDate, Utc}; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; @@ -24,9 +19,6 @@ pub struct PortfolioService { account_service: AccountService, activity_service: ActivityService, asset_service: AssetService, - fx_service: CurrencyExchangeService, - base_currency: String, - pool: Pool>, income_service: IncomeService, holdings_service: HoldingsService, history_service: HistoryService, @@ -42,44 +34,23 @@ impl PortfolioService { pub fn new( pool: Pool>, ) -> Result> { - let mut service = PortfolioService { + let mut conn = pool.get()?; + let settings_service = SettingsService::new(); + let settings = settings_service.get_settings(&mut conn)?; + let base_currency = settings.base_currency; + + Ok(PortfolioService { account_service: AccountService::new(pool.clone()), activity_service: ActivityService::new(pool.clone()), asset_service: AssetService::new(pool.clone()), - fx_service: CurrencyExchangeService::new(pool.clone()), - base_currency: String::new(), - pool: pool.clone(), income_service: IncomeService::new( pool.clone(), CurrencyExchangeService::new(pool.clone()), - String::new(), + base_currency.clone(), ), - holdings_service: HoldingsService::new(pool.clone(), String::new()), - history_service: HistoryService::new( - CurrencyExchangeService::new(pool.clone()), - String::new(), - ), - }; - service.initialize()?; - Ok(service) - } - - fn initialize(&mut self) -> Result<(), Box> { - let mut conn = self.pool.get()?; - let settings_service = SettingsService::new(); - let settings = settings_service.get_settings(&mut conn)?; - self.base_currency.clone_from(&settings.base_currency); - self.income_service = IncomeService::new( - self.pool.clone(), - CurrencyExchangeService::new(self.pool.clone()), - self.base_currency.clone(), - ); - self.holdings_service = HoldingsService::new(self.pool.clone(), self.base_currency.clone()); - self.history_service = HistoryService::new( - CurrencyExchangeService::new(self.pool.clone()), - self.base_currency.clone(), - ); - Ok(()) + holdings_service: HoldingsService::new(pool.clone(), base_currency.clone()), + history_service: HistoryService::new(pool.clone(), base_currency), + }) } pub fn compute_holdings(&self) -> Result, Box> { @@ -90,26 +61,32 @@ impl PortfolioService { fn fetch_data( &self, - ) -> Result<(Vec, Vec, Vec), Box> { - let accounts = self.account_service.get_accounts()?; - let activities = self.activity_service.get_activities()?; - let market_data = self.asset_service.get_history_quotes()?; + account_ids: Option>, + ) -> Result<(Vec, Vec), Box> { + let accounts = match &account_ids { + Some(ids) => self.account_service.get_accounts_by_ids(ids)?, + None => self.account_service.get_accounts()?, + }; - Ok((accounts, activities, market_data)) + let activities = match &account_ids { + Some(ids) => self.activity_service.get_activities_by_account_ids(ids)?, + None => self.activity_service.get_activities()?, + }; + + Ok((accounts, activities)) } - pub fn calculate_historical_portfolio_values( + pub fn calculate_historical_data( &self, - ) -> Result, Box> { + account_ids: Option>, + ) -> Result, Box> { let strt_time = std::time::Instant::now(); - let (accounts, activities, market_data) = self.fetch_data()?; + let (accounts, activities) = self.fetch_data(account_ids)?; - let results = self.history_service.calculate_historical_portfolio_values( - &accounts, - &activities, - &market_data, - ); + let results = self + .history_service + .calculate_historical_data(&accounts, &activities)?; println!( "Calculating historical portfolio values took: {:?}", @@ -126,4 +103,60 @@ impl PortfolioService { pub fn get_income_summary(&self) -> Result { self.income_service.get_income_summary() } + + pub async fn update_portfolio( + &self, + ) -> Result, Box> { + // First, sync quotes + self.asset_service.initialize_and_sync_quotes().await?; + + // Then, calculate historical data + self.calculate_historical_data(None) + } + + pub fn get_account_history( + &self, + account_id: &str, + ) -> Result, Box> { + self.history_service + .get_account_history(account_id) + .map_err(|e| Box::new(e) as Box) // Convert PortfolioError to Box + } + + pub fn get_accounts_summary(&self) -> Result, Box> { + let accounts = self.account_service.get_accounts()?; + let mut account_summaries = Vec::new(); + + // First, get the total portfolio value + let total_portfolio_value = + if let Ok(total_history) = self.history_service.get_latest_account_history("TOTAL") { + total_history.market_value + } else { + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Total portfolio history not found", + ))); + }; + + // Then, calculate the allocation percentage for each account + for account in accounts { + if let Ok(history) = self.history_service.get_latest_account_history(&account.id) { + let allocation_percentage = if total_portfolio_value > 0.0 { + (history.market_value / total_portfolio_value) * 100.0 + } else { + 0.0 + }; + + account_summaries.push(AccountSummary { + account, + performance: PortfolioHistory { + allocation_percentage, + ..history + }, + }); + } + } + + Ok(account_summaries) + } } diff --git a/src-tauri/src/commands/portfolio.rs b/src-tauri/src/commands/portfolio.rs index e94e936..466a80d 100644 --- a/src-tauri/src/commands/portfolio.rs +++ b/src-tauri/src/commands/portfolio.rs @@ -1,18 +1,22 @@ -use crate::models::{FinancialHistory, Holding, IncomeSummary}; +use crate::models::{AccountSummary, HistorySummary, Holding, IncomeSummary, PortfolioHistory}; use crate::portfolio::portfolio_service::PortfolioService; use crate::AppState; + use tauri::State; #[tauri::command] -pub async fn get_historical(state: State<'_, AppState>) -> Result, String> { +pub async fn calculate_historical_data( + state: State<'_, AppState>, + account_ids: Option>, +) -> Result, String> { println!("Fetching portfolio historical..."); let service = PortfolioService::new((*state.pool).clone()) .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; service - .calculate_historical_portfolio_values() - .map_err(|e| format!("Failed to fetch activities: {}", e)) + .calculate_historical_data(account_ids) + .map_err(|e| format!("Failed to calculate historical data: {}", e)) } #[tauri::command] @@ -37,3 +41,32 @@ pub async fn get_income_summary(state: State<'_, AppState>) -> Result, + account_id: String, +) -> Result, String> { + println!("Fetching account history for account ID: {}", account_id); + + let service = PortfolioService::new((*state.pool).clone()) + .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; + + service + .get_account_history(&account_id) + .map_err(|e| format!("Failed to fetch account history: {}", e)) +} + +#[tauri::command] +pub async fn get_accounts_summary( + state: State<'_, AppState>, +) -> Result, String> { + println!("Fetching active accounts performance..."); + + let service = PortfolioService::new((*state.pool).clone()) + .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; + + service + .get_accounts_summary() + .map_err(|e| format!("Failed to fetch active accounts performance: {}", e)) +} diff --git a/src/commands/market-data.ts b/src/commands/market-data.ts index 162af98..5d47dcd 100644 --- a/src/commands/market-data.ts +++ b/src/commands/market-data.ts @@ -5,7 +5,7 @@ export const searchTicker = async (query: string): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: - return invokeTauri('search_ticker', { query }); + return invokeTauri('search_symbol', { query }); default: throw new Error(`Unsupported`); } From 54dc828a309c92f75d10f98a904b0edc5ee5687b Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Mon, 16 Sep 2024 15:32:12 -0400 Subject: [PATCH 12/45] WIP saving historical data values --- .../down.sql | 20 + .../up.sql | 37 ++ src-core/src/error.rs | 9 + src-core/src/models.rs | 85 +++- src-core/src/portfolio/history_service.rs | 473 ++++++++++++------ src-core/src/schema.rs | 74 ++- src-tauri/src/main.rs | 23 +- src/commands/portfolio.ts | 22 +- src/commands/quote-listener.ts | 15 +- src/lib/types.ts | 26 + src/pages/dashboard/dashboard-page.tsx | 21 +- src/useGlobalEventListener.ts | 10 +- 12 files changed, 587 insertions(+), 228 deletions(-) create mode 100644 src-core/migrations/2024-09-16-023604_portfolio_history/down.sql create mode 100644 src-core/migrations/2024-09-16-023604_portfolio_history/up.sql diff --git a/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql b/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql new file mode 100644 index 0000000..702b8fa --- /dev/null +++ b/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql @@ -0,0 +1,20 @@ +DROP TABLE IF EXISTS portfolio_history; + +-- Revert changes to goals table +ALTER TABLE goals RENAME TO goals_new; + +CREATE TABLE goals ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "description" TEXT, + "target_amount" REAL NOT NULL, + "is_achieved" BOOLEAN +); + +INSERT INTO goals (id, title, description, target_amount, is_achieved) +SELECT id, title, description, CAST(target_amount AS REAL), is_achieved +FROM goals_new; + +DROP TABLE goals_new; + +DROP INDEX IF EXISTS idx_portfolio_history_account_date; diff --git a/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql b/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql new file mode 100644 index 0000000..bb00428 --- /dev/null +++ b/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql @@ -0,0 +1,37 @@ +CREATE TABLE portfolio_history ( + id TEXT NOT NULL PRIMARY KEY, + account_id TEXT NOT NULL, + date TEXT NOT NULL, + total_value NUMERIC NOT NULL DEFAULT 0, + market_value NUMERIC NOT NULL DEFAULT 0, + book_cost NUMERIC NOT NULL DEFAULT 0, + available_cash NUMERIC NOT NULL DEFAULT 0, + net_deposit NUMERIC NOT NULL DEFAULT 0, + currency TEXT NOT NULL, + base_currency TEXT NOT NULL, + total_gain_value NUMERIC NOT NULL DEFAULT 0, + total_gain_percentage NUMERIC NOT NULL DEFAULT 0, + day_gain_percentage NUMERIC NOT NULL DEFAULT 0, + day_gain_value NUMERIC NOT NULL DEFAULT 0, + allocation_percentage NUMERIC NOT NULL DEFAULT 0, + exchange_rate NUMERIC NOT NULL DEFAULT 0, + UNIQUE(account_id, date) +); +CREATE INDEX idx_portfolio_history_account_date ON portfolio_history(account_id, date); + +-- Update goals table +ALTER TABLE goals RENAME TO goals_old; + +CREATE TABLE goals ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "description" TEXT, + "target_amount" NUMERIC NOT NULL, + "is_achieved" BOOLEAN NOT NULL DEFAULT false +); +INSERT INTO goals (id, title, description, target_amount, is_achieved) +SELECT id, title, description, CAST(target_amount AS NUMERIC), is_achieved +FROM goals_old; + +DROP TABLE goals_old; + diff --git a/src-core/src/error.rs b/src-core/src/error.rs index 214c69b..c4596ac 100644 --- a/src-core/src/error.rs +++ b/src-core/src/error.rs @@ -1,9 +1,12 @@ +use diesel::r2d2; use thiserror::Error; #[derive(Error, Debug)] pub enum PortfolioError { #[error("Database error: {0}")] DatabaseError(#[from] diesel::result::Error), + #[error("Connection error: {0}")] + ConnectionError(r2d2::Error), #[error("Currency conversion error: {0}")] CurrencyConversionError(String), #[error("Asset not found: {0}")] @@ -12,4 +15,10 @@ pub enum PortfolioError { InvalidDataError(String), } +impl From for PortfolioError { + fn from(err: r2d2::Error) -> Self { + PortfolioError::ConnectionError(err) + } +} + pub type Result = std::result::Result; diff --git a/src-core/src/models.rs b/src-core/src/models.rs index 6dbe97c..73445eb 100644 --- a/src-core/src/models.rs +++ b/src-core/src/models.rs @@ -191,7 +191,15 @@ pub struct NewActivity { } #[derive( - Queryable, Identifiable, Insertable, Associations, Serialize, AsChangeset, Deserialize, Debug, + Queryable, + Identifiable, + Insertable, + Associations, + Serialize, + AsChangeset, + Deserialize, + Debug, + Clone, )] #[diesel(belongs_to(Asset, foreign_key = symbol))] #[diesel(table_name= crate::schema::quotes)] @@ -327,31 +335,11 @@ pub struct Holding { pub sectors: Option>, } -// FinancialSnapshot and FinancialHistory structs with serde for serialization/deserialization -#[derive(Serialize, Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct FinancialSnapshot { - pub date: String, - pub total_value: f64, - pub market_value: f64, - pub book_cost: f64, - pub available_cash: f64, - pub net_deposit: f64, - pub currency: String, - pub base_currency: String, - pub total_gain_value: f64, - pub total_gain_percentage: f64, - pub day_gain_percentage: f64, - pub day_gain_value: f64, - pub allocation_percentage: Option, - pub exchange_rate: Option, -} - #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct FinancialHistory { - pub account: Account, // Define Account struct accordingly - pub history: Vec, + pub account: Account, + pub history: Vec, } #[derive(Serialize, Deserialize, Debug)] @@ -499,3 +487,54 @@ pub struct IncomeSummary { pub total_income_ytd: f64, pub currency: String, } + +// FinancialSnapshot and FinancialHistory structs with serde for serialization/deserialization +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct FinancialSnapshot { + pub date: String, + pub total_value: f64, + pub market_value: f64, + pub book_cost: f64, + pub available_cash: f64, + pub net_deposit: f64, + pub currency: String, + pub base_currency: String, + pub total_gain_value: f64, + pub total_gain_percentage: f64, + pub day_gain_percentage: f64, + pub day_gain_value: f64, + pub allocation_percentage: Option, + pub exchange_rate: Option, +} +#[derive(Queryable, Selectable, Insertable, Associations, Debug, Clone, Serialize, Deserialize)] +#[diesel(belongs_to(Account, foreign_key = account_id))] +#[diesel(table_name = crate::schema::portfolio_history)] +#[serde(rename_all = "camelCase")] +pub struct PortfolioHistory { + pub id: String, + pub account_id: String, + pub date: String, + pub total_value: f64, + pub market_value: f64, + pub book_cost: f64, + pub available_cash: f64, + pub net_deposit: f64, + pub currency: String, + pub base_currency: String, + pub total_gain_value: f64, + pub total_gain_percentage: f64, + pub day_gain_percentage: f64, + pub day_gain_value: f64, + pub allocation_percentage: f64, + pub exchange_rate: f64, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct HistorySummary { + pub id: Option, + pub start_date: String, + pub end_date: String, + pub entries_count: usize, +} diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index 138640e..4a3641b 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -1,32 +1,50 @@ +use crate::error::{PortfolioError, Result}; use crate::fx::fx_service::CurrencyExchangeService; -use crate::models::{Account, Activity, FinancialHistory, FinancialSnapshot, Quote}; +use crate::models::{Account, Activity, HistorySummary, PortfolioHistory, Quote}; use chrono::{Duration, NaiveDate, Utc}; + +use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::SqliteConnection; use rayon::prelude::*; use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use uuid::Uuid; pub struct HistoryService { fx_service: CurrencyExchangeService, base_currency: String, + pool: Pool>, } impl HistoryService { - pub fn new(fx_service: CurrencyExchangeService, base_currency: String) -> Self { + pub fn new( + pool: Pool>, + fx_service: CurrencyExchangeService, + base_currency: String, + ) -> Self { Self { fx_service, base_currency, + pool, } } - pub fn calculate_historical_portfolio_values( + pub fn calculate_historical_data( &self, accounts: &[Account], activities: &[Activity], market_data: &[Quote], - ) -> Vec { - // Use Rayon's par_iter to process each account in parallel - let results: Vec = accounts + ) -> Result> { + println!("Starting calculate_historical_data"); + let end_date = Utc::now().naive_utc().date(); + + let all_histories = Arc::new(Mutex::new(Vec::new())); + let total_history = Arc::new(Mutex::new(Vec::new())); + + let mut summaries: Vec = accounts .par_iter() - .filter_map(|account| { + .map(|account| { let account_activities: Vec<_> = activities .iter() .filter(|a| a.account_id == account.id) @@ -34,129 +52,122 @@ impl HistoryService { .collect(); if account_activities.is_empty() { - None - } else { - let history = self.calculate_historical_value(&account_activities, market_data); - Some(FinancialHistory { - account: account.clone(), - history, - }) + println!("No activities for account {}", account.id); + return HistorySummary { + id: Some(account.id.clone()), + start_date: "".to_string(), + end_date: "".to_string(), + entries_count: 0, + }; } - }) - .collect(); - // Calculate the total value of the portfolio - let portfolio_total_value = results - .iter() - .map(|fh| fh.history.last().map_or(0.0, |s| s.total_value)) - .sum::(); - - // Calculate the percentage of each account - let mut results_with_percentage = results - .into_iter() - .map(|mut fh| { - let account_total: f64 = fh.history.last().map_or(0.0, |s| s.total_value); - let percentage = account_total / portfolio_total_value * 100.0; - if let Some(last_snapshot) = fh.history.last_mut() { - last_snapshot.allocation_percentage = Some(percentage); + let last_date = self.get_last_historical_date(&account.id).unwrap_or(None); + + let account_start_date = + last_date.map(|d| d + Duration::days(1)).unwrap_or_else(|| { + account_activities + .iter() + .map(|a| a.activity_date.date()) + .min() + .unwrap_or_else(|| Utc::now().naive_utc().date()) + }); + + let new_history = self.calculate_historical_value( + &account.id, + &account_activities, + market_data, + account_start_date, + end_date, + ); + + println!( + "Calculated {} historical entries for account {}", + new_history.len(), + account.id + ); + + if !new_history.is_empty() { + all_histories.lock().unwrap().push(new_history.clone()); } - fh - }) - .collect::>(); - - // Aggregate historical data from all accounts - let mut aggregated_history: HashMap = HashMap::new(); - for financial_history in &results_with_percentage { - self.aggregate_account_history(&mut aggregated_history, &financial_history.history); - } - let mut total_history: Vec<_> = aggregated_history.into_values().collect(); - total_history.sort_by(|a, b| a.date.cmp(&b.date)); + HistorySummary { + id: Some(account.id.clone()), + start_date: new_history + .first() + .map(|h| { + NaiveDate::parse_from_str(&h.date, "%Y-%m-%d") + .unwrap() + .to_string() + }) + .unwrap_or_default(), + end_date: new_history + .last() + .map(|h| { + NaiveDate::parse_from_str(&h.date, "%Y-%m-%d") + .unwrap() + .to_string() + }) + .unwrap_or_default(), + entries_count: new_history.len(), + } + }) + .collect(); - let total_account = self.create_total_account(); - results_with_percentage.push(FinancialHistory { - account: total_account, - history: total_history, - }); + let account_histories = all_histories.lock().unwrap(); - results_with_percentage - } + // Calculate total portfolio history + *total_history.lock().unwrap() = self.calculate_total_portfolio_history(&account_histories); - fn aggregate_account_history( - &self, - aggregated_history: &mut HashMap, - history: &[FinancialSnapshot], - ) { - for snapshot in history { - let entry = aggregated_history - .entry(snapshot.date.clone()) - .or_insert_with(|| FinancialSnapshot { - date: snapshot.date.clone(), - total_value: 0.0, - market_value: 0.0, - book_cost: 0.0, - available_cash: 0.0, - net_deposit: 0.0, - currency: snapshot.currency.to_string(), - base_currency: self.base_currency.to_string(), - total_gain_value: 0.0, - total_gain_percentage: 0.0, - day_gain_percentage: 0.0, - day_gain_value: 0.0, - allocation_percentage: None, - exchange_rate: Some(1.0), // Default exchange rate for base currency - }); + // Save all historical data + for history in account_histories.iter() { + if let Err(e) = self.save_historical_data(history) { + println!("Error saving account history: {:?}", e); + return Err(e); + } + } - let exchange_rate = self - .fx_service - .get_exchange_rate(&snapshot.currency, &self.base_currency) - .unwrap_or(1.0); + // Save total portfolio history + println!("Saving total portfolio history"); + if let Err(e) = self.save_historical_data(&total_history.lock().unwrap()) { + println!("Error saving total portfolio history: {:?}", e); + return Err(e); + } - // Convert values to base currency before aggregating - entry.total_value += snapshot.total_value * exchange_rate; - entry.market_value += snapshot.market_value * exchange_rate; - entry.book_cost += snapshot.book_cost * exchange_rate; - entry.available_cash += snapshot.available_cash * exchange_rate; - entry.net_deposit += snapshot.net_deposit * exchange_rate; - entry.total_gain_value += snapshot.total_gain_value * exchange_rate; - - // Recalculate percentage values based on aggregated totals - entry.total_gain_percentage = if entry.book_cost != 0.0 { - entry.total_gain_value / entry.book_cost * 100.0 - } else { - 0.0 + let total_summary = { + let total_history_guard = total_history.lock().expect("Failed to lock total_history"); + let parse_date = |h: &PortfolioHistory| -> String { + NaiveDate::parse_from_str(&h.date, "%Y-%m-%d") + .map(|date| date.to_string()) + .unwrap_or_default() }; - // Assuming day gain values are already in base currency or need similar conversion - entry.day_gain_percentage += snapshot.day_gain_percentage; - entry.day_gain_value += snapshot.day_gain_value * exchange_rate; - } - } + HistorySummary { + id: Some("TOTAL".to_string()), + start_date: total_history_guard + .first() + .map(parse_date) + .unwrap_or_default(), + end_date: total_history_guard + .last() + .map(parse_date) + .unwrap_or_default(), + entries_count: total_history_guard.len(), + } + }; - fn create_total_account(&self) -> Account { - Account { - id: "TOTAL".to_string(), - name: "Total".to_string(), - account_type: "TOTAL".to_string(), - group: Some("TOTAL".to_string()), - is_default: true, - is_active: true, - created_at: Utc::now().naive_utc(), - updated_at: Utc::now().naive_utc(), - platform_id: None, - currency: self.base_currency.to_string(), - } + // Add the total summary to the summaries array + summaries.push(total_summary); + Ok(summaries) } fn calculate_historical_value( &self, + account_id: &str, activities: &[Activity], quotes: &[Quote], - ) -> Vec { - let first_activity = activities[0].clone(); - - let start_date = first_activity.activity_date.date(); - let end_date = Utc::now().naive_utc().date(); + start_date: NaiveDate, + end_date: NaiveDate, + ) -> Vec { let all_dates = Self::get_dates_between(start_date, end_date); let mut currency = self.base_currency.as_str(); @@ -164,46 +175,53 @@ impl HistoryService { let mut holdings: HashMap = HashMap::new(); let mut results = Vec::new(); - let mut _initial_investment = 0.0; let mut net_deposit = 0.0; let mut book_cost = 0.0; - // HashMap to keep the last available quote for each symbol - let mut last_available_quotes: HashMap = HashMap::new(); + let mut last_available_quotes: HashMap = HashMap::new(); + let mut average_purchase_prices: HashMap = HashMap::new(); for date in all_dates { for activity in activities.iter().filter(|a| a.activity_date.date() == date) { currency = activity.currency.as_str(); - let activity_amount = activity.quantity; + let activity_amount = activity.quantity * activity.unit_price; let activity_fee = activity.fee; match activity.activity_type.as_str() { "BUY" => { let entry = holdings.entry(activity.asset_id.clone()).or_insert(0.0); - *entry += activity_amount; - let buy_cost = activity_amount * activity.unit_price + activity_fee; + let buy_cost = activity_amount + activity_fee; + let new_quantity = *entry + activity.quantity; + let avg_price = average_purchase_prices + .entry(activity.asset_id.clone()) + .or_insert(0.0); + *avg_price = (*avg_price * *entry + buy_cost) / new_quantity; + *entry = new_quantity; cumulative_cash -= buy_cost; - _initial_investment += activity_amount * activity.unit_price; book_cost += buy_cost; } "SELL" => { let entry = holdings.entry(activity.asset_id.clone()).or_insert(0.0); - *entry -= activity_amount; - let sell_profit = activity_amount * activity.unit_price - activity_fee; + let sell_quantity = activity.quantity.min(*entry); + let avg_price = *average_purchase_prices + .get(&activity.asset_id) + .unwrap_or(&0.0); + let sell_cost = sell_quantity * avg_price; + let sell_profit = activity_amount - activity_fee; + *entry -= sell_quantity; cumulative_cash += sell_profit; - _initial_investment -= activity_amount * activity.unit_price; - book_cost -= activity_amount * activity.unit_price + activity_fee; + book_cost -= sell_cost; } "DEPOSIT" | "TRANSFER_IN" | "CONVERSION_IN" => { - cumulative_cash += activity_amount * activity.unit_price - activity_fee; - net_deposit += activity_amount * activity.unit_price; + cumulative_cash += activity_amount - activity_fee; + net_deposit += activity_amount - activity_fee; } "DIVIDEND" | "INTEREST" => { - cumulative_cash += activity_amount * activity.unit_price - activity_fee; + cumulative_cash += activity_amount - activity_fee; } "WITHDRAWAL" | "TRANSFER_OUT" | "CONVERSION_OUT" => { - cumulative_cash -= activity_amount * activity.unit_price + activity_fee; - net_deposit -= activity_amount * activity.unit_price; + cumulative_cash -= activity_amount + activity_fee; + net_deposit -= activity_amount + activity_fee; } "FEE" | "TAX" => { cumulative_cash -= activity_fee; @@ -212,28 +230,8 @@ impl HistoryService { } } - let mut holdings_value = 0.0; - let mut day_gain_value = 0.0; - - for (symbol, &holding_amount) in &holdings { - let quote = quotes - .iter() - .find(|q| q.date.date() == date && q.symbol == *symbol) - .or_else(|| last_available_quotes.get(symbol).cloned()); - - if let Some(quote) = quote { - let holding_value_for_symbol = holding_amount * quote.close; - let daily_change_percent = ((quote.close - quote.open) / quote.open) * 100.0; - let day_gain_for_symbol = - (daily_change_percent / 100.0) * holding_value_for_symbol; - - holdings_value += holding_value_for_symbol; - day_gain_value += day_gain_for_symbol; - - // Update the last available quote for the symbol - last_available_quotes.insert(symbol.clone(), quote); - } - } + let (holdings_value, day_gain_value) = + self.calculate_holdings_value(&holdings, quotes, date, &mut last_available_quotes); let day_gain_percentage = if holdings_value != 0.0 { (day_gain_value / holdings_value) * 100.0 @@ -242,7 +240,7 @@ impl HistoryService { }; let total_value = cumulative_cash + holdings_value; - let total_gain_value = holdings_value - book_cost; + let total_gain_value = total_value - book_cost; let total_gain_percentage = if book_cost != 0.0 { (total_gain_value / book_cost) * 100.0 } else { @@ -254,7 +252,9 @@ impl HistoryService { .get_exchange_rate(currency, &self.base_currency) .unwrap_or(1.0); - results.push(FinancialSnapshot { + results.push(PortfolioHistory { + id: Uuid::new_v4().to_string(), + account_id: account_id.to_string(), date: date.format("%Y-%m-%d").to_string(), total_value, market_value: holdings_value, @@ -263,18 +263,167 @@ impl HistoryService { net_deposit, currency: currency.to_string(), base_currency: self.base_currency.to_string(), - total_gain_value: holdings_value - book_cost, + total_gain_value, total_gain_percentage, day_gain_percentage, day_gain_value, - allocation_percentage: None, // to Calculate later - exchange_rate: Some(exchange_rate), + allocation_percentage: 0.0, // to Calculate later + exchange_rate, }); } results } + fn calculate_total_portfolio_history( + &self, + account_histories: &[Vec], + ) -> Vec { + let mut total_history = HashMap::new(); + + for history in account_histories { + for snapshot in history { + let entry = total_history + .entry(snapshot.date.clone()) + .or_insert_with(|| PortfolioHistory { + id: Uuid::new_v4().to_string(), // Generate a new UUID for each day + account_id: "TOTAL".to_string(), + date: snapshot.date.clone(), + total_value: 0.0, + market_value: 0.0, + book_cost: 0.0, + available_cash: 0.0, + net_deposit: 0.0, + currency: self.base_currency.clone(), + base_currency: self.base_currency.clone(), + total_gain_value: 0.0, + total_gain_percentage: 0.0, + day_gain_percentage: 0.0, + day_gain_value: 0.0, + allocation_percentage: 0.0, + exchange_rate: 1.0, + }); + + let exchange_rate = self + .fx_service + .get_exchange_rate(&snapshot.currency, &self.base_currency) + .unwrap_or(1.0); + + entry.total_value += snapshot.total_value * exchange_rate; + entry.market_value += snapshot.market_value * exchange_rate; + entry.book_cost += snapshot.book_cost * exchange_rate; + entry.available_cash += snapshot.available_cash * exchange_rate; + entry.net_deposit += snapshot.net_deposit * exchange_rate; + entry.day_gain_value += snapshot.day_gain_value * exchange_rate; + } + } + + let mut total_history: Vec<_> = total_history.into_values().collect(); + total_history.sort_by(|a, b| a.date.cmp(&b.date)); + + // Recalculate percentages for total portfolio + for record in &mut total_history { + record.total_gain_value = record.total_value - record.book_cost; + record.total_gain_percentage = if record.book_cost != 0.0 { + (record.total_gain_value / record.book_cost) * 100.0 + } else { + 0.0 + }; + record.day_gain_percentage = if record.market_value != 0.0 { + (record.day_gain_value / record.market_value) * 100.0 + } else { + 0.0 + }; + } + + total_history + } + + fn save_historical_data(&self, history_data: &[PortfolioHistory]) -> Result<()> { + use crate::schema::portfolio_history::dsl::*; + let conn = &mut self.pool.get().unwrap(); + + let values: Vec<_> = history_data + .iter() + .map(|record| { + ( + id.eq(&record.id), + account_id.eq(&record.account_id), + date.eq(&record.date), + total_value.eq(record.total_value), + market_value.eq(record.market_value), + book_cost.eq(record.book_cost), + available_cash.eq(record.available_cash), + net_deposit.eq(record.net_deposit), + currency.eq(&record.currency), + base_currency.eq(&record.base_currency), + total_gain_value.eq(record.total_gain_value), + total_gain_percentage.eq(record.total_gain_percentage), + day_gain_percentage.eq(record.day_gain_percentage), + day_gain_value.eq(record.day_gain_value), + allocation_percentage.eq(record.allocation_percentage), + exchange_rate.eq(record.exchange_rate), + ) + }) + .collect(); + + diesel::replace_into(portfolio_history) + .values(&values) + .execute(conn)?; + + Ok(()) + } + + fn calculate_holdings_value( + &self, + holdings: &HashMap, + quotes: &[Quote], + date: NaiveDate, + last_available_quotes: &mut HashMap, + ) -> (f64, f64) { + let mut holdings_value = 0.0; + let mut day_gain_value = 0.0; + + for (symbol, &holding_amount) in holdings { + let quote = quotes + .iter() + .find(|q| q.date.date() == date && q.symbol == *symbol) + .or_else(|| last_available_quotes.get(symbol)) + .cloned(); + + if let Some(quote) = quote { + let holding_value_for_symbol = holding_amount * quote.close; + let daily_change_percent = ((quote.close - quote.open) / quote.open) * 100.0; + let day_gain_for_symbol = (daily_change_percent / 100.0) * holding_value_for_symbol; + + holdings_value += holding_value_for_symbol; + day_gain_value += day_gain_for_symbol; + + // Update the last available quote for the symbol + last_available_quotes.insert(symbol.clone(), quote); + } + } + + (holdings_value, day_gain_value) + } + + fn get_last_historical_date(&self, input_account_id: &str) -> Result> { + use crate::schema::portfolio_history::dsl::*; + let conn = &mut self.pool.get().unwrap(); + + portfolio_history + .filter(account_id.eq(input_account_id)) + .select(date) + .order(date.desc()) + .first::(conn) + .optional() + .map(|opt_date_str| { + opt_date_str + .and_then(|date_str| NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").ok()) + }) + .map_err(PortfolioError::DatabaseError) + } + fn get_dates_between(start: NaiveDate, end: NaiveDate) -> Vec { let mut dates = Vec::new(); let mut current = start; @@ -286,4 +435,18 @@ impl HistoryService { dates } + + pub fn get_account_history(&self, input_account_id: &str) -> Result> { + use crate::schema::portfolio_history::dsl::*; + use diesel::prelude::*; + + let conn = &mut self.pool.get().unwrap(); + + let history_data: Vec = portfolio_history + .filter(account_id.eq(input_account_id)) + .order(date.asc()) + .load::(conn)?; + + Ok(history_data) + } } diff --git a/src-core/src/schema.rs b/src-core/src/schema.rs index e3cdcdf..0f8266f 100644 --- a/src-core/src/schema.rs +++ b/src-core/src/schema.rs @@ -57,6 +57,25 @@ diesel::table! { } } +diesel::table! { + goals (id) { + id -> Text, + title -> Text, + description -> Nullable, + target_amount -> Double, + is_achieved -> Bool, + } +} + +diesel::table! { + goals_allocation (id) { + id -> Text, + percent_allocation -> Integer, + goal_id -> Text, + account_id -> Text, + } +} + diesel::table! { platforms (id) { id -> Text, @@ -65,6 +84,27 @@ diesel::table! { } } +diesel::table! { + portfolio_history (id) { + id -> Text, + account_id -> Text, + date -> Text, + total_value -> Double, + market_value -> Double, + book_cost -> Double, + available_cash -> Double, + net_deposit -> Double, + currency -> Text, + base_currency -> Text, + total_gain_value -> Double, + total_gain_percentage -> Double, + day_gain_percentage -> Double, + day_gain_value -> Double, + allocation_percentage -> Double, + exchange_rate -> Double, + } +} + diesel::table! { quotes (id) { id -> Text, @@ -90,33 +130,21 @@ diesel::table! { } } -diesel::table! { - goals (id) { - id -> Text, - title -> Text, - description -> Nullable, - target_amount -> Double, - is_achieved -> Bool, - } -} - -diesel::table! { - goals_allocation (id) { - id -> Text, - percent_allocation -> Integer, - goal_id -> Text, - account_id -> Text, - } -} - diesel::joinable!(accounts -> platforms (platform_id)); diesel::joinable!(activities -> accounts (account_id)); diesel::joinable!(activities -> assets (asset_id)); -diesel::joinable!(quotes -> assets (symbol)); +diesel::joinable!(goals_allocation -> accounts (account_id)); diesel::joinable!(goals_allocation -> goals (goal_id)); +diesel::joinable!(quotes -> assets (symbol)); diesel::allow_tables_to_appear_in_same_query!( - accounts, activities, assets, platforms, quotes, settings, + accounts, + activities, + assets, + goals, + goals_allocation, + platforms, + portfolio_history, + quotes, + settings, ); - -diesel::allow_tables_to_appear_in_same_query!(goals, goals_allocation); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6b3f224..aa133f7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -14,7 +14,9 @@ use commands::goal::{ }; use commands::market_data::{get_asset_data, search_symbol, synch_quotes}; -use commands::portfolio::{compute_holdings, get_historical, get_income_summary}; +use commands::portfolio::{ + calculate_historical_data, compute_holdings, get_account_history, get_income_summary, +}; use commands::settings::{get_settings, update_currency, update_settings}; use wealthfolio_core::db; @@ -87,7 +89,7 @@ fn main() { search_symbol, check_activities_import, create_activities, - get_historical, + calculate_historical_data, compute_holdings, get_asset_data, synch_quotes, @@ -101,6 +103,7 @@ fn main() { update_goal_allocations, load_goals_allocations, get_income_summary, + get_account_history, ]) .build(context) .expect("error while running wealthfolio application"); @@ -130,23 +133,23 @@ fn handle_menu_event(event: tauri::WindowMenuEvent) { fn spawn_quote_sync(app_handle: tauri::AppHandle, pool: Arc) { spawn(async move { - let asset_service = asset::asset_service::AssetService::new((*pool).clone()); + let portfolio_service = portfolio::PortfolioService::new((*pool).clone()) + .expect("Failed to create PortfolioService"); + app_handle - .emit_all("QUOTES_SYNC_START", ()) + .emit_all("PORTFOLIO_UPDATE_START", ()) .expect("Failed to emit event"); - let result = asset_service.initialize_and_sync_quotes().await; - - match result { + match portfolio_service.update_portfolio().await { Ok(_) => { app_handle - .emit_all("QUOTES_SYNC_COMPLETE", ()) + .emit_all("PORTFOLIO_UPDATE_COMPLETE", ()) .expect("Failed to emit event"); } Err(e) => { - eprintln!("Failed to sync history quotes: {}", e); + eprintln!("Failed to update portfolio: {}", e); app_handle - .emit_all("QUOTES_SYNC_ERROR", ()) + .emit_all("PORTFOLIO_UPDATE_ERROR", ()) .expect("Failed to emit event"); } } diff --git a/src/commands/portfolio.ts b/src/commands/portfolio.ts index 43e3061..3b0f17a 100644 --- a/src/commands/portfolio.ts +++ b/src/commands/portfolio.ts @@ -1,16 +1,16 @@ -import { FinancialHistory, Holding, IncomeSummary } from '@/lib/types'; import { getRunEnv, RUN_ENV, invokeTauri } from '@/adapters'; +import { Holding, IncomeSummary, HistorySummary, PortfolioHistory } from '@/lib/types'; -export const getHistorical = async (): Promise => { +export const calculate_historical_data = async (): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: - return invokeTauri('get_historical'); + return invokeTauri('calculate_historical_data'); default: throw new Error(`Unsupported`); } } catch (error) { - console.error('Error fetching accounts:', error); + console.error('Error calculating historical data:', error); throw error; } }; @@ -42,3 +42,17 @@ export const getIncomeSummary = async (): Promise => { throw error; } }; + +export const getAccountHistory = async (accountId: string): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('get_account_history', { accountId }); + default: + throw new Error(`Unsupported`); + } + } catch (error) { + console.error('Error fetching account history:', error); + throw error; + } +}; diff --git a/src/commands/quote-listener.ts b/src/commands/quote-listener.ts index b4b4ab6..03ac53d 100644 --- a/src/commands/quote-listener.ts +++ b/src/commands/quote-listener.ts @@ -1,5 +1,10 @@ import type { EventCallback, UnlistenFn } from '@/adapters'; -import { getRunEnv, RUN_ENV, listenQuotesSyncStartTauri, listenQuotesSyncCompleteTauri } from "@/adapters"; +import { + getRunEnv, + RUN_ENV, + listenQuotesSyncStartTauri, + listenQuotesSyncCompleteTauri, +} from '@/adapters'; // listenQuotesSyncStart export const listenQuotesSyncStart = async (handler: EventCallback): Promise => { @@ -11,13 +16,15 @@ export const listenQuotesSyncStart = async (handler: EventCallback): Promi throw new Error(`Unsupported`); } } catch (error) { - console.error('Error listen QUOTES_SYNC_START:', error); + console.error('Error listen PORTFOLIO_UPDATE_START:', error); throw error; } }; // listenQuotesSyncComplete -export const listenQuotesSyncComplete = async (handler: EventCallback): Promise => { +export const listenQuotesSyncComplete = async ( + handler: EventCallback, +): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: @@ -26,7 +33,7 @@ export const listenQuotesSyncComplete = async (handler: EventCallback): Pr throw new Error(`Unsupported`); } } catch (error) { - console.error('Error listen QUOTES_SYNC_COMPLETE:', error); + console.error('Error listen PORTFOLIO_UPDATE_COMPLETE:', error); throw error; } }; diff --git a/src/lib/types.ts b/src/lib/types.ts index 8a3ceaa..d4f1935 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -316,3 +316,29 @@ export interface IncomeSummary { } export type TimePeriod = '1D' | '1W' | '1M' | '3M' | '1Y' | 'ALL'; + +export interface HistorySummary { + id?: string; + startDate: string; + endDate: string; + entriesCount: number; +} + +export interface PortfolioHistory { + id: string; + accountId: string; + date: string; + totalValue: number; + marketValue: number; + bookCost: number; + availableCash: number; + netDeposit: number; + currency: string; + baseCurrency: string; + totalGainValue: number; + totalGainPercentage: number; + dayGainPercentage: number; + dayGainValue: number; + allocationPercentage: number | null; + exchangeRate: number | null; +} diff --git a/src/pages/dashboard/dashboard-page.tsx b/src/pages/dashboard/dashboard-page.tsx index ebad6c7..dc165a1 100644 --- a/src/pages/dashboard/dashboard-page.tsx +++ b/src/pages/dashboard/dashboard-page.tsx @@ -3,8 +3,8 @@ import { GainPercent } from '@/components/gain-percent'; import { HistoryChart } from '@/components/history-chart'; import Balance from './balance'; import { useQuery } from '@tanstack/react-query'; -import { FinancialHistory } from '@/lib/types'; -import { getHistorical } from '@/commands/portfolio'; +import { FinancialHistory, PortfolioHistory } from '@/lib/types'; +import { getHistorical, getAccountHistory } from '@/commands/portfolio'; import { Skeleton } from '@/components/ui/skeleton'; import { Accounts } from './accounts'; import SavingGoals from './goals'; @@ -27,17 +27,26 @@ function DashboardSkeleton() { } export default function DashboardPage() { - const { data: historyData, isLoading } = useQuery({ + const { data: historyData, isLoading: isHistoryLoading } = useQuery({ queryKey: ['portfolio_history'], queryFn: getHistorical, }); - if (isLoading) { + const { data: portfolioHistory, isLoading: isPortfolioHistoryLoading } = useQuery< + PortfolioHistory[], + Error + >({ + queryKey: ['account_history', 'TOTAL'], + queryFn: () => getAccountHistory('TOTAL'), + }); + + if (isHistoryLoading || isPortfolioHistoryLoading) { return ; } + console.log('portfolioHistory', portfolioHistory); const portfolio = historyData?.find((history) => history.account?.id === 'TOTAL'); - const todayValue = portfolio?.history[portfolio?.history.length - 1]; + const todayValue = portfolioHistory?.[portfolioHistory.length - 1]; const accountsData = formatAccountsData(historyData || [], portfolio?.account.currency); @@ -67,7 +76,7 @@ export default function DashboardPage() {
- +
{/* Responsive grid */} diff --git a/src/useGlobalEventListener.ts b/src/useGlobalEventListener.ts index ce4f94b..ac8bc3b 100644 --- a/src/useGlobalEventListener.ts +++ b/src/useGlobalEventListener.ts @@ -10,16 +10,20 @@ const useGlobalEventListener = () => { useEffect(() => { const handleQuoteSyncStart = () => { toast({ - title: 'Syncing quotes...', - description: 'Please wait while we sync your quotes', + title: 'Updating Market Data', + description: 'Fetching the latest market prices. This may take a moment.', + duration: 5000, }); }; const handleQuotesSyncComplete = () => { queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); queryClient.invalidateQueries({ queryKey: ['holdings'] }); + queryClient.invalidateQueries({ queryKey: ['account_history', 'TOTAL'] }); toast({ - title: 'Quotes synced successfully', + title: 'Portfolio Update Complete', + description: 'Your portfolio has been refreshed with the latest market data.', + duration: 5000, }); }; const setupListeners = async () => { From 86d5ef1750dbfdb82771e3ca592c8c63d5908dce Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Mon, 16 Sep 2024 17:58:42 -0400 Subject: [PATCH 13/45] refactor history calculation rebase refactor history calculation --- src-core/src/models.rs | 25 ++----- src-core/src/portfolio/history_service.rs | 15 +++++ src-tauri/src/main.rs | 4 +- src/commands/portfolio.ts | 22 ++++++- src/lib/portfolio-helper.ts | 49 +++----------- src/lib/types.ts | 49 ++------------ src/pages/account/account-detail.tsx | 4 +- src/pages/account/account-page.tsx | 41 +++++++----- src/pages/dashboard/accounts.tsx | 80 +++++++++++++---------- src/pages/dashboard/dashboard-page.tsx | 26 +++----- src/pages/dashboard/goals.tsx | 4 +- 11 files changed, 143 insertions(+), 176 deletions(-) diff --git a/src-core/src/models.rs b/src-core/src/models.rs index 73445eb..42a2677 100644 --- a/src-core/src/models.rs +++ b/src-core/src/models.rs @@ -488,25 +488,6 @@ pub struct IncomeSummary { pub currency: String, } -// FinancialSnapshot and FinancialHistory structs with serde for serialization/deserialization -#[derive(Serialize, Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct FinancialSnapshot { - pub date: String, - pub total_value: f64, - pub market_value: f64, - pub book_cost: f64, - pub available_cash: f64, - pub net_deposit: f64, - pub currency: String, - pub base_currency: String, - pub total_gain_value: f64, - pub total_gain_percentage: f64, - pub day_gain_percentage: f64, - pub day_gain_value: f64, - pub allocation_percentage: Option, - pub exchange_rate: Option, -} #[derive(Queryable, Selectable, Insertable, Associations, Debug, Clone, Serialize, Deserialize)] #[diesel(belongs_to(Account, foreign_key = account_id))] #[diesel(table_name = crate::schema::portfolio_history)] @@ -538,3 +519,9 @@ pub struct HistorySummary { pub end_date: String, pub entries_count: usize, } + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AccountSummary { + pub account: Account, + pub performance: PortfolioHistory, +} diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index 4a3641b..1c6dac8 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -449,4 +449,19 @@ impl HistoryService { Ok(history_data) } + + pub fn get_latest_account_history(&self, input_account_id: &str) -> Result { + use crate::schema::portfolio_history::dsl::*; + use diesel::prelude::*; + + let conn = &mut self.pool.get().unwrap(); + + let latest_history: PortfolioHistory = portfolio_history + .filter(account_id.eq(input_account_id)) + .order(date.desc()) + .first(conn) + .map_err(|e| PortfolioError::DatabaseError(e))?; + + Ok(latest_history) + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index aa133f7..4e5a18d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -15,7 +15,8 @@ use commands::goal::{ use commands::market_data::{get_asset_data, search_symbol, synch_quotes}; use commands::portfolio::{ - calculate_historical_data, compute_holdings, get_account_history, get_income_summary, + calculate_historical_data, compute_holdings, get_account_history, get_accounts_summary, + get_income_summary, }; use commands::settings::{get_settings, update_currency, update_settings}; @@ -104,6 +105,7 @@ fn main() { load_goals_allocations, get_income_summary, get_account_history, + get_accounts_summary ]) .build(context) .expect("error while running wealthfolio application"); diff --git a/src/commands/portfolio.ts b/src/commands/portfolio.ts index 3b0f17a..88f1e9b 100644 --- a/src/commands/portfolio.ts +++ b/src/commands/portfolio.ts @@ -1,5 +1,11 @@ import { getRunEnv, RUN_ENV, invokeTauri } from '@/adapters'; -import { Holding, IncomeSummary, HistorySummary, PortfolioHistory } from '@/lib/types'; +import { + Holding, + IncomeSummary, + HistorySummary, + PortfolioHistory, + AccountSummary, +} from '@/lib/types'; export const calculate_historical_data = async (): Promise => { try { @@ -56,3 +62,17 @@ export const getAccountHistory = async (accountId: string): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('get_accounts_summary'); + default: + throw new Error(`Unsupported`); + } + } catch (error) { + console.error('Error fetching active accounts summary:', error); + throw error; + } +}; diff --git a/src/lib/portfolio-helper.ts b/src/lib/portfolio-helper.ts index 585eefc..35728f4 100644 --- a/src/lib/portfolio-helper.ts +++ b/src/lib/portfolio-helper.ts @@ -1,13 +1,6 @@ // import type { Account, Asset } from '@/generated/client'; -import { - AccountTotal, - FinancialHistory, - Goal, - GoalAllocation, - GoalProgress, - Holding, -} from './types'; +import { AccountSummary, Goal, GoalAllocation, GoalProgress, Holding } from './types'; export function aggregateHoldingsBySymbol(holdings: Holding[]): Holding[] { const aggregated: Record = {}; @@ -54,43 +47,21 @@ export function aggregateHoldingsBySymbol(holdings: Holding[]): Holding[] { return result; } -export const formatAccountsData = ( - data: FinancialHistory[], - baseCurrency: String = 'USD', -): AccountTotal[] | undefined => { - return data - ?.filter((history) => history.account?.id !== 'TOTAL') - .map((history) => { - const todayValue = history.history[history.history.length - 1]; - return { - id: history.account?.id || '', - name: history.account?.name || '', - group: history.account?.group || '', - currency: history.account?.currency || '', - marketValue: todayValue?.marketValue || 0, - cashBalance: todayValue?.availableCash || 0, - totalGainAmount: todayValue?.totalGainValue || 0, - totalGainPercent: todayValue?.totalGainPercentage || 0, - totalValue: todayValue?.marketValue + todayValue?.availableCash, - totalValueConverted: - (todayValue?.marketValue + todayValue?.availableCash) * (todayValue?.exchangeRate || 1), - marketValueConverted: todayValue?.marketValue * (todayValue?.exchangeRate || 1) || 0, - cashBalanceConverted: todayValue?.availableCash * (todayValue?.exchangeRate || 1) || 0, - bookValueConverted: todayValue?.bookCost * (todayValue?.exchangeRate || 1) || 0, - baseCurrency: baseCurrency, - } as AccountTotal; - }); -}; - export function calculateGoalProgress( - accounts: AccountTotal[], + accounts: AccountSummary[], goals: Goal[], allocations: GoalAllocation[], ): GoalProgress[] { + // Extract base currency from the first account's performance, or default to 'USD' + const baseCurrency = accounts[0]?.performance?.baseCurrency || 'USD'; + // Create a map of accountId to marketValue for quick lookup const accountValueMap = new Map(); accounts.forEach((account) => { - accountValueMap.set(account.id, account.totalValueConverted); + accountValueMap.set( + account.account.id, + account?.performance?.totalValue * (account?.performance?.exchangeRate || 1), + ); }); // Sort goals by targetValue @@ -112,7 +83,7 @@ export function calculateGoalProgress( targetValue: goal.targetAmount, currentValue: totalAllocatedValue, progress: progress, - currency: accounts[0]?.baseCurrency || 'USD', + currency: baseCurrency, }; }); } diff --git a/src/lib/types.ts b/src/lib/types.ts index d4f1935..caab5f8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -67,28 +67,6 @@ export type ActivitySearchResponse = { export type NewActivity = z.infer; -export interface AccountTotal { - id: string; - name: string; - group: string; - marketValue: number; - bookValue: number; - cashBalance: number; - cashBalanceConverted: number; - marketValueConverted: number; - totalValue: number; - totalValueConverted: number; - bookValueConverted: number; - totalGainPercent: number; - totalGainAmount: number; - totalGainAmountConverted: number; - dayGainPercent: number; - dayGainAmount: number; - dayGainAmountConverted: number; - currency: string; - baseCurrency: string; -} - export interface AssetProfile { id: string; isin: string | null; @@ -142,28 +120,6 @@ export interface Tag { activityId: string | null; } -export interface FinancialSnapshot { - date: string; - totalValue: number; //investment + cash - marketValue: number; - bookCost: number; - availableCash: number; - netDeposit: number; - currency: string; - baseCurrency: string; - totalGainValue: number; - totalGainPercentage: number; - dayGainPercentage: number; - dayGainValue: number; - allocationPercentage?: number; - exchangeRate?: number; -} - -export interface FinancialHistory { - account: Account; // This can be an account or the entire portfolio. - history: FinancialSnapshot[]; -} - export type ValidationResult = { status: 'success' } | { status: 'error'; errors: string[] }; export interface ActivityImport { @@ -342,3 +298,8 @@ export interface PortfolioHistory { allocationPercentage: number | null; exchangeRate: number | null; } + +export interface AccountSummary { + account: Account; + performance: PortfolioHistory; +} diff --git a/src/pages/account/account-detail.tsx b/src/pages/account/account-detail.tsx index ac75951..545c1d0 100644 --- a/src/pages/account/account-detail.tsx +++ b/src/pages/account/account-detail.tsx @@ -4,10 +4,10 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { formatAmount, formatPercent } from '@/lib/utils'; -import { FinancialSnapshot } from '@/lib/types'; +import { PortfolioHistory } from '@/lib/types'; interface AccountDetailProps { - data?: FinancialSnapshot; + data?: PortfolioHistory; className?: string; } diff --git a/src/pages/account/account-page.tsx b/src/pages/account/account-page.tsx index 59c82b8..43a2155 100644 --- a/src/pages/account/account-page.tsx +++ b/src/pages/account/account-page.tsx @@ -12,18 +12,26 @@ import { useParams } from 'react-router-dom'; import AccountDetail from './account-detail'; import AccountHoldings from './account-holdings'; import { useQuery } from '@tanstack/react-query'; -import { FinancialHistory, Holding } from '@/lib/types'; -import { computeHoldings, getHistorical } from '@/commands/portfolio'; +import { Holding, PortfolioHistory, AccountSummary } from '@/lib/types'; +import { computeHoldings, getAccountHistory, getAccountsSummary } from '@/commands/portfolio'; const AccountPage = () => { const { id = '' } = useParams<{ id: string }>(); - const { data: portfolioHistory, isLoading: isLoadingHistory } = useQuery< - FinancialHistory[], + const { data: accounts, isLoading: isAccountsLoading } = useQuery({ + queryKey: ['accounts_summary'], + queryFn: getAccountsSummary, + }); + + const accountSummary = accounts?.find((account) => account.account.id === id); + + const { data: accountHistory, isLoading: isLoadingAccountHistory } = useQuery< + PortfolioHistory[], Error >({ - queryKey: ['portfolio_history'], - queryFn: getHistorical, + queryKey: ['account_history', id], + queryFn: () => getAccountHistory(id), + enabled: !!id, }); const { data: holdings, isLoading: isLoadingHoldings } = useQuery({ @@ -34,10 +42,9 @@ const AccountPage = () => { const accountHoldings = holdings ?.filter((holding) => holding.account?.id === id) .sort((a, b) => a.symbol.localeCompare(b.symbol)); - const accountHistory = portfolioHistory?.find((history) => history.account?.id === id); - const todayValue = accountHistory?.history[accountHistory?.history.length - 1]; - const account = accountHistory?.account; + const account = accountSummary?.account; + const performance = accountSummary?.performance; return ( @@ -51,19 +58,19 @@ const AccountPage = () => {

- {formatAmount(todayValue?.totalValue || 0, todayValue?.currency || 'USD')} + {formatAmount(performance?.totalValue || 0, performance?.currency || 'USD')}

@@ -71,20 +78,20 @@ const AccountPage = () => {
- {isLoadingHistory ? ( + {isLoadingAccountHistory ? ( ) : ( - + )}
- {isLoadingHistory && !todayValue ? ( + {isAccountsLoading && !performance ? ( ) : ( - + )}
diff --git a/src/pages/dashboard/accounts.tsx b/src/pages/dashboard/accounts.tsx index ac62a5a..b6646cf 100644 --- a/src/pages/dashboard/accounts.tsx +++ b/src/pages/dashboard/accounts.tsx @@ -1,30 +1,33 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { Icons } from '@/components/icons'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { AccountTotal } from '@/lib/types'; +import { AccountSummary } from '@/lib/types'; import { formatAmount, formatPercent } from '@/lib/utils'; import { useSettingsContext } from '@/lib/settings-provider'; // Helper function to calculate category summary -const calculateCategorySummary = (accountsInCategory: AccountTotal[]) => { +const calculateCategorySummary = (accountsInCategory: AccountSummary[]) => { const totalMarketValue = accountsInCategory.reduce( - (total, account) => total + account.marketValueConverted, + (total, account) => + total + account.performance.marketValue * (account.performance.exchangeRate || 1), 0, ); const bookValue = accountsInCategory.reduce( - (total, account) => total + account.bookValueConverted, + (total, account) => + total + account.performance.bookCost * (account.performance.exchangeRate || 1), 0, ); const totalCashBalance = accountsInCategory.reduce( - (total, account) => total + account.cashBalanceConverted, + (total, account) => + total + account.performance.availableCash * (account.performance.exchangeRate || 1), 0, ); return { - baseCurrency: accountsInCategory[0].baseCurrency, + baseCurrency: accountsInCategory[0].performance.baseCurrency, totalMarketValue, totalCashBalance, totalGainPercent: ((totalMarketValue - bookValue) / bookValue) * 100, @@ -79,36 +82,40 @@ const Summary = ({ ); }; -const AccountSummary = ({ account }: { account: AccountTotal }) => { - const navigate = useNavigate(); - const handleNavigate = () => { - navigate(`/accounts/${account.id}`, { state: { account: account } }); - }; +const AccountSummaryComponent = ({ accountSummary }: { accountSummary: AccountSummary }) => { return ( -
+
- {account.name} + {accountSummary.account.name} - {account.group ? `${account.group} - ${account.currency}` : account.currency} + {accountSummary.account.group + ? `${accountSummary.account.group} - ${accountSummary.account.currency}` + : accountSummary.account.currency}

- {formatAmount(account.totalValue, account.currency)} + {formatAmount(accountSummary.performance.totalValue, accountSummary.account.currency)}

- {account.totalGainAmount !== 0 && ( + {accountSummary.performance.totalGainValue !== 0 && (

0 ? 'text-green-500' : 'text-red-500'}`} + className={`text-sm font-light ${accountSummary.performance.totalGainPercentage > 0 ? 'text-green-500' : 'text-red-500'}`} > - {formatAmount(account.totalGainAmount, account.currency, false)} / - {formatPercent(account.totalGainPercent)} + {formatAmount( + accountSummary.performance.totalGainValue, + accountSummary.account.currency, + false, + )}{' '} + /{formatPercent(accountSummary.performance.totalGainPercentage)}

)}
- + + +
); @@ -118,20 +125,20 @@ export function Accounts({ accounts, className, }: { - accounts?: AccountTotal[]; + accounts?: AccountSummary[]; className?: string; }) { const { accountsGrouped, setAccountsGrouped } = useSettingsContext(); const [expandedCategories, setExpandedCategories] = useState>({}); const groupAccountsByCategory = () => { - const groupedAccounts: Record = {}; - for (const account of accounts || []) { - const category = account.group; + const groupedAccounts: Record = {}; + for (const accountSummary of accounts || []) { + const category = accountSummary.account.group || 'Uncategorized'; if (!groupedAccounts[category]) { groupedAccounts[category] = []; } - groupedAccounts[category].push(account); + groupedAccounts[category].push(accountSummary); } return groupedAccounts; }; @@ -148,13 +155,13 @@ export function Accounts({ accountsInCategory, }: { category: string; - accountsInCategory: AccountTotal[]; + accountsInCategory: AccountSummary[]; }) => { if (accountsInCategory.length === 1) { return ( - + ); @@ -179,9 +186,12 @@ export function Accounts({ {isExpanded && ( - {accountsInCategory.map((account) => ( + {accountsInCategory.map((accountSummary) => (
- +
))}
@@ -201,10 +211,10 @@ export function Accounts({ /> )); } else { - return accounts?.map((account) => ( - + return accounts?.map((accountSummary) => ( + - + )); diff --git a/src/pages/dashboard/dashboard-page.tsx b/src/pages/dashboard/dashboard-page.tsx index dc165a1..b6bce41 100644 --- a/src/pages/dashboard/dashboard-page.tsx +++ b/src/pages/dashboard/dashboard-page.tsx @@ -3,12 +3,11 @@ import { GainPercent } from '@/components/gain-percent'; import { HistoryChart } from '@/components/history-chart'; import Balance from './balance'; import { useQuery } from '@tanstack/react-query'; -import { FinancialHistory, PortfolioHistory } from '@/lib/types'; -import { getHistorical, getAccountHistory } from '@/commands/portfolio'; +import { PortfolioHistory, AccountSummary } from '@/lib/types'; +import { getAccountHistory, getAccountsSummary } from '@/commands/portfolio'; import { Skeleton } from '@/components/ui/skeleton'; import { Accounts } from './accounts'; import SavingGoals from './goals'; -import { formatAccountsData } from '@/lib/portfolio-helper'; // filter function DashboardSkeleton() { @@ -27,11 +26,6 @@ function DashboardSkeleton() { } export default function DashboardPage() { - const { data: historyData, isLoading: isHistoryLoading } = useQuery({ - queryKey: ['portfolio_history'], - queryFn: getHistorical, - }); - const { data: portfolioHistory, isLoading: isPortfolioHistoryLoading } = useQuery< PortfolioHistory[], Error @@ -40,16 +34,17 @@ export default function DashboardPage() { queryFn: () => getAccountHistory('TOTAL'), }); - if (isHistoryLoading || isPortfolioHistoryLoading) { + const { data: accounts, isLoading: isAccountsLoading } = useQuery({ + queryKey: ['accounts_summary'], + queryFn: getAccountsSummary, + }); + + if (isPortfolioHistoryLoading || isAccountsLoading) { return ; } - console.log('portfolioHistory', portfolioHistory); - const portfolio = historyData?.find((history) => history.account?.id === 'TOTAL'); const todayValue = portfolioHistory?.[portfolioHistory.length - 1]; - const accountsData = formatAccountsData(historyData || [], portfolio?.account.currency); - return (
@@ -83,17 +78,16 @@ export default function DashboardPage() {
{/* Column 1 */}
- +
{/* Column 2 */}
- +
{/* Column 3 */}
- {/* Grid container */}
); diff --git a/src/pages/dashboard/goals.tsx b/src/pages/dashboard/goals.tsx index d79744f..aa52d7a 100644 --- a/src/pages/dashboard/goals.tsx +++ b/src/pages/dashboard/goals.tsx @@ -2,13 +2,13 @@ import { getGoals, getGoalsAllocation } from '@/commands/goal'; import { Card, CardContent, CardHeader, CardDescription, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; import { calculateGoalProgress } from '@/lib/portfolio-helper'; -import { AccountTotal, Goal, GoalAllocation, GoalProgress } from '@/lib/types'; +import { AccountSummary, Goal, GoalAllocation, GoalProgress } from '@/lib/types'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useQuery } from '@tanstack/react-query'; import { formatAmount, formatPercent } from '@/lib/utils'; import { Icons } from '@/components/icons'; -export function SavingGoals({ accounts }: { accounts?: AccountTotal[] }) { +export function SavingGoals({ accounts }: { accounts?: AccountSummary[] }) { const { data: goals } = useQuery({ queryKey: ['goals'], queryFn: getGoals, From 1b172a0aa0f38c0fdaaf80a0de45f24c1a28f860 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Mon, 16 Sep 2024 18:55:55 -0400 Subject: [PATCH 14/45] calculate account allocation --- src-core/src/portfolio/history_service.rs | 8 ----- src-core/src/portfolio/holdings_service.rs | 2 -- src/adapters/tauri.ts | 34 +++++++++++++--------- src/components/history-chart.tsx | 6 ++-- src/pages/holdings/holdings-page.tsx | 5 +--- 5 files changed, 25 insertions(+), 30 deletions(-) diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index 1c6dac8..58e2f11 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -52,7 +52,6 @@ impl HistoryService { .collect(); if account_activities.is_empty() { - println!("No activities for account {}", account.id); return HistorySummary { id: Some(account.id.clone()), start_date: "".to_string(), @@ -80,12 +79,6 @@ impl HistoryService { end_date, ); - println!( - "Calculated {} historical entries for account {}", - new_history.len(), - account.id - ); - if !new_history.is_empty() { all_histories.lock().unwrap().push(new_history.clone()); } @@ -127,7 +120,6 @@ impl HistoryService { } // Save total portfolio history - println!("Saving total portfolio history"); if let Err(e) = self.save_historical_data(&total_history.lock().unwrap()) { println!("Error saving total portfolio history: {:?}", e); return Err(e); diff --git a/src-core/src/portfolio/holdings_service.rs b/src-core/src/portfolio/holdings_service.rs index f3390a4..d2c3d3e 100644 --- a/src-core/src/portfolio/holdings_service.rs +++ b/src-core/src/portfolio/holdings_service.rs @@ -15,7 +15,6 @@ pub struct HoldingsService { asset_service: AssetService, fx_service: CurrencyExchangeService, base_currency: String, - pool: Pool>, } impl HoldingsService { @@ -26,7 +25,6 @@ impl HoldingsService { asset_service: AssetService::new(pool.clone()), fx_service: CurrencyExchangeService::new(pool.clone()), base_currency, - pool, } } diff --git a/src/adapters/tauri.ts b/src/adapters/tauri.ts index 0bffbf6..c47bd39 100644 --- a/src/adapters/tauri.ts +++ b/src/adapters/tauri.ts @@ -5,34 +5,42 @@ export type { EventCallback, UnlistenFn }; export const invokeTauri = async (command: string, payload?: Record) => { const invoke = await import('@tauri-apps/api').then((mod) => mod.invoke); return await invoke(command, payload); -} +}; export const openCsvFileDialogTauri = async (): Promise => { const open = await import('@tauri-apps/api/dialog').then((mod) => mod.open); return open({ filters: [{ name: 'CSV', extensions: ['csv'] }] }); -} +}; -export const listenFileDropHoverTauri = async (handler: EventCallback): Promise => { +export const listenFileDropHoverTauri = async ( + handler: EventCallback, +): Promise => { const { listen } = await import('@tauri-apps/api/event'); return listen('tauri://file-drop-hover', handler); -} +}; export const listenFileDropTauri = async (handler: EventCallback): Promise => { const { listen } = await import('@tauri-apps/api/event'); return listen('tauri://file-drop', handler); -} +}; -export const listenFileDropCancelledTauri = async (handler: EventCallback): Promise => { +export const listenFileDropCancelledTauri = async ( + handler: EventCallback, +): Promise => { const { listen } = await import('@tauri-apps/api/event'); return listen('tauri://file-drop-cancelled', handler); -} +}; -export const listenQuotesSyncStartTauri = async (handler: EventCallback): Promise => { +export const listenQuotesSyncStartTauri = async ( + handler: EventCallback, +): Promise => { const { listen } = await import('@tauri-apps/api/event'); - return listen('QUOTES_SYNC_START', handler); -} + return listen('PORTFOLIO_UPDATE_START', handler); +}; -export const listenQuotesSyncCompleteTauri = async (handler: EventCallback): Promise => { +export const listenQuotesSyncCompleteTauri = async ( + handler: EventCallback, +): Promise => { const { listen } = await import('@tauri-apps/api/event'); - return listen('QUOTES_SYNC_COMPLETE', handler); -} + return listen('PORTFOLIO_UPDATE_COMPLETE', handler); +}; diff --git a/src/components/history-chart.tsx b/src/components/history-chart.tsx index eac7453..37dbe30 100644 --- a/src/components/history-chart.tsx +++ b/src/components/history-chart.tsx @@ -85,9 +85,9 @@ export function HistoryChart({ {/* @ts-ignore */} } /> - {interval != 'ALL' && interval != '1Y' ? ( - - ) : undefined} + {interval !== 'ALL' && interval !== '1Y' && ( + + )} { queryFn: computeHoldings, }); - const { data: historyData } = useQuery({ - queryKey: ['portfolio_history'], - queryFn: getHistorical, - }); + const historyData = []; const portfolio = historyData?.find((history) => history.account?.id === 'TOTAL'); const todayValue = portfolio?.history[portfolio?.history.length - 1]; From 27fccec14972d270d338f18c4b4e3e1fd70e090f Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Tue, 17 Sep 2024 11:56:53 -0400 Subject: [PATCH 15/45] WIP refactor history calculation --- src-core/Cargo.lock | 14 +- src-core/Cargo.toml | 4 +- .../up.sql | 1 + src-core/src/error.rs | 4 +- src-core/src/models.rs | 5 +- src-core/src/portfolio/history_service.rs | 239 +++++++++++------- src-core/src/portfolio/holdings_service.rs | 8 +- src-core/src/portfolio/mod.rs | 3 +- src-core/src/schema.rs | 1 + src-tauri/Cargo.lock | 2 +- src/pages/account/account-page.tsx | 1 + src/pages/dashboard/accounts.tsx | 4 +- src/pages/dashboard/dashboard-page.tsx | 2 + src/useGlobalEventListener.ts | 4 +- 14 files changed, 166 insertions(+), 126 deletions(-) diff --git a/src-core/Cargo.lock b/src-core/Cargo.lock index d5e68ed..cb0d183 100644 --- a/src-core/Cargo.lock +++ b/src-core/Cargo.lock @@ -1930,21 +1930,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.76", -] - [[package]] name = "tracing-core" version = "0.1.32" @@ -2116,6 +2104,7 @@ dependencies = [ "diesel", "diesel_migrations", "lazy_static", + "r2d2", "rayon", "regex", "reqwest", @@ -2123,7 +2112,6 @@ dependencies = [ "serde", "serde_json", "thiserror", - "tracing", "uuid", "yahoo_finance_api", ] diff --git a/src-core/Cargo.toml b/src-core/Cargo.toml index b6d8743..8ee2136 100644 --- a/src-core/Cargo.toml +++ b/src-core/Cargo.toml @@ -12,7 +12,7 @@ edition = "2021" [dependencies] serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" -diesel = { version = "2.2.4", features = ["sqlite", "chrono","r2d2", "numeric", "returning_clauses_for_sqlite_3_35"] } +diesel = { version = "2.2.4", features = ["sqlite", "chrono", "r2d2", "numeric", "returning_clauses_for_sqlite_3_35"] } chrono = { version = "0.4.38", features = ["serde"] } uuid = { version = "1.10.0", features = ["v4"] } rusqlite = { version = "0.32.1", features = ["bundled"] } @@ -24,4 +24,4 @@ thiserror = "1.0.63" lazy_static = "1.5.0" diesel_migrations = { version = "2.2.0", features = ["sqlite" ] } rayon = "1.10.0" -tracing = "0.1.40" +r2d2 = "0.8.10" diff --git a/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql b/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql index bb00428..f584166 100644 --- a/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql +++ b/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql @@ -15,6 +15,7 @@ CREATE TABLE portfolio_history ( day_gain_value NUMERIC NOT NULL DEFAULT 0, allocation_percentage NUMERIC NOT NULL DEFAULT 0, exchange_rate NUMERIC NOT NULL DEFAULT 0, + holdings TEXT, UNIQUE(account_id, date) ); CREATE INDEX idx_portfolio_history_account_date ON portfolio_history(account_id, date); diff --git a/src-core/src/error.rs b/src-core/src/error.rs index c4596ac..fe8f407 100644 --- a/src-core/src/error.rs +++ b/src-core/src/error.rs @@ -1,4 +1,4 @@ -use diesel::r2d2; +use r2d2; use thiserror::Error; #[derive(Error, Debug)] @@ -13,6 +13,8 @@ pub enum PortfolioError { AssetNotFoundError(String), #[error("Invalid data: {0}")] InvalidDataError(String), + #[error("Parse error: {0}")] + ParseError(String), } impl From for PortfolioError { diff --git a/src-core/src/models.rs b/src-core/src/models.rs index 42a2677..958e36d 100644 --- a/src-core/src/models.rs +++ b/src-core/src/models.rs @@ -488,8 +488,7 @@ pub struct IncomeSummary { pub currency: String, } -#[derive(Queryable, Selectable, Insertable, Associations, Debug, Clone, Serialize, Deserialize)] -#[diesel(belongs_to(Account, foreign_key = account_id))] +#[derive(Debug, Clone, Queryable, Insertable, Serialize, Deserialize)] #[diesel(table_name = crate::schema::portfolio_history)] #[serde(rename_all = "camelCase")] pub struct PortfolioHistory { @@ -509,6 +508,7 @@ pub struct PortfolioHistory { pub day_gain_value: f64, pub allocation_percentage: f64, pub exchange_rate: f64, + pub holdings: Option, // Holdings JSON } #[derive(Serialize, Deserialize, Debug)] @@ -521,6 +521,7 @@ pub struct HistorySummary { } #[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] pub struct AccountSummary { pub account: Account, pub performance: PortfolioHistory, diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index 58e2f11..24fe4ab 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -30,6 +30,35 @@ impl HistoryService { } } + pub fn get_account_history(&self, input_account_id: &str) -> Result> { + use crate::schema::portfolio_history::dsl::*; + use diesel::prelude::*; + + let conn = &mut self.pool.get().map_err(PortfolioError::from)?; + + let history_data: Vec = portfolio_history + .filter(account_id.eq(input_account_id)) + .order(date.asc()) + .load::(conn)?; + + Ok(history_data) + } + + pub fn get_latest_account_history(&self, input_account_id: &str) -> Result { + use crate::schema::portfolio_history::dsl::*; + use diesel::prelude::*; + + let conn = &mut self.pool.get().map_err(PortfolioError::from)?; + + let latest_history: PortfolioHistory = portfolio_history + .filter(account_id.eq(input_account_id)) + .order(date.desc()) + .first(conn) + .map_err(|e| PortfolioError::DatabaseError(e))?; + + Ok(latest_history) + } + pub fn calculate_historical_data( &self, accounts: &[Account], @@ -160,60 +189,69 @@ impl HistoryService { start_date: NaiveDate, end_date: NaiveDate, ) -> Vec { - let all_dates = Self::get_dates_between(start_date, end_date); + let last_history = self.get_last_portfolio_history(account_id).unwrap_or(None); + + // Initialize values from the last PortfolioHistory + let mut currency = last_history + .as_ref() + .map_or(self.base_currency.as_str(), |h| &h.currency); + let mut cumulative_cash = last_history.as_ref().map_or(0.0, |h| h.available_cash); + let mut net_deposit = last_history.as_ref().map_or(0.0, |h| h.net_deposit); + let mut book_cost = last_history.as_ref().map_or(0.0, |h| h.book_cost); + // let mut total_value = last_history.as_ref().map_or(0.0, |h| h.total_value); + //let mut market_value = last_history.as_ref().map_or(0.0, |h| h.market_value); + + // Initialize holdings based on the last history + let mut holdings: HashMap = last_history + .as_ref() + .and_then(|h| h.holdings.as_ref()) + .and_then(|json_str| serde_json::from_str(json_str).ok()) + .unwrap_or_default(); - let mut currency = self.base_currency.as_str(); - let mut cumulative_cash = 0.0; - let mut holdings: HashMap = HashMap::new(); + let mut last_available_quotes: HashMap = HashMap::new(); - let mut results = Vec::new(); - let mut net_deposit = 0.0; - let mut book_cost = 0.0; + // If there's a last history entry, start from the day after + let actual_start_date = last_history + .as_ref() + .map(|h| NaiveDate::parse_from_str(&h.date, "%Y-%m-%d").unwrap() + Duration::days(1)) + .unwrap_or(start_date); - let mut last_available_quotes: HashMap = HashMap::new(); - let mut average_purchase_prices: HashMap = HashMap::new(); + let all_dates = Self::get_dates_between(actual_start_date, end_date); + + let mut results = Vec::new(); for date in all_dates { + // Process activities for the current date for activity in activities.iter().filter(|a| a.activity_date.date() == date) { - currency = activity.currency.as_str(); + currency = &activity.currency; let activity_amount = activity.quantity * activity.unit_price; let activity_fee = activity.fee; match activity.activity_type.as_str() { "BUY" => { - let entry = holdings.entry(activity.asset_id.clone()).or_insert(0.0); let buy_cost = activity_amount + activity_fee; - let new_quantity = *entry + activity.quantity; - let avg_price = average_purchase_prices - .entry(activity.asset_id.clone()) - .or_insert(0.0); - *avg_price = (*avg_price * *entry + buy_cost) / new_quantity; - *entry = new_quantity; cumulative_cash -= buy_cost; book_cost += buy_cost; + *holdings.entry(activity.asset_id.clone()).or_insert(0.0) += + activity.quantity; } "SELL" => { - let entry = holdings.entry(activity.asset_id.clone()).or_insert(0.0); - let sell_quantity = activity.quantity.min(*entry); - let avg_price = *average_purchase_prices - .get(&activity.asset_id) - .unwrap_or(&0.0); - let sell_cost = sell_quantity * avg_price; let sell_profit = activity_amount - activity_fee; - *entry -= sell_quantity; cumulative_cash += sell_profit; - book_cost -= sell_cost; + book_cost -= activity_amount; + *holdings.entry(activity.asset_id.clone()).or_insert(0.0) -= + activity.quantity; } "DEPOSIT" | "TRANSFER_IN" | "CONVERSION_IN" => { cumulative_cash += activity_amount - activity_fee; - net_deposit += activity_amount - activity_fee; + net_deposit += activity_amount; } "DIVIDEND" | "INTEREST" => { cumulative_cash += activity_amount - activity_fee; } "WITHDRAWAL" | "TRANSFER_OUT" | "CONVERSION_OUT" => { cumulative_cash -= activity_amount + activity_fee; - net_deposit -= activity_amount + activity_fee; + net_deposit -= activity_amount; } "FEE" | "TAX" => { cumulative_cash -= activity_fee; @@ -222,19 +260,22 @@ impl HistoryService { } } - let (holdings_value, day_gain_value) = + // Update market value based on quotes + let (updated_market_value, day_gain_value) = self.calculate_holdings_value(&holdings, quotes, date, &mut last_available_quotes); - let day_gain_percentage = if holdings_value != 0.0 { - (day_gain_value / holdings_value) * 100.0 + let market_value = updated_market_value; + let total_value = cumulative_cash + market_value; + + let day_gain_percentage = if market_value != 0.0 { + (day_gain_value / market_value) * 100.0 } else { 0.0 }; - let total_value = cumulative_cash + holdings_value; - let total_gain_value = total_value - book_cost; - let total_gain_percentage = if book_cost != 0.0 { - (total_gain_value / book_cost) * 100.0 + let total_gain_value = total_value - net_deposit; + let total_gain_percentage = if net_deposit != 0.0 { + (total_gain_value / net_deposit) * 100.0 } else { 0.0 }; @@ -249,7 +290,7 @@ impl HistoryService { account_id: account_id.to_string(), date: date.format("%Y-%m-%d").to_string(), total_value, - market_value: holdings_value, + market_value, book_cost, available_cash: cumulative_cash, net_deposit, @@ -259,8 +300,13 @@ impl HistoryService { total_gain_percentage, day_gain_percentage, day_gain_value, - allocation_percentage: 0.0, // to Calculate later + allocation_percentage: if total_value != 0.0 { + (market_value / total_value) * 100.0 + } else { + 0.0 + }, exchange_rate, + holdings: Some(serde_json::to_string(&holdings).unwrap_or_default()), }); } @@ -278,7 +324,7 @@ impl HistoryService { let entry = total_history .entry(snapshot.date.clone()) .or_insert_with(|| PortfolioHistory { - id: Uuid::new_v4().to_string(), // Generate a new UUID for each day + id: Uuid::new_v4().to_string(), account_id: "TOTAL".to_string(), date: snapshot.date.clone(), total_value: 0.0, @@ -294,6 +340,7 @@ impl HistoryService { day_gain_value: 0.0, allocation_percentage: 0.0, exchange_rate: 1.0, + holdings: Some("{}".to_string()), }); let exchange_rate = self @@ -315,9 +362,9 @@ impl HistoryService { // Recalculate percentages for total portfolio for record in &mut total_history { - record.total_gain_value = record.total_value - record.book_cost; - record.total_gain_percentage = if record.book_cost != 0.0 { - (record.total_gain_value / record.book_cost) * 100.0 + record.total_gain_value = record.total_value - record.net_deposit; + record.total_gain_percentage = if record.net_deposit != 0.0 { + (record.total_gain_value / record.net_deposit) * 100.0 } else { 0.0 }; @@ -333,7 +380,7 @@ impl HistoryService { fn save_historical_data(&self, history_data: &[PortfolioHistory]) -> Result<()> { use crate::schema::portfolio_history::dsl::*; - let conn = &mut self.pool.get().unwrap(); + let conn = &mut self.pool.get().map_err(PortfolioError::from)?; // Use the From trait to convert r2d2::Error to PortfolioError let values: Vec<_> = history_data .iter() @@ -355,13 +402,15 @@ impl HistoryService { day_gain_value.eq(record.day_gain_value), allocation_percentage.eq(record.allocation_percentage), exchange_rate.eq(record.exchange_rate), + holdings.eq(&record.holdings), ) }) .collect(); diesel::replace_into(portfolio_history) .values(&values) - .execute(conn)?; + .execute(conn) + .map_err(PortfolioError::from)?; // Use the From trait to convert diesel::result::Error to PortfolioError Ok(()) } @@ -376,84 +425,80 @@ impl HistoryService { let mut holdings_value = 0.0; let mut day_gain_value = 0.0; - for (symbol, &holding_amount) in holdings { + for (asset_id, &quantity) in holdings { + // Find the quote for the specific asset and date let quote = quotes .iter() - .find(|q| q.date.date() == date && q.symbol == *symbol) - .or_else(|| last_available_quotes.get(symbol)) + .find(|q| q.date.date() == date && q.symbol == *asset_id) + .or_else(|| last_available_quotes.get(asset_id)) .cloned(); if let Some(quote) = quote { - let holding_value_for_symbol = holding_amount * quote.close; - let daily_change_percent = ((quote.close - quote.open) / quote.open) * 100.0; - let day_gain_for_symbol = (daily_change_percent / 100.0) * holding_value_for_symbol; - - holdings_value += holding_value_for_symbol; - day_gain_value += day_gain_for_symbol; - - // Update the last available quote for the symbol - last_available_quotes.insert(symbol.clone(), quote); + let holding_value = quantity * quote.close; + let day_gain = if quote.open != 0.0 { + quantity * (quote.close - quote.open) + } else { + 0.0 + }; + + holdings_value += holding_value; + day_gain_value += day_gain; + + // Update the last available quote for the asset + last_available_quotes.insert(asset_id.clone(), quote); + } else { + println!("No quote available for asset {} on date {}", asset_id, date); } } (holdings_value, day_gain_value) } - fn get_last_historical_date(&self, input_account_id: &str) -> Result> { + fn get_last_portfolio_history( + &self, + some_account_id: &str, + ) -> Result> { use crate::schema::portfolio_history::dsl::*; - let conn = &mut self.pool.get().unwrap(); - portfolio_history - .filter(account_id.eq(input_account_id)) - .select(date) + let conn = &mut self.pool.get().map_err(PortfolioError::from)?; + let last_history_opt = portfolio_history + .filter(account_id.eq(some_account_id)) .order(date.desc()) - .first::(conn) + .first::(conn) .optional() - .map(|opt_date_str| { - opt_date_str - .and_then(|date_str| NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").ok()) - }) - .map_err(PortfolioError::DatabaseError) - } - - fn get_dates_between(start: NaiveDate, end: NaiveDate) -> Vec { - let mut dates = Vec::new(); - let mut current = start; + .map_err(PortfolioError::from)?; - while current <= end { - dates.push(current); - current = current.checked_add_signed(Duration::days(1)).unwrap(); + if let Some(last_history) = last_history_opt { + Ok(Some(last_history)) + } else { + Ok(None) } - - dates } - pub fn get_account_history(&self, input_account_id: &str) -> Result> { - use crate::schema::portfolio_history::dsl::*; - use diesel::prelude::*; - - let conn = &mut self.pool.get().unwrap(); - - let history_data: Vec = portfolio_history - .filter(account_id.eq(input_account_id)) - .order(date.asc()) - .load::(conn)?; - - Ok(history_data) + fn get_dates_between(start_date: NaiveDate, end_date: NaiveDate) -> Vec { + (0..=(end_date - start_date).num_days()) + .map(|days| start_date + Duration::days(days)) + .collect() } - pub fn get_latest_account_history(&self, input_account_id: &str) -> Result { + fn get_last_historical_date(&self, some_account_id: &str) -> Result> { use crate::schema::portfolio_history::dsl::*; - use diesel::prelude::*; - - let conn = &mut self.pool.get().unwrap(); + let conn = &mut self.pool.get().map_err(PortfolioError::from)?; // Use the From trait to convert r2d2::Error to PortfolioError - let latest_history: PortfolioHistory = portfolio_history - .filter(account_id.eq(input_account_id)) + let last_date_opt = portfolio_history + .filter(account_id.eq(some_account_id)) + .select(date) .order(date.desc()) - .first(conn) - .map_err(|e| PortfolioError::DatabaseError(e))?; - - Ok(latest_history) + .first::(conn) + .optional() + .map_err(PortfolioError::from)?; // Use the From trait to convert diesel::result::Error to PortfolioError + + if let Some(last_date_str) = last_date_opt { + NaiveDate::parse_from_str(&last_date_str, "%Y-%m-%d") + .map(Some) + .map_err(|_| PortfolioError::ParseError("Invalid date format".to_string())) + } else { + Ok(None) + } } } diff --git a/src-core/src/portfolio/holdings_service.rs b/src-core/src/portfolio/holdings_service.rs index d2c3d3e..5a217df 100644 --- a/src-core/src/portfolio/holdings_service.rs +++ b/src-core/src/portfolio/holdings_service.rs @@ -7,7 +7,6 @@ use crate::models::{Holding, Performance}; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; use std::collections::{HashMap, HashSet}; -use tracing::{info, warn}; pub struct HoldingsService { account_service: AccountService, @@ -29,7 +28,7 @@ impl HoldingsService { } pub fn compute_holdings(&self) -> Result> { - info!("Computing holdings"); + println!("Computing holdings"); let mut holdings: HashMap = HashMap::new(); let accounts = self.account_service.get_accounts()?; let activities = self.activity_service.get_trading_activities()?; @@ -87,7 +86,7 @@ impl HoldingsService { holding.quantity -= activity.quantity; holding.book_value -= activity.quantity * activity.unit_price + activity.fee; } - _ => warn!("Unhandled activity type: {}", activity.activity_type), + _ => println!("Unhandled activity type: {}", activity.activity_type), } } @@ -107,8 +106,7 @@ impl HoldingsService { quotes.insert(symbol, quote); } Err(e) => { - warn!("Error fetching quote for symbol {}: {}", symbol, e); - // Handle the error as per your logic, e.g., continue, return an error, etc. + eprintln!("Error fetching quote for symbol {}: {}", symbol, e); } } } diff --git a/src-core/src/portfolio/mod.rs b/src-core/src/portfolio/mod.rs index af2b956..e014989 100644 --- a/src-core/src/portfolio/mod.rs +++ b/src-core/src/portfolio/mod.rs @@ -1,6 +1,7 @@ mod history_service; mod holdings_service; mod income_service; - +// mod portfolio_calculator; pub mod portfolio_service; + pub use portfolio_service::PortfolioService; diff --git a/src-core/src/schema.rs b/src-core/src/schema.rs index 0f8266f..f5b03c2 100644 --- a/src-core/src/schema.rs +++ b/src-core/src/schema.rs @@ -102,6 +102,7 @@ diesel::table! { day_gain_value -> Double, allocation_percentage -> Double, exchange_rate -> Double, + holdings -> Nullable, } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 674a4bc..5d17fb5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4453,6 +4453,7 @@ dependencies = [ "diesel", "diesel_migrations", "lazy_static", + "r2d2", "rayon", "regex", "reqwest 0.12.7", @@ -4460,7 +4461,6 @@ dependencies = [ "serde", "serde_json", "thiserror", - "tracing", "uuid", "yahoo_finance_api", ] diff --git a/src/pages/account/account-page.tsx b/src/pages/account/account-page.tsx index 43a2155..3bf847f 100644 --- a/src/pages/account/account-page.tsx +++ b/src/pages/account/account-page.tsx @@ -53,6 +53,7 @@ const AccountPage = () => { headingPrefix={account?.group || account?.currency} displayBack={true} /> + New
diff --git a/src/pages/dashboard/accounts.tsx b/src/pages/dashboard/accounts.tsx index b6646cf..002ac72 100644 --- a/src/pages/dashboard/accounts.tsx +++ b/src/pages/dashboard/accounts.tsx @@ -166,10 +166,10 @@ export function Accounts({ ); } - + console.log('accountsInCategory', accountsInCategory); const categorySummary = calculateCategorySummary(accountsInCategory); const isExpanded = expandedCategories[category]; - + console.log('categorySummary', categorySummary); return ( diff --git a/src/pages/dashboard/dashboard-page.tsx b/src/pages/dashboard/dashboard-page.tsx index b6bce41..71e637a 100644 --- a/src/pages/dashboard/dashboard-page.tsx +++ b/src/pages/dashboard/dashboard-page.tsx @@ -34,11 +34,13 @@ export default function DashboardPage() { queryFn: () => getAccountHistory('TOTAL'), }); + console.log('portfolioHistory', portfolioHistory); const { data: accounts, isLoading: isAccountsLoading } = useQuery({ queryKey: ['accounts_summary'], queryFn: getAccountsSummary, }); + console.log('accounts', accounts); if (isPortfolioHistoryLoading || isAccountsLoading) { return ; } diff --git a/src/useGlobalEventListener.ts b/src/useGlobalEventListener.ts index ac8bc3b..c3edefe 100644 --- a/src/useGlobalEventListener.ts +++ b/src/useGlobalEventListener.ts @@ -17,9 +17,9 @@ const useGlobalEventListener = () => { }; const handleQuotesSyncComplete = () => { - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); queryClient.invalidateQueries({ queryKey: ['holdings'] }); - queryClient.invalidateQueries({ queryKey: ['account_history', 'TOTAL'] }); + queryClient.invalidateQueries({ queryKey: ['account_history'] }); + queryClient.invalidateQueries({ queryKey: ['accounts_summary'] }); toast({ title: 'Portfolio Update Complete', description: 'Your portfolio has been refreshed with the latest market data.', From 60302f53c9d5b61b1b0aa0c8c314967041cb93af Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Tue, 17 Sep 2024 12:12:57 -0400 Subject: [PATCH 16/45] fix gain calculation --- src-core/src/portfolio/history_service.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index 24fe4ab..c013aa4 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -261,14 +261,14 @@ impl HistoryService { } // Update market value based on quotes - let (updated_market_value, day_gain_value) = + let (updated_market_value, day_gain_value, opening_market_value) = self.calculate_holdings_value(&holdings, quotes, date, &mut last_available_quotes); let market_value = updated_market_value; let total_value = cumulative_cash + market_value; - let day_gain_percentage = if market_value != 0.0 { - (day_gain_value / market_value) * 100.0 + let day_gain_percentage = if opening_market_value != 0.0 { + (day_gain_value / opening_market_value) * 100.0 } else { 0.0 }; @@ -421,9 +421,10 @@ impl HistoryService { quotes: &[Quote], date: NaiveDate, last_available_quotes: &mut HashMap, - ) -> (f64, f64) { + ) -> (f64, f64, f64) { let mut holdings_value = 0.0; let mut day_gain_value = 0.0; + let mut opening_market_value = 0.0; for (asset_id, &quantity) in holdings { // Find the quote for the specific asset and date @@ -435,14 +436,12 @@ impl HistoryService { if let Some(quote) = quote { let holding_value = quantity * quote.close; - let day_gain = if quote.open != 0.0 { - quantity * (quote.close - quote.open) - } else { - 0.0 - }; + let opening_value = quantity * quote.open; + let day_gain = quantity * (quote.close - quote.open); holdings_value += holding_value; day_gain_value += day_gain; + opening_market_value += opening_value; // Update the last available quote for the asset last_available_quotes.insert(asset_id.clone(), quote); @@ -451,7 +450,7 @@ impl HistoryService { } } - (holdings_value, day_gain_value) + (holdings_value, day_gain_value, opening_market_value) } fn get_last_portfolio_history( From 5e712d9b1c4f9e0460e7b4fdfa5263eee05eb98a Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Tue, 17 Sep 2024 12:51:13 -0400 Subject: [PATCH 17/45] Sync latest days data --- .../src/market_data/market_data_service.rs | 5 ++++- src-core/src/portfolio/history_service.rs | 20 ++++++++----------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src-core/src/market_data/market_data_service.rs b/src-core/src/market_data/market_data_service.rs index 8f169ab..14341dd 100644 --- a/src-core/src/market_data/market_data_service.rs +++ b/src-core/src/market_data/market_data_service.rs @@ -83,7 +83,10 @@ impl MarketDataService { .map_err(|e| format!("Error getting last sync date for {}: {}", symbol, e))? .unwrap_or_else(|| Utc::now().naive_utc() - Duration::days(3 * 365)); - let start_date: SystemTime = Utc.from_utc_datetime(&last_sync_date).into(); + // Ensure to synchronize the last 2 days data for freshness + let start_date: SystemTime = Utc + .from_utc_datetime(&(last_sync_date - Duration::days(2))) + .into(); match self .provider diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index c013aa4..fadae03 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -92,7 +92,8 @@ impl HistoryService { let last_date = self.get_last_historical_date(&account.id).unwrap_or(None); let account_start_date = - last_date.map(|d| d + Duration::days(1)).unwrap_or_else(|| { + last_date.map(|d| d - Duration::days(2)).unwrap_or_else(|| { + // -2 for more freshness of towo last days account_activities .iter() .map(|a| a.activity_date.date()) @@ -198,8 +199,6 @@ impl HistoryService { let mut cumulative_cash = last_history.as_ref().map_or(0.0, |h| h.available_cash); let mut net_deposit = last_history.as_ref().map_or(0.0, |h| h.net_deposit); let mut book_cost = last_history.as_ref().map_or(0.0, |h| h.book_cost); - // let mut total_value = last_history.as_ref().map_or(0.0, |h| h.total_value); - //let mut market_value = last_history.as_ref().map_or(0.0, |h| h.market_value); // Initialize holdings based on the last history let mut holdings: HashMap = last_history @@ -238,7 +237,7 @@ impl HistoryService { "SELL" => { let sell_profit = activity_amount - activity_fee; cumulative_cash += sell_profit; - book_cost -= activity_amount; + book_cost -= activity_amount + activity_fee; *holdings.entry(activity.asset_id.clone()).or_insert(0.0) -= activity.quantity; } @@ -273,9 +272,9 @@ impl HistoryService { 0.0 }; - let total_gain_value = total_value - net_deposit; - let total_gain_percentage = if net_deposit != 0.0 { - (total_gain_value / net_deposit) * 100.0 + let total_gain_value = total_value - book_cost; + let total_gain_percentage = if book_cost != 0.0 { + (total_gain_value / book_cost) * 100.0 } else { 0.0 }; @@ -300,11 +299,7 @@ impl HistoryService { total_gain_percentage, day_gain_percentage, day_gain_value, - allocation_percentage: if total_value != 0.0 { - (market_value / total_value) * 100.0 - } else { - 0.0 - }, + allocation_percentage: 0.0, // This will be calculated later in calculate_total_portfolio_history exchange_rate, holdings: Some(serde_json::to_string(&holdings).unwrap_or_default()), }); @@ -373,6 +368,7 @@ impl HistoryService { } else { 0.0 }; + record.allocation_percentage = 100.0; // The total portfolio always represents 100% of itself } total_history From c25cdd35b66d39208c862564afd7f01cd68a49cf Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Tue, 17 Sep 2024 15:47:18 -0400 Subject: [PATCH 18/45] Optimize history calculation --- .../src/market_data/market_data_service.rs | 58 ++++++++++++++- src-core/src/portfolio/history_service.rs | 73 ++++++++++++------- 2 files changed, 103 insertions(+), 28 deletions(-) diff --git a/src-core/src/market_data/market_data_service.rs b/src-core/src/market_data/market_data_service.rs index 14341dd..ad4e743 100644 --- a/src-core/src/market_data/market_data_service.rs +++ b/src-core/src/market_data/market_data_service.rs @@ -1,10 +1,11 @@ use crate::models::{Asset, NewAsset, Quote, QuoteSummary}; use crate::providers::yahoo_provider::YahooProvider; use crate::schema::{activities, quotes}; -use chrono::{Duration, NaiveDateTime, TimeZone, Utc}; +use chrono::{Duration, NaiveDate, NaiveDateTime, TimeZone, Utc}; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; +use std::collections::HashMap; use std::time::SystemTime; use uuid::Uuid; @@ -41,6 +42,61 @@ impl MarketDataService { quotes::table.load::(&mut conn) } + pub fn load_quotes(&self) -> HashMap<(String, NaiveDate), Quote> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + let quotes_result: QueryResult> = quotes::table.load::(&mut conn); + + match quotes_result { + Ok(quotes) => quotes + .into_iter() + .map(|quote| { + let quote_date = quote.date.date(); + ((quote.symbol.clone(), quote_date), quote) + }) + .collect(), + Err(e) => { + eprintln!("Error loading quotes: {}", e); + HashMap::new() + } + } + } + + // pub fn load_quotes( + // &self, + // asset_ids: &HashSet, + // start_date: NaiveDate, + // end_date: NaiveDate, + // ) -> HashMap<(String, NaiveDate), Quote> { + // let start_datetime = NaiveDateTime::new( + // start_date, + // chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + // ); + // let end_datetime = NaiveDateTime::new( + // end_date, + // chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap(), + // ); + + // let mut conn = self.pool.get().expect("Couldn't get db connection"); + // let quotes_result: QueryResult> = quotes::table + // .filter(quotes::symbol.eq_any(asset_ids)) + // .filter(quotes::date.between(start_datetime, end_datetime)) + // .load::(&mut conn); + + // match quotes_result { + // Ok(quotes) => quotes + // .into_iter() + // .map(|quote| { + // let quote_date = quote.date.date(); + // ((quote.symbol.clone(), quote_date), quote) + // }) + // .collect(), + // Err(e) => { + // eprintln!("Error loading quotes: {}", e); + // HashMap::new() + // } + // } + // } + pub async fn initialize_crumb_data(&self) -> Result<(), String> { self.provider.set_crumb().await.map_err(|e| { let error_message = format!("Failed to initialize crumb data: {}", e); diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index fadae03..6486afb 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -1,5 +1,6 @@ use crate::error::{PortfolioError, Result}; use crate::fx::fx_service::CurrencyExchangeService; +use crate::market_data::market_data_service::MarketDataService; use crate::models::{Account, Activity, HistorySummary, PortfolioHistory, Quote}; use chrono::{Duration, NaiveDate, Utc}; @@ -12,21 +13,19 @@ use std::sync::{Arc, Mutex}; use uuid::Uuid; pub struct HistoryService { - fx_service: CurrencyExchangeService, - base_currency: String, pool: Pool>, + base_currency: String, + market_data_service: MarketDataService, + fx_service: CurrencyExchangeService, } impl HistoryService { - pub fn new( - pool: Pool>, - fx_service: CurrencyExchangeService, - base_currency: String, - ) -> Self { + pub fn new(pool: Pool>, base_currency: String) -> Self { Self { - fx_service, + pool: pool.clone(), base_currency, - pool, + market_data_service: MarketDataService::new(pool.clone()), + fx_service: CurrencyExchangeService::new(pool.clone()), } } @@ -63,7 +62,6 @@ impl HistoryService { &self, accounts: &[Account], activities: &[Activity], - market_data: &[Quote], ) -> Result> { println!("Starting calculate_historical_data"); let end_date = Utc::now().naive_utc().date(); @@ -71,6 +69,9 @@ impl HistoryService { let all_histories = Arc::new(Mutex::new(Vec::new())); let total_history = Arc::new(Mutex::new(Vec::new())); + let quotes = self.market_data_service.load_quotes(); + println!("Loaded {} quotes", quotes.len()); + let mut summaries: Vec = accounts .par_iter() .map(|account| { @@ -104,7 +105,7 @@ impl HistoryService { let new_history = self.calculate_historical_value( &account.id, &account_activities, - market_data, + "es, account_start_date, end_date, ); @@ -186,7 +187,7 @@ impl HistoryService { &self, account_id: &str, activities: &[Activity], - quotes: &[Quote], + quotes: &HashMap<(String, NaiveDate), Quote>, start_date: NaiveDate, end_date: NaiveDate, ) -> Vec { @@ -207,8 +208,6 @@ impl HistoryService { .and_then(|json_str| serde_json::from_str(json_str).ok()) .unwrap_or_default(); - let mut last_available_quotes: HashMap = HashMap::new(); - // If there's a last history entry, start from the day after let actual_start_date = last_history .as_ref() @@ -217,6 +216,8 @@ impl HistoryService { let all_dates = Self::get_dates_between(actual_start_date, end_date); + // Load all quotes for the date range and assets + let mut results = Vec::new(); for date in all_dates { @@ -261,7 +262,7 @@ impl HistoryService { // Update market value based on quotes let (updated_market_value, day_gain_value, opening_market_value) = - self.calculate_holdings_value(&holdings, quotes, date, &mut last_available_quotes); + self.calculate_holdings_value(&holdings, "es, date); let market_value = updated_market_value; let total_value = cumulative_cash + market_value; @@ -414,21 +415,15 @@ impl HistoryService { fn calculate_holdings_value( &self, holdings: &HashMap, - quotes: &[Quote], + quotes: &HashMap<(String, NaiveDate), Quote>, date: NaiveDate, - last_available_quotes: &mut HashMap, ) -> (f64, f64, f64) { let mut holdings_value = 0.0; let mut day_gain_value = 0.0; let mut opening_market_value = 0.0; for (asset_id, &quantity) in holdings { - // Find the quote for the specific asset and date - let quote = quotes - .iter() - .find(|q| q.date.date() == date && q.symbol == *asset_id) - .or_else(|| last_available_quotes.get(asset_id)) - .cloned(); + let quote = self.get_latest_available_quote(quotes, asset_id, date); if let Some(quote) = quote { let holding_value = quantity * quote.close; @@ -438,17 +433,41 @@ impl HistoryService { holdings_value += holding_value; day_gain_value += day_gain; opening_market_value += opening_value; - - // Update the last available quote for the asset - last_available_quotes.insert(asset_id.clone(), quote); } else { - println!("No quote available for asset {} on date {}", asset_id, date); + println!( + "No quote available for symbol {} on or before date {}", + asset_id, date + ); } } (holdings_value, day_gain_value, opening_market_value) } + fn get_latest_available_quote<'a>( + &self, + quotes: &'a HashMap<(String, NaiveDate), Quote>, + asset_id: &str, + date: NaiveDate, + ) -> Option<&'a Quote> { + // First, check for an exact date match + if let Some(quote) = quotes.get(&(asset_id.to_string(), date)) { + return Some(quote); + } + + // If no exact match, search for the latest quote in previous dates + let found_quote = (1..=30) // Search up to 30 days back + .find_map(|days_back| { + println!( + "***Searching {} back {} days for quote on {}", + asset_id, days_back, date + ); + let search_date = date - Duration::days(days_back); + quotes.get(&(asset_id.to_string(), search_date)) + }); + found_quote + } + fn get_last_portfolio_history( &self, some_account_id: &str, From 1a790d41fb924be315cb3fd3ba2d0a49a9843715 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Tue, 17 Sep 2024 16:05:14 -0400 Subject: [PATCH 19/45] add badges to readme --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cafcd1d..1aa30b0 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,22 @@
-[Buy me a coffee button](https://www.buymeacoffee.com/afadil) +[Buy me a coffee button](https://www.buymeacoffee.com/afadil) + +
+ +
+ + Featured on Hacker News + + Wealthfolio - A boring, Local first, desktop Investment Tracking app | Product Hunt + + + afadil%2Fwealthfolio | Trendshift
From 6cbbde195117c35fa27459e10e887acfbd3e28a1 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Tue, 17 Sep 2024 16:18:29 -0400 Subject: [PATCH 20/45] wip trigger history calculation --- src-core/src/portfolio/portfolio_service.rs | 35 +++++++++++++++++++++ src-tauri/src/commands/portfolio.rs | 20 ++++++++++++ src-tauri/src/main.rs | 7 +++-- src/commands/portfolio.ts | 16 ++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 10616d9..2dc9a68 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -96,6 +96,41 @@ impl PortfolioService { Ok(results) } + pub fn calculate_accounts_historical_data( + &self, + account_ids: Vec, + ) -> Result, Box> { + let strt_time = std::time::Instant::now(); + + let (all_accounts, all_activities) = self.fetch_data()?; + + // Filter accounts and activities based on the provided account_ids + let accounts: Vec<_> = all_accounts + .into_iter() + .filter(|account| { + account_ids.contains(&account.id) || account_ids.contains(&"TOTAL".to_string()) + }) + .collect(); + let activities: Vec<_> = all_activities + .into_iter() + .filter(|activity| { + account_ids.contains(&activity.account_id) + || account_ids.contains(&"TOTAL".to_string()) + }) + .collect(); + + let results = self + .history_service + .calculate_historical_data(&accounts, &activities)?; + + println!( + "Calculating historical portfolio values for specific accounts took: {:?}", + std::time::Instant::now() - strt_time + ); + + Ok(results) + } + pub fn get_income_data(&self) -> Result, diesel::result::Error> { self.income_service.get_income_data() } diff --git a/src-tauri/src/commands/portfolio.rs b/src-tauri/src/commands/portfolio.rs index 466a80d..6934784 100644 --- a/src-tauri/src/commands/portfolio.rs +++ b/src-tauri/src/commands/portfolio.rs @@ -70,3 +70,23 @@ pub async fn get_accounts_summary( .get_accounts_summary() .map_err(|e| format!("Failed to fetch active accounts performance: {}", e)) } + +#[tauri::command] +pub async fn calculate_accounts_historical_data( + state: State<'_, AppState>, + account_ids: Vec, +) -> Result, String> { + println!("Calculating historical data for specific accounts..."); + + let service = PortfolioService::new((*state.pool).clone()) + .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; + + service + .calculate_accounts_historical_data(account_ids) + .map_err(|e| { + format!( + "Failed to calculate historical data for specific accounts: {}", + e + ) + }) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4e5a18d..fbbfc55 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -15,8 +15,8 @@ use commands::goal::{ use commands::market_data::{get_asset_data, search_symbol, synch_quotes}; use commands::portfolio::{ - calculate_historical_data, compute_holdings, get_account_history, get_accounts_summary, - get_income_summary, + calculate_accounts_historical_data, calculate_historical_data, compute_holdings, + get_account_history, get_accounts_summary, get_income_summary, }; use commands::settings::{get_settings, update_currency, update_settings}; @@ -105,7 +105,8 @@ fn main() { load_goals_allocations, get_income_summary, get_account_history, - get_accounts_summary + get_accounts_summary, + calculate_accounts_historical_data, ]) .build(context) .expect("error while running wealthfolio application"); diff --git a/src/commands/portfolio.ts b/src/commands/portfolio.ts index 88f1e9b..86487f5 100644 --- a/src/commands/portfolio.ts +++ b/src/commands/portfolio.ts @@ -76,3 +76,19 @@ export const getAccountsSummary = async (): Promise => { throw error; } }; + +export const calculateHistoricalDataForAccounts = async ( + accountIds: string[], +): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('calculate_accounts_historical_data', { accountIds }); + default: + throw new Error(`Unsupported`); + } + } catch (error) { + console.error('Error calculating historical data for specific accounts:', error); + throw error; + } +}; From 637b9e0d6dafdcee1822603fe3031c03f2348d40 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Tue, 17 Sep 2024 16:38:47 -0400 Subject: [PATCH 21/45] trigger history calculation for accountIds --- package.json | 2 +- src-core/src/account/account_repository.rs | 12 +++++++ src-core/src/account/account_service.rs | 9 +++++ src-core/src/activity/activity_repository.rs | 14 ++++++++ src-core/src/activity/activity_service.rs | 5 +++ src-core/src/portfolio/history_service.rs | 4 --- src-core/src/portfolio/portfolio_service.rs | 35 -------------------- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/portfolio.rs | 20 ----------- src-tauri/src/main.rs | 5 ++- src-tauri/tauri.conf.json | 2 +- src/commands/portfolio.ts | 22 +++--------- src/pages/account/account-page.tsx | 1 - 14 files changed, 50 insertions(+), 85 deletions(-) diff --git a/package.json b/package.json index 02f1c21..fcadb1c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wealthfolio-app", "private": true, - "version": "1.0.13", + "version": "1.0.14", "type": "module", "scripts": { "dev": "vite", diff --git a/src-core/src/account/account_repository.rs b/src-core/src/account/account_repository.rs index 6d88244..a3c2f91 100644 --- a/src-core/src/account/account_repository.rs +++ b/src-core/src/account/account_repository.rs @@ -67,4 +67,16 @@ impl AccountRepository { diesel::delete(accounts.filter(id.eq(account_id))).execute(conn) } + + pub fn load_accounts_by_ids( + &self, + conn: &mut SqliteConnection, + account_ids: &[String], + ) -> Result, diesel::result::Error> { + accounts + .filter(id.eq_any(account_ids)) + .filter(is_active.eq(true)) + .order(created_at.desc()) + .load::(conn) + } } diff --git a/src-core/src/account/account_service.rs b/src-core/src/account/account_service.rs index f2423ed..93035df 100644 --- a/src-core/src/account/account_service.rs +++ b/src-core/src/account/account_service.rs @@ -83,4 +83,13 @@ impl AccountService { self.account_repo .delete_account(&mut conn, account_id_to_delete) } + + pub fn get_accounts_by_ids( + &self, + account_ids: &[String], + ) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.account_repo + .load_accounts_by_ids(&mut conn, account_ids) + } } diff --git a/src-core/src/activity/activity_repository.rs b/src-core/src/activity/activity_repository.rs index 155999e..3fb745d 100644 --- a/src-core/src/activity/activity_repository.rs +++ b/src-core/src/activity/activity_repository.rs @@ -192,4 +192,18 @@ impl ActivityRepository { ) -> Result { diesel::delete(activities::table.filter(activities::id.eq(activity_id))).execute(conn) } + + pub fn get_activities_by_account_ids( + &self, + conn: &mut SqliteConnection, + account_ids: &[String], + ) -> Result, diesel::result::Error> { + activities::table + .inner_join(accounts::table.on(accounts::id.eq(activities::account_id))) + .filter(accounts::is_active.eq(true)) + .filter(activities::account_id.eq_any(account_ids)) + .select(activities::all_columns) + .order(activities::activity_date.asc()) + .load::(conn) + } } diff --git a/src-core/src/activity/activity_service.rs b/src-core/src/activity/activity_service.rs index 674256d..eefb6cd 100644 --- a/src-core/src/activity/activity_service.rs +++ b/src-core/src/activity/activity_service.rs @@ -186,4 +186,9 @@ impl ActivityService { let mut conn = self.pool.get().expect("Couldn't get db connection"); self.repo.delete_activity(&mut conn, activity_id) } + + pub fn get_activities_by_account_ids(&self, account_ids: &[String]) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.repo.get_activities_by_account_ids(&mut conn, account_ids) + } } diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index 6486afb..112ba97 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -458,10 +458,6 @@ impl HistoryService { // If no exact match, search for the latest quote in previous dates let found_quote = (1..=30) // Search up to 30 days back .find_map(|days_back| { - println!( - "***Searching {} back {} days for quote on {}", - asset_id, days_back, date - ); let search_date = date - Duration::days(days_back); quotes.get(&(asset_id.to_string(), search_date)) }); diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 2dc9a68..10616d9 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -96,41 +96,6 @@ impl PortfolioService { Ok(results) } - pub fn calculate_accounts_historical_data( - &self, - account_ids: Vec, - ) -> Result, Box> { - let strt_time = std::time::Instant::now(); - - let (all_accounts, all_activities) = self.fetch_data()?; - - // Filter accounts and activities based on the provided account_ids - let accounts: Vec<_> = all_accounts - .into_iter() - .filter(|account| { - account_ids.contains(&account.id) || account_ids.contains(&"TOTAL".to_string()) - }) - .collect(); - let activities: Vec<_> = all_activities - .into_iter() - .filter(|activity| { - account_ids.contains(&activity.account_id) - || account_ids.contains(&"TOTAL".to_string()) - }) - .collect(); - - let results = self - .history_service - .calculate_historical_data(&accounts, &activities)?; - - println!( - "Calculating historical portfolio values for specific accounts took: {:?}", - std::time::Instant::now() - strt_time - ); - - Ok(results) - } - pub fn get_income_data(&self) -> Result, diesel::result::Error> { self.income_service.get_income_data() } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5d17fb5..bf1682e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4435,7 +4435,7 @@ dependencies = [ [[package]] name = "wealthfolio-app" -version = "1.0.13" +version = "1.0.14" dependencies = [ "diesel", "dotenvy", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c2a4621..2590318 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wealthfolio-app" -version = "1.0.13" +version = "1.0.14" description = "Portfolio tracker" authors = ["Aziz Fadil"] license = "LGPL-3.0" diff --git a/src-tauri/src/commands/portfolio.rs b/src-tauri/src/commands/portfolio.rs index 6934784..466a80d 100644 --- a/src-tauri/src/commands/portfolio.rs +++ b/src-tauri/src/commands/portfolio.rs @@ -70,23 +70,3 @@ pub async fn get_accounts_summary( .get_accounts_summary() .map_err(|e| format!("Failed to fetch active accounts performance: {}", e)) } - -#[tauri::command] -pub async fn calculate_accounts_historical_data( - state: State<'_, AppState>, - account_ids: Vec, -) -> Result, String> { - println!("Calculating historical data for specific accounts..."); - - let service = PortfolioService::new((*state.pool).clone()) - .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; - - service - .calculate_accounts_historical_data(account_ids) - .map_err(|e| { - format!( - "Failed to calculate historical data for specific accounts: {}", - e - ) - }) -} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fbbfc55..edf22a2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -15,8 +15,8 @@ use commands::goal::{ use commands::market_data::{get_asset_data, search_symbol, synch_quotes}; use commands::portfolio::{ - calculate_accounts_historical_data, calculate_historical_data, compute_holdings, - get_account_history, get_accounts_summary, get_income_summary, + calculate_historical_data, compute_holdings, get_account_history, get_accounts_summary, + get_income_summary, }; use commands::settings::{get_settings, update_currency, update_settings}; @@ -106,7 +106,6 @@ fn main() { get_income_summary, get_account_history, get_accounts_summary, - calculate_accounts_historical_data, ]) .build(context) .expect("error while running wealthfolio application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3666542..3ac741e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "Wealthfolio", - "version": "1.0.13" + "version": "1.0.14" }, "tauri": { "allowlist": { diff --git a/src/commands/portfolio.ts b/src/commands/portfolio.ts index 86487f5..52ee87c 100644 --- a/src/commands/portfolio.ts +++ b/src/commands/portfolio.ts @@ -7,11 +7,13 @@ import { AccountSummary, } from '@/lib/types'; -export const calculate_historical_data = async (): Promise => { +export const calculate_historical_data = async ( + accountIds?: string[], +): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: - return invokeTauri('calculate_historical_data'); + return invokeTauri('calculate_historical_data', { accountIds }); default: throw new Error(`Unsupported`); } @@ -76,19 +78,3 @@ export const getAccountsSummary = async (): Promise => { throw error; } }; - -export const calculateHistoricalDataForAccounts = async ( - accountIds: string[], -): Promise => { - try { - switch (getRunEnv()) { - case RUN_ENV.DESKTOP: - return invokeTauri('calculate_accounts_historical_data', { accountIds }); - default: - throw new Error(`Unsupported`); - } - } catch (error) { - console.error('Error calculating historical data for specific accounts:', error); - throw error; - } -}; diff --git a/src/pages/account/account-page.tsx b/src/pages/account/account-page.tsx index 3bf847f..43a2155 100644 --- a/src/pages/account/account-page.tsx +++ b/src/pages/account/account-page.tsx @@ -53,7 +53,6 @@ const AccountPage = () => { headingPrefix={account?.group || account?.currency} displayBack={true} /> - New
From cf9b79dbfbd7ee572563aa3b884ef5a97176e1a4 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Tue, 17 Sep 2024 17:33:47 -0400 Subject: [PATCH 22/45] add querries cache keys --- src/lib/query-keys.ts | 21 +++++++++ src/lib/settings-provider.tsx | 4 +- src/pages/account/account-page.tsx | 7 +-- src/pages/activity/activity-page.tsx | 7 ++- .../activity/components/activity-form.tsx | 11 ++--- .../activity/import/activity-import-page.tsx | 7 ++- src/pages/activity/import/import-form.tsx | 10 +++- src/pages/dashboard/dashboard-page.tsx | 5 +- .../holdings/components/income-dashboard.tsx | 3 +- src/pages/settings/accounts/accounts-page.tsx | 9 ++-- .../accounts/components/account-form.tsx | 47 ++++++++++++++----- .../settings/goals/components/goal-form.tsx | 7 +-- src/pages/settings/goals/goals-page.tsx | 15 +++--- src/useGlobalEventListener.ts | 6 +-- 14 files changed, 102 insertions(+), 57 deletions(-) create mode 100644 src/lib/query-keys.ts diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts new file mode 100644 index 0000000..c2b068f --- /dev/null +++ b/src/lib/query-keys.ts @@ -0,0 +1,21 @@ +export const QueryKeys = { + // Account related keys + ACCOUNTS: 'accounts', + ACCOUNTS_SUMMARY: 'accounts_summary', + ACCOUNTS_HISTORY: 'accounts_history', + + // Activity related keys + ACTIVITY_DATA: 'activity-data', + + // Portfolio related keys + PORTFOLIO_HISTORY: 'portfolio_history', + HOLDINGS: 'holdings', + INCOME_SUMMARY: 'incomeSummary', + + // Goals related keys + GOALS: 'goals', + GOALS_ALLOCATIONS: 'goals_allocations', + + // Helper function to create account-specific keys + accountHistory: (id: string) => ['account_history', id], +} as const; diff --git a/src/lib/settings-provider.tsx b/src/lib/settings-provider.tsx index bb24876..ba87a14 100644 --- a/src/lib/settings-provider.tsx +++ b/src/lib/settings-provider.tsx @@ -18,9 +18,7 @@ export function SettingsProvider({ children }: { children: ReactNode }) { onSuccess: (updatedSettings) => { setSettings(updatedSettings); applySettingsToDocument(updatedSettings); - queryClient.invalidateQueries({ queryKey: ['settings'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); + queryClient.invalidateQueries(); toast({ title: 'Settings updated successfully.', className: 'bg-green-500 text-white border-none', diff --git a/src/pages/account/account-page.tsx b/src/pages/account/account-page.tsx index 43a2155..9246e23 100644 --- a/src/pages/account/account-page.tsx +++ b/src/pages/account/account-page.tsx @@ -14,12 +14,13 @@ import AccountHoldings from './account-holdings'; import { useQuery } from '@tanstack/react-query'; import { Holding, PortfolioHistory, AccountSummary } from '@/lib/types'; import { computeHoldings, getAccountHistory, getAccountsSummary } from '@/commands/portfolio'; +import { QueryKeys } from '@/lib/query-keys'; const AccountPage = () => { const { id = '' } = useParams<{ id: string }>(); const { data: accounts, isLoading: isAccountsLoading } = useQuery({ - queryKey: ['accounts_summary'], + queryKey: [QueryKeys.ACCOUNTS_SUMMARY], queryFn: getAccountsSummary, }); @@ -29,13 +30,13 @@ const AccountPage = () => { PortfolioHistory[], Error >({ - queryKey: ['account_history', id], + queryKey: QueryKeys.accountHistory(id), queryFn: () => getAccountHistory(id), enabled: !!id, }); const { data: holdings, isLoading: isLoadingHoldings } = useQuery({ - queryKey: ['holdings'], + queryKey: [QueryKeys.HOLDINGS], queryFn: computeHoldings, }); diff --git a/src/pages/activity/activity-page.tsx b/src/pages/activity/activity-page.tsx index 310c177..6bb1370 100644 --- a/src/pages/activity/activity-page.tsx +++ b/src/pages/activity/activity-page.tsx @@ -12,6 +12,7 @@ import { getAccounts } from '@/commands/account'; import { ActivityDeleteModal } from './components/activity-delete-modal'; import { deleteActivity } from '@/commands/activity'; import { toast } from '@/components/ui/use-toast'; +import { QueryKeys } from '@/lib/query-keys'; const ActivityPage = () => { const [showEditModal, setShowEditModal] = useState(false); @@ -21,16 +22,14 @@ const ActivityPage = () => { const queryClient = useQueryClient(); const { data: accounts } = useQuery({ - queryKey: ['accounts'], + queryKey: [QueryKeys.ACCOUNTS], queryFn: getAccounts, }); const deleteActivityMutation = useMutation({ mutationFn: deleteActivity, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['activity-data'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); + queryClient.invalidateQueries(); toast({ title: 'Account updated successfully.', className: 'bg-green-500 text-white border-none', diff --git a/src/pages/activity/components/activity-form.tsx b/src/pages/activity/components/activity-form.tsx index d2c210e..301e64d 100644 --- a/src/pages/activity/components/activity-form.tsx +++ b/src/pages/activity/components/activity-form.tsx @@ -36,6 +36,7 @@ import { import { toast } from '@/components/ui/use-toast'; import { cn } from '@/lib/utils'; +import { QueryKeys } from '@/lib/query-keys'; import { newActivitySchema } from '@/lib/schemas'; import { createActivity, updateActivity } from '@/commands/activity'; @@ -75,9 +76,7 @@ export function ActivityForm({ accounts, defaultValues, onSuccess = () => {} }: const addActivityMutation = useMutation({ mutationFn: createActivity, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['activity-data'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); + queryClient.invalidateQueries(); toast({ title: 'Activity added successfully.', className: 'bg-green-500 text-white border-none', @@ -96,9 +95,7 @@ export function ActivityForm({ accounts, defaultValues, onSuccess = () => {} }: const updateActivityMutation = useMutation({ mutationFn: updateActivity, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['activity-data'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); + queryClient.invalidateQueries(); toast({ title: 'Activity updated successfully.', className: 'bg-green-500 text-white border-none', @@ -234,7 +231,7 @@ export function ActivityForm({ accounts, defaultValues, onSuccess = () => {} }: date > new Date() || date < new Date('1900-01-01')} initialFocus diff --git a/src/pages/activity/import/activity-import-page.tsx b/src/pages/activity/import/activity-import-page.tsx index e679822..c1c604a 100644 --- a/src/pages/activity/import/activity-import-page.tsx +++ b/src/pages/activity/import/activity-import-page.tsx @@ -12,6 +12,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { createActivities } from '@/commands/activity'; import { syncHistoryQuotes } from '@/commands/market-data'; import { ImportHelpPopover } from './import-help'; +import { QueryKeys } from '@/lib/query-keys'; const ActivityImportPage = () => { const navigate = useNavigate(); @@ -24,7 +25,7 @@ const ActivityImportPage = () => { const syncQuotesMutation = useMutation({ mutationFn: syncHistoryQuotes, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.PORTFOLIO_HISTORY] }); }, }); @@ -33,9 +34,7 @@ const ActivityImportPage = () => { onSuccess: () => { setError(null); setWarning(0); - queryClient.invalidateQueries({ queryKey: ['activity-data'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); + queryClient.invalidateQueries(); syncQuotesMutation.mutate(); toast({ title: 'Activities imported successfully', diff --git a/src/pages/activity/import/import-form.tsx b/src/pages/activity/import/import-form.tsx index 1b14992..27954d6 100644 --- a/src/pages/activity/import/import-form.tsx +++ b/src/pages/activity/import/import-form.tsx @@ -29,8 +29,14 @@ import type { Account, ActivityImport } from '@/lib/types'; import { getAccounts } from '@/commands/account'; import { useMutation, useQuery } from '@tanstack/react-query'; import { checkActivitiesImport } from '@/commands/activity'; -import { listenImportFileDrop, listenImportFileDropCancelled, listenImportFileDropHover, UnlistenFn } from '@/commands/import-listener'; +import { + listenImportFileDrop, + listenImportFileDropCancelled, + listenImportFileDropHover, + UnlistenFn, +} from '@/commands/import-listener'; import { openCsvFileDialog } from '@/commands/file'; +import { QueryKeys } from '@/lib/query-keys'; const importFormSchema = z.object({ account_id: z.string({ required_error: 'Please select an account.' }), @@ -45,7 +51,7 @@ type ActivityImportFormProps = { export const ActivityImportForm = ({ onSuccess, onError }: ActivityImportFormProps) => { const { data: accounts } = useQuery({ - queryKey: ['accounts'], + queryKey: [QueryKeys.ACCOUNTS], queryFn: getAccounts, }); const [dragging, setDragging] = useState(false); diff --git a/src/pages/dashboard/dashboard-page.tsx b/src/pages/dashboard/dashboard-page.tsx index 71e637a..8e7bb70 100644 --- a/src/pages/dashboard/dashboard-page.tsx +++ b/src/pages/dashboard/dashboard-page.tsx @@ -8,6 +8,7 @@ import { getAccountHistory, getAccountsSummary } from '@/commands/portfolio'; import { Skeleton } from '@/components/ui/skeleton'; import { Accounts } from './accounts'; import SavingGoals from './goals'; +import { QueryKeys } from '@/lib/query-keys'; // filter function DashboardSkeleton() { @@ -30,13 +31,13 @@ export default function DashboardPage() { PortfolioHistory[], Error >({ - queryKey: ['account_history', 'TOTAL'], + queryKey: QueryKeys.accountHistory('TOTAL'), queryFn: () => getAccountHistory('TOTAL'), }); console.log('portfolioHistory', portfolioHistory); const { data: accounts, isLoading: isAccountsLoading } = useQuery({ - queryKey: ['accounts_summary'], + queryKey: [QueryKeys.ACCOUNTS_SUMMARY], queryFn: getAccountsSummary, }); diff --git a/src/pages/holdings/components/income-dashboard.tsx b/src/pages/holdings/components/income-dashboard.tsx index 59b72c4..643e233 100644 --- a/src/pages/holdings/components/income-dashboard.tsx +++ b/src/pages/holdings/components/income-dashboard.tsx @@ -12,6 +12,7 @@ import { import { getIncomeSummary } from '@/commands/portfolio'; import type { IncomeSummary } from '@/lib/types'; import { formatAmount } from '@/lib/utils'; +import { QueryKeys } from '@/lib/query-keys'; export function IncomeDashboard() { const { @@ -19,7 +20,7 @@ export function IncomeDashboard() { isLoading, error, } = useQuery({ - queryKey: ['incomeSummary'], + queryKey: [QueryKeys.INCOME_SUMMARY], queryFn: getIncomeSummary, }); diff --git a/src/pages/settings/accounts/accounts-page.tsx b/src/pages/settings/accounts/accounts-page.tsx index 3657c4c..be4f504 100644 --- a/src/pages/settings/accounts/accounts-page.tsx +++ b/src/pages/settings/accounts/accounts-page.tsx @@ -11,12 +11,13 @@ import { deleteAccount, getAccounts } from '@/commands/account'; import { Skeleton } from '@/components/ui/skeleton'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from '@/components/ui/use-toast'; +import { QueryKeys } from '@/lib/query-keys'; const SettingsAccountsPage = () => { const queryClient = useQueryClient(); const { data: accounts, isLoading } = useQuery({ - queryKey: ['accounts'], + queryKey: [QueryKeys.ACCOUNTS], queryFn: getAccounts, }); @@ -31,9 +32,7 @@ const SettingsAccountsPage = () => { const deleteAccountMutation = useMutation({ mutationFn: deleteAccount, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['accounts'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); + queryClient.invalidateQueries(); toast({ title: 'Account deleted successfully.', className: 'bg-green-500 text-white border-none', @@ -77,7 +76,7 @@ const SettingsAccountsPage = () => { -
+
{accounts?.length ? (
{accounts.map((account: Account) => ( diff --git a/src/pages/settings/accounts/components/account-form.tsx b/src/pages/settings/accounts/components/account-form.tsx index d17b5d8..4a47a30 100644 --- a/src/pages/settings/accounts/components/account-form.tsx +++ b/src/pages/settings/accounts/components/account-form.tsx @@ -41,6 +41,8 @@ import { } from '@/components/ui/select'; import { toast } from '@/components/ui/use-toast'; +import { calculate_historical_data } from '@/commands/portfolio'; + const accountTypes = [ { label: 'Securities', value: 'SECURITIES' }, { label: 'Cash', value: 'CASH' }, @@ -62,13 +64,32 @@ interface AccountFormlProps { export function AccountForm({ defaultValues, onSuccess = () => {} }: AccountFormlProps) { const queryClient = useQueryClient(); - const addAccountMutation = useMutation({ + const calculateHistoricalDataMutation = useMutation({ + mutationFn: calculate_historical_data, + onSuccess: () => { + queryClient.invalidateQueries(); + toast({ + title: 'Account updated successfully.', + description: 'Historical data is being recalculated.', + className: 'bg-green-500 text-white border-none', + }); + }, + onError: () => { + toast({ + title: 'Failed to recalculate historical data.', + description: 'Please try refreshing the page or relaunching the app.', + className: 'bg-yellow-500 text-white border-none', + }); + }, + }); + + const createAccountMutation = useMutation({ mutationFn: createAccount, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['accounts'] }); + queryClient.invalidateQueries(); toast({ - title: 'Account added successfully.', - description: 'Start adding or importing this account activities.', + title: 'Account created successfully.', + description: 'Historical data is being calculated.', className: 'bg-green-500 text-white border-none', }); onSuccess(); @@ -76,22 +97,24 @@ export function AccountForm({ defaultValues, onSuccess = () => {} }: AccountForm onError: () => { toast({ title: 'Uh oh! Something went wrong.', - description: 'There was a problem adding this account.', + description: 'There was a problem creating this account.', className: 'bg-red-500 text-white border-none', }); }, }); + const updateAccountMutation = useMutation({ mutationFn: updateAccount, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['accounts'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); + onSuccess(); + calculateHistoricalDataMutation.mutate([]); + }, + onError: () => { toast({ - title: 'Account updated successfully.', - className: 'bg-green-500 text-white border-none', + title: 'Uh oh! Something went wrong.', + description: 'There was a problem updating this account.', + className: 'bg-red-500 text-white border-none', }); - onSuccess(); }, }); @@ -105,7 +128,7 @@ export function AccountForm({ defaultValues, onSuccess = () => {} }: AccountForm if (id) { return updateAccountMutation.mutate({ id, ...rest }); } - return addAccountMutation.mutate(data); + return createAccountMutation.mutate(rest); } return ( diff --git a/src/pages/settings/goals/components/goal-form.tsx b/src/pages/settings/goals/components/goal-form.tsx index 6fe0893..2c7047b 100644 --- a/src/pages/settings/goals/components/goal-form.tsx +++ b/src/pages/settings/goals/components/goal-form.tsx @@ -28,6 +28,7 @@ import { toast } from '@/components/ui/use-toast'; import { newGoalSchema } from '@/lib/schemas'; import { createGoal, updateGoal } from '@/commands/goal'; +import { QueryKeys } from '@/lib/query-keys'; type NewGoal = z.infer; @@ -42,7 +43,7 @@ export function GoalForm({ defaultValues, onSuccess = () => {} }: GoalFormlProps const addGoalMutation = useMutation({ mutationFn: createGoal, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['goals'] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.GOALS] }); toast({ title: 'Goal added successfully.', description: 'Start adding or importing this goal activities.', @@ -61,7 +62,7 @@ export function GoalForm({ defaultValues, onSuccess = () => {} }: GoalFormlProps const updateGoalMutation = useMutation({ mutationFn: updateGoal, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['goals'] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.GOALS] }); toast({ title: 'Goal updated successfully.', className: 'bg-green-500 text-white border-none', @@ -93,7 +94,7 @@ export function GoalForm({ defaultValues, onSuccess = () => {} }: GoalFormlProps -
+
{/* add input hidden for id */} diff --git a/src/pages/settings/goals/goals-page.tsx b/src/pages/settings/goals/goals-page.tsx index 4e586fc..55e76c7 100644 --- a/src/pages/settings/goals/goals-page.tsx +++ b/src/pages/settings/goals/goals-page.tsx @@ -13,17 +13,18 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from '@/components/ui/use-toast'; import GoalsAllocations from './components/goal-allocations'; import { useAccounts } from '@/pages/account/useAccounts'; +import { QueryKeys } from '@/lib/query-keys'; const SettingsGoalsPage = () => { const queryClient = useQueryClient(); const { data: goals, isLoading } = useQuery({ - queryKey: ['goals'], + queryKey: [QueryKeys.GOALS], queryFn: getGoals, }); const { data: allocations } = useQuery({ - queryKey: ['goals_allocations'], + queryKey: [QueryKeys.GOALS_ALLOCATIONS], queryFn: getGoalsAllocation, }); @@ -40,8 +41,8 @@ const SettingsGoalsPage = () => { const deleteGoalMutation = useMutation({ mutationFn: deleteGoal, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['goals'] }); - queryClient.invalidateQueries({ queryKey: ['goals_allocations'] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.GOALS] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.GOALS_ALLOCATIONS] }); setVisibleModal(false); toast({ title: 'Goal deleted successfully.', @@ -62,8 +63,8 @@ const SettingsGoalsPage = () => { const saveAllocationsMutation = useMutation({ mutationFn: updateGoalsAllocations, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['goals'] }); - queryClient.invalidateQueries({ queryKey: ['goals_allocations'] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.GOALS] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.GOALS_ALLOCATIONS] }); toast({ title: 'Allocation saved successfully.', className: 'bg-green-500 text-white border-none', @@ -94,7 +95,7 @@ const SettingsGoalsPage = () => { -
+
{goals?.length ? ( <>

Goals

diff --git a/src/useGlobalEventListener.ts b/src/useGlobalEventListener.ts index c3edefe..36965f5 100644 --- a/src/useGlobalEventListener.ts +++ b/src/useGlobalEventListener.ts @@ -17,9 +17,7 @@ const useGlobalEventListener = () => { }; const handleQuotesSyncComplete = () => { - queryClient.invalidateQueries({ queryKey: ['holdings'] }); - queryClient.invalidateQueries({ queryKey: ['account_history'] }); - queryClient.invalidateQueries({ queryKey: ['accounts_summary'] }); + queryClient.invalidateQueries(); toast({ title: 'Portfolio Update Complete', description: 'Your portfolio has been refreshed with the latest market data.', @@ -41,7 +39,7 @@ const useGlobalEventListener = () => { }); }, [queryClient]); - return null; // Assuming this hook doesn't need to return anything + return null; }; export default useGlobalEventListener; From 707e8f747b53edac13aac8bfc23c9d5536e611ab Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Tue, 17 Sep 2024 17:56:06 -0400 Subject: [PATCH 23/45] refactor total portfolio calculation --- src-core/src/portfolio/history_service.rs | 154 ++++++++++-------- .../accounts/components/account-form.tsx | 8 +- 2 files changed, 90 insertions(+), 72 deletions(-) diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index 112ba97..ba0c10f 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -139,8 +139,9 @@ impl HistoryService { let account_histories = all_histories.lock().unwrap(); - // Calculate total portfolio history - *total_history.lock().unwrap() = self.calculate_total_portfolio_history(&account_histories); + // Calculate total portfolio history for all accounts + *total_history.lock().unwrap() = + self.calculate_total_portfolio_history_for_all_accounts()?; // Save all historical data for history in account_histories.iter() { @@ -183,6 +184,89 @@ impl HistoryService { Ok(summaries) } + // New method to calculate total portfolio history for all accounts + fn calculate_total_portfolio_history_for_all_accounts(&self) -> Result> { + use crate::schema::accounts::dsl as accounts_dsl; + use crate::schema::portfolio_history::dsl::*; + let conn = &mut self.pool.get().map_err(PortfolioError::from)?; + + // Get active account IDs + let active_account_ids: Vec = accounts_dsl::accounts + .filter(accounts_dsl::is_active.eq(true)) + .select(accounts_dsl::id) + .load::(conn)?; + + let all_histories: Vec = portfolio_history + .filter(account_id.ne("TOTAL")) + .filter(account_id.eq_any(active_account_ids)) + .order(date.asc()) + .load::(conn)?; + + let grouped_histories: HashMap> = all_histories + .into_iter() + .fold(HashMap::new(), |mut acc, history| { + acc.entry(history.date.clone()).or_default().push(history); + acc + }); + + let mut total_history: Vec = grouped_histories + .into_iter() + .map(|(history_date, histories)| { + let mut total = PortfolioHistory { + id: Uuid::new_v4().to_string(), + account_id: "TOTAL".to_string(), + date: history_date, + total_value: 0.0, + market_value: 0.0, + book_cost: 0.0, + available_cash: 0.0, + net_deposit: 0.0, + currency: self.base_currency.clone(), + base_currency: self.base_currency.clone(), + total_gain_value: 0.0, + total_gain_percentage: 0.0, + day_gain_percentage: 0.0, + day_gain_value: 0.0, + allocation_percentage: 100.0, + exchange_rate: 1.0, + holdings: Some("{}".to_string()), + }; + + for history in histories { + let currency_exchange_rate = self + .fx_service + .get_exchange_rate(&history.currency, &self.base_currency) + .unwrap_or(1.0); + + total.total_value += history.total_value * currency_exchange_rate; + total.market_value += history.market_value * currency_exchange_rate; + total.book_cost += history.book_cost * currency_exchange_rate; + total.available_cash += history.available_cash * currency_exchange_rate; + total.net_deposit += history.net_deposit * currency_exchange_rate; + total.day_gain_value += history.day_gain_value * currency_exchange_rate; + } + + // Recalculate percentages + total.total_gain_value = total.total_value - total.net_deposit; + total.total_gain_percentage = if total.net_deposit != 0.0 { + (total.total_gain_value / total.net_deposit) * 100.0 + } else { + 0.0 + }; + total.day_gain_percentage = if total.market_value != 0.0 { + (total.day_gain_value / total.market_value) * 100.0 + } else { + 0.0 + }; + + total + }) + .collect(); + + total_history.sort_by(|a, b| a.date.cmp(&b.date)); + Ok(total_history) + } + fn calculate_historical_value( &self, account_id: &str, @@ -309,72 +393,6 @@ impl HistoryService { results } - fn calculate_total_portfolio_history( - &self, - account_histories: &[Vec], - ) -> Vec { - let mut total_history = HashMap::new(); - - for history in account_histories { - for snapshot in history { - let entry = total_history - .entry(snapshot.date.clone()) - .or_insert_with(|| PortfolioHistory { - id: Uuid::new_v4().to_string(), - account_id: "TOTAL".to_string(), - date: snapshot.date.clone(), - total_value: 0.0, - market_value: 0.0, - book_cost: 0.0, - available_cash: 0.0, - net_deposit: 0.0, - currency: self.base_currency.clone(), - base_currency: self.base_currency.clone(), - total_gain_value: 0.0, - total_gain_percentage: 0.0, - day_gain_percentage: 0.0, - day_gain_value: 0.0, - allocation_percentage: 0.0, - exchange_rate: 1.0, - holdings: Some("{}".to_string()), - }); - - let exchange_rate = self - .fx_service - .get_exchange_rate(&snapshot.currency, &self.base_currency) - .unwrap_or(1.0); - - entry.total_value += snapshot.total_value * exchange_rate; - entry.market_value += snapshot.market_value * exchange_rate; - entry.book_cost += snapshot.book_cost * exchange_rate; - entry.available_cash += snapshot.available_cash * exchange_rate; - entry.net_deposit += snapshot.net_deposit * exchange_rate; - entry.day_gain_value += snapshot.day_gain_value * exchange_rate; - } - } - - let mut total_history: Vec<_> = total_history.into_values().collect(); - total_history.sort_by(|a, b| a.date.cmp(&b.date)); - - // Recalculate percentages for total portfolio - for record in &mut total_history { - record.total_gain_value = record.total_value - record.net_deposit; - record.total_gain_percentage = if record.net_deposit != 0.0 { - (record.total_gain_value / record.net_deposit) * 100.0 - } else { - 0.0 - }; - record.day_gain_percentage = if record.market_value != 0.0 { - (record.day_gain_value / record.market_value) * 100.0 - } else { - 0.0 - }; - record.allocation_percentage = 100.0; // The total portfolio always represents 100% of itself - } - - total_history - } - fn save_historical_data(&self, history_data: &[PortfolioHistory]) -> Result<()> { use crate::schema::portfolio_history::dsl::*; let conn = &mut self.pool.get().map_err(PortfolioError::from)?; // Use the From trait to convert r2d2::Error to PortfolioError diff --git a/src/pages/settings/accounts/components/account-form.tsx b/src/pages/settings/accounts/components/account-form.tsx index 4a47a30..1658018 100644 --- a/src/pages/settings/accounts/components/account-form.tsx +++ b/src/pages/settings/accounts/components/account-form.tsx @@ -70,13 +70,13 @@ export function AccountForm({ defaultValues, onSuccess = () => {} }: AccountForm queryClient.invalidateQueries(); toast({ title: 'Account updated successfully.', - description: 'Historical data is being recalculated.', + description: 'Portfolio data is being recalculated.', className: 'bg-green-500 text-white border-none', }); }, onError: () => { toast({ - title: 'Failed to recalculate historical data.', + title: 'Failed to recalculate portfolio data.', description: 'Please try refreshing the page or relaunching the app.', className: 'bg-yellow-500 text-white border-none', }); @@ -105,9 +105,9 @@ export function AccountForm({ defaultValues, onSuccess = () => {} }: AccountForm const updateAccountMutation = useMutation({ mutationFn: updateAccount, - onSuccess: () => { + onSuccess: (updatedAccount) => { onSuccess(); - calculateHistoricalDataMutation.mutate([]); + calculateHistoricalDataMutation.mutate([updatedAccount.id]); }, onError: () => { toast({ From 495bebe8de06b41a9f0a8f5d24368a579856d607 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Tue, 17 Sep 2024 20:59:34 -0400 Subject: [PATCH 24/45] force history calculation --- src-core/src/portfolio/history_service.rs | 66 +++++++++++++------ src-core/src/portfolio/portfolio_service.rs | 12 ++-- src-tauri/src/commands/portfolio.rs | 5 +- src/commands/portfolio.ts | 9 +-- .../activity/components/activity-form.tsx | 48 ++++++++++---- src/pages/dashboard/dashboard-page.tsx | 2 - .../accounts/components/account-form.tsx | 5 +- 7 files changed, 102 insertions(+), 45 deletions(-) diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index ba0c10f..06751ea 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -62,15 +62,14 @@ impl HistoryService { &self, accounts: &[Account], activities: &[Activity], + force_full_calculation: bool, ) -> Result> { - println!("Starting calculate_historical_data"); let end_date = Utc::now().naive_utc().date(); let all_histories = Arc::new(Mutex::new(Vec::new())); let total_history = Arc::new(Mutex::new(Vec::new())); let quotes = self.market_data_service.load_quotes(); - println!("Loaded {} quotes", quotes.len()); let mut summaries: Vec = accounts .par_iter() @@ -90,17 +89,24 @@ impl HistoryService { }; } - let last_date = self.get_last_historical_date(&account.id).unwrap_or(None); - - let account_start_date = - last_date.map(|d| d - Duration::days(2)).unwrap_or_else(|| { - // -2 for more freshness of towo last days - account_activities - .iter() - .map(|a| a.activity_date.date()) - .min() - .unwrap_or_else(|| Utc::now().naive_utc().date()) - }); + let account_start_date = if force_full_calculation { + account_activities + .iter() + .map(|a| a.activity_date.date()) + .min() + .unwrap_or_else(|| Utc::now().naive_utc().date()) + } else { + self.get_last_historical_date(&account.id) + .unwrap_or(None) + .map(|d| d - Duration::days(2)) + .unwrap_or_else(|| { + account_activities + .iter() + .map(|a| a.activity_date.date()) + .min() + .unwrap_or_else(|| Utc::now().naive_utc().date()) + }) + }; let new_history = self.calculate_historical_value( &account.id, @@ -108,6 +114,7 @@ impl HistoryService { "es, account_start_date, end_date, + force_full_calculation, ); if !new_history.is_empty() { @@ -190,6 +197,8 @@ impl HistoryService { use crate::schema::portfolio_history::dsl::*; let conn = &mut self.pool.get().map_err(PortfolioError::from)?; + println!("Calculating total portfolio history for all accounts"); + // Get active account IDs let active_account_ids: Vec = accounts_dsl::accounts .filter(accounts_dsl::is_active.eq(true)) @@ -274,10 +283,19 @@ impl HistoryService { quotes: &HashMap<(String, NaiveDate), Quote>, start_date: NaiveDate, end_date: NaiveDate, + force_full_calculation: bool, ) -> Vec { - let last_history = self.get_last_portfolio_history(account_id).unwrap_or(None); + println!( + "Calculating historical value for account: {} from {} to {} with force {}", + account_id, start_date, end_date, force_full_calculation + ); + let last_history = if force_full_calculation { + None + } else { + self.get_last_portfolio_history(account_id).unwrap_or(None) + }; - // Initialize values from the last PortfolioHistory + // Initialize values from the last PortfolioHistory or use default values let mut currency = last_history .as_ref() .map_or(self.base_currency.as_str(), |h| &h.currency); @@ -285,18 +303,24 @@ impl HistoryService { let mut net_deposit = last_history.as_ref().map_or(0.0, |h| h.net_deposit); let mut book_cost = last_history.as_ref().map_or(0.0, |h| h.book_cost); - // Initialize holdings based on the last history + // Initialize holdings based on the last history or use an empty HashMap let mut holdings: HashMap = last_history .as_ref() .and_then(|h| h.holdings.as_ref()) .and_then(|json_str| serde_json::from_str(json_str).ok()) .unwrap_or_default(); - // If there's a last history entry, start from the day after - let actual_start_date = last_history - .as_ref() - .map(|h| NaiveDate::parse_from_str(&h.date, "%Y-%m-%d").unwrap() + Duration::days(1)) - .unwrap_or(start_date); + // If there's a last history entry and we're not forcing full calculation, start from the day after + let actual_start_date = if force_full_calculation { + start_date + } else { + last_history + .as_ref() + .map(|h| { + NaiveDate::parse_from_str(&h.date, "%Y-%m-%d").unwrap() + Duration::days(1) + }) + .unwrap_or(start_date) + }; let all_dates = Self::get_dates_between(actual_start_date, end_date); diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 10616d9..d8e6b04 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -79,14 +79,18 @@ impl PortfolioService { pub fn calculate_historical_data( &self, account_ids: Option>, + force_full_calculation: bool, ) -> Result, Box> { + println!("Starting calculate_historical_data with account_ids: {:?}, force_full_calculation: {:?}", account_ids, force_full_calculation); let strt_time = std::time::Instant::now(); let (accounts, activities) = self.fetch_data(account_ids)?; - let results = self - .history_service - .calculate_historical_data(&accounts, &activities)?; + let results = self.history_service.calculate_historical_data( + &accounts, + &activities, + force_full_calculation, + )?; println!( "Calculating historical portfolio values took: {:?}", @@ -111,7 +115,7 @@ impl PortfolioService { self.asset_service.initialize_and_sync_quotes().await?; // Then, calculate historical data - self.calculate_historical_data(None) + self.calculate_historical_data(None, false) } pub fn get_account_history( diff --git a/src-tauri/src/commands/portfolio.rs b/src-tauri/src/commands/portfolio.rs index 466a80d..41e59fc 100644 --- a/src-tauri/src/commands/portfolio.rs +++ b/src-tauri/src/commands/portfolio.rs @@ -8,14 +8,15 @@ use tauri::State; pub async fn calculate_historical_data( state: State<'_, AppState>, account_ids: Option>, + force_full_calculation: bool, ) -> Result, String> { - println!("Fetching portfolio historical..."); + println!("Calculate portfolio historical..."); let service = PortfolioService::new((*state.pool).clone()) .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; service - .calculate_historical_data(account_ids) + .calculate_historical_data(account_ids, force_full_calculation) .map_err(|e| format!("Failed to calculate historical data: {}", e)) } diff --git a/src/commands/portfolio.ts b/src/commands/portfolio.ts index 52ee87c..d010c42 100644 --- a/src/commands/portfolio.ts +++ b/src/commands/portfolio.ts @@ -7,13 +7,14 @@ import { AccountSummary, } from '@/lib/types'; -export const calculate_historical_data = async ( - accountIds?: string[], -): Promise => { +export const calculate_historical_data = async (params: { + accountIds?: string[]; + forceFullCalculation: boolean; +}): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: - return invokeTauri('calculate_historical_data', { accountIds }); + return invokeTauri('calculate_historical_data', params); default: throw new Error(`Unsupported`); } diff --git a/src/pages/activity/components/activity-form.tsx b/src/pages/activity/components/activity-form.tsx index 301e64d..fcf9732 100644 --- a/src/pages/activity/components/activity-form.tsx +++ b/src/pages/activity/components/activity-form.tsx @@ -40,6 +40,7 @@ import { QueryKeys } from '@/lib/query-keys'; import { newActivitySchema } from '@/lib/schemas'; import { createActivity, updateActivity } from '@/commands/activity'; +import { calculate_historical_data } from '@/commands/portfolio'; import TickerSearchInput from './ticker-search'; const activityTypes = [ @@ -48,7 +49,6 @@ const activityTypes = [ { label: 'Deposit', value: 'DEPOSIT' }, { label: 'Withdrawal', value: 'WITHDRAWAL' }, { label: 'Dividend', value: 'DIVIDEND' }, - // { label: 'Split', value: 'SPLIT' }, // { label: 'Transfer', value: 'TRANSFER' }, { label: 'Interest', value: 'INTEREST' }, { label: 'Fee', value: 'FEE' }, @@ -73,15 +73,35 @@ interface ActivityFormProps { export function ActivityForm({ accounts, defaultValues, onSuccess = () => {} }: ActivityFormProps) { const queryClient = useQueryClient(); - const addActivityMutation = useMutation({ - mutationFn: createActivity, + const calculateHistoricalDataMutation = useMutation({ + mutationFn: calculate_historical_data, onSuccess: () => { - queryClient.invalidateQueries(); + queryClient.invalidateQueries({ queryKey: QueryKeys.accountHistory('TOTAL') }); + toast({ - title: 'Activity added successfully.', + title: 'Activities updated successfully.', + description: 'Portfolio data is being recalculated.', className: 'bg-green-500 text-white border-none', }); + }, + onError: () => { + queryClient.invalidateQueries(); + toast({ + title: 'Failed to recalculate portfolio data.', + description: 'Please try refreshing the page or relaunching the app.', + className: 'bg-yellow-500 text-white border-none', + }); + }, + }); + + const addActivityMutation = useMutation({ + mutationFn: createActivity, + onSuccess: (activity) => { onSuccess(); + calculateHistoricalDataMutation.mutate({ + accountIds: [activity.accountId ?? ''], + forceFullCalculation: true, + }); }, onError: (_error) => { toast({ @@ -94,13 +114,19 @@ export function ActivityForm({ accounts, defaultValues, onSuccess = () => {} }: const updateActivityMutation = useMutation({ mutationFn: updateActivity, - onSuccess: () => { - queryClient.invalidateQueries(); - toast({ - title: 'Activity updated successfully.', - className: 'bg-green-500 text-white border-none', - }); + onSuccess: (activity) => { onSuccess(); + if (activity.accountId) { + calculateHistoricalDataMutation.mutate({ + accountIds: [activity.accountId], + forceFullCalculation: true, + }); + } else { + calculateHistoricalDataMutation.mutate({ + accountIds: undefined, + forceFullCalculation: true, + }); + } }, }); diff --git a/src/pages/dashboard/dashboard-page.tsx b/src/pages/dashboard/dashboard-page.tsx index 8e7bb70..60bddec 100644 --- a/src/pages/dashboard/dashboard-page.tsx +++ b/src/pages/dashboard/dashboard-page.tsx @@ -35,13 +35,11 @@ export default function DashboardPage() { queryFn: () => getAccountHistory('TOTAL'), }); - console.log('portfolioHistory', portfolioHistory); const { data: accounts, isLoading: isAccountsLoading } = useQuery({ queryKey: [QueryKeys.ACCOUNTS_SUMMARY], queryFn: getAccountsSummary, }); - console.log('accounts', accounts); if (isPortfolioHistoryLoading || isAccountsLoading) { return ; } diff --git a/src/pages/settings/accounts/components/account-form.tsx b/src/pages/settings/accounts/components/account-form.tsx index 1658018..18bdfc1 100644 --- a/src/pages/settings/accounts/components/account-form.tsx +++ b/src/pages/settings/accounts/components/account-form.tsx @@ -107,7 +107,10 @@ export function AccountForm({ defaultValues, onSuccess = () => {} }: AccountForm mutationFn: updateAccount, onSuccess: (updatedAccount) => { onSuccess(); - calculateHistoricalDataMutation.mutate([updatedAccount.id]); + calculateHistoricalDataMutation.mutate({ + accountIds: [updatedAccount.id ?? ''], + forceFullCalculation: true, + }); }, onError: () => { toast({ From 807cfb8512c9791e86a02a1f217580982759e70b Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Wed, 18 Sep 2024 19:52:38 -0400 Subject: [PATCH 25/45] refactor history calculation --- src-core/Cargo.lock | 15 + src-core/Cargo.toml | 1 + src-core/src/activity/activity_repository.rs | 10 +- src-core/src/activity/activity_service.rs | 10 +- .../src/market_data/market_data_service.rs | 10 +- src-core/src/models.rs | 2 +- src-core/src/portfolio/history_service.rs | 452 +++++++++--------- src-tauri/Cargo.lock | 15 + src-tauri/src/commands/activity.rs | 2 +- src/commands/activity.ts | 5 +- src/hooks/useCalculateHistory.ts | 37 ++ src/pages/activity/activity-page.tsx | 17 +- .../activity/components/activity-form.tsx | 75 +-- .../activity/hooks/useActivityMutations.ts | 62 +++ .../accounts/components/account-form.tsx | 37 +- 15 files changed, 414 insertions(+), 336 deletions(-) create mode 100644 src/hooks/useCalculateHistory.ts create mode 100644 src/pages/activity/hooks/useActivityMutations.ts diff --git a/src-core/Cargo.lock b/src-core/Cargo.lock index cb0d183..854b345 100644 --- a/src-core/Cargo.lock +++ b/src-core/Cargo.lock @@ -293,6 +293,20 @@ dependencies = [ "syn 2.0.76", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "deranged" version = "0.3.11" @@ -2101,6 +2115,7 @@ version = "1.0.11" dependencies = [ "chrono", "csv", + "dashmap", "diesel", "diesel_migrations", "lazy_static", diff --git a/src-core/Cargo.toml b/src-core/Cargo.toml index 8ee2136..76af3ef 100644 --- a/src-core/Cargo.toml +++ b/src-core/Cargo.toml @@ -25,3 +25,4 @@ lazy_static = "1.5.0" diesel_migrations = { version = "2.2.0", features = ["sqlite" ] } rayon = "1.10.0" r2d2 = "0.8.10" +dashmap = "6.1.0" \ No newline at end of file diff --git a/src-core/src/activity/activity_repository.rs b/src-core/src/activity/activity_repository.rs index 3fb745d..347a058 100644 --- a/src-core/src/activity/activity_repository.rs +++ b/src-core/src/activity/activity_repository.rs @@ -189,8 +189,14 @@ impl ActivityRepository { &self, conn: &mut SqliteConnection, activity_id: String, - ) -> Result { - diesel::delete(activities::table.filter(activities::id.eq(activity_id))).execute(conn) + ) -> Result { + let activity = activities::table + .filter(activities::id.eq(&activity_id)) + .first::(conn)?; + + diesel::delete(activities::table.filter(activities::id.eq(activity_id))).execute(conn)?; + + Ok(activity) } pub fn get_activities_by_account_ids( diff --git a/src-core/src/activity/activity_service.rs b/src-core/src/activity/activity_service.rs index eefb6cd..850e4d8 100644 --- a/src-core/src/activity/activity_service.rs +++ b/src-core/src/activity/activity_service.rs @@ -182,13 +182,17 @@ impl ActivityService { } // delete an activity - pub fn delete_activity(&self, activity_id: String) -> Result { + pub fn delete_activity(&self, activity_id: String) -> Result { let mut conn = self.pool.get().expect("Couldn't get db connection"); self.repo.delete_activity(&mut conn, activity_id) } - pub fn get_activities_by_account_ids(&self, account_ids: &[String]) -> Result, diesel::result::Error> { + pub fn get_activities_by_account_ids( + &self, + account_ids: &[String], + ) -> Result, diesel::result::Error> { let mut conn = self.pool.get().expect("Couldn't get db connection"); - self.repo.get_activities_by_account_ids(&mut conn, account_ids) + self.repo + .get_activities_by_account_ids(&mut conn, account_ids) } } diff --git a/src-core/src/market_data/market_data_service.rs b/src-core/src/market_data/market_data_service.rs index ad4e743..905166e 100644 --- a/src-core/src/market_data/market_data_service.rs +++ b/src-core/src/market_data/market_data_service.rs @@ -5,7 +5,7 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, TimeZone, Utc}; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::time::SystemTime; use uuid::Uuid; @@ -53,10 +53,10 @@ impl MarketDataService { let quote_date = quote.date.date(); ((quote.symbol.clone(), quote_date), quote) }) - .collect(), + .collect(), // This will now create a HashMap Err(e) => { eprintln!("Error loading quotes: {}", e); - HashMap::new() + HashMap::new() // Return an empty HashMap } } } @@ -139,9 +139,9 @@ impl MarketDataService { .map_err(|e| format!("Error getting last sync date for {}: {}", symbol, e))? .unwrap_or_else(|| Utc::now().naive_utc() - Duration::days(3 * 365)); - // Ensure to synchronize the last 2 days data for freshness + // Ensure to synchronize the last day data for freshness let start_date: SystemTime = Utc - .from_utc_datetime(&(last_sync_date - Duration::days(2))) + .from_utc_datetime(&(last_sync_date - Duration::days(1))) .into(); match self diff --git a/src-core/src/models.rs b/src-core/src/models.rs index 958e36d..4ddd17e 100644 --- a/src-core/src/models.rs +++ b/src-core/src/models.rs @@ -511,7 +511,7 @@ pub struct PortfolioHistory { pub holdings: Option, // Holdings JSON } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct HistorySummary { pub id: Option, diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index 06751ea..fdd1d2c 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -4,13 +4,14 @@ use crate::market_data::market_data_service::MarketDataService; use crate::models::{Account, Activity, HistorySummary, PortfolioHistory, Quote}; use chrono::{Duration, NaiveDate, Utc}; +use dashmap::DashMap; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; + use diesel::SqliteConnection; use rayon::prelude::*; use std::collections::HashMap; -use std::sync::{Arc, Mutex}; -use uuid::Uuid; +use std::sync::Arc; pub struct HistoryService { pool: Pool>, @@ -23,9 +24,9 @@ impl HistoryService { pub fn new(pool: Pool>, base_currency: String) -> Self { Self { pool: pool.clone(), - base_currency, + base_currency: base_currency.clone(), market_data_service: MarketDataService::new(pool.clone()), - fx_service: CurrencyExchangeService::new(pool.clone()), + fx_service: CurrencyExchangeService::new(pool), } } @@ -33,12 +34,12 @@ impl HistoryService { use crate::schema::portfolio_history::dsl::*; use diesel::prelude::*; - let conn = &mut self.pool.get().map_err(PortfolioError::from)?; + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; let history_data: Vec = portfolio_history .filter(account_id.eq(input_account_id)) .order(date.asc()) - .load::(conn)?; + .load::(db_connection)?; Ok(history_data) } @@ -47,12 +48,12 @@ impl HistoryService { use crate::schema::portfolio_history::dsl::*; use diesel::prelude::*; - let conn = &mut self.pool.get().map_err(PortfolioError::from)?; + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; let latest_history: PortfolioHistory = portfolio_history .filter(account_id.eq(input_account_id)) .order(date.desc()) - .first(conn) + .first(db_connection) .map_err(|e| PortfolioError::DatabaseError(e))?; Ok(latest_history) @@ -65,13 +66,10 @@ impl HistoryService { force_full_calculation: bool, ) -> Result> { let end_date = Utc::now().naive_utc().date(); + let quotes = Arc::new(self.market_data_service.load_quotes()); - let all_histories = Arc::new(Mutex::new(Vec::new())); - let total_history = Arc::new(Mutex::new(Vec::new())); - - let quotes = self.market_data_service.load_quotes(); - - let mut summaries: Vec = accounts + // Process accounts in parallel and collect results + let summaries_and_histories: Vec<(HistorySummary, Vec)> = accounts .par_iter() .map(|account| { let account_activities: Vec<_> = activities @@ -81,12 +79,15 @@ impl HistoryService { .collect(); if account_activities.is_empty() { - return HistorySummary { - id: Some(account.id.clone()), - start_date: "".to_string(), - end_date: "".to_string(), - entries_count: 0, - }; + return ( + HistorySummary { + id: Some(account.id.clone()), + start_date: "".to_string(), + end_date: "".to_string(), + entries_count: 0, + }, + Vec::new(), + ); } let account_start_date = if force_full_calculation { @@ -98,7 +99,7 @@ impl HistoryService { } else { self.get_last_historical_date(&account.id) .unwrap_or(None) - .map(|d| d - Duration::days(2)) + .map(|d| d - Duration::days(1)) .unwrap_or_else(|| { account_activities .iter() @@ -117,76 +118,56 @@ impl HistoryService { force_full_calculation, ); - if !new_history.is_empty() { - all_histories.lock().unwrap().push(new_history.clone()); - } - - HistorySummary { + let summary = HistorySummary { id: Some(account.id.clone()), start_date: new_history .first() - .map(|h| { - NaiveDate::parse_from_str(&h.date, "%Y-%m-%d") - .unwrap() - .to_string() - }) + .map(|h| h.date.clone()) .unwrap_or_default(), end_date: new_history .last() - .map(|h| { - NaiveDate::parse_from_str(&h.date, "%Y-%m-%d") - .unwrap() - .to_string() - }) + .map(|h| h.date.clone()) .unwrap_or_default(), entries_count: new_history.len(), - } + }; + + (summary, new_history) }) .collect(); - let account_histories = all_histories.lock().unwrap(); - - // Calculate total portfolio history for all accounts - *total_history.lock().unwrap() = - self.calculate_total_portfolio_history_for_all_accounts()?; - - // Save all historical data - for history in account_histories.iter() { - if let Err(e) = self.save_historical_data(history) { - println!("Error saving account history: {:?}", e); - return Err(e); - } - } - - // Save total portfolio history - if let Err(e) = self.save_historical_data(&total_history.lock().unwrap()) { - println!("Error saving total portfolio history: {:?}", e); - return Err(e); - } + // Extract summaries and flatten histories + let mut summaries: Vec = summaries_and_histories + .iter() + .map(|(summary, _)| (*summary).clone()) + .collect(); + let account_histories: Vec = summaries_and_histories + .into_iter() + .flat_map(|(_, histories)| histories) + .collect(); - let total_summary = { - let total_history_guard = total_history.lock().expect("Failed to lock total_history"); - let parse_date = |h: &PortfolioHistory| -> String { - NaiveDate::parse_from_str(&h.date, "%Y-%m-%d") - .map(|date| date.to_string()) - .unwrap_or_default() - }; - - HistorySummary { - id: Some("TOTAL".to_string()), - start_date: total_history_guard - .first() - .map(parse_date) - .unwrap_or_default(), - end_date: total_history_guard - .last() - .map(parse_date) - .unwrap_or_default(), - entries_count: total_history_guard.len(), - } + // Save account histories + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; + self.save_historical_data(&account_histories, db_connection)?; + + // Calculate total portfolio history + let total_history = self.calculate_total_portfolio_history_for_all_accounts()?; + + // Save total history separately + self.save_historical_data(&total_history, db_connection)?; + + let total_summary = HistorySummary { + id: Some("TOTAL".to_string()), + start_date: total_history + .first() + .map(|h| h.date.clone()) + .unwrap_or_default(), + end_date: total_history + .last() + .map(|h| h.date.clone()) + .unwrap_or_default(), + entries_count: total_history.len(), }; - // Add the total summary to the summaries array summaries.push(total_summary); Ok(summaries) } @@ -195,21 +176,19 @@ impl HistoryService { fn calculate_total_portfolio_history_for_all_accounts(&self) -> Result> { use crate::schema::accounts::dsl as accounts_dsl; use crate::schema::portfolio_history::dsl::*; - let conn = &mut self.pool.get().map_err(PortfolioError::from)?; - - println!("Calculating total portfolio history for all accounts"); + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; // Get active account IDs let active_account_ids: Vec = accounts_dsl::accounts .filter(accounts_dsl::is_active.eq(true)) .select(accounts_dsl::id) - .load::(conn)?; + .load::(db_connection)?; let all_histories: Vec = portfolio_history .filter(account_id.ne("TOTAL")) .filter(account_id.eq_any(active_account_ids)) .order(date.asc()) - .load::(conn)?; + .load::(db_connection)?; let grouped_histories: HashMap> = all_histories .into_iter() @@ -222,7 +201,7 @@ impl HistoryService { .into_iter() .map(|(history_date, histories)| { let mut total = PortfolioHistory { - id: Uuid::new_v4().to_string(), + id: format!("TOTAL_{}", history_date), account_id: "TOTAL".to_string(), date: history_date, total_value: 0.0, @@ -285,6 +264,11 @@ impl HistoryService { end_date: NaiveDate, force_full_calculation: bool, ) -> Vec { + let max_history_days = 36500; // For example, 100 years + let today = Utc::now().naive_utc().date(); + let start_date = start_date.max(today - Duration::days(max_history_days)); + let end_date = end_date.min(today); + println!( "Calculating historical value for account: {} from {} to {} with force {}", account_id, start_date, end_date, force_full_calculation @@ -298,7 +282,8 @@ impl HistoryService { // Initialize values from the last PortfolioHistory or use default values let mut currency = last_history .as_ref() - .map_or(self.base_currency.as_str(), |h| &h.currency); + .map_or(self.base_currency.as_str(), |h| &h.currency) + .to_string(); let mut cumulative_cash = last_history.as_ref().map_or(0.0, |h| h.available_cash); let mut net_deposit = last_history.as_ref().map_or(0.0, |h| h.net_deposit); let mut book_cost = last_history.as_ref().map_or(0.0, |h| h.book_cost); @@ -322,109 +307,130 @@ impl HistoryService { .unwrap_or(start_date) }; - let all_dates = Self::get_dates_between(actual_start_date, end_date); - - // Load all quotes for the date range and assets - - let mut results = Vec::new(); - - for date in all_dates { - // Process activities for the current date - for activity in activities.iter().filter(|a| a.activity_date.date() == date) { - currency = &activity.currency; - let activity_amount = activity.quantity * activity.unit_price; - let activity_fee = activity.fee; - - match activity.activity_type.as_str() { - "BUY" => { - let buy_cost = activity_amount + activity_fee; - cumulative_cash -= buy_cost; - book_cost += buy_cost; - *holdings.entry(activity.asset_id.clone()).or_insert(0.0) += - activity.quantity; - } - "SELL" => { - let sell_profit = activity_amount - activity_fee; - cumulative_cash += sell_profit; - book_cost -= activity_amount + activity_fee; - *holdings.entry(activity.asset_id.clone()).or_insert(0.0) -= - activity.quantity; - } - "DEPOSIT" | "TRANSFER_IN" | "CONVERSION_IN" => { - cumulative_cash += activity_amount - activity_fee; - net_deposit += activity_amount; - } - "DIVIDEND" | "INTEREST" => { - cumulative_cash += activity_amount - activity_fee; - } - "WITHDRAWAL" | "TRANSFER_OUT" | "CONVERSION_OUT" => { - cumulative_cash -= activity_amount + activity_fee; - net_deposit -= activity_amount; - } - "FEE" | "TAX" => { - cumulative_cash -= activity_fee; - } - _ => {} + let all_dates = Self::get_days_between(actual_start_date, end_date); + + let quote_cache: DashMap<(String, NaiveDate), Option<&Quote>> = DashMap::new(); + + let results: Vec = all_dates + .iter() + .map(|&date| { + // Process activities for the current date + for activity in activities.iter().filter(|a| a.activity_date.date() == date) { + self.process_activity( + activity, + &mut holdings, + &mut cumulative_cash, + &mut net_deposit, + &mut book_cost, + &mut currency, + ); } - } - // Update market value based on quotes - let (updated_market_value, day_gain_value, opening_market_value) = - self.calculate_holdings_value(&holdings, "es, date); + // Update market value based on quotes + let (updated_market_value, day_gain_value, opening_market_value) = + self.calculate_holdings_value(&holdings, quotes, date, "e_cache); - let market_value = updated_market_value; - let total_value = cumulative_cash + market_value; + let market_value = updated_market_value; + let total_value = cumulative_cash + market_value; - let day_gain_percentage = if opening_market_value != 0.0 { - (day_gain_value / opening_market_value) * 100.0 - } else { - 0.0 - }; + let day_gain_percentage = if opening_market_value != 0.0 { + (day_gain_value / opening_market_value) * 100.0 + } else { + 0.0 + }; - let total_gain_value = total_value - book_cost; - let total_gain_percentage = if book_cost != 0.0 { - (total_gain_value / book_cost) * 100.0 - } else { - 0.0 - }; - - let exchange_rate = self - .fx_service - .get_exchange_rate(currency, &self.base_currency) - .unwrap_or(1.0); - - results.push(PortfolioHistory { - id: Uuid::new_v4().to_string(), - account_id: account_id.to_string(), - date: date.format("%Y-%m-%d").to_string(), - total_value, - market_value, - book_cost, - available_cash: cumulative_cash, - net_deposit, - currency: currency.to_string(), - base_currency: self.base_currency.to_string(), - total_gain_value, - total_gain_percentage, - day_gain_percentage, - day_gain_value, - allocation_percentage: 0.0, // This will be calculated later in calculate_total_portfolio_history - exchange_rate, - holdings: Some(serde_json::to_string(&holdings).unwrap_or_default()), - }); - } + let total_gain_value = total_value - book_cost; + let total_gain_percentage = if book_cost != 0.0 { + (total_gain_value / book_cost) * 100.0 + } else { + 0.0 + }; + + let exchange_rate = self + .fx_service + .get_exchange_rate(¤cy, &self.base_currency) + .unwrap_or(1.0); + + PortfolioHistory { + id: format!("{}_{}", account_id, date.format("%Y-%m-%d")), + account_id: account_id.to_string(), + date: date.format("%Y-%m-%d").to_string(), + total_value, + market_value, + book_cost, + available_cash: cumulative_cash, + net_deposit, + currency: currency.clone(), + base_currency: self.base_currency.to_string(), + total_gain_value, + total_gain_percentage, + day_gain_percentage, + day_gain_value, + allocation_percentage: 0.0, // This will be calculated later in calculate_total_portfolio_history + exchange_rate, + holdings: Some(serde_json::to_string(&holdings).unwrap_or_default()), + } + }) + .collect(); results } - fn save_historical_data(&self, history_data: &[PortfolioHistory]) -> Result<()> { + fn process_activity( + &self, + activity: &Activity, + holdings: &mut HashMap, + cumulative_cash: &mut f64, + net_deposit: &mut f64, + book_cost: &mut f64, + currency: &mut String, + ) { + let activity_amount = activity.quantity * activity.unit_price; + let activity_fee = activity.fee; + + match activity.activity_type.as_str() { + "BUY" => { + let buy_cost = activity_amount + activity_fee; + *cumulative_cash -= buy_cost; + *book_cost += buy_cost; + *holdings.entry(activity.asset_id.clone()).or_insert(0.0) += activity.quantity; + } + "SELL" => { + let sell_profit = activity_amount - activity_fee; + *cumulative_cash += sell_profit; + *book_cost -= activity_amount + activity_fee; + *holdings.entry(activity.asset_id.clone()).or_insert(0.0) -= activity.quantity; + } + "DEPOSIT" | "TRANSFER_IN" | "CONVERSION_IN" => { + *cumulative_cash += activity_amount - activity_fee; + *net_deposit += activity_amount; + } + "DIVIDEND" | "INTEREST" => { + *cumulative_cash += activity_amount - activity_fee; + } + "WITHDRAWAL" | "TRANSFER_OUT" | "CONVERSION_OUT" => { + *cumulative_cash -= activity_amount + activity_fee; + *net_deposit -= activity_amount; + } + "FEE" | "TAX" => { + *cumulative_cash -= activity_fee; + } + _ => {} + } + + *currency = activity.currency.clone(); + } + + fn save_historical_data( + &self, + history_data: &[PortfolioHistory], + db_connection: &mut SqliteConnection, + ) -> Result<()> { use crate::schema::portfolio_history::dsl::*; - let conn = &mut self.pool.get().map_err(PortfolioError::from)?; // Use the From trait to convert r2d2::Error to PortfolioError - let values: Vec<_> = history_data - .iter() - .map(|record| { - ( + for record in history_data { + diesel::insert_into(portfolio_history) + .values(( id.eq(&record.id), account_id.eq(&record.account_id), date.eq(&record.date), @@ -442,32 +448,46 @@ impl HistoryService { allocation_percentage.eq(record.allocation_percentage), exchange_rate.eq(record.exchange_rate), holdings.eq(&record.holdings), - ) - }) - .collect(); - - diesel::replace_into(portfolio_history) - .values(&values) - .execute(conn) - .map_err(PortfolioError::from)?; // Use the From trait to convert diesel::result::Error to PortfolioError + )) + .on_conflict(id) + .do_update() + .set(( + total_value.eq(record.total_value), + market_value.eq(record.market_value), + book_cost.eq(record.book_cost), + available_cash.eq(record.available_cash), + net_deposit.eq(record.net_deposit), + currency.eq(&record.currency), + base_currency.eq(&record.base_currency), + total_gain_value.eq(record.total_gain_value), + total_gain_percentage.eq(record.total_gain_percentage), + day_gain_percentage.eq(record.day_gain_percentage), + day_gain_value.eq(record.day_gain_value), + allocation_percentage.eq(record.allocation_percentage), + exchange_rate.eq(record.exchange_rate), + holdings.eq(&record.holdings), + )) + .execute(db_connection) + .map_err(PortfolioError::from)?; + } Ok(()) } - fn calculate_holdings_value( + fn calculate_holdings_value<'a>( &self, holdings: &HashMap, - quotes: &HashMap<(String, NaiveDate), Quote>, + quotes: &'a HashMap<(String, NaiveDate), Quote>, date: NaiveDate, + quote_cache: &DashMap<(String, NaiveDate), Option<&'a Quote>>, ) -> (f64, f64, f64) { let mut holdings_value = 0.0; let mut day_gain_value = 0.0; let mut opening_market_value = 0.0; for (asset_id, &quantity) in holdings { - let quote = self.get_latest_available_quote(quotes, asset_id, date); - - if let Some(quote) = quote { + if let Some(quote) = self.get_last_available_quote(asset_id, date, quotes, quote_cache) + { let holding_value = quantity * quote.close; let opening_value = quantity * quote.open; let day_gain = quantity * (quote.close - quote.open); @@ -477,7 +497,7 @@ impl HistoryService { opening_market_value += opening_value; } else { println!( - "No quote available for symbol {} on or before date {}", + "Warning: No quote found for asset {} on date {}", asset_id, date ); } @@ -486,24 +506,24 @@ impl HistoryService { (holdings_value, day_gain_value, opening_market_value) } - fn get_latest_available_quote<'a>( + fn get_last_available_quote<'a>( &self, - quotes: &'a HashMap<(String, NaiveDate), Quote>, asset_id: &str, date: NaiveDate, + quotes: &'a HashMap<(String, NaiveDate), Quote>, + quote_cache: &DashMap<(String, NaiveDate), Option<&'a Quote>>, ) -> Option<&'a Quote> { - // First, check for an exact date match - if let Some(quote) = quotes.get(&(asset_id.to_string(), date)) { - return Some(quote); - } - - // If no exact match, search for the latest quote in previous dates - let found_quote = (1..=30) // Search up to 30 days back - .find_map(|days_back| { - let search_date = date - Duration::days(days_back); - quotes.get(&(asset_id.to_string(), search_date)) - }); - found_quote + quote_cache + .entry((asset_id.to_string(), date)) + .or_insert_with(|| { + quotes.get(&(asset_id.to_string(), date)).or_else(|| { + (1..=30).find_map(|days_back| { + let lookup_date = date - chrono::Duration::days(days_back); + quotes.get(&(asset_id.to_string(), lookup_date)) + }) + }) + }) + .clone() } fn get_last_portfolio_history( @@ -512,11 +532,11 @@ impl HistoryService { ) -> Result> { use crate::schema::portfolio_history::dsl::*; - let conn = &mut self.pool.get().map_err(PortfolioError::from)?; + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; let last_history_opt = portfolio_history .filter(account_id.eq(some_account_id)) .order(date.desc()) - .first::(conn) + .first::(db_connection) .optional() .map_err(PortfolioError::from)?; @@ -527,23 +547,29 @@ impl HistoryService { } } - fn get_dates_between(start_date: NaiveDate, end_date: NaiveDate) -> Vec { - (0..=(end_date - start_date).num_days()) - .map(|days| start_date + Duration::days(days)) - .collect() + fn get_days_between(start: NaiveDate, end: NaiveDate) -> Vec { + let mut days = Vec::new(); + let mut current = start; + + while current <= end { + days.push(current); + current += Duration::days(1); + } + + days } fn get_last_historical_date(&self, some_account_id: &str) -> Result> { use crate::schema::portfolio_history::dsl::*; - let conn = &mut self.pool.get().map_err(PortfolioError::from)?; // Use the From trait to convert r2d2::Error to PortfolioError + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; let last_date_opt = portfolio_history .filter(account_id.eq(some_account_id)) .select(date) .order(date.desc()) - .first::(conn) + .first::(db_connection) .optional() - .map_err(PortfolioError::from)?; // Use the From trait to convert diesel::result::Error to PortfolioError + .map_err(PortfolioError::from)?; if let Some(last_date_str) = last_date_opt { NaiveDate::parse_from_str(&last_date_str, "%Y-%m-%d") diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bf1682e..b407981 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -637,6 +637,20 @@ dependencies = [ "syn 2.0.77", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "deranged" version = "0.3.11" @@ -4450,6 +4464,7 @@ version = "1.0.11" dependencies = [ "chrono", "csv", + "dashmap", "diesel", "diesel_migrations", "lazy_static", diff --git a/src-tauri/src/commands/activity.rs b/src-tauri/src/commands/activity.rs index ed52fc9..04d798c 100644 --- a/src-tauri/src/commands/activity.rs +++ b/src-tauri/src/commands/activity.rs @@ -85,7 +85,7 @@ pub fn update_activity( } #[tauri::command] -pub fn delete_activity(activity_id: String, state: State) -> Result { +pub fn delete_activity(activity_id: String, state: State) -> Result { println!("Deleting activity..."); let service = activity_service::ActivityService::new((*state.pool).clone()); service diff --git a/src/commands/activity.ts b/src/commands/activity.ts index ae1385b..bef8066 100644 --- a/src/commands/activity.ts +++ b/src/commands/activity.ts @@ -88,12 +88,11 @@ export const updateActivity = async (activity: NewActivity): Promise = }; // deleteActivity -export const deleteActivity = async (activityId: string): Promise => { +export const deleteActivity = async (activityId: string): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: - await invokeTauri('delete_activity', { activityId }); - return; + return invokeTauri('delete_activity', { activityId }); default: throw new Error(`Unsupported`); } diff --git a/src/hooks/useCalculateHistory.ts b/src/hooks/useCalculateHistory.ts new file mode 100644 index 0000000..2b99ac7 --- /dev/null +++ b/src/hooks/useCalculateHistory.ts @@ -0,0 +1,37 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from '@/components/ui/use-toast'; +import { calculate_historical_data } from '@/commands/portfolio'; + +interface UseCalculateHistoryMutationOptions { + successTitle?: string; + errorTitle?: string; +} + +export function useCalculateHistoryMutation({ + successTitle = 'Portfolio data updated successfully.', + errorTitle = 'Failed to recalculate portfolio data.', +}: UseCalculateHistoryMutationOptions = {}) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: calculate_historical_data, + onSuccess: () => { + queryClient.invalidateQueries(); + toast({ + title: successTitle, + description: + 'Your portfolio data has been recalculated and updated with the latest information.', + className: 'bg-[#cbd492] text-white border-none', + }); + }, + onError: (e) => { + console.error(e); + queryClient.invalidateQueries(); + toast({ + title: errorTitle, + description: 'Please try refreshing the page or relaunching the app.', + className: 'bg-yellow-500 text-white border-none', + }); + }, + }); +} diff --git a/src/pages/activity/activity-page.tsx b/src/pages/activity/activity-page.tsx index 6bb1370..9310b57 100644 --- a/src/pages/activity/activity-page.tsx +++ b/src/pages/activity/activity-page.tsx @@ -13,6 +13,8 @@ import { ActivityDeleteModal } from './components/activity-delete-modal'; import { deleteActivity } from '@/commands/activity'; import { toast } from '@/components/ui/use-toast'; import { QueryKeys } from '@/lib/query-keys'; +import { useCalculateHistoryMutation } from '@/hooks/useCalculateHistory'; +import { useActivityMutations } from './hooks/useActivityMutations'; const ActivityPage = () => { const [showEditModal, setShowEditModal] = useState(false); @@ -26,16 +28,7 @@ const ActivityPage = () => { queryFn: getAccounts, }); - const deleteActivityMutation = useMutation({ - mutationFn: deleteActivity, - onSuccess: () => { - queryClient.invalidateQueries(); - toast({ - title: 'Account updated successfully.', - className: 'bg-green-500 text-white border-none', - }); - }, - }); + const { deleteActivityMutation } = useActivityMutations(); const handleEdit = useCallback( (activity?: ActivityDetails) => { @@ -53,8 +46,8 @@ const ActivityPage = () => { [showDeleteAlert], ); - const handleDeleteConfirm = () => { - deleteActivityMutation.mutate(selectedActivity.id); + const handleDeleteConfirm = async () => { + await deleteActivityMutation.mutateAsync(selectedActivity.id); setShowDeleteAlert(false); setSelectedActivity(null); }; diff --git a/src/pages/activity/components/activity-form.tsx b/src/pages/activity/components/activity-form.tsx index fcf9732..22a2782 100644 --- a/src/pages/activity/components/activity-form.tsx +++ b/src/pages/activity/components/activity-form.tsx @@ -2,7 +2,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { format } from 'date-fns'; import { useEffect } from 'react'; import { useForm, useFormContext } from 'react-hook-form'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as z from 'zod'; import { AlertFeedback } from '@/components/alert-feedback'; @@ -33,14 +32,11 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { toast } from '@/components/ui/use-toast'; import { cn } from '@/lib/utils'; -import { QueryKeys } from '@/lib/query-keys'; import { newActivitySchema } from '@/lib/schemas'; -import { createActivity, updateActivity } from '@/commands/activity'; -import { calculate_historical_data } from '@/commands/portfolio'; +import { useActivityMutations } from '../hooks/useActivityMutations'; import TickerSearchInput from './ticker-search'; const activityTypes = [ @@ -71,77 +67,16 @@ interface ActivityFormProps { } export function ActivityForm({ accounts, defaultValues, onSuccess = () => {} }: ActivityFormProps) { - const queryClient = useQueryClient(); - - const calculateHistoricalDataMutation = useMutation({ - mutationFn: calculate_historical_data, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: QueryKeys.accountHistory('TOTAL') }); - - toast({ - title: 'Activities updated successfully.', - description: 'Portfolio data is being recalculated.', - className: 'bg-green-500 text-white border-none', - }); - }, - onError: () => { - queryClient.invalidateQueries(); - toast({ - title: 'Failed to recalculate portfolio data.', - description: 'Please try refreshing the page or relaunching the app.', - className: 'bg-yellow-500 text-white border-none', - }); - }, - }); - - const addActivityMutation = useMutation({ - mutationFn: createActivity, - onSuccess: (activity) => { - onSuccess(); - calculateHistoricalDataMutation.mutate({ - accountIds: [activity.accountId ?? ''], - forceFullCalculation: true, - }); - }, - onError: (_error) => { - toast({ - title: 'Uh oh! Something went wrong.', - description: 'There was a problem adding this activity.', - className: 'bg-red-500 text-white border-none', - }); - }, - }); - - const updateActivityMutation = useMutation({ - mutationFn: updateActivity, - onSuccess: (activity) => { - onSuccess(); - if (activity.accountId) { - calculateHistoricalDataMutation.mutate({ - accountIds: [activity.accountId], - forceFullCalculation: true, - }); - } else { - calculateHistoricalDataMutation.mutate({ - accountIds: undefined, - forceFullCalculation: true, - }); - } - }, - }); + const { submitActivity, addActivityMutation } = useActivityMutations(); const form = useForm({ resolver: zodResolver(newActivitySchema), defaultValues, }); - function onSubmit(data: ActivityFormValues) { - const { id, ...rest } = data; - - if (id) { - return updateActivityMutation.mutate({ id, ...rest }); - } - addActivityMutation.mutate(rest); + async function onSubmit(data: ActivityFormValues) { + await submitActivity(data); + onSuccess(); } const watchedType = form.watch('activityType'); diff --git a/src/pages/activity/hooks/useActivityMutations.ts b/src/pages/activity/hooks/useActivityMutations.ts new file mode 100644 index 0000000..d6423df --- /dev/null +++ b/src/pages/activity/hooks/useActivityMutations.ts @@ -0,0 +1,62 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from '@/components/ui/use-toast'; +import { createActivity, updateActivity, deleteActivity } from '@/commands/activity'; +import { useCalculateHistoryMutation } from '@/hooks/useCalculateHistory'; +import { newActivitySchema } from '@/lib/schemas'; +import * as z from 'zod'; + +type ActivityFormValues = z.infer; + +export function useActivityMutations() { + const queryClient = useQueryClient(); + const calculateHistoryMutation = useCalculateHistoryMutation({ + successTitle: 'Activity updated successfully.', + }); + + const createMutationOptions = (action: string) => ({ + onSuccess: (activity: { accountId?: string | null }) => { + queryClient.invalidateQueries(); + calculateHistoryMutation.mutate({ + accountIds: activity.accountId ? [activity.accountId] : undefined, + forceFullCalculation: true, + }); + }, + onError: () => { + toast({ + title: 'Uh oh! Something went wrong.', + description: `There was a problem ${action} this activity.`, + className: 'bg-red-500 text-white border-none', + }); + }, + }); + + const addActivityMutation = useMutation({ + mutationFn: createActivity, + ...createMutationOptions('adding'), + }); + + const updateActivityMutation = useMutation({ + mutationFn: updateActivity, + ...createMutationOptions('updating'), + }); + + const deleteActivityMutation = useMutation({ + mutationFn: deleteActivity, + ...createMutationOptions('deleting'), + }); + + const submitActivity = async (data: ActivityFormValues) => { + const { id, ...rest } = data; + if (id) { + return await updateActivityMutation.mutateAsync({ id, ...rest }); + } + return await addActivityMutation.mutateAsync(rest); + }; + + return { + addActivityMutation, + updateActivityMutation, + deleteActivityMutation, + submitActivity, + }; +} diff --git a/src/pages/settings/accounts/components/account-form.tsx b/src/pages/settings/accounts/components/account-form.tsx index 18bdfc1..753115a 100644 --- a/src/pages/settings/accounts/components/account-form.tsx +++ b/src/pages/settings/accounts/components/account-form.tsx @@ -41,7 +41,7 @@ import { } from '@/components/ui/select'; import { toast } from '@/components/ui/use-toast'; -import { calculate_historical_data } from '@/commands/portfolio'; +import { useCalculateHistoryMutation } from '@/hooks/useCalculateHistory'; const accountTypes = [ { label: 'Securities', value: 'SECURITIES' }, @@ -64,33 +64,17 @@ interface AccountFormlProps { export function AccountForm({ defaultValues, onSuccess = () => {} }: AccountFormlProps) { const queryClient = useQueryClient(); - const calculateHistoricalDataMutation = useMutation({ - mutationFn: calculate_historical_data, - onSuccess: () => { - queryClient.invalidateQueries(); - toast({ - title: 'Account updated successfully.', - description: 'Portfolio data is being recalculated.', - className: 'bg-green-500 text-white border-none', - }); - }, - onError: () => { - toast({ - title: 'Failed to recalculate portfolio data.', - description: 'Please try refreshing the page or relaunching the app.', - className: 'bg-yellow-500 text-white border-none', - }); - }, + const calculateHistoryMutation = useCalculateHistoryMutation({ + successTitle: 'Account updated successfully.', }); const createAccountMutation = useMutation({ mutationFn: createAccount, - onSuccess: () => { + onSuccess: (createdAccount) => { queryClient.invalidateQueries(); - toast({ - title: 'Account created successfully.', - description: 'Historical data is being calculated.', - className: 'bg-green-500 text-white border-none', + calculateHistoryMutation.mutate({ + accountIds: [createdAccount.id], + forceFullCalculation: true, }); onSuccess(); }, @@ -106,11 +90,12 @@ export function AccountForm({ defaultValues, onSuccess = () => {} }: AccountForm const updateAccountMutation = useMutation({ mutationFn: updateAccount, onSuccess: (updatedAccount) => { - onSuccess(); - calculateHistoricalDataMutation.mutate({ - accountIds: [updatedAccount.id ?? ''], + queryClient.invalidateQueries(); + calculateHistoryMutation.mutate({ + accountIds: [updatedAccount.id], forceFullCalculation: true, }); + onSuccess(); }, onError: () => { toast({ From 950da9ef1a33c8d98b3707bb4ced8bdc3341d211 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Wed, 18 Sep 2024 21:44:00 -0400 Subject: [PATCH 26/45] update ui --- .../src/market_data/market_data_service.rs | 2 +- src-core/src/portfolio/history_service.rs | 4 - src/commands/account.ts | 2 +- src/components/gain-amount.tsx | 2 +- src/components/gain-percent.tsx | 4 +- src/components/ui/badge.tsx | 3 +- src/components/ui/toast.tsx | 81 +++++++++---------- src/hooks/useCalculateHistory.ts | 6 +- src/lib/query-keys.ts | 3 + src/lib/settings-provider.tsx | 25 +----- src/lib/useSettings.ts | 3 +- src/lib/useSettingsMutation.ts | 42 ++++++++++ src/pages/account/account-detail.tsx | 4 +- src/pages/activity/activity-page.tsx | 7 +- .../activity/hooks/useActivityMutations.ts | 6 +- .../import/imported-activity-table.tsx | 4 +- src/pages/asset/symbol-card.tsx | 2 +- src/pages/asset/symbol-holding.tsx | 4 +- src/pages/dashboard/accounts.tsx | 40 +++++---- src/pages/dashboard/dashboard-page.tsx | 4 +- .../holdings/components/holdings-table.tsx | 4 +- .../accounts/components/account-form.tsx | 50 +----------- .../components/useAccountMutations.ts | 46 +++++++++++ .../goals/components/goal-allocations.tsx | 8 +- src/styles.css | 21 +++-- tailwind.config.js | 5 ++ 26 files changed, 209 insertions(+), 173 deletions(-) create mode 100644 src/lib/useSettingsMutation.ts create mode 100644 src/pages/settings/accounts/components/useAccountMutations.ts diff --git a/src-core/src/market_data/market_data_service.rs b/src-core/src/market_data/market_data_service.rs index 905166e..9dca5d3 100644 --- a/src-core/src/market_data/market_data_service.rs +++ b/src-core/src/market_data/market_data_service.rs @@ -5,7 +5,7 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, TimeZone, Utc}; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; -use std::collections::{BTreeMap, HashMap}; +use std::collections::HashMap; use std::time::SystemTime; use uuid::Uuid; diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index fdd1d2c..42f52f4 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -269,10 +269,6 @@ impl HistoryService { let start_date = start_date.max(today - Duration::days(max_history_days)); let end_date = end_date.min(today); - println!( - "Calculating historical value for account: {} from {} to {} with force {}", - account_id, start_date, end_date, force_full_calculation - ); let last_history = if force_full_calculation { None } else { diff --git a/src/commands/account.ts b/src/commands/account.ts index 63ce4ee..2bd4d8a 100644 --- a/src/commands/account.ts +++ b/src/commands/account.ts @@ -24,7 +24,7 @@ export const createAccount = async (account: NewAccount): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: - return invokeTauri('create_account', { account }); + return invokeTauri('create_account', { accountD: account }); default: throw new Error(`Unsupported`); } diff --git a/src/components/gain-amount.tsx b/src/components/gain-amount.tsx index e62b76a..0d7b8b9 100644 --- a/src/components/gain-amount.tsx +++ b/src/components/gain-amount.tsx @@ -19,7 +19,7 @@ export function GainAmount({
0 ? 'text-green-500' : 'text-red-500', + value === 0 ? 'text-foreground' : value > 0 ? 'text-success' : 'text-red-400', )} > {formatAmount(value, currency, displayCurrency)} diff --git a/src/components/gain-percent.tsx b/src/components/gain-percent.tsx index 554ae82..a93af13 100644 --- a/src/components/gain-percent.tsx +++ b/src/components/gain-percent.tsx @@ -10,9 +10,9 @@ export function GainPercent({ value, className, ...props }: GainPercentProps) { return (
0 ? 'text-green-500' : 'text-red-500', + value === 0 ? 'text-foreground' : value > 0 ? 'text-success' : 'text-red-400', )} {...props} > diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 7487fa3..b3b5d5f 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -14,7 +14,8 @@ const badgeVariants = cva( destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', outline: 'text-foreground', - success: 'border-transparent bg-[#cbd492] hover:bg-green-100/80', + // success: 'border-transparent bg-[#cbd492] hover:bg-green-100/80', + success: 'border-transparent bg-success text-success-foreground hover:bg-success/80', error: 'border-transparent bg-red-100 text-red-700 hover:bg-red-100/80', }, }, diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index a822477..e664fab 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -1,11 +1,11 @@ -import * as React from "react" -import * as ToastPrimitives from "@radix-ui/react-toast" -import { cva, type VariantProps } from "class-variance-authority" -import { X } from "lucide-react" +import * as React from 'react'; +import * as ToastPrimitives from '@radix-ui/react-toast'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { X } from 'lucide-react'; -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils'; -const ToastProvider = ToastPrimitives.Provider +const ToastProvider = ToastPrimitives.Provider; const ToastViewport = React.forwardRef< React.ElementRef, @@ -14,34 +14,35 @@ const ToastViewport = React.forwardRef< -)) -ToastViewport.displayName = ToastPrimitives.Viewport.displayName +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; const toastVariants = cva( - "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', { variants: { variant: { - default: "border bg-background text-foreground", + default: 'border bg-background text-foreground', destructive: - "destructive group border-destructive bg-destructive text-destructive-foreground", + 'destructive group border-destructive bg-destructive text-destructive-foreground', + success: + 'success group border-success-background bg-success-background text-success-foreground', }, }, defaultVariants: { - variant: "default", + variant: 'default', }, - } -) + }, +); const Toast = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps + React.ComponentPropsWithoutRef & VariantProps >(({ className, variant, ...props }, ref) => { return ( - ) -}) -Toast.displayName = ToastPrimitives.Root.displayName + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; const ToastAction = React.forwardRef< React.ElementRef, @@ -60,13 +61,13 @@ const ToastAction = React.forwardRef< -)) -ToastAction.displayName = ToastPrimitives.Action.displayName +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; const ToastClose = React.forwardRef< React.ElementRef, @@ -75,28 +76,24 @@ const ToastClose = React.forwardRef< -)) -ToastClose.displayName = ToastPrimitives.Close.displayName +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; const ToastTitle = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -ToastTitle.displayName = ToastPrimitives.Title.displayName + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; const ToastDescription = React.forwardRef< React.ElementRef, @@ -104,15 +101,15 @@ const ToastDescription = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -ToastDescription.displayName = ToastPrimitives.Description.displayName +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; -type ToastProps = React.ComponentPropsWithoutRef +type ToastProps = React.ComponentPropsWithoutRef; -type ToastActionElement = React.ReactElement +type ToastActionElement = React.ReactElement; export { type ToastProps, @@ -124,4 +121,4 @@ export { ToastDescription, ToastClose, ToastAction, -} +}; diff --git a/src/hooks/useCalculateHistory.ts b/src/hooks/useCalculateHistory.ts index 2b99ac7..58309cd 100644 --- a/src/hooks/useCalculateHistory.ts +++ b/src/hooks/useCalculateHistory.ts @@ -21,7 +21,7 @@ export function useCalculateHistoryMutation({ title: successTitle, description: 'Your portfolio data has been recalculated and updated with the latest information.', - className: 'bg-[#cbd492] text-white border-none', + className: 'bg-[#cbd492] border-none', }); }, onError: (e) => { @@ -29,8 +29,8 @@ export function useCalculateHistoryMutation({ queryClient.invalidateQueries(); toast({ title: errorTitle, - description: 'Please try refreshing the page or relaunching the app.', - className: 'bg-yellow-500 text-white border-none', + description: 'Please try again or report an issue if the problem persists.', + variant: 'destructive', }); }, }); diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index c2b068f..c9cf3df 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -16,6 +16,9 @@ export const QueryKeys = { GOALS: 'goals', GOALS_ALLOCATIONS: 'goals_allocations', + // Settings related keys + SETTINGS: 'settings', + // Helper function to create account-specific keys accountHistory: (id: string) => ['account_history', id], } as const; diff --git a/src/lib/settings-provider.tsx b/src/lib/settings-provider.tsx index ba87a14..7bfc426 100644 --- a/src/lib/settings-provider.tsx +++ b/src/lib/settings-provider.tsx @@ -1,37 +1,16 @@ import { createContext, useState, useEffect, ReactNode, useContext } from 'react'; import { Settings, SettingsContextType } from './types'; import { useSettings } from './useSettings'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from '@/components/ui/use-toast'; -import { saveSettings } from '@/commands/setting'; +import { useSettingsMutation } from './useSettingsMutation'; const SettingsContext = createContext(undefined); export function SettingsProvider({ children }: { children: ReactNode }) { - const queryClient = useQueryClient(); const { data, isLoading, isError } = useSettings(); const [settings, setSettings] = useState(null); const [accountsGrouped, setAccountsGrouped] = useState(true); - const updateMutation = useMutation({ - mutationFn: saveSettings, - onSuccess: (updatedSettings) => { - setSettings(updatedSettings); - applySettingsToDocument(updatedSettings); - queryClient.invalidateQueries(); - toast({ - title: 'Settings updated successfully.', - className: 'bg-green-500 text-white border-none', - }); - }, - onError: () => { - toast({ - title: 'Uh oh! Something went wrong.', - description: 'There was a problem updating your settings.', - className: 'bg-red-500 text-white border-none', - }); - }, - }); + const updateMutation = useSettingsMutation(setSettings, applySettingsToDocument, settings); const updateSettings = (newSettings: Settings) => { updateMutation.mutate(newSettings); diff --git a/src/lib/useSettings.ts b/src/lib/useSettings.ts index a5cf22c..c213afa 100644 --- a/src/lib/useSettings.ts +++ b/src/lib/useSettings.ts @@ -1,10 +1,11 @@ import { useQuery } from '@tanstack/react-query'; import { Settings } from './types'; import { getSettings } from '@/commands/setting'; +import { QueryKeys } from './query-keys'; export function useSettings() { return useQuery({ - queryKey: ['settings'], + queryKey: [QueryKeys.SETTINGS], queryFn: getSettings, }); } diff --git a/src/lib/useSettingsMutation.ts b/src/lib/useSettingsMutation.ts new file mode 100644 index 0000000..ebd5f71 --- /dev/null +++ b/src/lib/useSettingsMutation.ts @@ -0,0 +1,42 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from '@/components/ui/use-toast'; +import { saveSettings } from '@/commands/setting'; +import { Settings } from './types'; +import { useCalculateHistoryMutation } from '@/hooks/useCalculateHistory'; +import { QueryKeys } from './query-keys'; +export function useSettingsMutation( + setSettings: React.Dispatch>, + applySettingsToDocument: (newSettings: Settings) => void, + currentSettings: Settings | null, +) { + const queryClient = useQueryClient(); + const calculateHistoryMutation = useCalculateHistoryMutation({ + successTitle: 'Base currency updated successfully.', + }); + + return useMutation({ + mutationFn: saveSettings, + onSuccess: (updatedSettings) => { + setSettings(updatedSettings); + applySettingsToDocument(updatedSettings); + queryClient.invalidateQueries({ queryKey: [QueryKeys.SETTINGS] }); + toast({ + title: 'Settings updated successfully.', + variant: 'success', + }); + if (currentSettings?.baseCurrency !== updatedSettings.baseCurrency) { + calculateHistoryMutation.mutate({ + accountIds: [], + forceFullCalculation: true, + }); + } + }, + onError: () => { + toast({ + title: 'Uh oh! Something went wrong.', + description: 'There was a problem updating your settings.', + variant: 'destructive', + }); + }, + }); +} diff --git a/src/pages/account/account-detail.tsx b/src/pages/account/account-detail.tsx index 545c1d0..0f8e943 100644 --- a/src/pages/account/account-detail.tsx +++ b/src/pages/account/account-detail.tsx @@ -39,12 +39,12 @@ const AccountDetail: React.FC = ({ data, className }) => { { label: "Today's return", value: `${formatAmount(dayGainValue, currency)} (${formatPercent(dayGainPercentage)})`, - color: dayGainValue < 0 ? 'text-red-500' : 'text-green-500', + color: dayGainValue < 0 ? 'text-red-400' : 'text-green-500', }, { label: 'Total return', value: `${formatAmount(totalGainValue, currency)} (${formatPercent(totalGainPercentage)})`, - color: totalGainPercentage < 0 ? 'text-red-500' : 'text-green-500', + color: totalGainPercentage < 0 ? 'text-red-400' : 'text-green-500', }, ]; diff --git a/src/pages/activity/activity-page.tsx b/src/pages/activity/activity-page.tsx index 9310b57..68817ff 100644 --- a/src/pages/activity/activity-page.tsx +++ b/src/pages/activity/activity-page.tsx @@ -6,14 +6,11 @@ import { useCallback, useState } from 'react'; import { Link } from 'react-router-dom'; import { ActivityEditModal } from './components/activity-edit-modal'; import ActivityTable from './components/activity-table'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { Account, ActivityDetails } from '@/lib/types'; import { getAccounts } from '@/commands/account'; import { ActivityDeleteModal } from './components/activity-delete-modal'; -import { deleteActivity } from '@/commands/activity'; -import { toast } from '@/components/ui/use-toast'; import { QueryKeys } from '@/lib/query-keys'; -import { useCalculateHistoryMutation } from '@/hooks/useCalculateHistory'; import { useActivityMutations } from './hooks/useActivityMutations'; const ActivityPage = () => { @@ -21,8 +18,6 @@ const ActivityPage = () => { const [showDeleteAlert, setShowDeleteAlert] = useState(false); const [selectedActivity, setSelectedActivity] = useState(); - const queryClient = useQueryClient(); - const { data: accounts } = useQuery({ queryKey: [QueryKeys.ACCOUNTS], queryFn: getAccounts, diff --git a/src/pages/activity/hooks/useActivityMutations.ts b/src/pages/activity/hooks/useActivityMutations.ts index d6423df..fa9dc51 100644 --- a/src/pages/activity/hooks/useActivityMutations.ts +++ b/src/pages/activity/hooks/useActivityMutations.ts @@ -23,9 +23,9 @@ export function useActivityMutations() { }, onError: () => { toast({ - title: 'Uh oh! Something went wrong.', - description: `There was a problem ${action} this activity.`, - className: 'bg-red-500 text-white border-none', + title: `Uh oh! Something went wrong ${action} this activity.`, + description: 'Please try again or report an issue if the problem persists.', + variant: 'destructive', }); }, }); diff --git a/src/pages/activity/import/imported-activity-table.tsx b/src/pages/activity/import/imported-activity-table.tsx index 8a74ec9..b024904 100644 --- a/src/pages/activity/import/imported-activity-table.tsx +++ b/src/pages/activity/import/imported-activity-table.tsx @@ -95,10 +95,10 @@ export const columns: ColumnDef[] = [ - + -

{error}

+

{error}

diff --git a/src/pages/asset/symbol-card.tsx b/src/pages/asset/symbol-card.tsx index 785fa85..85c5590 100644 --- a/src/pages/asset/symbol-card.tsx +++ b/src/pages/asset/symbol-card.tsx @@ -98,7 +98,7 @@ const SymbolCard: React.FC<{

{formatAmount(marketPrice, currency)}

-

0 ? 'text-green-500' : 'text-red-500'}`}> +

0 ? 'text-green-500' : 'text-red-400'}`}> {formatAmount(ganAmount, currency)} ({formatPercent(percentage)}){' '} {intervalDescriptions[interval]}

diff --git a/src/pages/asset/symbol-holding.tsx b/src/pages/asset/symbol-holding.tsx index 0aeeb56..8ceb4a1 100644 --- a/src/pages/asset/symbol-holding.tsx +++ b/src/pages/asset/symbol-holding.tsx @@ -45,12 +45,12 @@ const SymbolHoldingCard: React.FC = ({ holdingData, classNam value: `${formatAmount(todaysReturn * numShares, currency)} (${formatPercent( todaysReturnPercent / 100, )})`, - color: todaysReturn < 0 ? 'text-red-500' : 'text-green-500', + color: todaysReturn < 0 ? 'text-red-400' : 'text-green-500', }, { label: 'Total return', value: `${formatAmount(totalReturn, currency)} (${formatPercent(totalReturnPercent)})`, - color: totalReturn < 0 ? 'text-red-500' : 'text-green-500', + color: totalReturn < 0 ? 'text-red-400' : 'text-green-500', }, ]; diff --git a/src/pages/dashboard/accounts.tsx b/src/pages/dashboard/accounts.tsx index 002ac72..16f4fba 100644 --- a/src/pages/dashboard/accounts.tsx +++ b/src/pages/dashboard/accounts.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { Icons } from '@/components/icons'; import { Button } from '@/components/ui/button'; +import { GainPercent } from '@/components/gain-percent'; +import { GainAmount } from '@/components/gain-amount'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { AccountSummary } from '@/lib/types'; import { formatAmount, formatPercent } from '@/lib/utils'; @@ -67,11 +69,16 @@ const Summary = ({
{formatAmount(value, currency)} {gain !== 0 && ( - 0 ? 'text-green-500' : 'text-red-500'}`} - > - {formatAmount(gain, currency, false)} / {formatPercent(gainPercent)} - +
+ +
+ +
)}
{accountSummary.performance.totalGainValue !== 0 && ( -

0 ? 'text-green-500' : 'text-red-500'}`} - > - {formatAmount( - accountSummary.performance.totalGainValue, - accountSummary.account.currency, - false, - )}{' '} - /{formatPercent(accountSummary.performance.totalGainPercentage)} -

+
+ +
+ +
)}
diff --git a/src/pages/dashboard/dashboard-page.tsx b/src/pages/dashboard/dashboard-page.tsx index 60bddec..eeaff3c 100644 --- a/src/pages/dashboard/dashboard-page.tsx +++ b/src/pages/dashboard/dashboard-page.tsx @@ -58,14 +58,14 @@ export default function DashboardPage() { />
diff --git a/src/pages/holdings/components/holdings-table.tsx b/src/pages/holdings/components/holdings-table.tsx index 06174bb..87d40f6 100644 --- a/src/pages/holdings/components/holdings-table.tsx +++ b/src/pages/holdings/components/holdings-table.tsx @@ -180,8 +180,8 @@ export const columns: ColumnDef[] = [ performance?.totalGainPercent === 0 ? 'text-base' : performance?.totalGainPercent > 0 - ? 'text-green-500' - : 'text-red-500' + ? 'text-green-500' + : 'text-red-400' } `} >
diff --git a/src/pages/settings/accounts/components/account-form.tsx b/src/pages/settings/accounts/components/account-form.tsx index 753115a..d4bd8ef 100644 --- a/src/pages/settings/accounts/components/account-form.tsx +++ b/src/pages/settings/accounts/components/account-form.tsx @@ -1,6 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; @@ -39,9 +38,8 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { toast } from '@/components/ui/use-toast'; -import { useCalculateHistoryMutation } from '@/hooks/useCalculateHistory'; +import { useAccountMutations } from './useAccountMutations'; const accountTypes = [ { label: 'Securities', value: 'SECURITIES' }, @@ -51,7 +49,6 @@ const accountTypes = [ import { worldCurrencies } from '@/lib/currencies'; import { newAccountSchema } from '@/lib/schemas'; -import { createAccount, updateAccount } from '@/commands/account'; import { ScrollArea } from '@/components/ui/scroll-area'; type NewAccount = z.infer; @@ -62,49 +59,7 @@ interface AccountFormlProps { } export function AccountForm({ defaultValues, onSuccess = () => {} }: AccountFormlProps) { - const queryClient = useQueryClient(); - - const calculateHistoryMutation = useCalculateHistoryMutation({ - successTitle: 'Account updated successfully.', - }); - - const createAccountMutation = useMutation({ - mutationFn: createAccount, - onSuccess: (createdAccount) => { - queryClient.invalidateQueries(); - calculateHistoryMutation.mutate({ - accountIds: [createdAccount.id], - forceFullCalculation: true, - }); - onSuccess(); - }, - onError: () => { - toast({ - title: 'Uh oh! Something went wrong.', - description: 'There was a problem creating this account.', - className: 'bg-red-500 text-white border-none', - }); - }, - }); - - const updateAccountMutation = useMutation({ - mutationFn: updateAccount, - onSuccess: (updatedAccount) => { - queryClient.invalidateQueries(); - calculateHistoryMutation.mutate({ - accountIds: [updatedAccount.id], - forceFullCalculation: true, - }); - onSuccess(); - }, - onError: () => { - toast({ - title: 'Uh oh! Something went wrong.', - description: 'There was a problem updating this account.', - className: 'bg-red-500 text-white border-none', - }); - }, - }); + const { createAccountMutation, updateAccountMutation } = useAccountMutations({ onSuccess }); const form = useForm({ resolver: zodResolver(newAccountSchema), @@ -132,7 +87,6 @@ export function AccountForm({ defaultValues, onSuccess = () => {} }: AccountForm
- {/* add input hidden for id */} void; +} + +export function useAccountMutations({ onSuccess = () => {} }: UseAccountMutationsProps) { + const queryClient = useQueryClient(); + + const calculateHistoryMutation = useCalculateHistoryMutation({ + successTitle: 'Account updated successfully.', + }); + + const createMutationOptions = (action: string) => ({ + onSuccess: (account: { id: string }) => { + queryClient.invalidateQueries(); + calculateHistoryMutation.mutate({ + accountIds: [account.id], + forceFullCalculation: true, + }); + onSuccess(); + }, + onError: () => { + toast({ + title: `Uh oh! Something went wrong ${action} this account.`, + description: 'Please try again or report an issue if the problem persists.', + variant: 'success', + }); + }, + }); + + const createAccountMutation = useMutation({ + mutationFn: createAccount, + ...createMutationOptions('creating'), + }); + + const updateAccountMutation = useMutation({ + mutationFn: updateAccount, + ...createMutationOptions('updating'), + }); + + return { createAccountMutation, updateAccountMutation }; +} diff --git a/src/pages/settings/goals/components/goal-allocations.tsx b/src/pages/settings/goals/components/goal-allocations.tsx index 70c410c..8184a0f 100644 --- a/src/pages/settings/goals/components/goal-allocations.tsx +++ b/src/pages/settings/goals/components/goal-allocations.tsx @@ -74,7 +74,7 @@ const GoalsAllocations: React.FC = ({ return ( <>
- +
@@ -93,8 +93,8 @@ const GoalsAllocations: React.FC = ({ {accounts.map((account) => ( 100 ? 'text-red-500' : '' + className={`border-l border-t px-4 py-2 text-right text-xs text-muted-foreground ${ + totalAllocations[account.id] > 100 ? 'text-red-400' : '' }`} > {totalAllocations[account.id]}% @@ -120,7 +120,7 @@ const GoalsAllocations: React.FC = ({ return ( handleAllocationChange(goal.id, account.id, Number(e.target.value)) diff --git a/src/styles.css b/src/styles.css index 750c0bd..ea93f29 100644 --- a/src/styles.css +++ b/src/styles.css @@ -41,6 +41,10 @@ --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; + --success: 122 57% 38%; + --success-foreground: var(--foreground); + --success-background: 67 43% 56%; + --border: 24 4.42% 84%; --input: 24 4.42% 84%; --ring: 222.2 84% 4.9%; @@ -57,31 +61,34 @@ .dark { --background: 10 0% 10%; - --foreground: 210 40% 98%; + --foreground: 141 7% 94%; --card: 10 0% 10%; - --card-foreground: 210 40% 98%; + --card-foreground: var(--foreground); --sidebar: 10 0% 10%; --popover: 10 0% 10%; - --popover-foreground: 210 40% 98%; + --popover-foreground: var(--foreground); - --primary: 210 40% 98%; + --primary: var(--foreground); --primary-foreground: 222.2 47.4% 11.2%; --secondary: 0 0% 30%; - --secondary-foreground: 210 40% 98%; + --secondary-foreground: var(--foreground); --muted: 0 0% 30%; --muted-foreground: 215 20.2% 65.1%; --accent: 0 0% 30%; - --accent-foreground: 210 40% 98%; + --accent-foreground: var(--foreground); --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; + --destructive-foreground: var(--foreground); + --success: 68 43% 74%; + --success-foreground: var(--background); + --border: 0 0% 20%; --input: 0 0% 30%; --ring: 0 0% 20%; diff --git a/tailwind.config.js b/tailwind.config.js index 975b31b..793611e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -58,6 +58,11 @@ module.exports = { sidebar: { DEFAULT: 'hsl(var(--sidebar))', }, + success: { + DEFAULT: 'hsl(var(--success))', + foreground: 'hsl(var(--success-foreground))', + background: 'hsl(var(--success-background))', + }, 'custom-green': '#E7EBCC', 'custom-green-dark': '#606448', }, From 318058e6f2876f11534f0f10e1274499869ff63a Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Thu, 19 Sep 2024 13:38:52 -0400 Subject: [PATCH 27/45] refactor mutations --- src-tauri/src/commands/portfolio.rs | 15 ++++ src-tauri/src/main.rs | 3 +- src/commands/account.ts | 2 +- src/commands/portfolio.ts | 14 ++++ src/components/ui/alert.tsx | 8 +- src/components/ui/badge.tsx | 4 +- src/hooks/useCalculateHistory.ts | 5 +- src/lib/useSettingsMutation.ts | 2 +- src/pages/account/account-detail.tsx | 4 +- .../activity/import/activity-import-page.tsx | 51 ++++--------- src/pages/activity/import/import-form.tsx | 18 ++--- .../import/import-validation-alert.tsx | 5 +- .../import/imported-activity-table.tsx | 11 +-- .../import/useActivityImportMutations.ts | 47 ++++++++++++ src/pages/settings/accounts/accounts-page.tsx | 27 +------ .../components/useAccountMutations.ts | 64 ++++++++++------ .../settings/goals/components/goal-form.tsx | 76 +------------------ src/pages/settings/goals/goals-page.tsx | 35 ++------- src/pages/settings/goals/useGoalMutations.ts | 63 +++++++++++++++ src/styles.css | 3 +- 20 files changed, 238 insertions(+), 219 deletions(-) create mode 100644 src/pages/activity/import/useActivityImportMutations.ts create mode 100644 src/pages/settings/goals/useGoalMutations.ts diff --git a/src-tauri/src/commands/portfolio.rs b/src-tauri/src/commands/portfolio.rs index 41e59fc..b284386 100644 --- a/src-tauri/src/commands/portfolio.rs +++ b/src-tauri/src/commands/portfolio.rs @@ -71,3 +71,18 @@ pub async fn get_accounts_summary( .get_accounts_summary() .map_err(|e| format!("Failed to fetch active accounts performance: {}", e)) } + +#[tauri::command] +pub async fn recalculate_portfolio( + state: State<'_, AppState>, +) -> Result, String> { + println!("Recalculating portfolio..."); + + let service = PortfolioService::new((*state.pool).clone()) + .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; + + service + .update_portfolio() + .await + .map_err(|e| format!("Failed to recalculate portfolio: {}", e)) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index edf22a2..d5ed3d1 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -16,7 +16,7 @@ use commands::goal::{ use commands::market_data::{get_asset_data, search_symbol, synch_quotes}; use commands::portfolio::{ calculate_historical_data, compute_holdings, get_account_history, get_accounts_summary, - get_income_summary, + get_income_summary, recalculate_portfolio, }; use commands::settings::{get_settings, update_currency, update_settings}; @@ -106,6 +106,7 @@ fn main() { get_income_summary, get_account_history, get_accounts_summary, + recalculate_portfolio, ]) .build(context) .expect("error while running wealthfolio application"); diff --git a/src/commands/account.ts b/src/commands/account.ts index 2bd4d8a..c24b649 100644 --- a/src/commands/account.ts +++ b/src/commands/account.ts @@ -24,7 +24,7 @@ export const createAccount = async (account: NewAccount): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: - return invokeTauri('create_account', { accountD: account }); + return invokeTauri('create_account', { account: account }); default: throw new Error(`Unsupported`); } diff --git a/src/commands/portfolio.ts b/src/commands/portfolio.ts index d010c42..8417afb 100644 --- a/src/commands/portfolio.ts +++ b/src/commands/portfolio.ts @@ -79,3 +79,17 @@ export const getAccountsSummary = async (): Promise => { throw error; } }; + +export const recalculatePortfolio = async (): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('recalculate_portfolio'); + default: + throw new Error(`Unsupported`); + } + } catch (error) { + console.error('Error recalculating portfolio:', error); + throw error; + } +}; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index 2a01b12..8362d6e 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -10,16 +10,16 @@ const alertVariants = cva( variant: { default: 'bg-background text-foreground', destructive: - 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + 'border-destructive/50 text-destructive bg-destructive/20 dark:border-destructive [&>svg]:text-destructive', error: - 'border-red-500/50 text-red-700 dark:border-red-500 [&>svg]:text-red-700 bg-red-50 dark:bg-red-950 ', + 'border-destructive/50 text-destructive bg-destructive/20 dark:border-destructive [&>svg]:text-destructive', success: - 'border-green-500/50 text-green-700 dark:border-green-500 [&>svg]:text-green-700 bg-green-50 dark:bg-green-950 ', + 'success group border-success-background bg-success-background/40 text-success-foreground dark:bg-success', warning: - 'border-yellow-500/50 text-yellow-700 dark:border-yellow-500 [&>svg]:text-yellow-700 bg-yellow-50 dark:bg-yellow-950 ', + 'border-yellow-500/50 bg-yellow-50 text-yellow-800 dark:border-yellow-500 dark:bg-yellow-900/50 dark:text-yellow-300 [&>svg]:text-yellow-600 dark:text-yellow-400', }, }, defaultVariants: { diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index b3b5d5f..36d4360 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -14,8 +14,8 @@ const badgeVariants = cva( destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', outline: 'text-foreground', - // success: 'border-transparent bg-[#cbd492] hover:bg-green-100/80', - success: 'border-transparent bg-success text-success-foreground hover:bg-success/80', + success: + 'border-transparent bg-success-background/40 text-success-foreground hover:bg-success/80 dark:bg-success', error: 'border-transparent bg-red-100 text-red-700 hover:bg-red-100/80', }, }, diff --git a/src/hooks/useCalculateHistory.ts b/src/hooks/useCalculateHistory.ts index 58309cd..8f2c46b 100644 --- a/src/hooks/useCalculateHistory.ts +++ b/src/hooks/useCalculateHistory.ts @@ -21,11 +21,10 @@ export function useCalculateHistoryMutation({ title: successTitle, description: 'Your portfolio data has been recalculated and updated with the latest information.', - className: 'bg-[#cbd492] border-none', + variant: 'success', }); }, - onError: (e) => { - console.error(e); + onError: () => { queryClient.invalidateQueries(); toast({ title: errorTitle, diff --git a/src/lib/useSettingsMutation.ts b/src/lib/useSettingsMutation.ts index ebd5f71..111fcea 100644 --- a/src/lib/useSettingsMutation.ts +++ b/src/lib/useSettingsMutation.ts @@ -26,7 +26,7 @@ export function useSettingsMutation( }); if (currentSettings?.baseCurrency !== updatedSettings.baseCurrency) { calculateHistoryMutation.mutate({ - accountIds: [], + accountIds: undefined, forceFullCalculation: true, }); } diff --git a/src/pages/account/account-detail.tsx b/src/pages/account/account-detail.tsx index 0f8e943..ae14b9a 100644 --- a/src/pages/account/account-detail.tsx +++ b/src/pages/account/account-detail.tsx @@ -39,12 +39,12 @@ const AccountDetail: React.FC = ({ data, className }) => { { label: "Today's return", value: `${formatAmount(dayGainValue, currency)} (${formatPercent(dayGainPercentage)})`, - color: dayGainValue < 0 ? 'text-red-400' : 'text-green-500', + color: dayGainValue < 0 ? 'text-red-400' : 'text-success', }, { label: 'Total return', value: `${formatAmount(totalGainValue, currency)} (${formatPercent(totalGainPercentage)})`, - color: totalGainPercentage < 0 ? 'text-red-400' : 'text-green-500', + color: totalGainPercentage < 0 ? 'text-red-400' : 'text-success', }, ]; diff --git a/src/pages/activity/import/activity-import-page.tsx b/src/pages/activity/import/activity-import-page.tsx index c1c604a..0bfaf42 100644 --- a/src/pages/activity/import/activity-import-page.tsx +++ b/src/pages/activity/import/activity-import-page.tsx @@ -1,56 +1,23 @@ import { AlertFeedback } from '@/components/alert-feedback'; import { ApplicationHeader } from '@/components/header'; import { Separator } from '@/components/ui/separator'; -import { toast } from '@/components/ui/use-toast'; import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import type { ActivityImport } from '@/lib/types'; import { ActivityImportForm } from './import-form'; import ValidationAlert from './import-validation-alert'; -import ImportedActivitiesTable from './imported-activity-table'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { createActivities } from '@/commands/activity'; -import { syncHistoryQuotes } from '@/commands/market-data'; import { ImportHelpPopover } from './import-help'; -import { QueryKeys } from '@/lib/query-keys'; +import ImportedActivitiesTable from './imported-activity-table'; +import { useActivityImportMutations } from './useActivityImportMutations'; const ActivityImportPage = () => { const navigate = useNavigate(); - const queryClient = useQueryClient(); const [activities, setActivities] = useState([]); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const [warning, setWarning] = useState(0); - const syncQuotesMutation = useMutation({ - mutationFn: syncHistoryQuotes, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QueryKeys.PORTFOLIO_HISTORY] }); - }, - }); - - const confirmImportMutation = useMutation({ - mutationFn: createActivities, - onSuccess: () => { - setError(null); - setWarning(0); - queryClient.invalidateQueries(); - syncQuotesMutation.mutate(); - toast({ - title: 'Activities imported successfully', - className: 'bg-green-500 text-white border-none', - }); - navigate('/activities'); - }, - onError: (error: any) => { - setError(error); - toast({ - title: 'Uh oh! Something went wrong.', - description: 'Please check your csv file and try again.', - className: 'bg-red-500 text-white border-none', - }); - }, - }); + const { confirmImportMutation } = useActivityImportMutations(); function cancelImport() { setActivities([]); @@ -67,7 +34,6 @@ const ActivityImportPage = () => { } function confirmImport() { - //map activities to new activity const newActivities = activities.map((activity) => ({ id: activity.id, accountId: activity.accountId || '', @@ -82,7 +48,16 @@ const ActivityImportPage = () => { comment: activity.comment, })); - confirmImportMutation.mutate(newActivities); + confirmImportMutation.mutate(newActivities, { + onSuccess: () => { + setError(null); + setWarning(0); + navigate('/activities'); + }, + onError: (error: any) => { + setError(error); + }, + }); } return ( diff --git a/src/pages/activity/import/import-form.tsx b/src/pages/activity/import/import-form.tsx index 27954d6..59db36e 100644 --- a/src/pages/activity/import/import-form.tsx +++ b/src/pages/activity/import/import-form.tsx @@ -27,8 +27,7 @@ import { import type { Account, ActivityImport } from '@/lib/types'; import { getAccounts } from '@/commands/account'; -import { useMutation, useQuery } from '@tanstack/react-query'; -import { checkActivitiesImport } from '@/commands/activity'; +import { useQuery } from '@tanstack/react-query'; import { listenImportFileDrop, listenImportFileDropCancelled, @@ -37,6 +36,7 @@ import { } from '@/commands/import-listener'; import { openCsvFileDialog } from '@/commands/file'; import { QueryKeys } from '@/lib/query-keys'; +import { useActivityImportMutations } from './useActivityImportMutations'; const importFormSchema = z.object({ account_id: z.string({ required_error: 'Please select an account.' }), @@ -50,10 +50,12 @@ type ActivityImportFormProps = { }; export const ActivityImportForm = ({ onSuccess, onError }: ActivityImportFormProps) => { + const { checkImportMutation } = useActivityImportMutations(onSuccess, onError); const { data: accounts } = useQuery({ queryKey: [QueryKeys.ACCOUNTS], queryFn: getAccounts, }); + const [dragging, setDragging] = useState(false); useEffect(() => { @@ -90,16 +92,6 @@ export const ActivityImportForm = ({ onSuccess, onError }: ActivityImportFormPro }; }, []); - const checkImportMutation = useMutation({ - mutationFn: checkActivitiesImport, - onSuccess: (data) => { - onSuccess(data); - }, - onError: (error: any) => { - onError(error); - }, - }); - const form = useForm({ resolver: zodResolver(importFormSchema), }); @@ -192,7 +184,7 @@ export const ActivityImportForm = ({ onSuccess, onError }: ActivityImportFormPro Drag and drop your CSV file here - Or click the button below to choose a file. + Or click here to choose a file. )} diff --git a/src/pages/activity/import/import-validation-alert.tsx b/src/pages/activity/import/import-validation-alert.tsx index 3f45b7c..0c2d835 100644 --- a/src/pages/activity/import/import-validation-alert.tsx +++ b/src/pages/activity/import/import-validation-alert.tsx @@ -33,6 +33,9 @@ const ValidationAlert: React.FC = ({ Please review them in the table below and either correct or remove these entries to proceed with the import.

+

+ Hover over the error icon on each line for more details about the specific issue. +

@@ -59,7 +62,7 @@ const ValidationAlert: React.FC = ({
- - - - - date > new Date() || date < new Date('1900-01-01')} - initialFocus - /> - - - - )} - /> */} {defaultValues?.id ? ( { - const queryClient = useQueryClient(); - const { data: goals, isLoading } = useQuery({ queryKey: [QueryKeys.GOALS], queryFn: getGoals, @@ -33,24 +31,13 @@ const SettingsGoalsPage = () => { const [visibleModal, setVisibleModal] = useState(false); const [selectedGoal, setSelectedGoal] = useState(null); + const { deleteGoalMutation, saveAllocationsMutation } = useGoalMutations(); + const handleAddGoal = () => { setSelectedGoal(null); setVisibleModal(true); }; - const deleteGoalMutation = useMutation({ - mutationFn: deleteGoal, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QueryKeys.GOALS] }); - queryClient.invalidateQueries({ queryKey: [QueryKeys.GOALS_ALLOCATIONS] }); - setVisibleModal(false); - toast({ - title: 'Goal deleted successfully.', - className: 'bg-green-500 text-white border-none', - }); - }, - }); - const handleEditGoal = (goal: Goal) => { setSelectedGoal(goal); setVisibleModal(true); @@ -60,18 +47,6 @@ const SettingsGoalsPage = () => { deleteGoalMutation.mutate(goal.id); }; - const saveAllocationsMutation = useMutation({ - mutationFn: updateGoalsAllocations, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QueryKeys.GOALS] }); - queryClient.invalidateQueries({ queryKey: [QueryKeys.GOALS_ALLOCATIONS] }); - toast({ - title: 'Allocation saved successfully.', - className: 'bg-green-500 text-white border-none', - }); - }, - }); - const handleAddAllocation = (allocationData: GoalAllocation[]) => { saveAllocationsMutation.mutate(allocationData); }; diff --git a/src/pages/settings/goals/useGoalMutations.ts b/src/pages/settings/goals/useGoalMutations.ts new file mode 100644 index 0000000..fe97a02 --- /dev/null +++ b/src/pages/settings/goals/useGoalMutations.ts @@ -0,0 +1,63 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { deleteGoal, updateGoalsAllocations, createGoal, updateGoal } from '@/commands/goal'; +import { QueryKeys } from '@/lib/query-keys'; +import { toast } from '@/components/ui/use-toast'; + +export const useGoalMutations = () => { + const queryClient = useQueryClient(); + + const handleSuccess = (message: string, invalidateKeys: string[]) => { + invalidateKeys.forEach((key) => queryClient.invalidateQueries({ queryKey: [key] })); + toast({ + title: message, + variant: 'success', + }); + }; + + const handleError = (action: string) => { + toast({ + title: 'Uh oh! Something went wrong.', + description: `There was a problem ${action}.`, + variant: 'destructive', + }); + }; + + const addGoalMutation = useMutation({ + mutationFn: createGoal, + onSuccess: () => + handleSuccess('Goal added successfully. Start adding or importing this goal activities.', [ + QueryKeys.GOALS, + ]), + onError: () => handleError('adding this goal'), + }); + + const updateGoalMutation = useMutation({ + mutationFn: updateGoal, + onSuccess: () => handleSuccess('Goal updated successfully.', [QueryKeys.GOALS]), + onError: () => handleError('updating this goal'), + }); + + const deleteGoalMutation = useMutation({ + mutationFn: deleteGoal, + onSuccess: () => + handleSuccess('Goal deleted successfully.', [QueryKeys.GOALS, QueryKeys.GOALS_ALLOCATIONS]), + onError: () => handleError('deleting this goal'), + }); + + const saveAllocationsMutation = useMutation({ + mutationFn: updateGoalsAllocations, + onSuccess: () => + handleSuccess('Allocation saved successfully.', [ + QueryKeys.GOALS, + QueryKeys.GOALS_ALLOCATIONS, + ]), + onError: () => handleError('saving the allocations'), + }); + + return { + deleteGoalMutation, + saveAllocationsMutation, + addGoalMutation, + updateGoalMutation, + }; +}; diff --git a/src/styles.css b/src/styles.css index ea93f29..3b45c9f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -83,7 +83,8 @@ --accent: 0 0% 30%; --accent-foreground: var(--foreground); - --destructive: 0 62.8% 30.6%; + + --destructuve: 0 84 64 --destructive-foreground: var(--foreground); --success: 68 43% 74%; From accec80141833ca32a4635ca4cf4f37afa5968cb Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Thu, 19 Sep 2024 17:59:54 -0400 Subject: [PATCH 28/45] Fix #22 Generic error when adding activity --- .../2023-11-08-162221_init_db/down.sql | 14 +++- .../down.sql | 18 ----- .../up.sql | 24 +++--- src-core/src/activity/activity_service.rs | 42 +++++++--- src-core/src/asset/asset_service.rs | 44 ++++++++-- .../src/market_data/market_data_service.rs | 17 ++++ src-core/src/portfolio/history_service.rs | 80 ++++++++++++++----- src-tauri/src/commands/activity.rs | 26 +++--- src/pages/dashboard/accounts.tsx | 65 ++++++++------- src/pages/onboarding/onboarding-page.tsx | 18 +++-- 10 files changed, 229 insertions(+), 119 deletions(-) diff --git a/src-core/migrations/2023-11-08-162221_init_db/down.sql b/src-core/migrations/2023-11-08-162221_init_db/down.sql index d9a93fe..404faac 100644 --- a/src-core/migrations/2023-11-08-162221_init_db/down.sql +++ b/src-core/migrations/2023-11-08-162221_init_db/down.sql @@ -1 +1,13 @@ --- This file should undo anything in `up.sql` + +DROP TABLE IF EXISTS goals_allocation; +DROP TABLE IF EXISTS goals; +DROP TABLE IF EXISTS settings; +DROP TABLE IF EXISTS quotes; +DROP TABLE IF EXISTS activities; +DROP TABLE IF EXISTS assets; +DROP TABLE IF EXISTS accounts; +DROP TABLE IF EXISTS platforms; + +DROP INDEX IF EXISTS market_data_data_source_date_symbol_key; +DROP INDEX IF EXISTS market_data_symbol_idx; +DROP INDEX IF EXISTS assets_data_source_symbol_key; diff --git a/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql b/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql index 702b8fa..943ee8a 100644 --- a/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql +++ b/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql @@ -1,20 +1,2 @@ DROP TABLE IF EXISTS portfolio_history; - --- Revert changes to goals table -ALTER TABLE goals RENAME TO goals_new; - -CREATE TABLE goals ( - "id" TEXT NOT NULL PRIMARY KEY, - "title" TEXT NOT NULL, - "description" TEXT, - "target_amount" REAL NOT NULL, - "is_achieved" BOOLEAN -); - -INSERT INTO goals (id, title, description, target_amount, is_achieved) -SELECT id, title, description, CAST(target_amount AS REAL), is_achieved -FROM goals_new; - -DROP TABLE goals_new; - DROP INDEX IF EXISTS idx_portfolio_history_account_date; diff --git a/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql b/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql index f584166..c3ec93c 100644 --- a/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql +++ b/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql @@ -20,19 +20,13 @@ CREATE TABLE portfolio_history ( ); CREATE INDEX idx_portfolio_history_account_date ON portfolio_history(account_id, date); --- Update goals table -ALTER TABLE goals RENAME TO goals_old; - -CREATE TABLE goals ( - "id" TEXT NOT NULL PRIMARY KEY, - "title" TEXT NOT NULL, - "description" TEXT, - "target_amount" NUMERIC NOT NULL, - "is_achieved" BOOLEAN NOT NULL DEFAULT false -); -INSERT INTO goals (id, title, description, target_amount, is_achieved) -SELECT id, title, description, CAST(target_amount AS NUMERIC), is_achieved -FROM goals_old; - -DROP TABLE goals_old; +-- change goals table column types +ALTER TABLE "goals" ADD COLUMN "target_amount_new" NUMERIC NOT NULL DEFAULT 0; +UPDATE "goals" SET "target_amount_new" = "target_amount"; +ALTER TABLE "goals" DROP COLUMN "target_amount"; +ALTER TABLE "goals" RENAME COLUMN "target_amount_new" TO "target_amount"; +ALTER TABLE "goals" ADD COLUMN "is_achieved_new" BOOLEAN NOT NULL DEFAULT false; +UPDATE "goals" SET "is_achieved_new" = COALESCE("is_achieved", false); +ALTER TABLE "goals" DROP COLUMN "is_achieved"; +ALTER TABLE "goals" RENAME COLUMN "is_achieved_new" TO "is_achieved"; diff --git a/src-core/src/activity/activity_service.rs b/src-core/src/activity/activity_service.rs index 850e4d8..8141396 100644 --- a/src-core/src/activity/activity_service.rs +++ b/src-core/src/activity/activity_service.rs @@ -83,7 +83,12 @@ impl ActivityService { let asset_id = activity.asset_id.clone(); let asset_service = AssetService::new(self.pool.clone()); - let _asset_profile = asset_service.get_asset_profile(&asset_id).await?; + let asset_profile = asset_service.get_asset_profile(&asset_id).await?; + + // Update activity currency if asset_profile.currency is available + if !asset_profile.currency.is_empty() { + activity.currency = asset_profile.currency; + } // Adjust unit price based on activity type if ["DEPOSIT", "WITHDRAWAL", "INTEREST", "FEE", "DIVIDEND"] @@ -96,6 +101,32 @@ impl ActivityService { self.repo.insert_new_activity(&mut conn, activity) } + // update an activity + pub async fn update_activity( + &self, + mut activity: ActivityUpdate, + ) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + let asset_service = AssetService::new(self.pool.clone()); + + let asset_profile = asset_service.get_asset_profile(&activity.asset_id).await?; + + // Update activity currency if asset_profile.currency is available + if !asset_profile.currency.is_empty() { + activity.currency = asset_profile.currency; + } + + // Adjust unit price based on activity type + if ["DEPOSIT", "WITHDRAWAL", "INTEREST", "FEE", "DIVIDEND"] + .contains(&activity.activity_type.as_str()) + { + activity.unit_price = 1.0; + } + + // Update the activity in the database + self.repo.update_activity(&mut conn, activity) + } + // verify the activities import from csv file pub async fn check_activities_import( &self, @@ -172,15 +203,6 @@ impl ActivityService { }) } - // update an activity - pub fn update_activity( - &self, - activity: ActivityUpdate, - ) -> Result { - let mut conn = self.pool.get().expect("Couldn't get db connection"); - self.repo.update_activity(&mut conn, activity) - } - // delete an activity pub fn delete_activity(&self, activity_id: String) -> Result { let mut conn = self.pool.get().expect("Couldn't get db connection"); diff --git a/src-core/src/asset/asset_service.rs b/src-core/src/asset/asset_service.rs index cce8211..58ce6db 100644 --- a/src-core/src/asset/asset_service.rs +++ b/src-core/src/asset/asset_service.rs @@ -185,24 +185,56 @@ impl AssetService { } pub async fn get_asset_profile(&self, asset_id: &str) -> Result { + println!("Getting asset profile for asset_id: {}", asset_id); let mut conn = self.pool.get().expect("Couldn't get db connection"); use crate::schema::assets::dsl::*; match assets.find(asset_id).first::(&mut conn) { - Ok(existing_profile) => Ok(existing_profile), + Ok(existing_profile) => { + println!("Found existing profile for asset_id: {}", asset_id); + Ok(existing_profile) + } Err(diesel::NotFound) => { + println!("Asset profile not found in database for asset_id: {}. Fetching from market data service.", asset_id); let fetched_profile = self .market_data_service .fetch_symbol_summary(asset_id) .await - .map_err(|_e| diesel::result::Error::NotFound)?; - - diesel::insert_into(assets) + .map_err(|e| { + println!( + "Failed to fetch symbol summary for asset_id: {}. Error: {:?}", + asset_id, e + ); + diesel::result::Error::NotFound + })?; + + println!("Inserting new asset profile for asset_id: {}", asset_id); + let inserted_asset = diesel::insert_into(assets) .values(&fetched_profile) .returning(Asset::as_returning()) - .get_result(&mut conn) + .get_result(&mut conn)?; + + // Sync history quotes for the newly inserted asset + self.market_data_service + .sync_history_quotes_for_all_assets(&[inserted_asset.clone()]) + .await + .map_err(|e| { + println!( + "Failed to sync history quotes for asset_id: {}. Error: {:?}", + asset_id, e + ); + diesel::result::Error::NotFound + })?; + + Ok(inserted_asset) + } + Err(e) => { + println!( + "Error while getting asset profile for asset_id: {}. Error: {:?}", + asset_id, e + ); + Err(e) } - Err(e) => Err(e), } } diff --git a/src-core/src/market_data/market_data_service.rs b/src-core/src/market_data/market_data_service.rs index 9dca5d3..be33580 100644 --- a/src-core/src/market_data/market_data_service.rs +++ b/src-core/src/market_data/market_data_service.rs @@ -201,9 +201,26 @@ impl MarketDataService { } pub async fn fetch_symbol_summary(&self, symbol: &str) -> Result { + self.initialize_crumb_data().await?; self.provider .fetch_quote_summary(symbol) .await .map_err(|e| e.to_string()) } + + pub fn get_asset_currencies(&self, asset_ids: Vec) -> HashMap { + use crate::schema::assets::dsl::*; + + let db_connection = &mut self.pool.get().expect("Couldn't get db connection"); + + assets + .filter(id.eq_any(asset_ids)) + .select((id, currency)) + .load::<(String, String)>(db_connection) + .map(|results| results.into_iter().collect::>()) + .unwrap_or_else(|e| { + eprintln!("Error fetching asset currencies: {}", e); + HashMap::new() + }) + } } diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index 42f52f4..b95edba 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -275,11 +275,11 @@ impl HistoryService { self.get_last_portfolio_history(account_id).unwrap_or(None) }; + let account_currency = self + .get_account_currency(account_id) + .unwrap_or(self.base_currency.clone()); + // Initialize values from the last PortfolioHistory or use default values - let mut currency = last_history - .as_ref() - .map_or(self.base_currency.as_str(), |h| &h.currency) - .to_string(); let mut cumulative_cash = last_history.as_ref().map_or(0.0, |h| h.available_cash); let mut net_deposit = last_history.as_ref().map_or(0.0, |h| h.net_deposit); let mut book_cost = last_history.as_ref().map_or(0.0, |h| h.book_cost); @@ -318,13 +318,19 @@ impl HistoryService { &mut cumulative_cash, &mut net_deposit, &mut book_cost, - &mut currency, + &account_currency, ); } // Update market value based on quotes - let (updated_market_value, day_gain_value, opening_market_value) = - self.calculate_holdings_value(&holdings, quotes, date, "e_cache); + let (updated_market_value, day_gain_value, opening_market_value) = self + .calculate_holdings_value( + &holdings, + quotes, + date, + "e_cache, + &account_currency, + ); let market_value = updated_market_value; let total_value = cumulative_cash + market_value; @@ -344,7 +350,7 @@ impl HistoryService { let exchange_rate = self .fx_service - .get_exchange_rate(¤cy, &self.base_currency) + .get_exchange_rate(&account_currency, &self.base_currency) .unwrap_or(1.0); PortfolioHistory { @@ -356,13 +362,13 @@ impl HistoryService { book_cost, available_cash: cumulative_cash, net_deposit, - currency: currency.clone(), + currency: account_currency.clone(), base_currency: self.base_currency.to_string(), total_gain_value, total_gain_percentage, day_gain_percentage, day_gain_value, - allocation_percentage: 0.0, // This will be calculated later in calculate_total_portfolio_history + allocation_percentage: 0.0, exchange_rate, holdings: Some(serde_json::to_string(&holdings).unwrap_or_default()), } @@ -379,10 +385,15 @@ impl HistoryService { cumulative_cash: &mut f64, net_deposit: &mut f64, book_cost: &mut f64, - currency: &mut String, + account_currency: &str, ) { - let activity_amount = activity.quantity * activity.unit_price; - let activity_fee = activity.fee; + let exchange_rate = self + .fx_service + .get_exchange_rate(&activity.currency, account_currency) + .unwrap_or(1.0); + + let activity_amount = activity.quantity * activity.unit_price * exchange_rate; + let activity_fee = activity.fee * exchange_rate; match activity.activity_type.as_str() { "BUY" => { @@ -413,8 +424,6 @@ impl HistoryService { } _ => {} } - - *currency = activity.currency.clone(); } fn save_historical_data( @@ -476,17 +485,38 @@ impl HistoryService { quotes: &'a HashMap<(String, NaiveDate), Quote>, date: NaiveDate, quote_cache: &DashMap<(String, NaiveDate), Option<&'a Quote>>, + account_currency: &str, ) -> (f64, f64, f64) { let mut holdings_value = 0.0; let mut day_gain_value = 0.0; let mut opening_market_value = 0.0; + // Fetch all asset currencies at once + let asset_currencies = self + .market_data_service + .get_asset_currencies(holdings.keys().cloned().collect()); + for (asset_id, &quantity) in holdings { if let Some(quote) = self.get_last_available_quote(asset_id, date, quotes, quote_cache) { - let holding_value = quantity * quote.close; - let opening_value = quantity * quote.open; - let day_gain = quantity * (quote.close - quote.open); + // Use the pre-fetched asset currency + let asset_currency = asset_currencies + .get(asset_id) + .map(String::as_str) + .unwrap_or(account_currency); + + let exchange_rate = self + .fx_service + .get_exchange_rate(asset_currency, account_currency) + .unwrap_or(1.0); + + println!("asset_currency: {}", asset_currency); + println!("account_currency: {}", account_currency); + println!("exchange_rate: {}", exchange_rate); + + let holding_value = quantity * quote.close * exchange_rate; + let opening_value = quantity * quote.open * exchange_rate; + let day_gain = quantity * (quote.close - quote.open) * exchange_rate; holdings_value += holding_value; day_gain_value += day_gain; @@ -499,6 +529,8 @@ impl HistoryService { } } + println!("holdings_value: {} {}", date, holdings_value); + (holdings_value, day_gain_value, opening_market_value) } @@ -575,4 +607,16 @@ impl HistoryService { Ok(None) } } + + // Add this new method to get account currency + fn get_account_currency(&self, account_id: &str) -> Result { + use crate::schema::accounts::dsl::*; + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; + + accounts + .filter(id.eq(account_id)) + .select(currency) + .first::(db_connection) + .map_err(PortfolioError::from) + } } diff --git a/src-tauri/src/commands/activity.rs b/src-tauri/src/commands/activity.rs index 04d798c..4c5f8c8 100644 --- a/src-tauri/src/commands/activity.rs +++ b/src-tauri/src/commands/activity.rs @@ -41,6 +41,20 @@ pub fn create_activity(activity: NewActivity, state: State) -> Result< result.map_err(|e| format!("Failed to add new activity: {}", e)) } +#[tauri::command] +pub fn update_activity( + activity: ActivityUpdate, + state: State, +) -> Result { + println!("Updating activity..."); + let result = tauri::async_runtime::block_on(async { + let service = activity_service::ActivityService::new((*state.pool).clone()); + service.update_activity(activity).await + }); + + result.map_err(|e| format!("Failed to update activity: {}", e)) +} + #[tauri::command] pub fn check_activities_import( account_id: String, @@ -72,18 +86,6 @@ pub fn create_activities( .map_err(|err| format!("Failed to import activities: {}", err)) } -#[tauri::command] -pub fn update_activity( - activity: ActivityUpdate, - state: State, -) -> Result { - println!("Updating activity..."); - let service = activity_service::ActivityService::new((*state.pool).clone()); - service - .update_activity(activity) - .map_err(|e| format!("Failed to update activity: {}", e)) -} - #[tauri::command] pub fn delete_activity(activity_id: String, state: State) -> Result { println!("Deleting activity..."); diff --git a/src/pages/dashboard/accounts.tsx b/src/pages/dashboard/accounts.tsx index 16f4fba..d381326 100644 --- a/src/pages/dashboard/accounts.tsx +++ b/src/pages/dashboard/accounts.tsx @@ -6,7 +6,7 @@ import { GainPercent } from '@/components/gain-percent'; import { GainAmount } from '@/components/gain-amount'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { AccountSummary } from '@/lib/types'; -import { formatAmount, formatPercent } from '@/lib/utils'; +import { formatAmount } from '@/lib/utils'; import { useSettingsContext } from '@/lib/settings-provider'; // Helper function to calculate category summary @@ -105,7 +105,7 @@ const AccountSummaryComponent = ({ accountSummary }: { accountSummary: AccountSu

{formatAmount(accountSummary.performance.totalValue, accountSummary.account.currency)}

- {accountSummary.performance.totalGainValue !== 0 && ( + {accountSummary.performance.totalGainPercentage !== 0 && (
{ const groupedAccounts: Record = {}; + const ungroupedAccounts: AccountSummary[] = []; + for (const accountSummary of accounts || []) { - const category = accountSummary.account.group || 'Uncategorized'; - if (!groupedAccounts[category]) { - groupedAccounts[category] = []; + const category = accountSummary.account.group; + if (category) { + if (!groupedAccounts[category]) { + groupedAccounts[category] = []; + } + groupedAccounts[category].push(accountSummary); + } else { + ungroupedAccounts.push(accountSummary); } - groupedAccounts[category].push(accountSummary); } - return groupedAccounts; + return { groupedAccounts, ungroupedAccounts }; }; const toggleCategory = (category: string) => { @@ -167,19 +173,8 @@ export function Accounts({ category: string; accountsInCategory: AccountSummary[]; }) => { - if (accountsInCategory.length === 1) { - return ( - - - - - - ); - } - console.log('accountsInCategory', accountsInCategory); const categorySummary = calculateCategorySummary(accountsInCategory); const isExpanded = expandedCategories[category]; - console.log('categorySummary', categorySummary); return ( @@ -197,11 +192,8 @@ export function Accounts({ {isExpanded && ( {accountsInCategory.map((accountSummary) => ( -
- +
+
))} @@ -212,14 +204,25 @@ export function Accounts({ const renderAccounts = () => { if (accountsGrouped) { - const groupedAccounts = groupAccountsByCategory(); - return Object.entries(groupedAccounts).map(([category, accountsInCategory]) => ( - - )); + const { groupedAccounts, ungroupedAccounts } = groupAccountsByCategory(); + return ( + <> + {Object.entries(groupedAccounts).map(([category, accountsInCategory]) => ( + + ))} + {ungroupedAccounts.map((accountSummary) => ( + + + + + + ))} + + ); } else { return accounts?.map((accountSummary) => ( diff --git a/src/pages/onboarding/onboarding-page.tsx b/src/pages/onboarding/onboarding-page.tsx index 040a67b..13e95e7 100644 --- a/src/pages/onboarding/onboarding-page.tsx +++ b/src/pages/onboarding/onboarding-page.tsx @@ -61,14 +61,16 @@ export const OnboardingPage = () => {
-
- -
+ {currentStep === 0 && ( +
+ +
+ )}
From ebfe8ab4dde3af0d77d8812f9da4b7ce6c2d702f Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Thu, 19 Sep 2024 21:03:42 -0400 Subject: [PATCH 29/45] clean close and small fixes --- .../src/market_data/market_data_service.rs | 36 -- src-core/src/portfolio/history_service.rs | 17 +- src-core/src/portfolio/holdings_service.rs | 61 ++- src-core/src/portfolio/portfolio_service.rs | 10 +- src-core/src/providers/company.rs | 69 --- src-core/src/providers/mod.rs | 1 - src-core/src/providers/models copy.rs | 370 --------------- src-core/src/providers/models.rs | 169 ------- src-core/src/providers/res.json | 442 ------------------ src-core/src/providers/yahoo_connector.rs | 97 ---- src-core/src/providers/yahoo_provider.rs | 2 +- .../import/useActivityImportMutations.ts | 4 +- src/pages/dashboard/accounts.tsx | 2 +- .../holdings/components/holdings-table.tsx | 33 +- .../holdings/components/income-dashboard.tsx | 131 +++--- src/pages/holdings/holdings-page.tsx | 17 +- 16 files changed, 159 insertions(+), 1302 deletions(-) delete mode 100644 src-core/src/providers/company.rs delete mode 100644 src-core/src/providers/models copy.rs delete mode 100644 src-core/src/providers/res.json delete mode 100644 src-core/src/providers/yahoo_connector.rs diff --git a/src-core/src/market_data/market_data_service.rs b/src-core/src/market_data/market_data_service.rs index be33580..7db786e 100644 --- a/src-core/src/market_data/market_data_service.rs +++ b/src-core/src/market_data/market_data_service.rs @@ -61,42 +61,6 @@ impl MarketDataService { } } - // pub fn load_quotes( - // &self, - // asset_ids: &HashSet, - // start_date: NaiveDate, - // end_date: NaiveDate, - // ) -> HashMap<(String, NaiveDate), Quote> { - // let start_datetime = NaiveDateTime::new( - // start_date, - // chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(), - // ); - // let end_datetime = NaiveDateTime::new( - // end_date, - // chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap(), - // ); - - // let mut conn = self.pool.get().expect("Couldn't get db connection"); - // let quotes_result: QueryResult> = quotes::table - // .filter(quotes::symbol.eq_any(asset_ids)) - // .filter(quotes::date.between(start_datetime, end_datetime)) - // .load::(&mut conn); - - // match quotes_result { - // Ok(quotes) => quotes - // .into_iter() - // .map(|quote| { - // let quote_date = quote.date.date(); - // ((quote.symbol.clone(), quote_date), quote) - // }) - // .collect(), - // Err(e) => { - // eprintln!("Error loading quotes: {}", e); - // HashMap::new() - // } - // } - // } - pub async fn initialize_crumb_data(&self) -> Result<(), String> { self.provider.set_crumb().await.map_err(|e| { let error_message = format!("Failed to initialize crumb data: {}", e); diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index b95edba..029f51f 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -341,9 +341,9 @@ impl HistoryService { 0.0 }; - let total_gain_value = total_value - book_cost; - let total_gain_percentage = if book_cost != 0.0 { - (total_gain_value / book_cost) * 100.0 + let total_gain_value = total_value - net_deposit; + let total_gain_percentage = if net_deposit != 0.0 { + (total_gain_value / net_deposit) * 100.0 } else { 0.0 }; @@ -387,11 +387,16 @@ impl HistoryService { book_cost: &mut f64, account_currency: &str, ) { + // Get echange rate if activity currency is different of account currency let exchange_rate = self .fx_service .get_exchange_rate(&activity.currency, account_currency) .unwrap_or(1.0); + println!( + "Exchange rate for {} to {}: {}", + activity.currency, account_currency, exchange_rate + ); let activity_amount = activity.quantity * activity.unit_price * exchange_rate; let activity_fee = activity.fee * exchange_rate; @@ -510,10 +515,6 @@ impl HistoryService { .get_exchange_rate(asset_currency, account_currency) .unwrap_or(1.0); - println!("asset_currency: {}", asset_currency); - println!("account_currency: {}", account_currency); - println!("exchange_rate: {}", exchange_rate); - let holding_value = quantity * quote.close * exchange_rate; let opening_value = quantity * quote.open * exchange_rate; let day_gain = quantity * (quote.close - quote.open) * exchange_rate; @@ -529,8 +530,6 @@ impl HistoryService { } } - println!("holdings_value: {} {}", date, holdings_value); - (holdings_value, day_gain_value, opening_market_value) } diff --git a/src-core/src/portfolio/holdings_service.rs b/src-core/src/portfolio/holdings_service.rs index 5a217df..a9f8ee3 100644 --- a/src-core/src/portfolio/holdings_service.rs +++ b/src-core/src/portfolio/holdings_service.rs @@ -34,6 +34,13 @@ impl HoldingsService { let activities = self.activity_service.get_trading_activities()?; let assets = self.asset_service.get_assets()?; + println!( + "Found {} accounts, {} activities, and {} assets", + accounts.len(), + activities.len(), + assets.len() + ); + for activity in activities { let asset = assets .iter() @@ -46,6 +53,7 @@ impl HoldingsService { .ok_or_else(|| PortfolioError::InvalidDataError("Account not found".to_string()))?; let key = format!("{}-{}", activity.account_id, activity.asset_id); + let holding = holdings.entry(key.clone()).or_insert_with(|| Holding { id: key, symbol: activity.asset_id.clone(), @@ -103,7 +111,7 @@ impl HoldingsService { for symbol in symbols { match self.asset_service.get_latest_quote(&symbol) { Ok(quote) => { - quotes.insert(symbol, quote); + quotes.insert(symbol.clone(), quote); } Err(e) => { eprintln!("Error fetching quote for symbol {}: {}", symbol, e); @@ -113,20 +121,40 @@ impl HoldingsService { // Post-processing for each holding for holding in holdings.values_mut() { + println!("Post-processing holding: {}", holding.symbol); if let Some(quote) = quotes.get(&holding.symbol) { holding.market_price = Some(quote.close); + + // Calculate day gain using quote open and close prices + let opening_value = holding.quantity * quote.open; + let closing_value = holding.quantity * quote.close; + holding.performance.day_gain_amount = Some(closing_value - opening_value); + holding.performance.day_gain_percent = Some(if opening_value != 0.0 { + (closing_value - opening_value) / opening_value * 100.0 + } else { + 0.0 + }); } holding.average_cost = Some(holding.book_value / holding.quantity); holding.market_value = holding.quantity * holding.market_price.unwrap_or(0.0); - holding.market_value_converted = self - .fx_service - .convert_currency(holding.market_value, &holding.currency, &self.base_currency) - .map_err(|e| PortfolioError::CurrencyConversionError(e.to_string()))?; - holding.book_value_converted = self + // Get exchange rate for the holding's currency to base currency + let exchange_rate = match self .fx_service - .convert_currency(holding.book_value, &holding.currency, &self.base_currency) - .map_err(|e| PortfolioError::CurrencyConversionError(e.to_string()))?; + .get_exchange_rate(&holding.currency, &self.base_currency) + { + Ok(rate) => rate, + Err(e) => { + eprintln!( + "Error getting exchange rate for {} to {}: {}. Using 1 as default.", + holding.currency, self.base_currency, e + ); + 1.0 + } + }; + + holding.market_value_converted = holding.market_value * exchange_rate; + holding.book_value_converted = holding.book_value * exchange_rate; // Calculate performance metrics holding.performance.total_gain_amount = holding.market_value - holding.book_value; @@ -135,16 +163,17 @@ impl HoldingsService { } else { 0.0 }; - holding.performance.total_gain_amount_converted = self - .fx_service - .convert_currency( - holding.performance.total_gain_amount, - &holding.currency, - &self.base_currency, - ) - .map_err(|e| PortfolioError::CurrencyConversionError(e.to_string()))?; + holding.performance.total_gain_amount_converted = + holding.performance.total_gain_amount * exchange_rate; + + // Convert day gain to base currency + if let Some(day_gain_amount) = holding.performance.day_gain_amount { + holding.performance.day_gain_amount_converted = + Some(day_gain_amount * exchange_rate); + } } + println!("Computed {} holdings", holdings.len()); Ok(holdings .into_values() .filter(|holding| holding.quantity > 0.0) diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index d8e6b04..bf52b75 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -54,9 +54,13 @@ impl PortfolioService { } pub fn compute_holdings(&self) -> Result, Box> { - self.holdings_service - .compute_holdings() - .map_err(|e| Box::new(e) as Box) + match self.holdings_service.compute_holdings() { + Ok(holdings) => Ok(holdings), + Err(e) => { + eprintln!("Error computing holdings: {}", e); + Err(Box::new(e) as Box) + } + } } fn fetch_data( diff --git a/src-core/src/providers/company.rs b/src-core/src/providers/company.rs deleted file mode 100644 index 12fe35e..0000000 --- a/src-core/src/providers/company.rs +++ /dev/null @@ -1,69 +0,0 @@ -// use reqwest::header::{HeaderMap, COOKIE}; -// use reqwest::Client; - -// async fn fetch_company_profile() -> Result { -// let client = Client::new(); - -// // Make the first call to extract the Crumb cookie -// let response = client -// .get("https://finance.yahoo.com/quote/MSFT/profile?p=MSFT") -// .send() -// .await?; - -// // Extract the Crumb cookie from the response headers -// let headers = response.headers(); -// let crumb_cookie = headers -// .get("Set-Cookie") -// .and_then(|cookie| cookie.to_str().ok()) -// .and_then(|cookie| cookie.split(';').next()) -// .and_then(|cookie| cookie.strip_prefix("B=")) -// .unwrap_or(""); - -// //https://query1.finance.yahoo.com/v1/test/getcrumb -// Ok(crumb_cookie.to_string()) -// } - -// use async_std::sync::Arc; -// use futures::future::BoxFuture; - -// use super::*; -// use crate::api::model::CompanyData; -// use crate::YAHOO_CRUMB; - -// /// Returns a companies profile information. Only needs to be returned once. -// pub struct Company { -// symbol: String, -// } - -// impl Company { -// pub fn new(symbol: String) -> Company { -// Company { symbol } -// } -// } - -// impl AsyncTask for Company { -// type Input = String; -// type Response = CompanyData; - -// fn update_interval(&self) -> Option { -// None -// } - -// fn input(&self) -> Self::Input { -// self.symbol.clone() -// } - -// fn task<'a>(input: Arc) -> BoxFuture<'a, Option> { -// Box::pin(async move { -// let symbol = input.as_ref(); - -// let crumb = YAHOO_CRUMB.read().clone(); - -// if let Some(crumb) = crumb { -// crate::CLIENT.get_company_data(symbol, crumb).await.ok() -// } else { -// None -// } -// }) -// } -// } diff --git a/src-core/src/providers/mod.rs b/src-core/src/providers/mod.rs index 1007113..a86ac21 100644 --- a/src-core/src/providers/mod.rs +++ b/src-core/src/providers/mod.rs @@ -1,3 +1,2 @@ -// pub mod yahoo_connector; pub mod models; pub mod yahoo_provider; diff --git a/src-core/src/providers/models copy.rs b/src-core/src/providers/models copy.rs deleted file mode 100644 index 17df28a..0000000 --- a/src-core/src/providers/models copy.rs +++ /dev/null @@ -1,370 +0,0 @@ -// use std::{collections::HashMap, fmt}; - -// use serde::{Deserialize, Serialize}; -// #[derive(Serialize, Deserialize, Debug)] -// #[serde(rename_all = "camelCase")] -// pub struct YahooAssetProfile { -// pub name: String, -// pub address1: String, -// pub city: String, -// pub state: String, -// pub zip: String, -// pub country: String, -// pub phone: String, -// pub website: String, -// pub industry: String, -// pub sector: String, -// pub long_business_summary: String, -// pub full_time_employees: i64, -// pub audit_risk: i64, -// pub board_risk: i64, -// pub compensation_risk: i64, -// pub share_holder_rights_risk: i64, -// pub overall_risk: i64, -// pub governance_epoch_date: String, // Handling dates as strings for simplicity -// pub compensation_as_of_epoch_date: String, -// pub max_age: i64, -// } - -// #[derive(Debug, Serialize, Deserialize)] -// #[serde(rename_all = "camelCase")] -// pub struct YahooFinanceResponse { -// pub asset_profile: YahooAssetProfile, -// } - -// #[derive(Serialize, Deserialize)] -// #[serde(rename_all = "camelCase", deny_unknown_fields)] -// pub struct YahooResult { -// pub quote_summary: YahooQuoteSummary, -// } - -// #[derive(Serialize, Deserialize)] -// #[serde(rename_all = "camelCase")] -// pub struct YahooQuoteSummary { -// pub result: Vec, -// pub error: Option, -// } - -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct QuoteSummaryResult { -// pub price: Option, -// //pub summary_profile: Option, -// //pub top_holdings: Option, -// } - -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct Price { -// #[serde(flatten)] -// pub other: HashMap, -// pub average_daily_volume_10_day: Option, -// pub average_daily_volume_3_month: Option, -// pub exchange: String, -// pub exchange_name: String, -// pub exchange_data_delayed_by: i64, -// pub max_age: i64, -// pub post_market_change_percent: Option, -// pub short_name: String, -// pub long_name: String, -// pub quote_type: String, -// pub symbol: String, -// pub currency: Option, -// pub currency_symbol: Option, -// pub from_currency: Option, -// pub to_currency: Option, -// } - -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct QuoteType { -// #[serde(flatten)] -// pub other: HashMap, -// pub exchange: String, -// pub quote_type: String, -// pub symbol: String, -// pub underlying_symbol: String, -// pub short_name: Option, -// pub long_name: Option, -// pub first_trade_date_epoch_utc: Option, -// pub time_zone_full_name: String, -// pub time_zone_short_name: String, -// pub uuid: String, -// pub message_board_id: Option, -// pub gmt_off_set_milliseconds: i64, -// pub max_age: i64, -// } -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct SummaryProfile { -// #[serde(flatten)] -// pub other: HashMap, -// pub address1: Option, -// pub address2: Option, -// pub address3: Option, -// pub city: Option, -// pub state: Option, -// pub zip: Option, -// pub country: Option, -// pub phone: Option, -// pub fax: Option, -// pub website: Option, -// pub industry: Option, -// pub industry_disp: Option, -// pub sector: Option, -// pub sector_disp: Option, -// pub long_business_summary: Option, -// pub full_time_employees: Option, -// pub company_officers: Vec, // or a more specific type if known -// pub max_age: i64, -// pub twitter: Option, -// pub name: Option, -// pub start_date: Option, // String representation of Date -// pub description: Option, -// } - -// #[derive(Debug)] -// pub enum AssetClass { -// Alternative, -// Cryptocurrency, -// Equity, -// Commodity, -// } -// impl fmt::Display for AssetClass { -// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { -// let display_string = match self { -// AssetClass::Alternative => "Alternative", -// AssetClass::Cryptocurrency => "Cryptocurrency", -// AssetClass::Equity => "Equity", -// AssetClass::Commodity => "Commodity", -// }; -// write!(f, "{}", display_string) -// } -// } - -// #[derive(Debug)] -// pub enum AssetSubClass { -// Alternative, -// Cryptocurrency, -// Stock, -// Etf, -// Commodity, -// PreciousMetal, -// MutualFund, -// } -// impl fmt::Display for AssetSubClass { -// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { -// let display_string = match self { -// AssetSubClass::Alternative => "Alternative", -// AssetSubClass::Cryptocurrency => "Cryptocurrency", -// AssetSubClass::Stock => "Stock", -// AssetSubClass::Etf => "ETF", -// AssetSubClass::Commodity => "Commodity", -// AssetSubClass::PreciousMetal => "Precious Metal", -// AssetSubClass::MutualFund => "Mutual Fund", -// }; -// write!(f, "{}", display_string) -// } -// } - -use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, fmt}; - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct YahooResult { - pub quote_summary: QuoteSummary, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct QuoteSummary { - pub result: Vec, - pub error: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct QuoteSummaryResult { - pub price: Option, - pub summary_profile: Option, - pub top_holdings: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Price { - pub max_age: Option, - pub pre_market_change_percent: Option, - pub pre_market_change: Option, - pub pre_market_time: Option, - pub pre_market_price: Option, - pub pre_market_source: Option, - pub post_market_change_percent: Option, - pub post_market_change: Option, - pub post_market_time: Option, - pub post_market_price: Option, - pub post_market_source: Option, - pub regular_market_change_percent: Option, - pub regular_market_change: Option, - pub regular_market_time: Option, - pub regular_market_price: Option, - pub regular_market_day_high: Option, - pub regular_market_day_low: Option, - pub regular_market_volume: Option, - pub average_daily_volume_10_day: Option, - pub average_daily_volume_3_month: Option, - pub regular_market_previous_close: Option, - pub regular_market_source: Option, - pub regular_market_open: Option, - pub strike_price: Option, - pub open_interest: Option, - pub exchange: Option, - pub exchange_name: Option, - pub exchange_data_delayed_by: Option, - pub market_state: String, - pub quote_type: String, - pub symbol: String, - pub underlying_symbol: Option, - pub short_name: String, - pub long_name: String, - pub currency: Option, - pub quote_source_name: Option, - pub currency_symbol: Option, - pub from_currency: Option, - pub to_currency: Option, - pub last_market: Option, - pub volume_24_hr: Option, - pub volume_all_currencies: Option, - pub circulating_supply: Option, - pub market_cap: Option, - - #[serde(flatten)] - pub other: HashMap, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Change { - pub raw: Option, - pub fmt: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PriceDetail { - pub raw: Option, - pub fmt: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Volume { - pub raw: Option, - pub fmt: Option, - pub long_fmt: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MarketCap { - pub raw: Option, - pub fmt: Option, - pub long_fmt: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SummaryProfile { - pub address1: Option, - pub city: Option, - pub state: Option, - pub zip: Option, - pub country: Option, - pub phone: Option, - pub website: Option, - pub industry: Option, - pub industry_key: Option, - pub industry_disp: Option, - pub sector: Option, - pub sector_key: Option, - pub sector_disp: Option, - pub long_business_summary: Option, - pub full_time_employees: Option, - pub company_officers: Option>, - pub max_age: Option, -} - -#[derive(Debug, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct TopHoldings { - pub stock_position: Option, - pub bond_position: Option, - pub sector_weightings: Vec, - pub cash_position: Option, - pub other_position: Option, - pub preferred_position: Option, - pub convertible_position: Option, -} - -#[derive(Debug, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct TopHoldingsSectorWeighting { - #[serde(flatten)] - pub other: HashMap, - pub realestate: Option, - pub consumer_cyclical: Option, - pub basic_materials: Option, - pub consumer_defensive: Option, - pub technology: Option, - pub communication_services: Option, - pub financial_services: Option, - pub utilities: Option, - pub industrials: Option, - pub energy: Option, - pub healthcare: Option, -} - -#[derive(Debug)] -pub enum AssetClass { - Alternative, - Cryptocurrency, - Equity, - Commodity, -} -impl fmt::Display for AssetClass { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let display_string = match self { - AssetClass::Alternative => "Alternative", - AssetClass::Cryptocurrency => "Cryptocurrency", - AssetClass::Equity => "Equity", - AssetClass::Commodity => "Commodity", - }; - write!(f, "{}", display_string) - } -} - -#[derive(Debug)] -pub enum AssetSubClass { - Alternative, - Cryptocurrency, - Stock, - Etf, - Commodity, - PreciousMetal, - MutualFund, -} -impl fmt::Display for AssetSubClass { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let display_string = match self { - AssetSubClass::Alternative => "Alternative", - AssetSubClass::Cryptocurrency => "Cryptocurrency", - AssetSubClass::Stock => "Stock", - AssetSubClass::Etf => "ETF", - AssetSubClass::Commodity => "Commodity", - AssetSubClass::PreciousMetal => "Precious Metal", - AssetSubClass::MutualFund => "Mutual Fund", - }; - write!(f, "{}", display_string) - } -} diff --git a/src-core/src/providers/models.rs b/src-core/src/providers/models.rs index 37d0465..f74c55f 100644 --- a/src-core/src/providers/models.rs +++ b/src-core/src/providers/models.rs @@ -1,172 +1,3 @@ -// use std::{collections::HashMap, fmt}; - -// use serde::{Deserialize, Serialize}; -// #[derive(Serialize, Deserialize, Debug)] -// #[serde(rename_all = "camelCase")] -// pub struct YahooAssetProfile { -// pub name: String, -// pub address1: String, -// pub city: String, -// pub state: String, -// pub zip: String, -// pub country: String, -// pub phone: String, -// pub website: String, -// pub industry: String, -// pub sector: String, -// pub long_business_summary: String, -// pub full_time_employees: i64, -// pub audit_risk: i64, -// pub board_risk: i64, -// pub compensation_risk: i64, -// pub share_holder_rights_risk: i64, -// pub overall_risk: i64, -// pub governance_epoch_date: String, // Handling dates as strings for simplicity -// pub compensation_as_of_epoch_date: String, -// pub max_age: i64, -// } - -// #[derive(Debug, Serialize, Deserialize)] -// #[serde(rename_all = "camelCase")] -// pub struct YahooFinanceResponse { -// pub asset_profile: YahooAssetProfile, -// } - -// #[derive(Serialize, Deserialize)] -// #[serde(rename_all = "camelCase", deny_unknown_fields)] -// pub struct YahooResult { -// pub quote_summary: YahooQuoteSummary, -// } - -// #[derive(Serialize, Deserialize)] -// #[serde(rename_all = "camelCase")] -// pub struct YahooQuoteSummary { -// pub result: Vec, -// pub error: Option, -// } - -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct QuoteSummaryResult { -// pub price: Option, -// //pub summary_profile: Option, -// //pub top_holdings: Option, -// } - -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct Price { -// #[serde(flatten)] -// pub other: HashMap, -// pub average_daily_volume_10_day: Option, -// pub average_daily_volume_3_month: Option, -// pub exchange: String, -// pub exchange_name: String, -// pub exchange_data_delayed_by: i64, -// pub max_age: i64, -// pub post_market_change_percent: Option, -// pub short_name: String, -// pub long_name: String, -// pub quote_type: String, -// pub symbol: String, -// pub currency: Option, -// pub currency_symbol: Option, -// pub from_currency: Option, -// pub to_currency: Option, -// } - -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct QuoteType { -// #[serde(flatten)] -// pub other: HashMap, -// pub exchange: String, -// pub quote_type: String, -// pub symbol: String, -// pub underlying_symbol: String, -// pub short_name: Option, -// pub long_name: Option, -// pub first_trade_date_epoch_utc: Option, -// pub time_zone_full_name: String, -// pub time_zone_short_name: String, -// pub uuid: String, -// pub message_board_id: Option, -// pub gmt_off_set_milliseconds: i64, -// pub max_age: i64, -// } -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct SummaryProfile { -// #[serde(flatten)] -// pub other: HashMap, -// pub address1: Option, -// pub address2: Option, -// pub address3: Option, -// pub city: Option, -// pub state: Option, -// pub zip: Option, -// pub country: Option, -// pub phone: Option, -// pub fax: Option, -// pub website: Option, -// pub industry: Option, -// pub industry_disp: Option, -// pub sector: Option, -// pub sector_disp: Option, -// pub long_business_summary: Option, -// pub full_time_employees: Option, -// pub company_officers: Vec, // or a more specific type if known -// pub max_age: i64, -// pub twitter: Option, -// pub name: Option, -// pub start_date: Option, // String representation of Date -// pub description: Option, -// } - -// #[derive(Debug)] -// pub enum AssetClass { -// Alternative, -// Cryptocurrency, -// Equity, -// Commodity, -// } -// impl fmt::Display for AssetClass { -// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { -// let display_string = match self { -// AssetClass::Alternative => "Alternative", -// AssetClass::Cryptocurrency => "Cryptocurrency", -// AssetClass::Equity => "Equity", -// AssetClass::Commodity => "Commodity", -// }; -// write!(f, "{}", display_string) -// } -// } - -// #[derive(Debug)] -// pub enum AssetSubClass { -// Alternative, -// Cryptocurrency, -// Stock, -// Etf, -// Commodity, -// PreciousMetal, -// MutualFund, -// } -// impl fmt::Display for AssetSubClass { -// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { -// let display_string = match self { -// AssetSubClass::Alternative => "Alternative", -// AssetSubClass::Cryptocurrency => "Cryptocurrency", -// AssetSubClass::Stock => "Stock", -// AssetSubClass::Etf => "ETF", -// AssetSubClass::Commodity => "Commodity", -// AssetSubClass::PreciousMetal => "Precious Metal", -// AssetSubClass::MutualFund => "Mutual Fund", -// }; -// write!(f, "{}", display_string) -// } -// } - use serde::{Deserialize, Serialize}; use std::{collections::HashMap, fmt}; diff --git a/src-core/src/providers/res.json b/src-core/src/providers/res.json deleted file mode 100644 index 14141c6..0000000 --- a/src-core/src/providers/res.json +++ /dev/null @@ -1,442 +0,0 @@ -// { -// "quoteSummary": { -// "result": [ -// { -// "summaryProfile": { "phone": "+1 8003611392", "companyOfficers": [], "maxAge": 86400 }, -// "topHoldings": { -// "maxAge": 1, -// "cashPosition": { "raw": 0.0016, "fmt": "0.16%" }, -// "stockPosition": { "raw": 0.8007, "fmt": "80.07%" }, -// "bondPosition": { "raw": 0.19600001, "fmt": "19.60%" }, -// "otherPosition": { "raw": 0.0015, "fmt": "0.15%" }, -// "preferredPosition": { "raw": 1.0e-4, "fmt": "0.01%" }, -// "convertiblePosition": { "raw": 0.0, "fmt": "0.00%" }, -// "holdings": [ -// { -// "symbol": "ZSP", -// "holdingName": "BMO S&P 500 ETF (CAD)", -// "holdingPercent": { "raw": 0.35031158, "fmt": "35.03%" } -// }, -// { -// "symbol": "ZCN", -// "holdingName": "BMO S&P/TSX Capped Composite ETF", -// "holdingPercent": { "raw": 0.19643499, "fmt": "19.64%" } -// }, -// { -// "symbol": "ZEA", -// "holdingName": "BMO MSCI EAFE ETF", -// "holdingPercent": { "raw": 0.16319071, "fmt": "16.32%" } -// }, -// { -// "symbol": "ZAG", -// "holdingName": "BMO Aggregate Bond ETF", -// "holdingPercent": { "raw": 0.1375522, "fmt": "13.76%" } -// }, -// { -// "symbol": "ZEM", -// "holdingName": "BMO MSCI Emerging Markets ETF", -// "holdingPercent": { "raw": 0.0634233, "fmt": "6.34%" } -// }, -// { -// "symbol": "ZUAG.F", -// "holdingName": "BMO US Aggregate Bond ETF Hedged Units", -// "holdingPercent": { "raw": 0.0589472, "fmt": "5.89%" } -// }, -// { -// "symbol": "ZMID", -// "holdingName": "BMO S&P US Mid Cap ETF", -// "holdingPercent": { "raw": 0.021700699, "fmt": "2.17%" } -// }, -// { -// "symbol": "ZSML", -// "holdingName": "BMO S&P US Small Cap ETF", -// "holdingPercent": { "raw": 0.0087133, "fmt": "0.87%" } -// } -// ], -// "equityHoldings": { -// "priceToEarnings": { "raw": 0.0615, "fmt": "0.06" }, -// "priceToBook": { "raw": 0.44667, "fmt": "0.45" }, -// "priceToSales": { "raw": 0.59001, "fmt": "0.59" }, -// "priceToCashflow": { "raw": 0.09053, "fmt": "0.09" }, -// "medianMarketCap": {}, -// "threeYearEarningsGrowth": {}, -// "priceToEarningsCat": {}, -// "priceToBookCat": {}, -// "priceToSalesCat": {}, -// "priceToCashflowCat": {}, -// "medianMarketCapCat": {}, -// "threeYearEarningsGrowthCat": {} -// }, -// "bondHoldings": { -// "maturity": {}, -// "duration": { "raw": 2.94, "fmt": "2.94" }, -// "creditQuality": {}, -// "maturityCat": {}, -// "durationCat": {}, -// "creditQualityCat": {} -// }, -// "bondRatings": [ -// { "bb": { "raw": 0.0011, "fmt": "0.11%" } }, -// { "aa": { "raw": 0.42290002, "fmt": "42.29%" } }, -// { "aaa": { "raw": 0.29540002, "fmt": "29.54%" } }, -// { "a": { "raw": 0.12189999, "fmt": "12.19%" } }, -// { "other": { "raw": 0.0507, "fmt": "5.07%" } }, -// { "b": { "raw": 0.0, "fmt": "0.00%" } }, -// { "bbb": { "raw": 0.108, "fmt": "10.80%" } }, -// { "below_b": { "raw": 0.0, "fmt": "0.00%" } }, -// { "us_government": { "raw": 0.1287, "fmt": "12.87%" } } -// ], -// "sectorWeightings": [ -// { "realestate": { "raw": 0.026400002, "fmt": "2.64%" } }, -// { "consumer_cyclical": { "raw": 0.0991, "fmt": "9.91%" } }, -// { "basic_materials": { "raw": 0.0614, "fmt": "6.14%" } }, -// { "consumer_defensive": { "raw": 0.0627, "fmt": "6.27%" } }, -// { "technology": { "raw": 0.18540001, "fmt": "18.54%" } }, -// { "communication_services": { "raw": 0.065, "fmt": "6.50%" } }, -// { "financial_services": { "raw": 0.184, "fmt": "18.40%" } }, -// { "utilities": { "raw": 0.0295, "fmt": "2.95%" } }, -// { "industrials": { "raw": 0.1158, "fmt": "11.58%" } }, -// { "energy": { "raw": 0.0784, "fmt": "7.84%" } }, -// { "healthcare": { "raw": 0.0925, "fmt": "9.25%" } } -// ] -// }, -// "price": { -// "maxAge": 1, -// "preMarketChange": {}, -// "preMarketPrice": {}, -// "postMarketChange": {}, -// "postMarketPrice": {}, -// "regularMarketChangePercent": { "raw": -0.0029123805, "fmt": "-0.29%" }, -// "regularMarketChange": { "raw": -0.11000061, "fmt": "-0.11" }, -// "regularMarketTime": 1701184076, -// "priceHint": { "raw": 2, "fmt": "2", "longFmt": "2" }, -// "regularMarketPrice": { "raw": 37.66, "fmt": "37.66" }, -// "regularMarketDayHigh": { "raw": 37.76, "fmt": "37.76" }, -// "regularMarketDayLow": { "raw": 37.66, "fmt": "37.66" }, -// "regularMarketVolume": { "raw": 2668, "fmt": "2.67k", "longFmt": "2,668.00" }, -// "averageDailyVolume10Day": {}, -// "averageDailyVolume3Month": {}, -// "regularMarketPreviousClose": { "raw": 37.77, "fmt": "37.77" }, -// "regularMarketSource": "FREE_REALTIME", -// "regularMarketOpen": { "raw": 37.76, "fmt": "37.76" }, -// "strikePrice": {}, -// "openInterest": {}, -// "exchange": "TOR", -// "exchangeName": "Toronto", -// "exchangeDataDelayedBy": 15, -// "marketState": "REGULAR", -// "quoteType": "ETF", -// "symbol": "ZGRO.TO", -// "underlyingSymbol": null, -// "shortName": "BMO GROWTH ETF", -// "longName": "BMO Growth ETF", -// "currency": "CAD", -// "quoteSourceName": "Free Realtime Quote", -// "currencySymbol": "$", -// "fromCurrency": null, -// "toCurrency": null, -// "lastMarket": null, -// "volume24Hr": {}, -// "volumeAllCurrencies": {}, -// "circulatingSupply": {}, -// "marketCap": {} -// } -// } -// ], -// "error": null -// } -// } - -// [ -// { "name": "Consumer Cyclical", "weight": { "fmt": "9.91%", "raw": 0.0991 } }, -// { "name": "Basic Materials", "weight": { "fmt": "6.14%", "raw": 0.0614 } }, -// { "name": "Consumer Staples", "weight": { "fmt": "6.27%", "raw": 0.0627 } }, -// { "name": "Communication Services", "weight": { "fmt": "6.50%", "raw": 0.065 } }, -// { "name": "Financial Services", "weight": { "fmt": "18.40%", "raw": 0.184 } } -// ] - -// { -// "quoteSummary": { -// "result": [ -// { -// "summaryProfile": { "phone": "1-800-474-2737", "companyOfficers": [], "maxAge": 86400 }, -// "topHoldings": { -// "maxAge": 1, -// "cashPosition": { "raw": 0.0058, "fmt": "0.58%" }, -// "stockPosition": { "raw": 0.8051, "fmt": "80.51%" }, -// "bondPosition": { "raw": 0.1885, "fmt": "18.85%" }, -// "otherPosition": { "raw": 2.0e-4, "fmt": "0.02%" }, -// "preferredPosition": { "raw": 2.0e-4, "fmt": "0.02%" }, -// "convertiblePosition": { "raw": 2.0e-4, "fmt": "0.02%" }, -// "holdings": [ -// { -// "symbol": "ITOT", -// "holdingName": "iShares Core S&P Total US Stock Mkt ETF", -// "holdingPercent": { "raw": 0.3811914, "fmt": "38.12%" } -// }, -// { -// "symbol": "XEF.TO", -// "holdingName": "iShares Core MSCI EAFE IMI ETF", -// "holdingPercent": { "raw": 0.19702668, "fmt": "19.70%" } -// }, -// { -// "symbol": "XIC", -// "holdingName": "iShares Core S&P/TSX Capped Compost ETF", -// "holdingPercent": { "raw": 0.18982759, "fmt": "18.98%" } -// }, -// { -// "symbol": "XBB", -// "holdingName": "iShares Core Canadian Universe Bond ETF", -// "holdingPercent": { "raw": 0.1196289, "fmt": "11.96%" } -// }, -// { -// "symbol": "XEC", -// "holdingName": "iShares Core MSCI Emer Mkts IMI ETF", -// "holdingPercent": { "raw": 0.0400306, "fmt": "4.00%" } -// }, -// { -// "symbol": "XSH", -// "holdingName": "iShares Core Canadian ST Corp Bd ETF", -// "holdingPercent": { "raw": 0.0311276, "fmt": "3.11%" } -// }, -// { -// "symbol": "GOVT", -// "holdingName": "iShares US Treasury Bond ETF", -// "holdingPercent": { "raw": 0.0190967, "fmt": "1.91%" } -// }, -// { -// "symbol": "USIG", -// "holdingName": "iShares Broad USD Invm Grd Corp Bd ETF", -// "holdingPercent": { "raw": 0.0189961, "fmt": "1.90%" } -// } -// ], -// "equityHoldings": { -// "priceToEarnings": { "raw": 0.06465, "fmt": "0.06" }, -// "priceToBook": { "raw": 0.47801, "fmt": "0.48" }, -// "priceToSales": { "raw": 0.63484, "fmt": "0.63" }, -// "priceToCashflow": { "raw": 0.09472, "fmt": "0.09" }, -// "medianMarketCap": {}, -// "threeYearEarningsGrowth": {}, -// "priceToEarningsCat": {}, -// "priceToBookCat": {}, -// "priceToSalesCat": {}, -// "priceToCashflowCat": {}, -// "medianMarketCapCat": {}, -// "threeYearEarningsGrowthCat": {} -// }, -// "bondHoldings": { -// "maturity": {}, -// "duration": { "raw": 3.21, "fmt": "3.21" }, -// "creditQuality": {}, -// "maturityCat": {}, -// "durationCat": {}, -// "creditQualityCat": {} -// }, -// "bondRatings": [ -// { "bb": { "raw": 0.0021, "fmt": "0.21%" } }, -// { "aa": { "raw": 0.36470002, "fmt": "36.47%" } }, -// { "aaa": { "raw": 0.2604, "fmt": "26.04%" } }, -// { "a": { "raw": 0.1966, "fmt": "19.66%" } }, -// { "other": { "raw": 0.0074, "fmt": "0.74%" } }, -// { "b": { "raw": 0.0, "fmt": "0.00%" } }, -// { "bbb": { "raw": 0.16870001, "fmt": "16.87%" } }, -// { "below_b": { "raw": 0.0, "fmt": "0.00%" } }, -// { "us_government": { "raw": 0.1069, "fmt": "10.69%" } } -// ], -// "sectorWeightings": [ -// { "realestate": { "raw": 0.0286, "fmt": "2.86%" } }, -// { "consumer_cyclical": { "raw": 0.09729999, "fmt": "9.73%" } }, -// { "basic_materials": { "raw": 0.060700003, "fmt": "6.07%" } }, -// { "consumer_defensive": { "raw": 0.0627, "fmt": "6.27%" } }, -// { "technology": { "raw": 0.18049999, "fmt": "18.05%" } }, -// { "communication_services": { "raw": 0.0617, "fmt": "6.17%" } }, -// { "financial_services": { "raw": 0.18200001, "fmt": "18.20%" } }, -// { "utilities": { "raw": 0.0292, "fmt": "2.92%" } }, -// { "industrials": { "raw": 0.12, "fmt": "12.00%" } }, -// { "energy": { "raw": 0.0825, "fmt": "8.25%" } }, -// { "healthcare": { "raw": 0.0947, "fmt": "9.47%" } } -// ] -// }, -// "price": { -// "maxAge": 1, -// "preMarketChange": {}, -// "preMarketPrice": {}, -// "postMarketChange": {}, -// "postMarketPrice": {}, -// "regularMarketChangePercent": { "raw": -0.0019888321, "fmt": "-0.20%" }, -// "regularMarketChange": { "raw": -0.049999237, "fmt": "-0.05" }, -// "regularMarketTime": 1701199596, -// "priceHint": { "raw": 2, "fmt": "2", "longFmt": "2" }, -// "regularMarketPrice": { "raw": 25.09, "fmt": "25.09" }, -// "regularMarketDayHigh": { "raw": 25.16, "fmt": "25.16" }, -// "regularMarketDayLow": { "raw": 25.07, "fmt": "25.07" }, -// "regularMarketVolume": { "raw": 12452, "fmt": "12.45k", "longFmt": "12,452.00" }, -// "averageDailyVolume10Day": {}, -// "averageDailyVolume3Month": {}, -// "regularMarketPreviousClose": { "raw": 25.14, "fmt": "25.14" }, -// "regularMarketSource": "FREE_REALTIME", -// "regularMarketOpen": { "raw": 25.1, "fmt": "25.10" }, -// "strikePrice": {}, -// "openInterest": {}, -// "exchange": "TOR", -// "exchangeName": "Toronto", -// "exchangeDataDelayedBy": 15, -// "marketState": "REGULAR", -// "quoteType": "ETF", -// "symbol": "XGRO.TO", -// "underlyingSymbol": null, -// "shortName": "ISHARES CORE GROWTH ETF PORTFOL", -// "longName": "iShares Core Growth ETF Portfolio", -// "currency": "CAD", -// "currencySymbol": "$", -// "fromCurrency": null, -// "toCurrency": null, -// "lastMarket": null, -// "volume24Hr": {}, -// "volumeAllCurrencies": {}, -// "circulatingSupply": {}, -// "marketCap": {} -// } -// } -// ], -// "error": null -// } -// } - -{ - "quoteSummary": { - "result": [ - { - "price": { - "maxAge": 1, - "preMarketChange": {}, - "preMarketPrice": {}, - "postMarketChange": {}, - "postMarketPrice": {}, - "regularMarketChangePercent": { "raw": 0.0334232, "fmt": "3.34%" }, - "regularMarketChange": { "raw": 1671.9453, "fmt": "1,671.95" }, - "regularMarketTime": 1701202025, - "priceHint": { "raw": 2, "fmt": "2", "longFmt": "2" }, - "regularMarketPrice": { "raw": 51695.484, "fmt": "51,695.48" }, - "regularMarketDayHigh": { "raw": 51938.348, "fmt": "51,938.35" }, - "regularMarketDayLow": { "raw": 50211.676, "fmt": "50,211.68" }, - "regularMarketVolume": { - "raw": 27614005248, - "fmt": "27.61B", - "longFmt": "27,614,005,248.00" - }, - "averageDailyVolume10Day": {}, - "averageDailyVolume3Month": {}, - "regularMarketPreviousClose": { "raw": 50680.848, "fmt": "50,680.85" }, - "regularMarketSource": "FREE_REALTIME", - "regularMarketOpen": { "raw": 50680.848, "fmt": "50,680.85" }, - "strikePrice": {}, - "openInterest": {}, - "exchange": "CCC", - "exchangeName": "CCC", - "exchangeDataDelayedBy": 0, - "marketState": "REGULAR", - "quoteType": "CRYPTOCURRENCY", - "symbol": "BTC-CAD", - "underlyingSymbol": null, - "shortName": "Bitcoin CAD", - "longName": "Bitcoin CAD", - "currency": "CAD", - "quoteSourceName": "CoinMarketCap", - "currencySymbol": "$", - "fromCurrency": "BTC", - "toCurrency": "CAD=X", - "lastMarket": "CoinMarketCap", - "volume24Hr": { "raw": 27614005248, "fmt": "27.61B", "longFmt": "27,614,005,248.00" }, - "volumeAllCurrencies": { - "raw": 27614005248, - "fmt": "27.61B", - "longFmt": "27,614,005,248.00" - }, - "circulatingSupply": { "raw": 19555532, "fmt": "19.56M", "longFmt": "19,555,532.00" }, - "marketCap": { "raw": 1010932645888, "fmt": "1.01T", "longFmt": "1,010,932,645,888.00" } - }, - "summaryProfile": { - "companyOfficers": [], - "name": "Bitcoin", - "startDate": "2010-07-13", - "description": "Bitcoin (BTC) is a cryptocurrency launched in 2010. Users are able to generate BTC through the process of mining. Bitcoin has a current supply of 19,555,118. The last known price of Bitcoin is 37,128.22689696 USD and is down -0.85 over the last 24 hours. It is currently trading on 10574 active market(s) with $18,703,915,541.89 traded over the last 24 hours. More information can be found at https://bitcoin.org/.", - "maxAge": 86400 - } - } - ], - "error": null - } -} - -// { -// "quoteSummary": { -// "result": [ -// { -// "price": { -// "maxAge": 1, -// "preMarketChange": {}, -// "preMarketPrice": {}, -// "postMarketChange": {}, -// "postMarketPrice": {}, -// "regularMarketChangePercent": { "raw": 0.033825435, "fmt": "3.38%" }, -// "regularMarketChange": { "raw": 0.017199755, "fmt": "0.017200" }, -// "regularMarketTime": 1701201905, -// "priceHint": { "raw": 6, "fmt": "6", "longFmt": "6" }, -// "regularMarketPrice": { "raw": 0.52568704, "fmt": "0.525687" }, -// "regularMarketDayHigh": { "raw": 0.5264007, "fmt": "0.526401" }, -// "regularMarketDayLow": { "raw": 0.5036838, "fmt": "0.503684" }, -// "regularMarketVolume": { -// "raw": 373698496, -// "fmt": "373.70M", -// "longFmt": "373,698,496.00" -// }, -// "averageDailyVolume10Day": {}, -// "averageDailyVolume3Month": {}, -// "regularMarketPreviousClose": { "raw": 0.51478374, "fmt": "0.514784" }, -// "regularMarketSource": "FREE_REALTIME", -// "regularMarketOpen": { "raw": 0.51478374, "fmt": "0.514784" }, -// "strikePrice": {}, -// "openInterest": {}, -// "exchange": "CCC", -// "exchangeName": "CCC", -// "exchangeDataDelayedBy": 0, -// "marketState": "REGULAR", -// "quoteType": "CRYPTOCURRENCY", -// "symbol": "ADA-CAD", -// "underlyingSymbol": null, -// "shortName": "Cardano CAD", -// "longName": "Cardano CAD", -// "currency": "CAD", -// "quoteSourceName": "CoinMarketCap", -// "currencySymbol": "$", -// "fromCurrency": "ADA", -// "toCurrency": "CAD=X", -// "lastMarket": "CoinMarketCap", -// "volume24Hr": { "raw": 373698496, "fmt": "373.70M", "longFmt": "373,698,496.00" }, -// "volumeAllCurrencies": { -// "raw": 373698496, -// "fmt": "373.70M", -// "longFmt": "373,698,496.00" -// }, -// "circulatingSupply": { -// "raw": 35298205696, -// "fmt": "35.30B", -// "longFmt": "35,298,205,696.00" -// }, -// "marketCap": { "raw": 18555809792, "fmt": "18.56B", "longFmt": "18,555,809,792.00" } -// }, -// "summaryProfile": { -// "companyOfficers": [], -// "twitter": "\"https://twitter.com/cardano\"", -// "name": "Cardano", -// "startDate": "2017-10-01", -// "description": "Cardano (ADA) is a cryptocurrency launched in 2017. Cardano has a current supply of 36,395,268,923.436 with 35,298,225,395.595 in circulation. The last known price of Cardano is 0.3735945 USD and is down -3.20 over the last 24 hours. It is currently trading on 984 active market(s) with $263,724,779.52 traded over the last 24 hours. More information can be found at https://www.cardano.org.", -// "maxAge": 86400 -// } -// } -// ], -// "error": null -// } - -// } \ No newline at end of file diff --git a/src-core/src/providers/yahoo_connector.rs b/src-core/src/providers/yahoo_connector.rs deleted file mode 100644 index 0104700..0000000 --- a/src-core/src/providers/yahoo_connector.rs +++ /dev/null @@ -1,97 +0,0 @@ -use reqwest::Client; - -const YCHART_URL: &str = "https://query1.finance.yahoo.com/v8/finance/chart"; -const YSEARCH_URL: &str = "https://query2.finance.yahoo.com/v1/finance/search"; - -pub struct YahooConnector { - client: Client, - url: &'static str, - search_url: &'static str, -} - -impl YahooConnector { - /// Retrieve the quotes of the last day for the given ticker - pub async fn get_latest_quotes( - &self, - ticker: &str, - interval: &str, - ) -> Result { - self.get_quote_range(ticker, interval, "1mo").await - } - - /// Retrieve the quote history for the given ticker form date start to end (inclusive), if available - pub async fn get_quote_history( - &self, - ticker: &str, - start: OffsetDateTime, - end: OffsetDateTime, - ) -> Result { - self.get_quote_history_interval(ticker, start, end, "1d") - .await - } - - /// Retrieve quotes for the given ticker for an arbitrary range - pub async fn get_quote_range( - &self, - ticker: &str, - interval: &str, - range: &str, - ) -> Result { - let url: String = format!( - YCHART_RANGE_QUERY!(), - url = self.url, - symbol = ticker, - interval = interval, - range = range - ); - YResponse::from_json(self.send_request(&url).await?) - } - - /// Retrieve the quote history for the given ticker form date start to end (inclusive), if available; specifying the interval of the ticker. - pub async fn get_quote_history_interval( - &self, - ticker: &str, - start: OffsetDateTime, - end: OffsetDateTime, - interval: &str, - ) -> Result { - let url = format!( - YCHART_PERIOD_QUERY!(), - url = self.url, - symbol = ticker, - start = start.unix_timestamp(), - end = end.unix_timestamp(), - interval = interval - ); - YResponse::from_json(self.send_request(&url).await?) - } - - /// Retrieve the list of quotes found searching a given name - pub async fn search_ticker_opt(&self, name: &str) -> Result { - let url = format!(YTICKER_QUERY!(), url = self.search_url, name = name); - YSearchResultOpt::from_json(self.send_request(&url).await?) - } - - /// Retrieve the list of quotes found searching a given name - pub async fn search_ticker(&self, name: &str) -> Result { - let result = self.search_ticker_opt(name).await?; - Ok(YSearchResult::from_opt(&result)) - } - - /// Get list for options for a given name - pub async fn search_options(&self, name: &str) -> Result { - let url = format!("https://finance.yahoo.com/quote/{name}/options?p={name}"); - let resp = self.client.get(url).send().await?.text().await?; - Ok(YOptionResults::scrape(&resp)) - } - - /// Send request to yahoo! finance server and transform response to JSON value - async fn send_request(&self, url: &str) -> Result { - let resp = self.client.get(url).send().await?; - - match resp.status() { - StatusCode::OK => Ok(resp.json().await?), - status => Err(YahooError::FetchFailed(format!("{}", status))), - } - } -} diff --git a/src-core/src/providers/yahoo_provider.rs b/src-core/src/providers/yahoo_provider.rs index 87db56d..f81e811 100644 --- a/src-core/src/providers/yahoo_provider.rs +++ b/src-core/src/providers/yahoo_provider.rs @@ -395,7 +395,7 @@ impl YahooProvider { "realestate" => "Real Estate".to_string(), "technology" => "Technology".to_string(), "utilities" => "Utilities".to_string(), - _ => "UNKNOWN".to_string(), + _ => a_string.to_string(), } } } diff --git a/src/pages/activity/import/useActivityImportMutations.ts b/src/pages/activity/import/useActivityImportMutations.ts index b60df92..beb6c44 100644 --- a/src/pages/activity/import/useActivityImportMutations.ts +++ b/src/pages/activity/import/useActivityImportMutations.ts @@ -5,8 +5,8 @@ import { useCalculateHistoryMutation } from '@/hooks/useCalculateHistory'; import { toast } from '@/components/ui/use-toast'; export function useActivityImportMutations( - onSuccess: (activities: ActivityImport[]) => void, - onError: (error: string) => void, + onSuccess?: (activities: ActivityImport[]) => void, + onError?: (error: string) => void, ) { const calculateHistoryMutation = useCalculateHistoryMutation({ successTitle: 'Account updated successfully.', diff --git a/src/pages/dashboard/accounts.tsx b/src/pages/dashboard/accounts.tsx index d381326..ac262ea 100644 --- a/src/pages/dashboard/accounts.tsx +++ b/src/pages/dashboard/accounts.tsx @@ -73,7 +73,7 @@ const Summary = ({
diff --git a/src/pages/holdings/components/holdings-table.tsx b/src/pages/holdings/components/holdings-table.tsx index 87d40f6..41c574b 100644 --- a/src/pages/holdings/components/holdings-table.tsx +++ b/src/pages/holdings/components/holdings-table.tsx @@ -3,12 +3,14 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { DataTable } from '@/components/ui/data-table'; import { DataTableColumnHeader } from '@/components/ui/data-table/data-table-column-header'; -import { formatAmount, formatPercent } from '@/lib/utils'; +import { formatAmount } from '@/lib/utils'; import type { ColumnDef } from '@tanstack/react-table'; -import { useNavigate } from 'react-router-dom'; +import { GainAmount } from '@/components/gain-amount'; +import { GainPercent } from '@/components/gain-percent'; import { Skeleton } from '@/components/ui/skeleton'; import { Holding } from '@/lib/types'; +import { useNavigate } from 'react-router-dom'; export const HoldingsTable = ({ holdings, @@ -175,26 +177,13 @@ export const columns: ColumnDef[] = [ const currency = row.getValue('currency') as string; return ( -
0 - ? 'text-green-500' - : 'text-red-400' - } `} - > -
- {performance?.totalGainPercent > 0 ? ( - - ) : ( - - )} - {formatPercent(Math.abs(performance?.totalGainPercent))} -
- - {formatAmount(performance?.totalGainAmount, currency)} - +
+ +
); }, diff --git a/src/pages/holdings/components/income-dashboard.tsx b/src/pages/holdings/components/income-dashboard.tsx index 643e233..0d7249a 100644 --- a/src/pages/holdings/components/income-dashboard.tsx +++ b/src/pages/holdings/components/income-dashboard.tsx @@ -9,6 +9,7 @@ import { ChartTooltip, ChartTooltipContent, } from '@/components/ui/chart'; +import { Icons } from '@/components/icons'; import { getIncomeSummary } from '@/commands/portfolio'; import type { IncomeSummary } from '@/lib/types'; import { formatAmount } from '@/lib/utils'; @@ -124,72 +125,86 @@ export function IncomeDashboard() { Last 12 months - - ({ - month, - income, - cumulative: monthlyIncomeData - .slice(0, index + 1) - .reduce((sum, [, value]) => sum + value, 0), - }))} + {monthlyIncomeData.length === 0 ? ( +
+ +

No income history available

+
+ ) : ( + - - value.slice(5)} // Show only MM part of YYYY-MM - /> - - - } /> - } /> - - -
-
+ ({ + month, + income, + cumulative: monthlyIncomeData + .slice(0, index + 1) + .reduce((sum, [, value]) => sum + value, 0), + }))} + > + + value.slice(5)} // Show only MM part of YYYY-MM + /> + + + } /> + } /> + + + + + )}
Top 10 Dividend Sources - -
- {topDividendStocks.map(([symbol, income], index) => ( -
-
{symbol}
-
- {formatAmount(income, incomeSummary.currency)} + + {topDividendStocks.length === 0 ? ( +
+ +

No dividend income recorded

+
+ ) : ( +
+ {topDividendStocks.map(([symbol, income], index) => ( +
+
{symbol}
+
+ {formatAmount(income, incomeSummary.currency)} +
-
- ))} -
+ ))} +
+ )}
diff --git a/src/pages/holdings/holdings-page.tsx b/src/pages/holdings/holdings-page.tsx index 35f1e8c..d8cd1de 100644 --- a/src/pages/holdings/holdings-page.tsx +++ b/src/pages/holdings/holdings-page.tsx @@ -8,25 +8,30 @@ import { ClassesChart } from './components/classes-chart'; import { HoldingsTable } from './components/holdings-table'; import { PortfolioComposition } from './components/portfolio-composition'; import { SectorsChart } from './components/sectors-chart'; -import { computeHoldings, getHistorical } from '@/commands/portfolio'; +import { computeHoldings } from '@/commands/portfolio'; import { useQuery } from '@tanstack/react-query'; import { aggregateHoldingsBySymbol } from '@/lib/portfolio-helper'; -import { FinancialHistory, Holding } from '@/lib/types'; +import { Holding } from '@/lib/types'; import { HoldingCurrencyChart } from './components/currency-chart'; import { useSettingsContext } from '@/lib/settings-provider'; import { IncomeDashboard } from './components/income-dashboard'; +import { QueryKeys } from '@/lib/query-keys'; +import { PortfolioHistory } from '@/lib/types'; +import { getAccountHistory } from '@/commands/portfolio'; export const HoldingsPage = () => { const { settings } = useSettingsContext(); const { data, isLoading } = useQuery({ - queryKey: ['holdings'], + queryKey: [QueryKeys.HOLDINGS], queryFn: computeHoldings, }); - const historyData = []; + const { data: portfolioHistory } = useQuery({ + queryKey: QueryKeys.accountHistory('TOTAL'), + queryFn: () => getAccountHistory('TOTAL'), + }); - const portfolio = historyData?.find((history) => history.account?.id === 'TOTAL'); - const todayValue = portfolio?.history[portfolio?.history.length - 1]; + const todayValue = portfolioHistory?.[portfolioHistory.length - 1]; const holdings = useMemo(() => { return aggregateHoldingsBySymbol(data || []); From 882dadeb83d1dc32cbf03728cd462ba6a43454fc Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Fri, 20 Sep 2024 10:32:55 -0400 Subject: [PATCH 30/45] improve exchange rate service --- src-core/Cargo.lock | 4 +- src-core/src/asset/asset_service.rs | 26 +-- src-core/src/fx/fx_service.rs | 168 +++++++++--------- .../src/market_data/market_data_service.rs | 99 ++++------- src-core/src/portfolio/history_service.rs | 8 +- src-core/src/portfolio/holdings_service.rs | 3 +- src-core/src/portfolio/portfolio_service.rs | 14 +- src-core/src/providers/yahoo_provider.rs | 42 ++++- src-tauri/src/commands/market_data.rs | 2 +- 9 files changed, 186 insertions(+), 180 deletions(-) diff --git a/src-core/Cargo.lock b/src-core/Cargo.lock index 854b345..54e799b 100644 --- a/src-core/Cargo.lock +++ b/src-core/Cargo.lock @@ -1829,9 +1829,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.39.3" +version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babc99b9923bfa4804bd74722ff02c0381021eafa4db9949217e3be8e84fff5" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" dependencies = [ "backtrace", "bytes", diff --git a/src-core/src/asset/asset_service.rs b/src-core/src/asset/asset_service.rs index 58ce6db..8ded7af 100644 --- a/src-core/src/asset/asset_service.rs +++ b/src-core/src/asset/asset_service.rs @@ -216,7 +216,7 @@ impl AssetService { // Sync history quotes for the newly inserted asset self.market_data_service - .sync_history_quotes_for_all_assets(&[inserted_asset.clone()]) + .sync_quotes(&[inserted_asset.clone()]) .await .map_err(|e| { println!( @@ -238,17 +238,17 @@ impl AssetService { } } - pub async fn sync_history_quotes_for_all_assets(&self) -> Result<(), String> { - let asset_list = self.get_assets().map_err(|e| e.to_string())?; - self.market_data_service - .sync_history_quotes_for_all_assets(&asset_list) - .await - } + // pub async fn sync_history_quotes_for_all_assets(&self) -> Result<(), String> { + // let asset_list = self.get_assets().map_err(|e| e.to_string())?; + // self.market_data_service + // .sync_history_quotes_for_all_assets(&asset_list) + // .await + // } - pub async fn initialize_and_sync_quotes(&self) -> Result<(), String> { - let asset_list = self.get_assets().map_err(|e| e.to_string())?; - self.market_data_service - .initialize_and_sync_quotes(&asset_list) - .await - } + // pub async fn initialize_and_sync_quotes(&self) -> Result<(), String> { + // let asset_list = self.get_assets().map_err(|e| e.to_string())?; + // self.market_data_service + // .initialize_and_sync_quotes(&asset_list) + // .await + // } } diff --git a/src-core/src/fx/fx_service.rs b/src-core/src/fx/fx_service.rs index b47b437..aff5de4 100644 --- a/src-core/src/fx/fx_service.rs +++ b/src-core/src/fx/fx_service.rs @@ -1,121 +1,125 @@ -use crate::models::Quote; -use crate::schema::quotes; +use chrono::NaiveDateTime; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, RwLock}; pub struct CurrencyExchangeService { - exchange_rates: Arc>>, pool: Pool>, - is_loading: Arc, + exchange_rates: Arc>>, } impl CurrencyExchangeService { pub fn new(pool: Pool>) -> Self { Self { - exchange_rates: Arc::new(Mutex::new(HashMap::new())), pool, - is_loading: Arc::new(AtomicBool::new(false)), + exchange_rates: Arc::new(RwLock::new(HashMap::new())), } } - fn load_exchange_rates( + pub fn get_latest_exchange_rate( &self, from_currency: &str, to_currency: &str, ) -> Result> { - let mut conn = self.pool.get().expect("Couldn't get db connection"); - let direct_symbol = format!("{}{}=X", from_currency, to_currency); + if from_currency == to_currency { + return Ok(1.0); + } + + let symbol = format!("{}{}=X", from_currency, to_currency); let inverse_symbol = format!("{}{}=X", to_currency, from_currency); - let latest_quote: Option = quotes::table - .filter( - quotes::symbol - .eq(&direct_symbol) - .or(quotes::symbol.eq(&inverse_symbol)), - ) - .order(quotes::date.desc()) - .first(&mut *conn) - .optional()?; - - if let Some(quote) = latest_quote { - let rate = if quote.symbol == direct_symbol { - quote.close - } else { - 1.0 / quote.close - }; - - let mut exchange_rates = self - .exchange_rates - .lock() - .map_err(|_| "Failed to acquire lock")?; - exchange_rates.insert(direct_symbol.clone(), rate); - exchange_rates.insert(inverse_symbol, 1.0 / rate); - - Ok(rate) - } else { - Err(format!( - "No exchange rate found for {} to {}", - from_currency, to_currency - ) - .into()) + // Check cache first + { + let cache = self.exchange_rates.read().map_err(|_| "RwLock poisoned")?; + if let Some(&(rate, _)) = cache.get(&symbol) { + return Ok(rate); + } + if let Some(&(rate, _)) = cache.get(&inverse_symbol) { + return Ok(1.0 / rate); + } + } + + let mut conn = self + .pool + .get() + .map_err(|e| Box::new(e) as Box)?; + + // Try to get the direct rate + if let Some((rate, date)) = self.get_latest_rate_from_db(&mut conn, &symbol)? { + self.cache_rate(&symbol, rate, date)?; + return Ok(rate); } + + // If not found, try the inverse rate + if let Some((rate, date)) = self.get_latest_rate_from_db(&mut conn, &inverse_symbol)? { + let inverse_rate = 1.0 / rate; + self.cache_rate(&symbol, inverse_rate, date)?; + return Ok(inverse_rate); + } + + // If still not found, try USD conversion + let (from_usd, from_date) = self.get_latest_usd_rate(&mut conn, from_currency)?; + let (to_usd, to_date) = self.get_latest_usd_rate(&mut conn, to_currency)?; + + let rate = from_usd / to_usd; + let date = from_date.max(to_date); + self.cache_rate(&symbol, rate, date)?; + Ok(rate) } - pub fn convert_currency( + fn get_latest_rate_from_db( &self, - amount: f64, - from_currency: &str, - to_currency: &str, - ) -> Result> { - if from_currency == to_currency { - return Ok(amount); + conn: &mut SqliteConnection, + fx_symbol: &str, + ) -> Result, diesel::result::Error> { + use crate::schema::quotes::dsl::*; + + quotes + .filter(symbol.eq(fx_symbol)) + .order(date.desc()) + .select((close, date)) + .first(conn) + .optional() + } + + fn get_latest_usd_rate( + &self, + conn: &mut SqliteConnection, + currency: &str, + ) -> Result<(f64, NaiveDateTime), Box> { + if currency == "USD" { + return Ok((1.0, chrono::Utc::now().naive_utc())); } - let rate = self.load_exchange_rates(from_currency, to_currency)?; - Ok(amount * rate) + let symbol = format!("{}USD=X", currency); + self.get_latest_rate_from_db(conn, &symbol)? + .ok_or_else(|| format!("No USD rate found for {}", currency).into()) } - pub fn get_exchange_rate( + fn cache_rate( &self, + symbol: &str, + rate: f64, + date: NaiveDateTime, + ) -> Result<(), Box> { + let mut cache = self.exchange_rates.write().map_err(|_| "RwLock poisoned")?; + cache.insert(symbol.to_string(), (rate, date)); + Ok(()) + } + + pub fn convert_currency( + &self, + amount: f64, from_currency: &str, to_currency: &str, ) -> Result> { if from_currency == to_currency { - return Ok(1.0); - } - - let direct_key = format!("{}{}=X", from_currency, to_currency); - let inverse_key = format!("{}{}=X", to_currency, from_currency); - - { - let exchange_rates = self - .exchange_rates - .lock() - .map_err(|_| "Failed to acquire lock")?; - if let Some(&rate) = exchange_rates.get(&direct_key) { - return Ok(rate); - } else if let Some(&rate) = exchange_rates.get(&inverse_key) { - return Ok(1.0 / rate); - } + return Ok(amount); } - // Use atomic flag to prevent multiple threads from loading the same rate simultaneously - if self - .is_loading - .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) - .is_ok() - { - let result = self.load_exchange_rates(from_currency, to_currency); - self.is_loading.store(false, Ordering::Release); - result - } else { - // Another thread is loading, wait and retry - std::thread::yield_now(); - self.get_exchange_rate(from_currency, to_currency) - } + let rate = self.get_latest_exchange_rate(from_currency, to_currency)?; + Ok(amount * rate) } } diff --git a/src-core/src/market_data/market_data_service.rs b/src-core/src/market_data/market_data_service.rs index 7db786e..33dcf3b 100644 --- a/src-core/src/market_data/market_data_service.rs +++ b/src-core/src/market_data/market_data_service.rs @@ -7,7 +7,6 @@ use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; use std::collections::HashMap; use std::time::SystemTime; -use uuid::Uuid; pub struct MarketDataService { provider: YahooProvider, @@ -29,6 +28,14 @@ impl MarketDataService { .map_err(|e| e.to_string()) } + pub async fn initialize_crumb_data(&self) -> Result<(), String> { + self.provider.set_crumb().await.map_err(|e| { + let error_message = format!("Failed to initialize crumb data: {}", e); + eprintln!("{}", &error_message); + error_message + }) + } + pub fn get_latest_quote(&self, symbol: &str) -> QueryResult { let mut conn = self.pool.get().expect("Couldn't get db connection"); quotes::table @@ -53,46 +60,16 @@ impl MarketDataService { let quote_date = quote.date.date(); ((quote.symbol.clone(), quote_date), quote) }) - .collect(), // This will now create a HashMap + .collect(), Err(e) => { eprintln!("Error loading quotes: {}", e); - HashMap::new() // Return an empty HashMap + HashMap::new() } } } - pub async fn initialize_crumb_data(&self) -> Result<(), String> { - self.provider.set_crumb().await.map_err(|e| { - let error_message = format!("Failed to initialize crumb data: {}", e); - eprintln!("{}", &error_message); - error_message - }) - } - - fn get_last_quote_sync_date( - &self, - ticker: &str, - ) -> Result, diesel::result::Error> { - let mut conn = self.pool.get().expect("Couldn't get db connection"); - - quotes::table - .filter(quotes::symbol.eq(ticker)) - .select(diesel::dsl::max(quotes::date)) - .first::>(&mut conn) - .or_else(|_| { - activities::table - .filter(activities::asset_id.eq(ticker)) - .select(diesel::dsl::min(activities::activity_date)) - .first::>(&mut conn) - }) - } - - pub async fn sync_history_quotes_for_all_assets( - &self, - asset_list: &[Asset], - ) -> Result<(), String> { + pub async fn sync_quotes(&self, asset_list: &[Asset]) -> Result<(), String> { println!("Syncing history quotes for all assets..."); - let end_date = SystemTime::now(); let mut all_quotes_to_insert = Vec::new(); @@ -103,7 +80,6 @@ impl MarketDataService { .map_err(|e| format!("Error getting last sync date for {}: {}", symbol, e))? .unwrap_or_else(|| Utc::now().naive_utc() - Duration::days(3 * 365)); - // Ensure to synchronize the last day data for freshness let start_date: SystemTime = Utc .from_utc_datetime(&(last_sync_date - Duration::days(1))) .into(); @@ -113,13 +89,7 @@ impl MarketDataService { .fetch_stock_history(symbol, start_date, end_date) .await { - Ok(quotes_history) => { - for yahoo_quote in quotes_history { - if let Some(new_quote) = self.create_quote_from_yahoo(yahoo_quote, symbol) { - all_quotes_to_insert.push(new_quote); - } - } - } + Ok(quotes) => all_quotes_to_insert.extend(quotes), Err(e) => eprintln!("Error fetching history for {}: {}. Skipping.", symbol, e), } } @@ -127,27 +97,22 @@ impl MarketDataService { self.insert_quotes(&all_quotes_to_insert) } - fn create_quote_from_yahoo( + fn get_last_quote_sync_date( &self, - yahoo_quote: yahoo_finance_api::Quote, - symbol: &str, - ) -> Option { - chrono::DateTime::from_timestamp(yahoo_quote.timestamp as i64, 0).map(|datetime| { - let naive_datetime = datetime.naive_utc(); - Quote { - id: Uuid::new_v4().to_string(), - created_at: naive_datetime, - data_source: "YAHOO".to_string(), - date: naive_datetime, - symbol: symbol.to_string(), - open: yahoo_quote.open, - high: yahoo_quote.high, - low: yahoo_quote.low, - volume: yahoo_quote.volume as f64, - close: yahoo_quote.close, - adjclose: yahoo_quote.adjclose, - } - }) + ticker: &str, + ) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + + quotes::table + .filter(quotes::symbol.eq(ticker)) + .select(diesel::dsl::max(quotes::date)) + .first::>(&mut conn) + .or_else(|_| { + activities::table + .filter(activities::asset_id.eq(ticker)) + .select(diesel::dsl::min(activities::activity_date)) + .first::>(&mut conn) + }) } fn insert_quotes(&self, quotes: &[Quote]) -> Result<(), String> { @@ -159,9 +124,15 @@ impl MarketDataService { Ok(()) } - pub async fn initialize_and_sync_quotes(&self, asset_list: &[Asset]) -> Result<(), String> { + pub async fn initialize_and_sync_quotes(&self) -> Result<(), String> { + use crate::schema::assets::dsl::*; self.initialize_crumb_data().await?; - self.sync_history_quotes_for_all_assets(asset_list).await + let conn = &mut self.pool.get().map_err(|e| e.to_string())?; + let asset_list: Vec = assets + .load::(conn) + .map_err(|e| format!("Failed to load assets: {}", e))?; + + self.sync_quotes(&asset_list).await } pub async fn fetch_symbol_summary(&self, symbol: &str) -> Result { diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index 029f51f..a1f8679 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -223,7 +223,7 @@ impl HistoryService { for history in histories { let currency_exchange_rate = self .fx_service - .get_exchange_rate(&history.currency, &self.base_currency) + .get_latest_exchange_rate(&history.currency, &self.base_currency) .unwrap_or(1.0); total.total_value += history.total_value * currency_exchange_rate; @@ -350,7 +350,7 @@ impl HistoryService { let exchange_rate = self .fx_service - .get_exchange_rate(&account_currency, &self.base_currency) + .get_latest_exchange_rate(&account_currency, &self.base_currency) .unwrap_or(1.0); PortfolioHistory { @@ -390,7 +390,7 @@ impl HistoryService { // Get echange rate if activity currency is different of account currency let exchange_rate = self .fx_service - .get_exchange_rate(&activity.currency, account_currency) + .get_latest_exchange_rate(&activity.currency, account_currency) .unwrap_or(1.0); println!( @@ -512,7 +512,7 @@ impl HistoryService { let exchange_rate = self .fx_service - .get_exchange_rate(asset_currency, account_currency) + .get_latest_exchange_rate(asset_currency, account_currency) .unwrap_or(1.0); let holding_value = quantity * quote.close * exchange_rate; diff --git a/src-core/src/portfolio/holdings_service.rs b/src-core/src/portfolio/holdings_service.rs index a9f8ee3..f323b8b 100644 --- a/src-core/src/portfolio/holdings_service.rs +++ b/src-core/src/portfolio/holdings_service.rs @@ -121,7 +121,6 @@ impl HoldingsService { // Post-processing for each holding for holding in holdings.values_mut() { - println!("Post-processing holding: {}", holding.symbol); if let Some(quote) = quotes.get(&holding.symbol) { holding.market_price = Some(quote.close); @@ -141,7 +140,7 @@ impl HoldingsService { // Get exchange rate for the holding's currency to base currency let exchange_rate = match self .fx_service - .get_exchange_rate(&holding.currency, &self.base_currency) + .get_latest_exchange_rate(&holding.currency, &self.base_currency) { Ok(rate) => rate, Err(e) => { diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index bf52b75..6763859 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -1,7 +1,7 @@ use crate::account::account_service::AccountService; use crate::activity::activity_service::ActivityService; -use crate::asset::asset_service::AssetService; use crate::fx::fx_service::CurrencyExchangeService; +use crate::market_data::market_data_service::MarketDataService; use crate::models::{ Account, AccountSummary, Activity, HistorySummary, Holding, IncomeData, IncomeSummary, PortfolioHistory, @@ -11,6 +11,8 @@ use crate::settings::SettingsService; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; +use std::sync::Arc; + use crate::portfolio::history_service::HistoryService; use crate::portfolio::holdings_service::HoldingsService; use crate::portfolio::income_service::IncomeService; @@ -18,7 +20,7 @@ use crate::portfolio::income_service::IncomeService; pub struct PortfolioService { account_service: AccountService, activity_service: ActivityService, - asset_service: AssetService, + market_data_service: Arc, income_service: IncomeService, holdings_service: HoldingsService, history_service: HistoryService, @@ -39,10 +41,12 @@ impl PortfolioService { let settings = settings_service.get_settings(&mut conn)?; let base_currency = settings.base_currency; + let market_data_service = Arc::new(MarketDataService::new(pool.clone())); + Ok(PortfolioService { account_service: AccountService::new(pool.clone()), activity_service: ActivityService::new(pool.clone()), - asset_service: AssetService::new(pool.clone()), + market_data_service: market_data_service.clone(), income_service: IncomeService::new( pool.clone(), CurrencyExchangeService::new(pool.clone()), @@ -116,7 +120,9 @@ impl PortfolioService { &self, ) -> Result, Box> { // First, sync quotes - self.asset_service.initialize_and_sync_quotes().await?; + self.market_data_service + .initialize_and_sync_quotes() + .await?; // Then, calculate historical data self.calculate_historical_data(None, false) diff --git a/src-core/src/providers/yahoo_provider.rs b/src-core/src/providers/yahoo_provider.rs index f81e811..d5e7f52 100644 --- a/src-core/src/providers/yahoo_provider.rs +++ b/src-core/src/providers/yahoo_provider.rs @@ -1,6 +1,8 @@ use std::{sync::RwLock, time::SystemTime}; -use crate::models::{CrumbData, NewAsset, QuoteSummary}; +use super::models::{AssetClass, AssetSubClass, PriceDetail, YahooResult}; +use crate::models::{CrumbData, NewAsset, Quote as ModelQuote, QuoteSummary}; +use chrono::{DateTime, Utc}; use lazy_static::lazy_static; use reqwest::{header, Client}; use serde_json::json; @@ -8,8 +10,6 @@ use thiserror::Error; use yahoo::{YQuoteItem, YahooError}; use yahoo_finance_api as yahoo; -use super::models::{AssetClass, AssetSubClass, PriceDetail, YahooResult}; - impl From<&YQuoteItem> for QuoteSummary { fn from(item: &YQuoteItem) -> Self { QuoteSummary { @@ -33,8 +33,8 @@ impl From<&YQuoteItem> for QuoteSummary { impl From<&YQuoteItem> for NewAsset { fn from(item: &YQuoteItem) -> Self { NewAsset { - id: item.symbol.clone(), // Assuming the symbol is used as the id - isin: None, // Map the rest of the fields accordingly + id: item.symbol.clone(), + isin: None, // TODO: Implement isin name: Some(item.long_name.clone()), asset_type: Some(item.quote_type.clone()), symbol: item.symbol.clone(), @@ -66,6 +66,26 @@ impl YahooProvider { Ok(YahooProvider { provider }) } + fn yahoo_quote_to_model_quote(&self, symbol: String, yahoo_quote: yahoo::Quote) -> ModelQuote { + let date = DateTime::::from_timestamp(yahoo_quote.timestamp as i64, 0) + .unwrap_or_default() + .naive_utc(); + + ModelQuote { + id: format!("{}_{}", date.format("%Y%m%d"), symbol), + created_at: chrono::Utc::now().naive_utc(), + data_source: "YAHOO".to_string(), + date, + symbol: symbol, + open: yahoo_quote.open, + high: yahoo_quote.high, + low: yahoo_quote.low, + volume: yahoo_quote.volume as f64, + close: yahoo_quote.close, + adjclose: yahoo_quote.adjclose, + } + } + // pub async fn set_crumb() -> Result<(), yahoo::YahooError> { pub async fn set_crumb(&self) -> Result<(), yahoo::YahooError> { let client = Client::new(); @@ -253,12 +273,11 @@ impl YahooProvider { symbol: &str, start: SystemTime, end: SystemTime, - ) -> Result, yahoo::YahooError> { + ) -> Result, yahoo::YahooError> { if symbol.starts_with("$CASH-") { return Ok(vec![]); } - // Convert SystemTime to OffsetDateTime as required by get_quote_history let start_offset = start.into(); let end_offset = end.into(); @@ -267,7 +286,14 @@ impl YahooProvider { .get_quote_history(symbol, start_offset, end_offset) .await?; - response.quotes() + // Use the new method to convert quotes + let quotes = response + .quotes()? + .into_iter() + .map(|q| self.yahoo_quote_to_model_quote(symbol.to_string(), q)) + .collect(); + + Ok(quotes) } pub async fn fetch_asset_profile( diff --git a/src-tauri/src/commands/market_data.rs b/src-tauri/src/commands/market_data.rs index cbc50fe..b5bed0d 100644 --- a/src-tauri/src/commands/market_data.rs +++ b/src-tauri/src/commands/market_data.rs @@ -28,6 +28,6 @@ pub fn get_asset_data(asset_id: String, state: State) -> Result) -> Result<(), String> { println!("Synching quotes history"); - let service = AssetService::new((*state.pool).clone()); + let service = MarketDataService::new((*state.pool).clone()); service.initialize_and_sync_quotes().await } From 2c277a79d0ec99df91567d35b97d729c70ca9dfd Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Fri, 20 Sep 2024 16:02:52 -0400 Subject: [PATCH 31/45] fx improvment --- src-core/src/account/account_service.rs | 17 ++---- src-core/src/activity/activity_service.rs | 74 +++++++++++++++-------- src-core/src/asset/asset_service.rs | 62 ++++++++++++------- 3 files changed, 95 insertions(+), 58 deletions(-) diff --git a/src-core/src/account/account_service.rs b/src-core/src/account/account_service.rs index 93035df..c62905e 100644 --- a/src-core/src/account/account_service.rs +++ b/src-core/src/account/account_service.rs @@ -41,17 +41,12 @@ impl AccountService { let settings = settings_service.get_settings(conn)?; let base_currency = settings.base_currency; - // Create exchange rate asset if necessary - if new_account.currency != base_currency { - let asset_id = format!("{}{}=X", base_currency, new_account.currency); - if asset_service.get_asset_by_id(&asset_id).is_err() { - asset_service.create_rate_exchange_asset( - conn, - &base_currency, - &new_account.currency, - )?; - } - } + // Create exchange rate assets if necessary + asset_service.create_exchange_rate_symbols( + conn, + &base_currency, + &new_account.currency, + )?; // Create cash ($CASH-CURRENCY) asset if necessary let cash_asset_id = format!("$CASH-{}", new_account.currency); diff --git a/src-core/src/activity/activity_service.rs b/src-core/src/activity/activity_service.rs index 8141396..3255691 100644 --- a/src-core/src/activity/activity_service.rs +++ b/src-core/src/activity/activity_service.rs @@ -82,23 +82,34 @@ impl ActivityService { let mut conn = self.pool.get().expect("Couldn't get db connection"); let asset_id = activity.asset_id.clone(); let asset_service = AssetService::new(self.pool.clone()); - + let account_service = AccountService::new(self.pool.clone()); let asset_profile = asset_service.get_asset_profile(&asset_id).await?; + let account = account_service.get_account_by_id(&activity.account_id)?; - // Update activity currency if asset_profile.currency is available - if !asset_profile.currency.is_empty() { - activity.currency = asset_profile.currency; - } + conn.transaction(|conn| { + // Update activity currency if asset_profile.currency is available + if !asset_profile.currency.is_empty() { + activity.currency = asset_profile.currency.clone(); + } + // Adjust unit price based on activity type + if ["DEPOSIT", "WITHDRAWAL", "INTEREST", "FEE", "DIVIDEND"] + .contains(&activity.activity_type.as_str()) + { + activity.unit_price = 1.0; + } - // Adjust unit price based on activity type - if ["DEPOSIT", "WITHDRAWAL", "INTEREST", "FEE", "DIVIDEND"] - .contains(&activity.activity_type.as_str()) - { - activity.unit_price = 1.0; - } + // Insert the new activity into the database + let inserted_activity = self.repo.insert_new_activity(conn, activity)?; - // Insert the new activity into the database - self.repo.insert_new_activity(&mut conn, activity) + // Create currency symbols if asset currency is different from account currency + if asset_profile.currency != account.currency { + asset_service + .create_exchange_rate_symbols(conn, &asset_profile.currency, &account.currency) + .map_err(|e| diesel::result::Error::RollbackTransaction)?; + } + + Ok(inserted_activity) + }) } // update an activity @@ -108,23 +119,34 @@ impl ActivityService { ) -> Result { let mut conn = self.pool.get().expect("Couldn't get db connection"); let asset_service = AssetService::new(self.pool.clone()); - + let account_service = AccountService::new(self.pool.clone()); let asset_profile = asset_service.get_asset_profile(&activity.asset_id).await?; + let account = account_service.get_account_by_id(&activity.account_id)?; - // Update activity currency if asset_profile.currency is available - if !asset_profile.currency.is_empty() { - activity.currency = asset_profile.currency; - } + conn.transaction(|conn| { + // Update activity currency if asset_profile.currency is available + if !asset_profile.currency.is_empty() { + activity.currency = asset_profile.currency.clone(); + } + // Adjust unit price based on activity type + if ["DEPOSIT", "WITHDRAWAL", "INTEREST", "FEE", "DIVIDEND"] + .contains(&activity.activity_type.as_str()) + { + activity.unit_price = 1.0; + } - // Adjust unit price based on activity type - if ["DEPOSIT", "WITHDRAWAL", "INTEREST", "FEE", "DIVIDEND"] - .contains(&activity.activity_type.as_str()) - { - activity.unit_price = 1.0; - } + // Update the activity in the database + let updated_activity = self.repo.update_activity(conn, activity)?; - // Update the activity in the database - self.repo.update_activity(&mut conn, activity) + // Create currency symbols if asset currency is different from account currency + if asset_profile.currency != account.currency { + asset_service + .create_exchange_rate_symbols(conn, &asset_profile.currency, &account.currency) + .map_err(|e| diesel::result::Error::RollbackTransaction)?; + } + + Ok(updated_activity) + }) } // verify the activities import from csv file diff --git a/src-core/src/asset/asset_service.rs b/src-core/src/asset/asset_service.rs index 8ded7af..376dd88 100644 --- a/src-core/src/asset/asset_service.rs +++ b/src-core/src/asset/asset_service.rs @@ -4,7 +4,6 @@ use crate::schema::{assets, quotes}; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; -use std::collections::HashMap; pub struct AssetService { market_data_service: MarketDataService, @@ -78,29 +77,50 @@ impl AssetService { .load::(&mut conn) } - pub fn load_exchange_rates( + pub fn create_exchange_rate_symbols( &self, - base_currency: &str, - ) -> Result, diesel::result::Error> { - let mut conn = self.pool.get().expect("Couldn't get db connection"); - use crate::schema::quotes::dsl::{date, quotes, symbol}; - - let mut exchange_rates = HashMap::new(); - let currency_assets = self.load_currency_assets(base_currency)?; - - for asset in currency_assets { - let latest_quote = quotes - .filter(symbol.eq(&asset.symbol)) - .order(date.desc()) - .first::(&mut conn) - .ok(); - - if let Some(quote) = latest_quote { - exchange_rates.insert(asset.symbol, quote.close); - } + conn: &mut SqliteConnection, + from_currency: &str, + to_currency: &str, + ) -> Result<(), Box> { + let mut symbols = Vec::new(); + if from_currency != to_currency { + symbols.push(format!("{}{}=X", from_currency, to_currency)); + symbols.push(format!("{}{}=X", to_currency, from_currency)); + } + if from_currency != "USD" { + symbols.push(format!("{}USD=X", from_currency)); } - Ok(exchange_rates) + let new_assets: Vec = symbols + .iter() + .filter(|symbol| self.get_asset_by_id(symbol).is_err()) + .map(|symbol| NewAsset { + id: symbol.to_string(), + isin: None, + name: None, + asset_type: Some("Currency".to_string()), + symbol: symbol.to_string(), + symbol_mapping: None, + asset_class: Some("".to_string()), + asset_sub_class: Some("".to_string()), + comment: None, + countries: None, + categories: None, + classes: None, + attributes: None, + currency: to_currency.to_string(), + data_source: "MANUAL".to_string(), + sectors: None, + url: None, + }) + .collect(); + + diesel::replace_into(assets::table) + .values(&new_assets) + .execute(conn)?; + + Ok(()) } pub fn create_cash_asset( From 599bb4c2f29eb9a4cb10f24e107e90181f21195d Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Fri, 20 Sep 2024 21:03:36 -0400 Subject: [PATCH 32/45] reactof backend --- src-core/src/activity/activity_service.rs | 53 ++++++++++++++++++++--- src-tauri/src/commands/activity.rs | 15 +++---- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src-core/src/activity/activity_service.rs b/src-core/src/activity/activity_service.rs index 3255691..d6c0db7 100644 --- a/src-core/src/activity/activity_service.rs +++ b/src-core/src/activity/activity_service.rs @@ -11,6 +11,8 @@ use crate::schema::activities; use csv::ReaderBuilder; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; +use std::time::Duration; + use uuid::Uuid; pub struct ActivityService { @@ -83,7 +85,9 @@ impl ActivityService { let asset_id = activity.asset_id.clone(); let asset_service = AssetService::new(self.pool.clone()); let account_service = AccountService::new(self.pool.clone()); - let asset_profile = asset_service.get_asset_profile(&asset_id).await?; + let asset_profile = asset_service + .get_asset_profile(&asset_id, Some(true)) + .await?; let account = account_service.get_account_by_id(&activity.account_id)?; conn.transaction(|conn| { @@ -105,7 +109,7 @@ impl ActivityService { if asset_profile.currency != account.currency { asset_service .create_exchange_rate_symbols(conn, &asset_profile.currency, &account.currency) - .map_err(|e| diesel::result::Error::RollbackTransaction)?; + .map_err(|_e| diesel::result::Error::RollbackTransaction)?; } Ok(inserted_activity) @@ -120,7 +124,9 @@ impl ActivityService { let mut conn = self.pool.get().expect("Couldn't get db connection"); let asset_service = AssetService::new(self.pool.clone()); let account_service = AccountService::new(self.pool.clone()); - let asset_profile = asset_service.get_asset_profile(&activity.asset_id).await?; + let asset_profile = asset_service + .get_asset_profile(&activity.asset_id, Some(true)) + .await?; let account = account_service.get_account_by_id(&activity.account_id)?; conn.transaction(|conn| { @@ -142,7 +148,7 @@ impl ActivityService { if asset_profile.currency != account.currency { asset_service .create_exchange_rate_symbols(conn, &asset_profile.currency, &account.currency) - .map_err(|e| diesel::result::Error::RollbackTransaction)?; + .map_err(|_e| diesel::result::Error::RollbackTransaction)?; } Ok(updated_activity) @@ -155,32 +161,52 @@ impl ActivityService { _account_id: String, file_path: String, ) -> Result, String> { + use std::time::Instant; + let start = Instant::now(); + let asset_service = AssetService::new(self.pool.clone()); let account_service = AccountService::new(self.pool.clone()); let account = account_service .get_account_by_id(&_account_id) .map_err(|e| e.to_string())?; + println!("Account retrieval took: {:?}", start.elapsed()); + let file_open_start = Instant::now(); + let file = File::open(&file_path).map_err(|e| e.to_string())?; let mut rdr = ReaderBuilder::new() .delimiter(b',') .has_headers(true) .from_reader(file); let mut activities_with_status: Vec = Vec::new(); + let mut symbols_to_sync: Vec = Vec::new(); + + println!( + "File opening and reader setup took: {:?}", + file_open_start.elapsed() + ); + let processing_start = Instant::now(); for (line_number, result) in rdr.deserialize().enumerate() { let line_number = line_number + 1; // Adjust for human-readable line number let mut activity_import: ActivityImport = result.map_err(|e| e.to_string())?; + let symbol_profile_start = Instant::now(); // Load the symbol profile here, now awaiting the async call let symbol_profile_result = asset_service - .get_asset_profile(&activity_import.symbol) + .get_asset_profile(&activity_import.symbol, Some(false)) .await; + println!( + "Symbol profile retrieval for {} took: {:?}", + activity_import.symbol, + symbol_profile_start.elapsed() + ); // Check if symbol profile is valid let (is_valid, error) = match symbol_profile_result { Ok(profile) => { activity_import.symbol_name = profile.name; + symbols_to_sync.push(activity_import.symbol.clone()); (Some("true".to_string()), None) } Err(_) => { @@ -203,6 +229,23 @@ impl ActivityService { activities_with_status.push(activity_import); } + println!( + "Processing all activities took: {:?}", + processing_start.elapsed() + ); + + // Sync quotes for all valid symbols + if !symbols_to_sync.is_empty() { + let sync_start = Instant::now(); + asset_service.sync_symbol_quotes(&symbols_to_sync).await?; + println!( + "Syncing quotes for {} symbols took: {:?}", + symbols_to_sync.len(), + sync_start.elapsed() + ); + } + + println!("Total function duration: {:?}", start.elapsed()); Ok(activities_with_status) } diff --git a/src-tauri/src/commands/activity.rs b/src-tauri/src/commands/activity.rs index 4c5f8c8..c9db472 100644 --- a/src-tauri/src/commands/activity.rs +++ b/src-tauri/src/commands/activity.rs @@ -56,22 +56,21 @@ pub fn update_activity( } #[tauri::command] -pub fn check_activities_import( +pub async fn check_activities_import( account_id: String, file_path: String, - state: State, + state: State<'_, AppState>, ) -> Result, String> { println!( "Checking activities import...: {}, {}", account_id, file_path ); - let result = tauri::async_runtime::block_on(async { - let service = activity_service::ActivityService::new((*state.pool).clone()); - service.check_activities_import(account_id, file_path).await - }); - - result.map_err(|e| e.to_string()) + let service = activity_service::ActivityService::new((*state.pool).clone()); + service + .check_activities_import(account_id, file_path) + .await + .map_err(|e| e.to_string()) } #[tauri::command] From e5e2e8c923b2bf7f428484be0f01c35cc85181a5 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Fri, 20 Sep 2024 21:03:47 -0400 Subject: [PATCH 33/45] refactor backend --- src-core/src/account/account_service.rs | 4 +- src-core/src/activity/activity_service.rs | 37 +--------- src-core/src/asset/asset_service.rs | 70 ++++++++----------- .../src/market_data/market_data_service.rs | 38 +++++----- src-core/src/portfolio/history_service.rs | 16 ++--- src-core/src/portfolio/holdings_service.rs | 15 ++-- src-core/src/portfolio/portfolio_service.rs | 22 ++---- src-core/src/providers/yahoo_provider.rs | 21 +++--- src-tauri/src/commands/account.rs | 6 +- src-tauri/src/commands/market_data.rs | 17 +++-- src-tauri/src/commands/portfolio.rs | 28 +++++--- src-tauri/src/main.rs | 1 + src/App.tsx | 1 + src/components/empty-placeholder.tsx | 2 +- src/pages/activity/import/import-form.tsx | 1 - 15 files changed, 126 insertions(+), 153 deletions(-) diff --git a/src-core/src/account/account_service.rs b/src-core/src/account/account_service.rs index c62905e..5251959 100644 --- a/src-core/src/account/account_service.rs +++ b/src-core/src/account/account_service.rs @@ -29,12 +29,12 @@ impl AccountService { self.account_repo.load_account_by_id(&mut conn, account_id) } - pub fn create_account( + pub async fn create_account( &self, new_account: NewAccount, ) -> Result> { let mut conn = self.pool.get()?; - let asset_service = AssetService::new(self.pool.clone()); + let asset_service = AssetService::new(self.pool.clone()).await; let settings_service = SettingsService::new(); conn.transaction(|conn| { diff --git a/src-core/src/activity/activity_service.rs b/src-core/src/activity/activity_service.rs index d6c0db7..363137b 100644 --- a/src-core/src/activity/activity_service.rs +++ b/src-core/src/activity/activity_service.rs @@ -11,7 +11,6 @@ use crate::schema::activities; use csv::ReaderBuilder; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; -use std::time::Duration; use uuid::Uuid; @@ -83,7 +82,7 @@ impl ActivityService { ) -> Result { let mut conn = self.pool.get().expect("Couldn't get db connection"); let asset_id = activity.asset_id.clone(); - let asset_service = AssetService::new(self.pool.clone()); + let asset_service = AssetService::new(self.pool.clone()).await; let account_service = AccountService::new(self.pool.clone()); let asset_profile = asset_service .get_asset_profile(&asset_id, Some(true)) @@ -122,7 +121,7 @@ impl ActivityService { mut activity: ActivityUpdate, ) -> Result { let mut conn = self.pool.get().expect("Couldn't get db connection"); - let asset_service = AssetService::new(self.pool.clone()); + let asset_service = AssetService::new(self.pool.clone()).await; let account_service = AccountService::new(self.pool.clone()); let asset_profile = asset_service .get_asset_profile(&activity.asset_id, Some(true)) @@ -161,18 +160,12 @@ impl ActivityService { _account_id: String, file_path: String, ) -> Result, String> { - use std::time::Instant; - let start = Instant::now(); - - let asset_service = AssetService::new(self.pool.clone()); + let asset_service = AssetService::new(self.pool.clone()).await; let account_service = AccountService::new(self.pool.clone()); let account = account_service .get_account_by_id(&_account_id) .map_err(|e| e.to_string())?; - println!("Account retrieval took: {:?}", start.elapsed()); - let file_open_start = Instant::now(); - let file = File::open(&file_path).map_err(|e| e.to_string())?; let mut rdr = ReaderBuilder::new() .delimiter(b',') @@ -181,26 +174,14 @@ impl ActivityService { let mut activities_with_status: Vec = Vec::new(); let mut symbols_to_sync: Vec = Vec::new(); - println!( - "File opening and reader setup took: {:?}", - file_open_start.elapsed() - ); - let processing_start = Instant::now(); - for (line_number, result) in rdr.deserialize().enumerate() { let line_number = line_number + 1; // Adjust for human-readable line number let mut activity_import: ActivityImport = result.map_err(|e| e.to_string())?; - let symbol_profile_start = Instant::now(); // Load the symbol profile here, now awaiting the async call let symbol_profile_result = asset_service .get_asset_profile(&activity_import.symbol, Some(false)) .await; - println!( - "Symbol profile retrieval for {} took: {:?}", - activity_import.symbol, - symbol_profile_start.elapsed() - ); // Check if symbol profile is valid let (is_valid, error) = match symbol_profile_result { @@ -229,23 +210,11 @@ impl ActivityService { activities_with_status.push(activity_import); } - println!( - "Processing all activities took: {:?}", - processing_start.elapsed() - ); - // Sync quotes for all valid symbols if !symbols_to_sync.is_empty() { - let sync_start = Instant::now(); asset_service.sync_symbol_quotes(&symbols_to_sync).await?; - println!( - "Syncing quotes for {} symbols took: {:?}", - symbols_to_sync.len(), - sync_start.elapsed() - ); } - println!("Total function duration: {:?}", start.elapsed()); Ok(activities_with_status) } diff --git a/src-core/src/asset/asset_service.rs b/src-core/src/asset/asset_service.rs index 376dd88..f4236c0 100644 --- a/src-core/src/asset/asset_service.rs +++ b/src-core/src/asset/asset_service.rs @@ -4,9 +4,9 @@ use crate::schema::{assets, quotes}; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; - +use std::sync::Arc; pub struct AssetService { - market_data_service: MarketDataService, + market_data_service: Arc, pool: Pool>, } @@ -29,10 +29,11 @@ impl From for Quote { } impl AssetService { - pub fn new(pool: Pool>) -> Self { - AssetService { - market_data_service: MarketDataService::new(pool.clone()), + pub async fn new(pool: Pool>) -> Self { + let market_data_service = Arc::new(MarketDataService::new(pool.clone()).await); + Self { pool, + market_data_service, } } @@ -200,22 +201,20 @@ impl AssetService { self.market_data_service.search_symbol(query).await } - pub async fn initialize_crumb_data(&self) -> Result<(), String> { - self.market_data_service.initialize_crumb_data().await - } - - pub async fn get_asset_profile(&self, asset_id: &str) -> Result { - println!("Getting asset profile for asset_id: {}", asset_id); + pub async fn get_asset_profile( + &self, + asset_id: &str, + sync: Option, + ) -> Result { let mut conn = self.pool.get().expect("Couldn't get db connection"); use crate::schema::assets::dsl::*; + let should_sync = sync.unwrap_or(true); + match assets.find(asset_id).first::(&mut conn) { - Ok(existing_profile) => { - println!("Found existing profile for asset_id: {}", asset_id); - Ok(existing_profile) - } + Ok(existing_profile) => Ok(existing_profile), Err(diesel::NotFound) => { - println!("Asset profile not found in database for asset_id: {}. Fetching from market data service.", asset_id); + // symbol not found in database. Fetching from market data service. let fetched_profile = self .market_data_service .fetch_symbol_summary(asset_id) @@ -228,23 +227,22 @@ impl AssetService { diesel::result::Error::NotFound })?; - println!("Inserting new asset profile for asset_id: {}", asset_id); let inserted_asset = diesel::insert_into(assets) .values(&fetched_profile) .returning(Asset::as_returning()) .get_result(&mut conn)?; - // Sync history quotes for the newly inserted asset - self.market_data_service - .sync_quotes(&[inserted_asset.clone()]) - .await - .map_err(|e| { - println!( - "Failed to sync history quotes for asset_id: {}. Error: {:?}", - asset_id, e - ); - diesel::result::Error::NotFound - })?; + if should_sync { + self.sync_symbol_quotes(&[inserted_asset.symbol.clone()]) + .await + .map_err(|e| { + println!( + "Failed to sync quotes for asset_id: {}. Error: {:?}", + asset_id, e + ); + diesel::result::Error::NotFound + })?; + } Ok(inserted_asset) } @@ -258,17 +256,7 @@ impl AssetService { } } - // pub async fn sync_history_quotes_for_all_assets(&self) -> Result<(), String> { - // let asset_list = self.get_assets().map_err(|e| e.to_string())?; - // self.market_data_service - // .sync_history_quotes_for_all_assets(&asset_list) - // .await - // } - - // pub async fn initialize_and_sync_quotes(&self) -> Result<(), String> { - // let asset_list = self.get_assets().map_err(|e| e.to_string())?; - // self.market_data_service - // .initialize_and_sync_quotes(&asset_list) - // .await - // } + pub async fn sync_symbol_quotes(&self, symbols: &[String]) -> Result<(), String> { + self.market_data_service.sync_quotes(symbols).await + } } diff --git a/src-core/src/market_data/market_data_service.rs b/src-core/src/market_data/market_data_service.rs index 33dcf3b..3cd0c50 100644 --- a/src-core/src/market_data/market_data_service.rs +++ b/src-core/src/market_data/market_data_service.rs @@ -14,9 +14,11 @@ pub struct MarketDataService { } impl MarketDataService { - pub fn new(pool: Pool>) -> Self { + pub async fn new(pool: Pool>) -> Self { MarketDataService { - provider: YahooProvider::new().expect("Failed to initialize YahooProvider"), + provider: YahooProvider::new() + .await + .expect("Failed to initialize YahooProvider"), pool, } } @@ -28,13 +30,13 @@ impl MarketDataService { .map_err(|e| e.to_string()) } - pub async fn initialize_crumb_data(&self) -> Result<(), String> { - self.provider.set_crumb().await.map_err(|e| { - let error_message = format!("Failed to initialize crumb data: {}", e); - eprintln!("{}", &error_message); - error_message - }) - } + // pub async fn initialize_provider(&self) -> Result<(), String> { + // self.provider.set_crumb().await.map_err(|e| { + // let error_message = format!("Failed to initialize crumb data: {}", e); + // eprintln!("{}", &error_message); + // error_message + // }) + // } pub fn get_latest_quote(&self, symbol: &str) -> QueryResult { let mut conn = self.pool.get().expect("Couldn't get db connection"); @@ -68,13 +70,12 @@ impl MarketDataService { } } - pub async fn sync_quotes(&self, asset_list: &[Asset]) -> Result<(), String> { + pub async fn sync_quotes(&self, symbols: &[String]) -> Result<(), String> { println!("Syncing history quotes for all assets..."); let end_date = SystemTime::now(); let mut all_quotes_to_insert = Vec::new(); - for asset in asset_list { - let symbol = asset.symbol.as_str(); + for symbol in symbols { let last_sync_date = self .get_last_quote_sync_date(symbol) .map_err(|e| format!("Error getting last sync date for {}: {}", symbol, e))? @@ -126,17 +127,22 @@ impl MarketDataService { pub async fn initialize_and_sync_quotes(&self) -> Result<(), String> { use crate::schema::assets::dsl::*; - self.initialize_crumb_data().await?; + // self.initialize_provider().await?; let conn = &mut self.pool.get().map_err(|e| e.to_string())?; let asset_list: Vec = assets .load::(conn) .map_err(|e| format!("Failed to load assets: {}", e))?; - self.sync_quotes(&asset_list).await + self.sync_quotes( + &asset_list + .iter() + .map(|asset| asset.symbol.clone()) + .collect::>(), + ) + .await } - + //self.initialize_provider().await?; pub async fn fetch_symbol_summary(&self, symbol: &str) -> Result { - self.initialize_crumb_data().await?; self.provider .fetch_quote_summary(symbol) .await diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index a1f8679..0e904b2 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -16,16 +16,20 @@ use std::sync::Arc; pub struct HistoryService { pool: Pool>, base_currency: String, - market_data_service: MarketDataService, + market_data_service: Arc, fx_service: CurrencyExchangeService, } impl HistoryService { - pub fn new(pool: Pool>, base_currency: String) -> Self { + pub fn new( + pool: Pool>, + base_currency: String, + market_data_service: Arc, + ) -> Self { Self { pool: pool.clone(), - base_currency: base_currency.clone(), - market_data_service: MarketDataService::new(pool.clone()), + base_currency, + market_data_service, fx_service: CurrencyExchangeService::new(pool), } } @@ -393,10 +397,6 @@ impl HistoryService { .get_latest_exchange_rate(&activity.currency, account_currency) .unwrap_or(1.0); - println!( - "Exchange rate for {} to {}: {}", - activity.currency, account_currency, exchange_rate - ); let activity_amount = activity.quantity * activity.unit_price * exchange_rate; let activity_fee = activity.fee * exchange_rate; diff --git a/src-core/src/portfolio/holdings_service.rs b/src-core/src/portfolio/holdings_service.rs index f323b8b..fa0aa38 100644 --- a/src-core/src/portfolio/holdings_service.rs +++ b/src-core/src/portfolio/holdings_service.rs @@ -17,18 +17,21 @@ pub struct HoldingsService { } impl HoldingsService { - pub fn new(pool: Pool>, base_currency: String) -> Self { + pub async fn new( + pool: Pool>, + base_currency: String, + ) -> Self { HoldingsService { account_service: AccountService::new(pool.clone()), activity_service: ActivityService::new(pool.clone()), - asset_service: AssetService::new(pool.clone()), + asset_service: AssetService::new(pool.clone()).await, fx_service: CurrencyExchangeService::new(pool.clone()), base_currency, } } - pub fn compute_holdings(&self) -> Result> { - println!("Computing holdings"); + let start_time = std::time::Instant::now(); + let mut holdings: HashMap = HashMap::new(); let accounts = self.account_service.get_accounts()?; let activities = self.activity_service.get_trading_activities()?; @@ -172,7 +175,9 @@ impl HoldingsService { } } - println!("Computed {} holdings", holdings.len()); + let duration = start_time.elapsed(); + println!("Computed {} holdings in {:?}", holdings.len(), duration); + Ok(holdings .into_values() .filter(|holding| holding.quantity > 0.0) diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 6763859..09e6437 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -26,14 +26,8 @@ pub struct PortfolioService { history_service: HistoryService, } -/// This module contains the implementation of the `PortfolioService` struct. -/// The `PortfolioService` struct provides methods for fetching and aggregating holdings, -/// computing holdings, calculating historical portfolio values, and aggregating account history. -/// It also includes helper methods for converting currency, fetching exchange rates, -/// and getting dates between two given dates. - impl PortfolioService { - pub fn new( + pub async fn new( pool: Pool>, ) -> Result> { let mut conn = pool.get()?; @@ -41,7 +35,7 @@ impl PortfolioService { let settings = settings_service.get_settings(&mut conn)?; let base_currency = settings.base_currency; - let market_data_service = Arc::new(MarketDataService::new(pool.clone())); + let market_data_service = Arc::new(MarketDataService::new(pool.clone()).await); Ok(PortfolioService { account_service: AccountService::new(pool.clone()), @@ -52,8 +46,8 @@ impl PortfolioService { CurrencyExchangeService::new(pool.clone()), base_currency.clone(), ), - holdings_service: HoldingsService::new(pool.clone(), base_currency.clone()), - history_service: HistoryService::new(pool.clone(), base_currency), + holdings_service: HoldingsService::new(pool.clone(), base_currency.clone()).await, + history_service: HistoryService::new(pool.clone(), base_currency, market_data_service), }) } @@ -89,9 +83,6 @@ impl PortfolioService { account_ids: Option>, force_full_calculation: bool, ) -> Result, Box> { - println!("Starting calculate_historical_data with account_ids: {:?}, force_full_calculation: {:?}", account_ids, force_full_calculation); - let strt_time = std::time::Instant::now(); - let (accounts, activities) = self.fetch_data(account_ids)?; let results = self.history_service.calculate_historical_data( @@ -100,11 +91,6 @@ impl PortfolioService { force_full_calculation, )?; - println!( - "Calculating historical portfolio values took: {:?}", - std::time::Instant::now() - strt_time - ); - Ok(results) } diff --git a/src-core/src/providers/yahoo_provider.rs b/src-core/src/providers/yahoo_provider.rs index d5e7f52..09dd7ca 100644 --- a/src-core/src/providers/yahoo_provider.rs +++ b/src-core/src/providers/yahoo_provider.rs @@ -61,9 +61,11 @@ pub struct YahooProvider { } impl YahooProvider { - pub fn new() -> Result { + pub async fn new() -> Result { let provider = yahoo::YahooConnector::new()?; - Ok(YahooProvider { provider }) + let yahoo_provider = YahooProvider { provider }; + yahoo_provider.set_crumb().await?; + Ok(yahoo_provider) } fn yahoo_quote_to_model_quote(&self, symbol: String, yahoo_quote: yahoo::Quote) -> ModelQuote { @@ -86,8 +88,7 @@ impl YahooProvider { } } - // pub async fn set_crumb() -> Result<(), yahoo::YahooError> { - pub async fn set_crumb(&self) -> Result<(), yahoo::YahooError> { + async fn set_crumb(&self) -> Result<(), yahoo::YahooError> { let client = Client::new(); // Make the first call to extract the Crumb cookie @@ -300,11 +301,13 @@ impl YahooProvider { &self, symbol: &str, ) -> Result { - let crumb_data = YAHOO_CRUMB.read().unwrap(); - - let crumb_data = crumb_data - .as_ref() - .ok_or_else(|| YahooError::FetchFailed("Crumb data not found".into()))?; + let crumb_data = { + let guard = YAHOO_CRUMB.read().unwrap(); + guard + .as_ref() + .ok_or_else(|| YahooError::FetchFailed("Crumb data not found".into()))? + .clone() + }; let url = format!( "https://query1.finance.yahoo.com/v10/finance/quoteSummary/{}?modules=price,summaryProfile,topHoldings&crumb={}", diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 3599d75..3f3c473 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -13,11 +13,15 @@ pub fn get_accounts(state: State) -> Result, String> { } #[tauri::command] -pub fn create_account(account: NewAccount, state: State) -> Result { +pub async fn create_account( + account: NewAccount, + state: State<'_, AppState>, +) -> Result { println!("Adding new account..."); let service = AccountService::new((*state.pool).clone()); service .create_account(account) + .await .map_err(|e| format!("Failed to add new account: {}", e)) } diff --git a/src-tauri/src/commands/market_data.rs b/src-tauri/src/commands/market_data.rs index b5bed0d..ab859cb 100644 --- a/src-tauri/src/commands/market_data.rs +++ b/src-tauri/src/commands/market_data.rs @@ -11,8 +11,7 @@ pub async fn search_symbol( state: State<'_, AppState>, ) -> Result, String> { println!("Searching for ticker symbol: {}", query); - let service = MarketDataService::new((*state.pool).clone()); - + let service = MarketDataService::new((*state.pool).clone()).await; service .search_symbol(&query) .await @@ -20,14 +19,20 @@ pub async fn search_symbol( } #[tauri::command] -pub fn get_asset_data(asset_id: String, state: State) -> Result { - let service = AssetService::new((*state.pool).clone()); +pub async fn get_asset_data( + asset_id: String, + state: State<'_, AppState>, +) -> Result { + let service = AssetService::new((*state.pool).clone()).await; service.get_asset_data(&asset_id).map_err(|e| e.to_string()) } #[tauri::command] pub async fn synch_quotes(state: State<'_, AppState>) -> Result<(), String> { println!("Synching quotes history"); - let service = MarketDataService::new((*state.pool).clone()); - service.initialize_and_sync_quotes().await + let service = MarketDataService::new((*state.pool).clone()).await; + service + .initialize_and_sync_quotes() + .await + .map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/portfolio.rs b/src-tauri/src/commands/portfolio.rs index b284386..15c09ab 100644 --- a/src-tauri/src/commands/portfolio.rs +++ b/src-tauri/src/commands/portfolio.rs @@ -13,6 +13,7 @@ pub async fn calculate_historical_data( println!("Calculate portfolio historical..."); let service = PortfolioService::new((*state.pool).clone()) + .await .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; service @@ -25,6 +26,7 @@ pub async fn compute_holdings(state: State<'_, AppState>) -> Result println!("Compute holdings..."); let service = PortfolioService::new((*state.pool).clone()) + .await .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; service @@ -32,17 +34,6 @@ pub async fn compute_holdings(state: State<'_, AppState>) -> Result .map_err(|e| format!("Failed to fetch activities: {}", e)) } -#[tauri::command] -pub async fn get_income_summary(state: State<'_, AppState>) -> Result { - println!("Fetching income summary..."); - let service = PortfolioService::new((*state.pool).clone()) - .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; - - service - .get_income_summary() - .map_err(|e| format!("Failed to fetch income summary: {}", e)) -} - #[tauri::command] pub async fn get_account_history( state: State<'_, AppState>, @@ -51,6 +42,7 @@ pub async fn get_account_history( println!("Fetching account history for account ID: {}", account_id); let service = PortfolioService::new((*state.pool).clone()) + .await .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; service @@ -65,6 +57,7 @@ pub async fn get_accounts_summary( println!("Fetching active accounts performance..."); let service = PortfolioService::new((*state.pool).clone()) + .await .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; service @@ -79,6 +72,7 @@ pub async fn recalculate_portfolio( println!("Recalculating portfolio..."); let service = PortfolioService::new((*state.pool).clone()) + .await .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; service @@ -86,3 +80,15 @@ pub async fn recalculate_portfolio( .await .map_err(|e| format!("Failed to recalculate portfolio: {}", e)) } + +#[tauri::command] +pub async fn get_income_summary(state: State<'_, AppState>) -> Result { + println!("Fetching income summary..."); + let service = PortfolioService::new((*state.pool).clone()) + .await + .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; + + service + .get_income_summary() + .map_err(|e| format!("Failed to fetch income summary: {}", e)) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d5ed3d1..5048d38 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -137,6 +137,7 @@ fn handle_menu_event(event: tauri::WindowMenuEvent) { fn spawn_quote_sync(app_handle: tauri::AppHandle, pool: Arc) { spawn(async move { let portfolio_service = portfolio::PortfolioService::new((*pool).clone()) + .await .expect("Failed to create PortfolioService"); app_handle diff --git a/src/App.tsx b/src/App.tsx index e50a9ca..2ed66ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ function App() { queries: { refetchOnWindowFocus: false, staleTime: 5 * 60 * 1000, + retry: 2, }, }, }), diff --git a/src/components/empty-placeholder.tsx b/src/components/empty-placeholder.tsx index 45da9fc..aefc958 100644 --- a/src/components/empty-placeholder.tsx +++ b/src/components/empty-placeholder.tsx @@ -57,7 +57,7 @@ EmptyPlaceholder.Description = function EmptyPlaceholderDescription({ ...props }: EmptyPlacholderDescriptionProps) { return ( -

From 75f3d3e370315f408c2e8e20c969e66cb8562aa3 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Fri, 20 Sep 2024 21:34:05 -0400 Subject: [PATCH 34/45] minor ui change --- src/pages/account/account-page.tsx | 11 ++++++++++- src/pages/holdings/components/income-dashboard.tsx | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/pages/account/account-page.tsx b/src/pages/account/account-page.tsx index 9246e23..a773599 100644 --- a/src/pages/account/account-page.tsx +++ b/src/pages/account/account-page.tsx @@ -80,7 +80,16 @@ const AccountPage = () => {
{isLoadingAccountHistory ? ( - +
+ + + + + + + + +
) : ( )} diff --git a/src/pages/holdings/components/income-dashboard.tsx b/src/pages/holdings/components/income-dashboard.tsx index 0d7249a..776372b 100644 --- a/src/pages/holdings/components/income-dashboard.tsx +++ b/src/pages/holdings/components/income-dashboard.tsx @@ -198,7 +198,7 @@ export function IncomeDashboard() { {topDividendStocks.map(([symbol, income], index) => (
{symbol}
-
+
{formatAmount(income, incomeSummary.currency)}
From 16009421be3d30eefabe3d413733624085767dc5 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Fri, 20 Sep 2024 22:10:32 -0400 Subject: [PATCH 35/45] small enhancements --- src/adapters/tauri.ts | 10 +++------ .../activity/components/activity-form.tsx | 22 ++++++++++++++----- .../activity/hooks/useActivityMutations.ts | 3 ++- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/adapters/tauri.ts b/src/adapters/tauri.ts index c47bd39..39798a1 100644 --- a/src/adapters/tauri.ts +++ b/src/adapters/tauri.ts @@ -1,46 +1,42 @@ +import { invoke } from '@tauri-apps/api'; +import { open } from '@tauri-apps/api/dialog'; +import { listen } from '@tauri-apps/api/event'; import type { EventCallback, UnlistenFn } from '@tauri-apps/api/event'; export type { EventCallback, UnlistenFn }; export const invokeTauri = async (command: string, payload?: Record) => { - const invoke = await import('@tauri-apps/api').then((mod) => mod.invoke); return await invoke(command, payload); }; export const openCsvFileDialogTauri = async (): Promise => { - const open = await import('@tauri-apps/api/dialog').then((mod) => mod.open); return open({ filters: [{ name: 'CSV', extensions: ['csv'] }] }); }; export const listenFileDropHoverTauri = async ( handler: EventCallback, ): Promise => { - const { listen } = await import('@tauri-apps/api/event'); return listen('tauri://file-drop-hover', handler); }; export const listenFileDropTauri = async (handler: EventCallback): Promise => { - const { listen } = await import('@tauri-apps/api/event'); return listen('tauri://file-drop', handler); }; export const listenFileDropCancelledTauri = async ( handler: EventCallback, ): Promise => { - const { listen } = await import('@tauri-apps/api/event'); return listen('tauri://file-drop-cancelled', handler); }; export const listenQuotesSyncStartTauri = async ( handler: EventCallback, ): Promise => { - const { listen } = await import('@tauri-apps/api/event'); return listen('PORTFOLIO_UPDATE_START', handler); }; export const listenQuotesSyncCompleteTauri = async ( handler: EventCallback, ): Promise => { - const { listen } = await import('@tauri-apps/api/event'); return listen('PORTFOLIO_UPDATE_COMPLETE', handler); }; diff --git a/src/pages/activity/components/activity-form.tsx b/src/pages/activity/components/activity-form.tsx index 22a2782..2ed45f5 100644 --- a/src/pages/activity/components/activity-form.tsx +++ b/src/pages/activity/components/activity-form.tsx @@ -67,7 +67,7 @@ interface ActivityFormProps { } export function ActivityForm({ accounts, defaultValues, onSuccess = () => {} }: ActivityFormProps) { - const { submitActivity, addActivityMutation } = useActivityMutations(); + const { addActivityMutation, updateActivityMutation } = useActivityMutations(onSuccess); const form = useForm({ resolver: zodResolver(newActivitySchema), @@ -75,10 +75,14 @@ export function ActivityForm({ accounts, defaultValues, onSuccess = () => {} }: }); async function onSubmit(data: ActivityFormValues) { - await submitActivity(data); - onSuccess(); + const { id, ...rest } = data; + if (id) { + return await updateActivityMutation.mutateAsync({ id, ...rest }); + } + return await addActivityMutation.mutateAsync(rest); } + const isLoading = addActivityMutation.isPending || updateActivityMutation.isPending; const watchedType = form.watch('activityType'); const currentAccountCurrency = accounts.find((account) => account.value === form.watch('accountId'))?.currency || 'USD'; @@ -213,10 +217,16 @@ export function ActivityForm({ accounts, defaultValues, onSuccess = () => {} }:
- + - + + + ) : ( + + )} +
+ )} +
+ ); +} diff --git a/src/pages/settings/currencies/useExchangeRate.ts b/src/pages/settings/currencies/useExchangeRate.ts index 9590826..548eb30 100644 --- a/src/pages/settings/currencies/useExchangeRate.ts +++ b/src/pages/settings/currencies/useExchangeRate.ts @@ -1,7 +1,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from '@/components/ui/use-toast'; import { ExchangeRate } from '@/lib/types'; -import { getExchangeRates, updateExchangeRate } from '@/commands/exchange-rates'; +import { + getExchangeRateSymbols, + updateExchangeRate as updateExchangeRateApi, +} from '@/commands/exchange-rates'; import { QueryKeys } from '@/lib/query-keys'; import { useCalculateHistoryMutation } from '@/hooks/useCalculateHistory'; @@ -11,34 +14,48 @@ export function useExchangeRates() { successTitle: 'Exchange rate updated and calculation triggered successfully.', }); - const { data: exchangeRates, isLoading } = useQuery({ - queryKey: [QueryKeys.EXCHANGE_RATES], - queryFn: getExchangeRates, + const { data: exchangeRateSymbols, isLoading: isLoadingSymbols } = useQuery< + ExchangeRate[], + Error + >({ + queryKey: [QueryKeys.EXCHANGE_RATE_SYMBOLS], + queryFn: getExchangeRateSymbols, }); const updateExchangeRateMutation = useMutation({ - mutationFn: updateExchangeRate, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QueryKeys.EXCHANGE_RATES] }); - toast({ title: 'Exchange rate updated successfully', variant: 'success' }); + mutationFn: updateExchangeRateApi, + onSuccess: (updatedRate) => { + queryClient.invalidateQueries({ queryKey: [QueryKeys.EXCHANGE_RATE_SYMBOLS] }); + queryClient.invalidateQueries({ + queryKey: [QueryKeys.QUOTE, `${updatedRate.fromCurrency}${updatedRate.toCurrency}=X`], + }); + toast({ + title: 'Exchange rate updated successfully', + description: `${updatedRate.fromCurrency}/${updatedRate.toCurrency} rate updated to ${updatedRate.rate}`, + variant: 'success', + }); calculateHistoryMutation.mutate({ accountIds: undefined, forceFullCalculation: true, }); }, - onError: () => { + onError: (error) => { toast({ title: 'Uh oh! Something went wrong.', - description: 'There was a problem updating the exchange rate.', + description: `There was a problem updating the exchange rate: ${error.message}`, variant: 'destructive', }); }, }); + const updateExchangeRate = (rate: ExchangeRate) => { + updateExchangeRateMutation.mutate(rate); + }; + return { - exchangeRates, - isLoading, - updateExchangeRate: updateExchangeRateMutation.mutate, + exchangeRateSymbols, + isLoadingSymbols, + updateExchangeRate, }; } From 9f7d6d103e42ff68c56ce657da67b5f9c98c66ee Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 22 Sep 2024 09:35:31 -0400 Subject: [PATCH 39/45] WIP edit exchange rate --- src-core/src/settings/settings_service.rs | 18 +++++++++ src-tauri/src/commands/settings.rs | 36 +++++++++++------- src-tauri/src/main.rs | 6 +-- src/commands/exchange-rates.ts | 37 +++---------------- .../currencies/exchange-rates-page.tsx | 6 +-- src/pages/settings/currencies/rate-cell.tsx | 20 +--------- .../settings/currencies/useExchangeRate.ts | 20 ++++------ 7 files changed, 60 insertions(+), 83 deletions(-) diff --git a/src-core/src/settings/settings_service.rs b/src-core/src/settings/settings_service.rs index 55b3f1b..90a0534 100644 --- a/src-core/src/settings/settings_service.rs +++ b/src-core/src/settings/settings_service.rs @@ -114,4 +114,22 @@ impl SettingsService { .first(conn) .optional() } + + pub fn get_exchange_rates( + &self, + conn: &mut SqliteConnection, + ) -> Result, diesel::result::Error> { + // Get exchange rate symbols + let mut exchange_rates = self.get_exchange_rate_symbols(conn)?; + + // For each exchange rate, get the latest quote + for rate in &mut exchange_rates { + let fx_symbol = format!("{}{}=X", rate.from_currency, rate.to_currency); + if let Some(quote) = self.get_latest_quote(conn, &fx_symbol)? { + rate.rate = quote.close; + } + } + + Ok(exchange_rates) + } } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index f338c39..06a247d 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -64,22 +64,32 @@ pub fn update_exchange_rate( .map_err(|e| format!("Failed to update exchange rate: {}", e)) } -#[tauri::command] -pub fn get_exchange_rate_symbols(state: State) -> Result, String> { - println!("Fetching exchange rate symbols..."); - let mut conn = get_connection(&state)?; - let service = settings_service::SettingsService::new(); - service - .get_exchange_rate_symbols(&mut conn) - .map_err(|e| format!("Failed to load exchange rate symbols: {}", e)) -} +// #[tauri::command] +// pub fn get_exchange_rate_symbols(state: State) -> Result, String> { +// println!("Fetching exchange rate symbols..."); +// let mut conn = get_connection(&state)?; +// let service = settings_service::SettingsService::new(); +// service +// .get_exchange_rate_symbols(&mut conn) +// .map_err(|e| format!("Failed to load exchange rate symbols: {}", e)) +// } + +// #[tauri::command] +// pub fn get_latest_quote(state: State, symbol: String) -> Result, String> { +// println!("Fetching latest quote for symbol: {}", symbol); +// let mut conn = get_connection(&state)?; +// let service = settings_service::SettingsService::new(); +// service +// .get_latest_quote(&mut conn, &symbol) +// .map_err(|e| format!("Failed to load latest quote: {}", e)) +// } #[tauri::command] -pub fn get_latest_quote(state: State, symbol: String) -> Result, String> { - println!("Fetching latest quote for symbol: {}", symbol); +pub fn get_exchange_rates(state: State) -> Result, String> { + println!("Fetching exchange rates..."); let mut conn = get_connection(&state)?; let service = settings_service::SettingsService::new(); service - .get_latest_quote(&mut conn, &symbol) - .map_err(|e| format!("Failed to load latest quote: {}", e)) + .get_exchange_rates(&mut conn) + .map_err(|e| format!("Failed to load exchange rates: {}", e)) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7041f49..a9cbe1b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -19,8 +19,7 @@ use commands::portfolio::{ get_income_summary, recalculate_portfolio, }; use commands::settings::{ - get_exchange_rate_symbols, get_latest_quote, get_settings, update_currency, - update_exchange_rate, update_settings, + get_exchange_rates, get_settings, update_currency, update_exchange_rate, update_settings, }; use wealthfolio_core::db; @@ -100,8 +99,7 @@ fn main() { get_settings, update_settings, update_currency, - get_latest_quote, - get_exchange_rate_symbols, + get_exchange_rates, update_exchange_rate, create_goal, update_goal, diff --git a/src/commands/exchange-rates.ts b/src/commands/exchange-rates.ts index 1f54021..33890e1 100644 --- a/src/commands/exchange-rates.ts +++ b/src/commands/exchange-rates.ts @@ -1,30 +1,16 @@ -import type { ExchangeRate, Quote } from '@/lib/types'; +import type { ExchangeRate } from '@/lib/types'; import { getRunEnv, RUN_ENV, invokeTauri } from '@/adapters'; -export const getExchangeRateSymbols = async (): Promise => { +export const getExchangeRates = async (): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: - return invokeTauri('get_exchange_rate_symbols'); + return invokeTauri('get_exchange_rates'); default: throw new Error('Unsupported environment'); } } catch (error) { - console.error('Error fetching exchange rate symbols:', error); - return []; - } -}; - -export const getLatestQuotes = async (symbols: string[]): Promise => { - try { - switch (getRunEnv()) { - case RUN_ENV.DESKTOP: - return invokeTauri('get_latest_quotes', { symbols }); - default: - throw new Error('Unsupported environment'); - } - } catch (error) { - console.error('Error fetching latest quotes:', error); + console.error('Error fetching exchange rates:', error); return []; } }; @@ -33,7 +19,7 @@ export const updateExchangeRate = async (updatedRate: ExchangeRate): Promise => { - try { - switch (getRunEnv()) { - case RUN_ENV.DESKTOP: - return invokeTauri('get_latest_quote', { symbol }); - default: - throw new Error('Unsupported environment'); - } - } catch (error) { - console.error('Error fetching latest quote:', error); - return null; - } -}; diff --git a/src/pages/settings/currencies/exchange-rates-page.tsx b/src/pages/settings/currencies/exchange-rates-page.tsx index 46f0e52..af820b9 100644 --- a/src/pages/settings/currencies/exchange-rates-page.tsx +++ b/src/pages/settings/currencies/exchange-rates-page.tsx @@ -8,7 +8,7 @@ import { Separator } from '@/components/ui/separator'; import { SettingsHeader } from '../header'; export default function ExchangeRatesPage() { - const { exchangeRateSymbols, isLoadingSymbols, updateExchangeRate } = useExchangeRates(); + const { exchangeRates, isLoadingRates, updateExchangeRate } = useExchangeRates(); const columns: ColumnDef[] = [ { @@ -38,14 +38,14 @@ export default function ExchangeRatesPage() { text="Manage and view exchange rates for different currencies." /> - {isLoadingSymbols ? ( + {isLoadingRates ? (
{[...Array(5)].map((_, index) => ( ))}
) : ( - + )}
); diff --git a/src/pages/settings/currencies/rate-cell.tsx b/src/pages/settings/currencies/rate-cell.tsx index f7770c7..4a61e4e 100644 --- a/src/pages/settings/currencies/rate-cell.tsx +++ b/src/pages/settings/currencies/rate-cell.tsx @@ -1,8 +1,5 @@ import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { ExchangeRate, Quote } from '@/lib/types'; -import { getLatestQuote } from '@/commands/exchange-rates'; -import { QueryKeys } from '@/lib/query-keys'; +import { ExchangeRate } from '@/lib/types'; import { Icons } from '@/components/icons'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; @@ -18,13 +15,6 @@ export function RateCell({ rate, onUpdate }: RateCellProps) { const [editedRate, setEditedRate] = useState(rate.rate.toString()); const isManual = rate.source === 'MANUAL'; - const { data: quote, isLoading } = useQuery({ - queryKey: [QueryKeys.QUOTE, `${rate.fromCurrency}${rate.toCurrency}=X`], - queryFn: () => getLatestQuote(`${rate.fromCurrency}${rate.toCurrency}=X`), - }); - - console.log('++++quote', quote); - const handleEdit = () => { if (!isManual) { toast({ @@ -58,12 +48,6 @@ export function RateCell({ rate, onUpdate }: RateCellProps) { setIsEditing(false); }; - if (isLoading) { - return ; - } - - const displayRate = quote ? Math.max(quote.open, quote.close).toFixed(4) : '-'; - return (
@@ -74,7 +58,7 @@ export function RateCell({ rate, onUpdate }: RateCellProps) { className="w-full" /> ) : ( - {displayRate} + {rate.rate ? rate.rate.toFixed(4) : '-'} )}
{isManual && ( diff --git a/src/pages/settings/currencies/useExchangeRate.ts b/src/pages/settings/currencies/useExchangeRate.ts index 548eb30..29b2489 100644 --- a/src/pages/settings/currencies/useExchangeRate.ts +++ b/src/pages/settings/currencies/useExchangeRate.ts @@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from '@/components/ui/use-toast'; import { ExchangeRate } from '@/lib/types'; import { - getExchangeRateSymbols, + getExchangeRates, updateExchangeRate as updateExchangeRateApi, } from '@/commands/exchange-rates'; import { QueryKeys } from '@/lib/query-keys'; @@ -14,21 +14,15 @@ export function useExchangeRates() { successTitle: 'Exchange rate updated and calculation triggered successfully.', }); - const { data: exchangeRateSymbols, isLoading: isLoadingSymbols } = useQuery< - ExchangeRate[], - Error - >({ - queryKey: [QueryKeys.EXCHANGE_RATE_SYMBOLS], - queryFn: getExchangeRateSymbols, + const { data: exchangeRates, isLoading: isLoadingRates } = useQuery({ + queryKey: [QueryKeys.EXCHANGE_RATES], + queryFn: getExchangeRates, }); const updateExchangeRateMutation = useMutation({ mutationFn: updateExchangeRateApi, onSuccess: (updatedRate) => { - queryClient.invalidateQueries({ queryKey: [QueryKeys.EXCHANGE_RATE_SYMBOLS] }); - queryClient.invalidateQueries({ - queryKey: [QueryKeys.QUOTE, `${updatedRate.fromCurrency}${updatedRate.toCurrency}=X`], - }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.EXCHANGE_RATES] }); toast({ title: 'Exchange rate updated successfully', description: `${updatedRate.fromCurrency}/${updatedRate.toCurrency} rate updated to ${updatedRate.rate}`, @@ -54,8 +48,8 @@ export function useExchangeRates() { }; return { - exchangeRateSymbols, - isLoadingSymbols, + exchangeRates, + isLoadingRates, updateExchangeRate, }; } From cf268d160b740c2b07b1132e6d1b4e36beecf3e4 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 22 Sep 2024 09:38:22 -0400 Subject: [PATCH 40/45] WIP exchange rate edit --- src/lib/types.ts | 2 ++ .../currencies/exchange-rates-page.tsx | 19 ++++++++++++++++++- .../settings/currencies/useExchangeRate.ts | 15 ++++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/lib/types.ts b/src/lib/types.ts index 4492753..a5bb606 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -308,6 +308,8 @@ export interface ExchangeRate { id: string; fromCurrency: string; toCurrency: string; + fromCurrencyName?: string; + toCurrencyName?: string; rate: number; source: string; isLoading?: boolean; diff --git a/src/pages/settings/currencies/exchange-rates-page.tsx b/src/pages/settings/currencies/exchange-rates-page.tsx index af820b9..e9b9c8f 100644 --- a/src/pages/settings/currencies/exchange-rates-page.tsx +++ b/src/pages/settings/currencies/exchange-rates-page.tsx @@ -7,17 +7,34 @@ import { RateCell } from './rate-cell'; import { Separator } from '@/components/ui/separator'; import { SettingsHeader } from '../header'; +type ExtendedExchangeRate = ExchangeRate & { + fromCurrencyName: string; + toCurrencyName: string; +}; + export default function ExchangeRatesPage() { const { exchangeRates, isLoadingRates, updateExchangeRate } = useExchangeRates(); - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ { accessorKey: 'fromCurrency', header: 'From', + cell: ({ row }) => ( +
+
{row.original.fromCurrency}
+
{row.original.fromCurrencyName}
+
+ ), }, { accessorKey: 'toCurrency', header: 'To', + cell: ({ row }) => ( +
+
{row.original.toCurrency}
+
{row.original.toCurrencyName}
+
+ ), }, { accessorKey: 'source', diff --git a/src/pages/settings/currencies/useExchangeRate.ts b/src/pages/settings/currencies/useExchangeRate.ts index 29b2489..c27558f 100644 --- a/src/pages/settings/currencies/useExchangeRate.ts +++ b/src/pages/settings/currencies/useExchangeRate.ts @@ -7,6 +7,7 @@ import { } from '@/commands/exchange-rates'; import { QueryKeys } from '@/lib/query-keys'; import { useCalculateHistoryMutation } from '@/hooks/useCalculateHistory'; +import { worldCurrencies } from '@/lib/currencies'; export function useExchangeRates() { const queryClient = useQueryClient(); @@ -14,9 +15,21 @@ export function useExchangeRates() { successTitle: 'Exchange rate updated and calculation triggered successfully.', }); + const getCurrencyName = (code: string) => { + const currency = worldCurrencies.find((c) => c.value === code); + return currency ? currency.label.split(' (')[0] : code; + }; + const { data: exchangeRates, isLoading: isLoadingRates } = useQuery({ queryKey: [QueryKeys.EXCHANGE_RATES], - queryFn: getExchangeRates, + queryFn: async () => { + const rates = await getExchangeRates(); + return rates.map((rate) => ({ + ...rate, + fromCurrencyName: getCurrencyName(rate.fromCurrency), + toCurrencyName: getCurrencyName(rate.toCurrency), + })); + }, }); const updateExchangeRateMutation = useMutation({ From 63e0f3e491072c88b1b5d84272154707ffbe07a7 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 22 Sep 2024 10:16:30 -0400 Subject: [PATCH 41/45] WIP exchange rate --- src-core/src/fx/fx_repository.rs | 100 +++++++----------- src-core/src/fx/fx_service.rs | 39 +++++++ src-core/src/fx/mod.rs | 1 + src-core/src/settings/settings_service.rs | 86 ++------------- src-tauri/src/commands/settings.rs | 37 ++----- src-tauri/src/main.rs | 1 + .../currencies/exchange-rates-page.tsx | 7 +- .../settings/currencies/useExchangeRate.ts | 16 ++- 8 files changed, 110 insertions(+), 177 deletions(-) diff --git a/src-core/src/fx/fx_repository.rs b/src-core/src/fx/fx_repository.rs index 4fbc51e..88e2b59 100644 --- a/src-core/src/fx/fx_repository.rs +++ b/src-core/src/fx/fx_repository.rs @@ -1,75 +1,53 @@ -use crate::models::{ExchangeRate, NewExchangeRate}; -use crate::schema::exchange_rates; -use chrono::Utc; +use crate::models::{Asset, ExchangeRate}; +use crate::schema::assets; + use diesel::prelude::*; use diesel::sqlite::SqliteConnection; -use uuid::Uuid; pub struct FxRepository; impl FxRepository { - pub fn create( - conn: &mut SqliteConnection, - new_rate: NewExchangeRate, - ) -> QueryResult { - let id = new_rate.id.unwrap_or_else(|| Uuid::new_v4().to_string()); - let now = Utc::now().naive_utc(); - - let rate = ExchangeRate { - id, - from_currency: new_rate.from_currency, - to_currency: new_rate.to_currency, - rate: new_rate.rate, - source: new_rate.source, - created_at: now, - updated_at: now, - }; - - diesel::insert_into(exchange_rates::table) - .values(&rate) - .execute(conn)?; - - Ok(rate) - } - - pub fn read(conn: &mut SqliteConnection, id: &str) -> QueryResult { - exchange_rates::table.find(id).first(conn) + pub fn get_exchange_rates(conn: &mut SqliteConnection) -> QueryResult> { + use crate::schema::assets::dsl as assets_dsl; + + let asset_rates: Vec = assets_dsl::assets + .filter(assets_dsl::asset_type.eq("Currency")) + .load::(conn)?; + + Ok(asset_rates + .into_iter() + .map(|asset| { + let symbol_parts: Vec<&str> = asset.symbol.split('=').collect(); + ExchangeRate { + id: asset.id, + from_currency: symbol_parts[0][..3].to_string(), + to_currency: symbol_parts[0][3..].to_string(), + rate: 0.0, + source: asset.data_source, + } + }) + .collect()) } - pub fn read_by_currencies( + pub fn update_exchange_rate( conn: &mut SqliteConnection, - from: &str, - to: &str, + rate: &ExchangeRate, ) -> QueryResult { - exchange_rates::table - .filter(exchange_rates::from_currency.eq(from)) - .filter(exchange_rates::to_currency.eq(to)) - .first(conn) - } - - pub fn update( - conn: &mut SqliteConnection, - id: &str, - updated_rate: NewExchangeRate, - ) -> QueryResult { - let now = Utc::now().naive_utc(); - - diesel::update(exchange_rates::table.find(id)) - .set(( - exchange_rates::from_currency.eq(updated_rate.from_currency), - exchange_rates::to_currency.eq(updated_rate.to_currency), - exchange_rates::rate.eq(updated_rate.rate), - exchange_rates::source.eq(updated_rate.source), - exchange_rates::updated_at.eq(now), - )) - .get_result(conn) - } + let asset = Asset { + id: rate.id.clone(), + symbol: format!("{}{}=X", rate.from_currency, rate.to_currency), + name: Some(rate.rate.to_string()), + asset_type: Some("Currency".to_string()), + data_source: rate.source.clone(), + currency: rate.to_currency.clone(), + updated_at: chrono::Utc::now().naive_utc(), + ..Default::default() + }; - pub fn delete(conn: &mut SqliteConnection, id: &str) -> QueryResult { - diesel::delete(exchange_rates::table.find(id)).execute(conn) - } + diesel::update(assets::table.find(&asset.id)) + .set(&asset) + .execute(conn)?; - pub fn list_all(conn: &mut SqliteConnection) -> QueryResult> { - exchange_rates::table.load::(conn) + Ok(rate.clone()) } } diff --git a/src-core/src/fx/fx_service.rs b/src-core/src/fx/fx_service.rs index aff5de4..aa1dfd7 100644 --- a/src-core/src/fx/fx_service.rs +++ b/src-core/src/fx/fx_service.rs @@ -1,3 +1,5 @@ +use crate::fx::fx_repository::FxRepository; +use crate::models::{ExchangeRate, Quote}; use chrono::NaiveDateTime; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; @@ -69,6 +71,43 @@ impl CurrencyExchangeService { Ok(rate) } + pub fn get_exchange_rates(&self) -> Result, Box> { + let mut conn = self.pool.get()?; + let mut exchange_rates = FxRepository::get_exchange_rates(&mut conn)?; + + for rate in &mut exchange_rates { + let fx_symbol = format!("{}{}=X", rate.from_currency, rate.to_currency); + if let Some(quote) = self.get_latest_quote(&fx_symbol)? { + rate.rate = quote.close; + rate.source = quote.data_source; + } + } + + Ok(exchange_rates) + } + + pub fn update_exchange_rate( + &self, + rate: &ExchangeRate, + ) -> Result> { + let mut conn = self.pool.get()?; + Ok(FxRepository::update_exchange_rate(&mut conn, rate)?) + } + + fn get_latest_quote( + &self, + fx_symbol: &str, + ) -> Result, Box> { + use crate::schema::quotes::dsl::*; + let mut conn = self.pool.get()?; + + Ok(quotes + .filter(symbol.eq(fx_symbol)) + .order(date.desc()) + .first(&mut conn) + .optional()?) + } + fn get_latest_rate_from_db( &self, conn: &mut SqliteConnection, diff --git a/src-core/src/fx/mod.rs b/src-core/src/fx/mod.rs index c036ee6..f0da596 100644 --- a/src-core/src/fx/mod.rs +++ b/src-core/src/fx/mod.rs @@ -1 +1,2 @@ +pub mod fx_repository; pub mod fx_service; diff --git a/src-core/src/settings/settings_service.rs b/src-core/src/settings/settings_service.rs index 90a0534..6b34ba6 100644 --- a/src-core/src/settings/settings_service.rs +++ b/src-core/src/settings/settings_service.rs @@ -1,7 +1,6 @@ // settings_service.rs -use crate::models::{Asset, ExchangeRate, NewSettings, Quote, Settings}; -use crate::schema::assets::dsl::*; +use crate::models::{NewSettings, Settings}; use crate::schema::settings::dsl::*; use diesel::prelude::*; use diesel::sqlite::SqliteConnection; @@ -54,82 +53,9 @@ impl SettingsService { Ok(()) } - pub fn update_exchange_rate( - &self, - conn: &mut SqliteConnection, - rate: &ExchangeRate, - ) -> Result { - let asset = Asset { - id: rate.id.clone(), - symbol: format!("{}{}=X", rate.from_currency, rate.to_currency), - name: Some(rate.rate.to_string()), - asset_type: Some("Currency".to_string()), - data_source: rate.source.clone(), - currency: rate.to_currency.clone(), - updated_at: chrono::Utc::now().naive_utc(), - ..Default::default() - }; - - diesel::update(assets.find(&asset.id)) - .set(&asset) - .execute(conn)?; - - Ok(rate.clone()) - } - - pub fn get_exchange_rate_symbols( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { - use crate::schema::assets::dsl as assets_dsl; - - let asset_rates: Vec = assets_dsl::assets - .filter(assets_dsl::asset_type.eq("Currency")) - .load::(conn)?; - - Ok(asset_rates - .into_iter() - .map(|asset| { - let symbol_parts: Vec<&str> = asset.symbol.split('=').collect(); - ExchangeRate { - id: asset.id, - from_currency: symbol_parts[0][..3].to_string(), - to_currency: symbol_parts[0][3..].to_string(), - rate: 0.0, - source: asset.data_source, - } - }) - .collect()) - } - - pub fn get_latest_quote( - &self, - conn: &mut SqliteConnection, - fx_symbol: &str, - ) -> Result, diesel::result::Error> { - use crate::schema::quotes::dsl::*; - quotes - .filter(symbol.eq(fx_symbol)) - .order(date.desc()) - .first(conn) - .optional() - } - - pub fn get_exchange_rates( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { - // Get exchange rate symbols - let mut exchange_rates = self.get_exchange_rate_symbols(conn)?; - - // For each exchange rate, get the latest quote - for rate in &mut exchange_rates { - let fx_symbol = format!("{}{}=X", rate.from_currency, rate.to_currency); - if let Some(quote) = self.get_latest_quote(conn, &fx_symbol)? { - rate.rate = quote.close; - } - } - - Ok(exchange_rates) - } + // Remove the following methods: + // - update_exchange_rate + // - get_exchange_rate_symbols + // - get_latest_quote + // - get_exchange_rates } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 06a247d..6e82061 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1,4 +1,5 @@ -use crate::models::{ExchangeRate, NewSettings, Quote, Settings}; +use crate::fx::fx_service::CurrencyExchangeService; +use crate::models::{ExchangeRate, NewSettings, Settings}; use crate::settings::settings_service; use crate::AppState; use diesel::r2d2::ConnectionManager; @@ -57,39 +58,17 @@ pub fn update_exchange_rate( state: State, ) -> Result { println!("Updating exchange rate..."); - let mut conn = get_connection(&state)?; - let service = settings_service::SettingsService::new(); - service - .update_exchange_rate(&mut conn, &rate) + let fx_service = CurrencyExchangeService::new((*state.pool).clone()); + fx_service + .update_exchange_rate(&rate) .map_err(|e| format!("Failed to update exchange rate: {}", e)) } -// #[tauri::command] -// pub fn get_exchange_rate_symbols(state: State) -> Result, String> { -// println!("Fetching exchange rate symbols..."); -// let mut conn = get_connection(&state)?; -// let service = settings_service::SettingsService::new(); -// service -// .get_exchange_rate_symbols(&mut conn) -// .map_err(|e| format!("Failed to load exchange rate symbols: {}", e)) -// } - -// #[tauri::command] -// pub fn get_latest_quote(state: State, symbol: String) -> Result, String> { -// println!("Fetching latest quote for symbol: {}", symbol); -// let mut conn = get_connection(&state)?; -// let service = settings_service::SettingsService::new(); -// service -// .get_latest_quote(&mut conn, &symbol) -// .map_err(|e| format!("Failed to load latest quote: {}", e)) -// } - #[tauri::command] pub fn get_exchange_rates(state: State) -> Result, String> { println!("Fetching exchange rates..."); - let mut conn = get_connection(&state)?; - let service = settings_service::SettingsService::new(); - service - .get_exchange_rates(&mut conn) + let fx_service = CurrencyExchangeService::new((*state.pool).clone()); + fx_service + .get_exchange_rates() .map_err(|e| format!("Failed to load exchange rates: {}", e)) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a9cbe1b..4369073 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -31,6 +31,7 @@ use wealthfolio_core::asset; use wealthfolio_core::goal; use wealthfolio_core::market_data; +use wealthfolio_core::fx; use wealthfolio_core::portfolio; use wealthfolio_core::settings; diff --git a/src/pages/settings/currencies/exchange-rates-page.tsx b/src/pages/settings/currencies/exchange-rates-page.tsx index e9b9c8f..c9ccde5 100644 --- a/src/pages/settings/currencies/exchange-rates-page.tsx +++ b/src/pages/settings/currencies/exchange-rates-page.tsx @@ -7,15 +7,10 @@ import { RateCell } from './rate-cell'; import { Separator } from '@/components/ui/separator'; import { SettingsHeader } from '../header'; -type ExtendedExchangeRate = ExchangeRate & { - fromCurrencyName: string; - toCurrencyName: string; -}; - export default function ExchangeRatesPage() { const { exchangeRates, isLoadingRates, updateExchangeRate } = useExchangeRates(); - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ { accessorKey: 'fromCurrency', header: 'From', diff --git a/src/pages/settings/currencies/useExchangeRate.ts b/src/pages/settings/currencies/useExchangeRate.ts index c27558f..5007f3f 100644 --- a/src/pages/settings/currencies/useExchangeRate.ts +++ b/src/pages/settings/currencies/useExchangeRate.ts @@ -24,11 +24,25 @@ export function useExchangeRates() { queryKey: [QueryKeys.EXCHANGE_RATES], queryFn: async () => { const rates = await getExchangeRates(); - return rates.map((rate) => ({ + const processedRates = rates.map((rate) => ({ ...rate, fromCurrencyName: getCurrencyName(rate.fromCurrency), toCurrencyName: getCurrencyName(rate.toCurrency), })); + + // For manual rates, keep only from->to and filter out the reverse + return processedRates.filter((rate) => { + if (rate.source === 'MANUAL') { + const reverseManualRate = processedRates.find( + (r) => + r.fromCurrency === rate.toCurrency && + r.toCurrency === rate.fromCurrency && + r.source === 'MANUAL', + ); + return !reverseManualRate || rate.fromCurrency < rate.toCurrency; + } + return true; // Keep all non-manual rates + }); }, }); From 27363e5e8bbe0dffb9067953241c7c3eeccec02c Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 22 Sep 2024 17:03:14 -0400 Subject: [PATCH 42/45] settings and base currency refactoring --- .../down.sql | 3 + .../up.sql | 14 +- .../2024-09-22-023605_settings_to_kv/down.sql | 17 ++ .../2024-09-22-023605_settings_to_kv/up.sql | 16 ++ src-core/src/account/account_service.rs | 42 ++-- src-core/src/activity/activity_service.rs | 10 +- src-core/src/fx/fx_repository.rs | 84 ++++---- src-core/src/fx/fx_service.rs | 188 ++++++++++-------- src-core/src/models.rs | 36 ++-- src-core/src/portfolio/holdings_service.rs | 10 +- src-core/src/portfolio/income_service.rs | 5 +- src-core/src/portfolio/portfolio_service.rs | 35 ++-- src-core/src/schema.rs | 25 ++- src-core/src/settings/mod.rs | 2 + src-core/src/settings/settings_repository.rs | 81 ++++++++ src-core/src/settings/settings_service.rs | 67 +++---- src-tauri/src/commands/account.rs | 12 +- src-tauri/src/commands/activity.rs | 19 +- src-tauri/src/commands/portfolio.rs | 36 ++-- src-tauri/src/commands/settings.rs | 41 ++-- src-tauri/src/main.rs | 27 ++- src/commands/{setting.ts => settings.ts} | 0 src/lib/types.ts | 1 - src/lib/useSettings.ts | 2 +- src/lib/useSettingsMutation.ts | 5 +- src/pages/settings/general/general-form.tsx | 1 - 26 files changed, 485 insertions(+), 294 deletions(-) create mode 100644 src-core/migrations/2024-09-22-023605_settings_to_kv/down.sql create mode 100644 src-core/migrations/2024-09-22-023605_settings_to_kv/up.sql create mode 100644 src-core/src/settings/settings_repository.rs rename src/commands/{setting.ts => settings.ts} (100%) diff --git a/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql b/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql index 943ee8a..26d8d59 100644 --- a/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql +++ b/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql @@ -1,2 +1,5 @@ DROP TABLE IF EXISTS portfolio_history; DROP INDEX IF EXISTS idx_portfolio_history_account_date; + +DROP TABLE IF EXISTS exchange_rates; +DROP INDEX IF EXISTS idx_exchange_rates_currencies; diff --git a/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql b/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql index c3ec93c..0c4d329 100644 --- a/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql +++ b/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql @@ -1,7 +1,7 @@ CREATE TABLE portfolio_history ( id TEXT NOT NULL PRIMARY KEY, account_id TEXT NOT NULL, - date TEXT NOT NULL, + date DATE NOT NULL, total_value NUMERIC NOT NULL DEFAULT 0, market_value NUMERIC NOT NULL DEFAULT 0, book_cost NUMERIC NOT NULL DEFAULT 0, @@ -30,3 +30,15 @@ UPDATE "goals" SET "is_achieved_new" = COALESCE("is_achieved", false); ALTER TABLE "goals" DROP COLUMN "is_achieved"; ALTER TABLE "goals" RENAME COLUMN "is_achieved_new" TO "is_achieved"; +CREATE TABLE exchange_rates ( + id TEXT NOT NULL PRIMARY KEY, + from_currency TEXT NOT NULL, + to_currency TEXT NOT NULL, + rate NUMERIC NOT NULL, + source TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(from_currency, to_currency) +); + +CREATE INDEX idx_exchange_rates_currencies ON exchange_rates(from_currency, to_currency); diff --git a/src-core/migrations/2024-09-22-023605_settings_to_kv/down.sql b/src-core/migrations/2024-09-22-023605_settings_to_kv/down.sql new file mode 100644 index 0000000..75419f7 --- /dev/null +++ b/src-core/migrations/2024-09-22-023605_settings_to_kv/down.sql @@ -0,0 +1,17 @@ +-- Create a temporary table with the original structure +CREATE TABLE "settings" ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + theme TEXT NOT NULL DEFAULT 'light', + font TEXT NOT NULL, + base_currency TEXT NOT NULL +); + +-- Migrate data back from app_settings to settings +INSERT INTO settings (theme, font, base_currency) +SELECT + (SELECT setting_value FROM app_settings WHERE setting_key = 'theme'), + (SELECT setting_value FROM app_settings WHERE setting_key = 'font'), + (SELECT setting_value FROM app_settings WHERE setting_key = 'base_currency'); + +-- Drop the new app_settings table +DROP TABLE "app_settings"; \ No newline at end of file diff --git a/src-core/migrations/2024-09-22-023605_settings_to_kv/up.sql b/src-core/migrations/2024-09-22-023605_settings_to_kv/up.sql new file mode 100644 index 0000000..63f567b --- /dev/null +++ b/src-core/migrations/2024-09-22-023605_settings_to_kv/up.sql @@ -0,0 +1,16 @@ +-- Create the new app_settings table with key-value structure +CREATE TABLE "app_settings" ( + "setting_key" TEXT NOT NULL PRIMARY KEY, + "setting_value" TEXT NOT NULL +); + +-- Migrate existing settings to the new table +INSERT INTO "app_settings" ("setting_key", "setting_value") +SELECT 'theme', theme FROM settings +UNION ALL +SELECT 'font', font FROM settings +UNION ALL +SELECT 'base_currency', base_currency FROM settings; + +-- Drop the old settings table +DROP TABLE "settings"; \ No newline at end of file diff --git a/src-core/src/account/account_service.rs b/src-core/src/account/account_service.rs index 5251959..07d0df1 100644 --- a/src-core/src/account/account_service.rs +++ b/src-core/src/account/account_service.rs @@ -1,7 +1,6 @@ use crate::account::AccountRepository; -use crate::asset::asset_service::AssetService; -use crate::models::{Account, AccountUpdate, NewAccount}; -use crate::settings::SettingsService; +use crate::fx::fx_service::CurrencyExchangeService; +use crate::models::{Account, AccountUpdate, ExchangeRate, NewAccount}; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::Connection; use diesel::SqliteConnection; @@ -9,13 +8,15 @@ use diesel::SqliteConnection; pub struct AccountService { account_repo: AccountRepository, pool: Pool>, + base_currency: String, } impl AccountService { - pub fn new(pool: Pool>) -> Self { + pub fn new(pool: Pool>, base_currency: String) -> Self { AccountService { account_repo: AccountRepository::new(), pool, + base_currency, } } @@ -34,24 +35,25 @@ impl AccountService { new_account: NewAccount, ) -> Result> { let mut conn = self.pool.get()?; - let asset_service = AssetService::new(self.pool.clone()).await; - let settings_service = SettingsService::new(); + let base_currency = self.base_currency.clone(); + println!( + "Creating account..., base_currency: {}, new_account.currency: {}", + base_currency, new_account.currency + ); conn.transaction(|conn| { - let settings = settings_service.get_settings(conn)?; - let base_currency = settings.base_currency; - - // Create exchange rate assets if necessary - asset_service.create_exchange_rate_symbols( - conn, - &base_currency, - &new_account.currency, - )?; - - // Create cash ($CASH-CURRENCY) asset if necessary - let cash_asset_id = format!("$CASH-{}", new_account.currency); - if asset_service.get_asset_by_id(&cash_asset_id).is_err() { - asset_service.create_cash_asset(conn, &new_account.currency)?; + if new_account.currency != base_currency { + let fx_service = CurrencyExchangeService::new(self.pool.clone()); + let exchange_rate = ExchangeRate { + id: format!("{}{}=X", base_currency, new_account.currency), + created_at: chrono::Utc::now().naive_utc(), + updated_at: chrono::Utc::now().naive_utc(), + from_currency: base_currency.clone(), + to_currency: new_account.currency.clone(), + source: "MANUAL".to_string(), + rate: 1.0, // Default rate, should be updated with actual rate + }; + fx_service.upsert_exchange_rate(conn, exchange_rate)?; } // Insert new account diff --git a/src-core/src/activity/activity_service.rs b/src-core/src/activity/activity_service.rs index 363137b..d938a9c 100644 --- a/src-core/src/activity/activity_service.rs +++ b/src-core/src/activity/activity_service.rs @@ -17,13 +17,15 @@ use uuid::Uuid; pub struct ActivityService { repo: ActivityRepository, pool: Pool>, + base_currency: String, } impl ActivityService { - pub fn new(pool: Pool>) -> Self { + pub fn new(pool: Pool>, base_currency: String) -> Self { ActivityService { repo: ActivityRepository::new(), pool, + base_currency, } } @@ -83,7 +85,7 @@ impl ActivityService { let mut conn = self.pool.get().expect("Couldn't get db connection"); let asset_id = activity.asset_id.clone(); let asset_service = AssetService::new(self.pool.clone()).await; - let account_service = AccountService::new(self.pool.clone()); + let account_service = AccountService::new(self.pool.clone(), self.base_currency.clone()); let asset_profile = asset_service .get_asset_profile(&asset_id, Some(true)) .await?; @@ -122,7 +124,7 @@ impl ActivityService { ) -> Result { let mut conn = self.pool.get().expect("Couldn't get db connection"); let asset_service = AssetService::new(self.pool.clone()).await; - let account_service = AccountService::new(self.pool.clone()); + let account_service = AccountService::new(self.pool.clone(), self.base_currency.clone()); let asset_profile = asset_service .get_asset_profile(&activity.asset_id, Some(true)) .await?; @@ -161,7 +163,7 @@ impl ActivityService { file_path: String, ) -> Result, String> { let asset_service = AssetService::new(self.pool.clone()).await; - let account_service = AccountService::new(self.pool.clone()); + let account_service = AccountService::new(self.pool.clone(), self.base_currency.clone()); let account = account_service .get_account_by_id(&_account_id) .map_err(|e| e.to_string())?; diff --git a/src-core/src/fx/fx_repository.rs b/src-core/src/fx/fx_repository.rs index 88e2b59..5be0a92 100644 --- a/src-core/src/fx/fx_repository.rs +++ b/src-core/src/fx/fx_repository.rs @@ -1,6 +1,5 @@ -use crate::models::{Asset, ExchangeRate}; -use crate::schema::assets; - +use crate::models::ExchangeRate; +use crate::schema::exchange_rates; use diesel::prelude::*; use diesel::sqlite::SqliteConnection; @@ -8,46 +7,55 @@ pub struct FxRepository; impl FxRepository { pub fn get_exchange_rates(conn: &mut SqliteConnection) -> QueryResult> { - use crate::schema::assets::dsl as assets_dsl; - - let asset_rates: Vec = assets_dsl::assets - .filter(assets_dsl::asset_type.eq("Currency")) - .load::(conn)?; - - Ok(asset_rates - .into_iter() - .map(|asset| { - let symbol_parts: Vec<&str> = asset.symbol.split('=').collect(); - ExchangeRate { - id: asset.id, - from_currency: symbol_parts[0][..3].to_string(), - to_currency: symbol_parts[0][3..].to_string(), - rate: 0.0, - source: asset.data_source, - } - }) - .collect()) + exchange_rates::table.load::(conn) + } + + pub fn get_exchange_rate( + conn: &mut SqliteConnection, + from: &str, + to: &str, + ) -> QueryResult> { + let id = format!("{}{}=X", from, to); + exchange_rates::table.find(id).first(conn).optional() + } + + pub fn upsert_exchange_rate( + conn: &mut SqliteConnection, + new_rate: ExchangeRate, + ) -> QueryResult { + diesel::insert_into(exchange_rates::table) + .values(&new_rate) + .on_conflict(exchange_rates::id) + .do_update() + .set(( + exchange_rates::rate.eq(new_rate.rate), + exchange_rates::source.eq(&new_rate.source.clone()), + exchange_rates::updated_at.eq(chrono::Utc::now().naive_utc()), + )) + .get_result(conn) } pub fn update_exchange_rate( conn: &mut SqliteConnection, rate: &ExchangeRate, ) -> QueryResult { - let asset = Asset { - id: rate.id.clone(), - symbol: format!("{}{}=X", rate.from_currency, rate.to_currency), - name: Some(rate.rate.to_string()), - asset_type: Some("Currency".to_string()), - data_source: rate.source.clone(), - currency: rate.to_currency.clone(), - updated_at: chrono::Utc::now().naive_utc(), - ..Default::default() - }; - - diesel::update(assets::table.find(&asset.id)) - .set(&asset) - .execute(conn)?; - - Ok(rate.clone()) + diesel::update(exchange_rates::table.find(&rate.id)) + .set(( + exchange_rates::rate.eq(rate.rate), + exchange_rates::source.eq(&rate.source), + exchange_rates::updated_at.eq(chrono::Utc::now().naive_utc()), + )) + .get_result(conn) } + + // pub fn get_supported_currencies(conn: &mut SqliteConnection) -> QueryResult> { + // use diesel::dsl::sql; + // use diesel::sql_types::Text; + + // let currencies: Vec = exchange_rates::table + // .select(sql::("DISTINCT substr(id, 1, 3)")) + // .load(conn)?; + + // Ok(currencies) + // } } diff --git a/src-core/src/fx/fx_service.rs b/src-core/src/fx/fx_service.rs index aa1dfd7..0803178 100644 --- a/src-core/src/fx/fx_service.rs +++ b/src-core/src/fx/fx_service.rs @@ -1,7 +1,5 @@ use crate::fx::fx_repository::FxRepository; -use crate::models::{ExchangeRate, Quote}; -use chrono::NaiveDateTime; -use diesel::prelude::*; +use crate::models::ExchangeRate; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; use std::collections::HashMap; @@ -9,7 +7,7 @@ use std::sync::{Arc, RwLock}; pub struct CurrencyExchangeService { pool: Pool>, - exchange_rates: Arc>>, + exchange_rates: Arc>>, } impl CurrencyExchangeService { @@ -29,122 +27,59 @@ impl CurrencyExchangeService { return Ok(1.0); } - let symbol = format!("{}{}=X", from_currency, to_currency); - let inverse_symbol = format!("{}{}=X", to_currency, from_currency); + let key = format!("{}{}", from_currency, to_currency); // Check cache first { let cache = self.exchange_rates.read().map_err(|_| "RwLock poisoned")?; - if let Some(&(rate, _)) = cache.get(&symbol) { + if let Some(&rate) = cache.get(&key) { return Ok(rate); } - if let Some(&(rate, _)) = cache.get(&inverse_symbol) { - return Ok(1.0 / rate); - } } - let mut conn = self - .pool - .get() - .map_err(|e| Box::new(e) as Box)?; + let mut conn = self.pool.get()?; // Try to get the direct rate - if let Some((rate, date)) = self.get_latest_rate_from_db(&mut conn, &symbol)? { - self.cache_rate(&symbol, rate, date)?; - return Ok(rate); + if let Some(rate) = FxRepository::get_exchange_rate(&mut conn, from_currency, to_currency)? + { + self.cache_rate(&key, rate.rate)?; + return Ok(rate.rate); } // If not found, try the inverse rate - if let Some((rate, date)) = self.get_latest_rate_from_db(&mut conn, &inverse_symbol)? { - let inverse_rate = 1.0 / rate; - self.cache_rate(&symbol, inverse_rate, date)?; + if let Some(rate) = FxRepository::get_exchange_rate(&mut conn, to_currency, from_currency)? + { + let inverse_rate = 1.0 / rate.rate; + self.cache_rate(&key, inverse_rate)?; return Ok(inverse_rate); } // If still not found, try USD conversion - let (from_usd, from_date) = self.get_latest_usd_rate(&mut conn, from_currency)?; - let (to_usd, to_date) = self.get_latest_usd_rate(&mut conn, to_currency)?; + let from_usd = self.get_usd_rate(&mut conn, from_currency)?; + let to_usd = self.get_usd_rate(&mut conn, to_currency)?; let rate = from_usd / to_usd; - let date = from_date.max(to_date); - self.cache_rate(&symbol, rate, date)?; + self.cache_rate(&key, rate)?; Ok(rate) } - pub fn get_exchange_rates(&self) -> Result, Box> { - let mut conn = self.pool.get()?; - let mut exchange_rates = FxRepository::get_exchange_rates(&mut conn)?; - - for rate in &mut exchange_rates { - let fx_symbol = format!("{}{}=X", rate.from_currency, rate.to_currency); - if let Some(quote) = self.get_latest_quote(&fx_symbol)? { - rate.rate = quote.close; - rate.source = quote.data_source; - } - } - - Ok(exchange_rates) - } - - pub fn update_exchange_rate( - &self, - rate: &ExchangeRate, - ) -> Result> { - let mut conn = self.pool.get()?; - Ok(FxRepository::update_exchange_rate(&mut conn, rate)?) - } - - fn get_latest_quote( - &self, - fx_symbol: &str, - ) -> Result, Box> { - use crate::schema::quotes::dsl::*; - let mut conn = self.pool.get()?; - - Ok(quotes - .filter(symbol.eq(fx_symbol)) - .order(date.desc()) - .first(&mut conn) - .optional()?) - } - - fn get_latest_rate_from_db( - &self, - conn: &mut SqliteConnection, - fx_symbol: &str, - ) -> Result, diesel::result::Error> { - use crate::schema::quotes::dsl::*; - - quotes - .filter(symbol.eq(fx_symbol)) - .order(date.desc()) - .select((close, date)) - .first(conn) - .optional() - } - - fn get_latest_usd_rate( + fn get_usd_rate( &self, conn: &mut SqliteConnection, currency: &str, - ) -> Result<(f64, NaiveDateTime), Box> { + ) -> Result> { if currency == "USD" { - return Ok((1.0, chrono::Utc::now().naive_utc())); + return Ok(1.0); } - let symbol = format!("{}USD=X", currency); - self.get_latest_rate_from_db(conn, &symbol)? + FxRepository::get_exchange_rate(conn, currency, "USD")? + .map(|rate| rate.rate) .ok_or_else(|| format!("No USD rate found for {}", currency).into()) } - fn cache_rate( - &self, - symbol: &str, - rate: f64, - date: NaiveDateTime, - ) -> Result<(), Box> { + fn cache_rate(&self, key: &str, rate: f64) -> Result<(), Box> { let mut cache = self.exchange_rates.write().map_err(|_| "RwLock poisoned")?; - cache.insert(symbol.to_string(), (rate, date)); + cache.insert(key.to_string(), rate); Ok(()) } @@ -161,4 +96,81 @@ impl CurrencyExchangeService { let rate = self.get_latest_exchange_rate(from_currency, to_currency)?; Ok(amount * rate) } + + pub fn sync_rates_from_yahoo(&self) -> Result<(), Box> { + let mut conn = self.pool.get()?; + let existing_rates = FxRepository::get_exchange_rates(&mut conn)?; + + for rate in existing_rates { + let (from, to) = (&rate.from_currency, &rate.to_currency); + let new_rate = self.fetch_yahoo_rate(from, to)?; + + let updated_rate = ExchangeRate { + id: format!("{}{}=X", from, to), + from_currency: from.to_string(), + to_currency: to.to_string(), + rate: new_rate, + source: "Yahoo Finance".to_string(), + created_at: rate.created_at, + updated_at: chrono::Utc::now().naive_utc(), + }; + + let updated_rate = FxRepository::upsert_exchange_rate(&mut conn, updated_rate)?; + self.cache_rate(&format!("{}{}=X", from, to), updated_rate.rate)?; + } + Ok(()) + } + + fn fetch_yahoo_rate(&self, from: &str, to: &str) -> Result> { + let direct_id = format!("{}{}=X", from, to); + if let Ok(rate) = self.fetch_rate_from_yahoo(&direct_id) { + return Ok(rate); + } + + let inverse_id = format!("{}{}=X", to, from); + if let Ok(rate) = self.fetch_rate_from_yahoo(&inverse_id) { + return Ok(1.0 / rate); + } + + // If both direct and inverse fail, try USD conversion + let from_usd = self.fetch_rate_from_yahoo(&format!("USD{}=X", from))?; + let to_usd = self.fetch_rate_from_yahoo(&format!("USD{}=X", to))?; + + Ok(from_usd / to_usd) + } + + fn fetch_rate_from_yahoo(&self, symbol: &str) -> Result> { + // Implement Yahoo Finance API call here + // Return the fetched rate + unimplemented!( + "Yahoo Finance API call not implemented for symbol: {}", + symbol + ) + } + + pub fn update_exchange_rate( + &self, + rate: &ExchangeRate, + ) -> Result> { + let mut conn = self.pool.get()?; + let updated_rate = FxRepository::update_exchange_rate(&mut conn, rate)?; + self.cache_rate(&updated_rate.id, updated_rate.rate)?; + Ok(updated_rate) + } + + pub fn get_exchange_rates(&self) -> Result, Box> { + let mut conn = self.pool.get()?; + let rates = FxRepository::get_exchange_rates(&mut conn)?; + Ok(rates) + } + + pub fn upsert_exchange_rate( + &self, + conn: &mut SqliteConnection, + new_rate: ExchangeRate, + ) -> Result> { + let updated_rate = FxRepository::upsert_exchange_rate(conn, new_rate)?; + self.cache_rate(&updated_rate.id, updated_rate.rate)?; + Ok(updated_rate) + } } diff --git a/src-core/src/models.rs b/src-core/src/models.rs index 96d23e4..f1720b8 100644 --- a/src-core/src/models.rs +++ b/src-core/src/models.rs @@ -393,24 +393,21 @@ pub struct Sort { } #[derive(Queryable, Insertable, Serialize, Deserialize, Debug)] -#[diesel(table_name= crate::schema::settings)] +#[diesel(table_name= crate::schema::app_settings)] +#[serde(rename_all = "camelCase")] +pub struct AppSetting { + pub setting_key: String, + pub setting_value: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct Settings { - pub id: i32, pub theme: String, pub font: String, pub base_currency: String, } -#[derive(Insertable, Serialize, AsChangeset, Deserialize, Debug)] -#[diesel(table_name= crate::schema::settings)] -#[serde(rename_all = "camelCase")] -pub struct NewSettings<'a> { - pub theme: &'a str, - pub font: &'a str, - pub base_currency: &'a str, -} - #[derive( Queryable, Identifiable, @@ -527,7 +524,10 @@ pub struct AccountSummary { pub performance: PortfolioHistory, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive( + Queryable, Insertable, Identifiable, AsChangeset, Serialize, Deserialize, Debug, Clone, +)] +#[diesel(table_name = crate::schema::exchange_rates)] #[serde(rename_all = "camelCase")] pub struct ExchangeRate { pub id: String, @@ -535,4 +535,16 @@ pub struct ExchangeRate { pub to_currency: String, pub rate: f64, pub source: String, + pub created_at: chrono::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} + +#[derive(Insertable, AsChangeset, Serialize, Deserialize, Debug, Clone)] +#[diesel(table_name = crate::schema::exchange_rates)] +#[serde(rename_all = "camelCase")] +pub struct NewExchangeRate { + pub from_currency: String, + pub to_currency: String, + pub rate: f64, + pub source: String, } diff --git a/src-core/src/portfolio/holdings_service.rs b/src-core/src/portfolio/holdings_service.rs index fa0aa38..ad0b8b1 100644 --- a/src-core/src/portfolio/holdings_service.rs +++ b/src-core/src/portfolio/holdings_service.rs @@ -22,8 +22,8 @@ impl HoldingsService { base_currency: String, ) -> Self { HoldingsService { - account_service: AccountService::new(pool.clone()), - activity_service: ActivityService::new(pool.clone()), + account_service: AccountService::new(pool.clone(), base_currency.clone()), + activity_service: ActivityService::new(pool.clone(), base_currency.clone()), asset_service: AssetService::new(pool.clone()).await, fx_service: CurrencyExchangeService::new(pool.clone()), base_currency, @@ -143,13 +143,15 @@ impl HoldingsService { // Get exchange rate for the holding's currency to base currency let exchange_rate = match self .fx_service - .get_latest_exchange_rate(&holding.currency, &self.base_currency) + .get_latest_exchange_rate(&holding.currency, &self.base_currency.clone()) { Ok(rate) => rate, Err(e) => { eprintln!( "Error getting exchange rate for {} to {}: {}. Using 1 as default.", - holding.currency, self.base_currency, e + holding.currency, + self.base_currency.clone(), + e ); 1.0 } diff --git a/src-core/src/portfolio/income_service.rs b/src-core/src/portfolio/income_service.rs index 5255b9b..bfd3902 100644 --- a/src-core/src/portfolio/income_service.rs +++ b/src-core/src/portfolio/income_service.rs @@ -54,6 +54,7 @@ impl IncomeService { pub fn get_income_summary(&self) -> Result { let income_data = self.get_income_data()?; + let base_currency = self.base_currency.clone(); let mut by_month: HashMap = HashMap::new(); let mut by_type: HashMap = HashMap::new(); @@ -67,7 +68,7 @@ impl IncomeService { let month = data.date.format("%Y-%m").to_string(); let converted_amount = self .fx_service - .convert_currency(data.amount, &data.currency, &self.base_currency) + .convert_currency(data.amount, &data.currency, &base_currency) .unwrap_or(data.amount); *by_month.entry(month).or_insert(0.0) += converted_amount; @@ -86,7 +87,7 @@ impl IncomeService { by_symbol, total_income, total_income_ytd, - currency: self.base_currency.clone(), + currency: base_currency, }) } } diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 09e6437..090e601 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -3,10 +3,8 @@ use crate::activity::activity_service::ActivityService; use crate::fx::fx_service::CurrencyExchangeService; use crate::market_data::market_data_service::MarketDataService; use crate::models::{ - Account, AccountSummary, Activity, HistorySummary, Holding, IncomeData, IncomeSummary, - PortfolioHistory, + AccountSummary, HistorySummary, Holding, IncomeData, IncomeSummary, PortfolioHistory, }; -use crate::settings::SettingsService; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; @@ -29,17 +27,13 @@ pub struct PortfolioService { impl PortfolioService { pub async fn new( pool: Pool>, + base_currency: String, ) -> Result> { - let mut conn = pool.get()?; - let settings_service = SettingsService::new(); - let settings = settings_service.get_settings(&mut conn)?; - let base_currency = settings.base_currency; - let market_data_service = Arc::new(MarketDataService::new(pool.clone()).await); Ok(PortfolioService { - account_service: AccountService::new(pool.clone()), - activity_service: ActivityService::new(pool.clone()), + account_service: AccountService::new(pool.clone(), base_currency.clone()), + activity_service: ActivityService::new(pool.clone(), base_currency.clone()), market_data_service: market_data_service.clone(), income_service: IncomeService::new( pool.clone(), @@ -47,7 +41,11 @@ impl PortfolioService { base_currency.clone(), ), holdings_service: HoldingsService::new(pool.clone(), base_currency.clone()).await, - history_service: HistoryService::new(pool.clone(), base_currency, market_data_service), + history_service: HistoryService::new( + pool.clone(), + base_currency.clone(), + market_data_service, + ), }) } @@ -61,10 +59,11 @@ impl PortfolioService { } } - fn fetch_data( + pub fn calculate_historical_data( &self, account_ids: Option>, - ) -> Result<(Vec, Vec), Box> { + force_full_calculation: bool, + ) -> Result, Box> { let accounts = match &account_ids { Some(ids) => self.account_service.get_accounts_by_ids(ids)?, None => self.account_service.get_accounts()?, @@ -75,16 +74,6 @@ impl PortfolioService { None => self.activity_service.get_activities()?, }; - Ok((accounts, activities)) - } - - pub fn calculate_historical_data( - &self, - account_ids: Option>, - force_full_calculation: bool, - ) -> Result, Box> { - let (accounts, activities) = self.fetch_data(account_ids)?; - let results = self.history_service.calculate_historical_data( &accounts, &activities, diff --git a/src-core/src/schema.rs b/src-core/src/schema.rs index f5b03c2..02f9a3b 100644 --- a/src-core/src/schema.rs +++ b/src-core/src/schema.rs @@ -57,6 +57,18 @@ diesel::table! { } } +diesel::table! { + exchange_rates (id) { + id -> Text, + from_currency -> Text, + to_currency -> Text, + rate -> Double, + source -> Text, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + diesel::table! { goals (id) { id -> Text, @@ -88,7 +100,7 @@ diesel::table! { portfolio_history (id) { id -> Text, account_id -> Text, - date -> Text, + date -> Date, total_value -> Double, market_value -> Double, book_cost -> Double, @@ -123,11 +135,9 @@ diesel::table! { } diesel::table! { - settings (id) { - id -> Integer, - theme -> Text, - font -> Text, - base_currency -> Text, + app_settings (setting_key) { + setting_key -> Text, + setting_value -> Text, } } @@ -142,10 +152,11 @@ diesel::allow_tables_to_appear_in_same_query!( accounts, activities, assets, + exchange_rates, goals, goals_allocation, platforms, portfolio_history, quotes, - settings, + app_settings, ); diff --git a/src-core/src/settings/mod.rs b/src-core/src/settings/mod.rs index e183b15..dc0f355 100644 --- a/src-core/src/settings/mod.rs +++ b/src-core/src/settings/mod.rs @@ -1,3 +1,5 @@ +pub mod settings_repository; pub mod settings_service; +pub use settings_repository::SettingsRepository; pub use settings_service::SettingsService; diff --git a/src-core/src/settings/settings_repository.rs b/src-core/src/settings/settings_repository.rs new file mode 100644 index 0000000..95d2ef9 --- /dev/null +++ b/src-core/src/settings/settings_repository.rs @@ -0,0 +1,81 @@ +use crate::models::{AppSetting, Settings}; +use crate::schema::app_settings::dsl::*; +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; + +pub struct SettingsRepository; + +impl SettingsRepository { + pub fn get_settings(conn: &mut SqliteConnection) -> Result { + let theme = app_settings + .filter(setting_key.eq("theme")) + .select(setting_value) + .first::(conn)?; + + let font = app_settings + .filter(setting_key.eq("font")) + .select(setting_value) + .first::(conn)?; + + let base_currency = app_settings + .filter(setting_key.eq("base_currency")) + .select(setting_value) + .first::(conn)?; + + Ok(Settings { + theme, + font, + base_currency, + }) + } + + pub fn update_settings( + conn: &mut SqliteConnection, + new_settings: &Settings, + ) -> Result<(), diesel::result::Error> { + let settings_to_insert = vec![ + AppSetting { + setting_key: "theme".to_string(), + setting_value: new_settings.theme.clone(), + }, + AppSetting { + setting_key: "font".to_string(), + setting_value: new_settings.font.clone(), + }, + AppSetting { + setting_key: "base_currency".to_string(), + setting_value: new_settings.base_currency.clone(), + }, + ]; + + diesel::replace_into(app_settings) + .values(&settings_to_insert) + .execute(conn)?; + + Ok(()) + } + + pub fn get_setting( + conn: &mut SqliteConnection, + setting_key_param: &str, + ) -> Result { + app_settings + .filter(setting_key.eq(setting_key_param)) + .select(setting_value) + .first(conn) + } + + pub fn update_setting( + conn: &mut SqliteConnection, + setting_key_param: &str, + setting_value_param: &str, + ) -> Result<(), diesel::result::Error> { + diesel::replace_into(app_settings) + .values(AppSetting { + setting_key: setting_key_param.to_string(), + setting_value: setting_value_param.to_string(), + }) + .execute(conn)?; + Ok(()) + } +} diff --git a/src-core/src/settings/settings_service.rs b/src-core/src/settings/settings_service.rs index 6b34ba6..cbae646 100644 --- a/src-core/src/settings/settings_service.rs +++ b/src-core/src/settings/settings_service.rs @@ -1,45 +1,51 @@ -// settings_service.rs - -use crate::models::{NewSettings, Settings}; -use crate::schema::settings::dsl::*; -use diesel::prelude::*; +use super::settings_repository::SettingsRepository; +use crate::models::Settings; use diesel::sqlite::SqliteConnection; -pub struct SettingsService { - settings_id: i32, -} +pub struct SettingsService; impl SettingsService { pub fn new() -> Self { - SettingsService { settings_id: 1 } + SettingsService } pub fn get_settings( &self, conn: &mut SqliteConnection, ) -> Result { - settings.find(self.settings_id).first::(conn) + SettingsRepository::get_settings(conn) } pub fn update_settings( &self, conn: &mut SqliteConnection, - new_setting: &NewSettings, + new_settings: &Settings, + ) -> Result<(), diesel::result::Error> { + SettingsRepository::update_settings(conn, new_settings) + } + + pub fn get_setting( + &self, + conn: &mut SqliteConnection, + setting_key: &str, + ) -> Result { + SettingsRepository::get_setting(conn, setting_key) + } + + pub fn update_setting( + &self, + conn: &mut SqliteConnection, + setting_key: &str, + setting_value: &str, ) -> Result<(), diesel::result::Error> { - // First, try to update - let rows_affected = diesel::update(settings.find(self.settings_id)) - .set(new_setting) - .execute(conn)?; - - // Check if the update affected any rows - if rows_affected == 0 { - // If no rows were affected, perform an insert - diesel::insert_into(settings) - .values(new_setting) - .execute(conn)?; - } - - Ok(()) + SettingsRepository::update_setting(conn, setting_key, setting_value) + } + + pub fn get_base_currency( + &self, + conn: &mut SqliteConnection, + ) -> Result { + self.get_setting(conn, "base_currency") } pub fn update_base_currency( @@ -47,15 +53,6 @@ impl SettingsService { conn: &mut SqliteConnection, new_base_currency: &str, ) -> Result<(), diesel::result::Error> { - diesel::update(settings.find(self.settings_id)) - .set(base_currency.eq(new_base_currency)) - .execute(conn)?; - Ok(()) + self.update_setting(conn, "base_currency", new_base_currency) } - - // Remove the following methods: - // - update_exchange_rate - // - get_exchange_rate_symbols - // - get_latest_quote - // - get_exchange_rates } diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 3f3c473..e78abc2 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -6,7 +6,8 @@ use tauri::State; #[tauri::command] pub fn get_accounts(state: State) -> Result, String> { println!("Fetching active accounts..."); - let service = AccountService::new((*state.pool).clone()); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = AccountService::new((*state.pool).clone(), base_currency); service .get_accounts() .map_err(|e| format!("Failed to load accounts: {}", e)) @@ -18,7 +19,8 @@ pub async fn create_account( state: State<'_, AppState>, ) -> Result { println!("Adding new account..."); - let service = AccountService::new((*state.pool).clone()); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = AccountService::new((*state.pool).clone(), base_currency); service .create_account(account) .await @@ -28,7 +30,8 @@ pub async fn create_account( #[tauri::command] pub fn update_account(account: AccountUpdate, state: State) -> Result { println!("Updating account..."); - let service = AccountService::new((*state.pool).clone()); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = AccountService::new((*state.pool).clone(), base_currency); service .update_account(account) .map_err(|e| format!("Failed to update account: {}", e)) @@ -37,7 +40,8 @@ pub fn update_account(account: AccountUpdate, state: State) -> Result< #[tauri::command] pub fn delete_account(account_id: String, state: State) -> Result { println!("Deleting account..."); - let service = AccountService::new((*state.pool).clone()); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = AccountService::new((*state.pool).clone(), base_currency); service .delete_account(account_id) .map_err(|e| format!("Failed to delete account: {}", e)) diff --git a/src-tauri/src/commands/activity.rs b/src-tauri/src/commands/activity.rs index c9db472..65fd0fc 100644 --- a/src-tauri/src/commands/activity.rs +++ b/src-tauri/src/commands/activity.rs @@ -16,7 +16,8 @@ pub fn search_activities( state: State, ) -> Result { println!("Search activities... {}, {}", page, page_size); - let service = activity_service::ActivityService::new((*state.pool).clone()); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); service .search_activities( @@ -33,8 +34,9 @@ pub fn search_activities( #[tauri::command] pub fn create_activity(activity: NewActivity, state: State) -> Result { println!("Adding new activity..."); + let base_currency = state.base_currency.read().unwrap().clone(); let result = tauri::async_runtime::block_on(async { - let service = activity_service::ActivityService::new((*state.pool).clone()); + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); service.create_activity(activity).await }); @@ -47,8 +49,9 @@ pub fn update_activity( state: State, ) -> Result { println!("Updating activity..."); + let base_currency = state.base_currency.read().unwrap().clone(); let result = tauri::async_runtime::block_on(async { - let service = activity_service::ActivityService::new((*state.pool).clone()); + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); service.update_activity(activity).await }); @@ -65,8 +68,8 @@ pub async fn check_activities_import( "Checking activities import...: {}, {}", account_id, file_path ); - - let service = activity_service::ActivityService::new((*state.pool).clone()); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); service .check_activities_import(account_id, file_path) .await @@ -79,7 +82,8 @@ pub fn create_activities( state: State, ) -> Result { println!("Importing activities..."); - let service = activity_service::ActivityService::new((*state.pool).clone()); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); service .create_activities(activities) .map_err(|err| format!("Failed to import activities: {}", err)) @@ -88,7 +92,8 @@ pub fn create_activities( #[tauri::command] pub fn delete_activity(activity_id: String, state: State) -> Result { println!("Deleting activity..."); - let service = activity_service::ActivityService::new((*state.pool).clone()); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); service .delete_activity(activity_id) .map_err(|e| format!("Failed to delete activity: {}", e)) diff --git a/src-tauri/src/commands/portfolio.rs b/src-tauri/src/commands/portfolio.rs index 15c09ab..2f179c5 100644 --- a/src-tauri/src/commands/portfolio.rs +++ b/src-tauri/src/commands/portfolio.rs @@ -4,6 +4,13 @@ use crate::AppState; use tauri::State; +async fn create_portfolio_service(state: &State<'_, AppState>) -> Result { + let base_currency = state.base_currency.read().unwrap().clone(); + PortfolioService::new((*state.pool).clone(), base_currency) + .await + .map_err(|e| format!("Failed to create PortfolioService: {}", e)) +} + #[tauri::command] pub async fn calculate_historical_data( state: State<'_, AppState>, @@ -11,10 +18,7 @@ pub async fn calculate_historical_data( force_full_calculation: bool, ) -> Result, String> { println!("Calculate portfolio historical..."); - - let service = PortfolioService::new((*state.pool).clone()) - .await - .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; + let service = create_portfolio_service(&state).await?; service .calculate_historical_data(account_ids, force_full_calculation) @@ -24,10 +28,7 @@ pub async fn calculate_historical_data( #[tauri::command] pub async fn compute_holdings(state: State<'_, AppState>) -> Result, String> { println!("Compute holdings..."); - - let service = PortfolioService::new((*state.pool).clone()) - .await - .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; + let service = create_portfolio_service(&state).await?; service .compute_holdings() @@ -40,10 +41,7 @@ pub async fn get_account_history( account_id: String, ) -> Result, String> { println!("Fetching account history for account ID: {}", account_id); - - let service = PortfolioService::new((*state.pool).clone()) - .await - .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; + let service = create_portfolio_service(&state).await?; service .get_account_history(&account_id) @@ -55,10 +53,7 @@ pub async fn get_accounts_summary( state: State<'_, AppState>, ) -> Result, String> { println!("Fetching active accounts performance..."); - - let service = PortfolioService::new((*state.pool).clone()) - .await - .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; + let service = create_portfolio_service(&state).await?; service .get_accounts_summary() @@ -70,10 +65,7 @@ pub async fn recalculate_portfolio( state: State<'_, AppState>, ) -> Result, String> { println!("Recalculating portfolio..."); - - let service = PortfolioService::new((*state.pool).clone()) - .await - .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; + let service = create_portfolio_service(&state).await?; service .update_portfolio() @@ -84,9 +76,7 @@ pub async fn recalculate_portfolio( #[tauri::command] pub async fn get_income_summary(state: State<'_, AppState>) -> Result { println!("Fetching income summary..."); - let service = PortfolioService::new((*state.pool).clone()) - .await - .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; + let service = create_portfolio_service(&state).await?; service .get_income_summary() diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 6e82061..5c2d508 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1,5 +1,5 @@ use crate::fx::fx_service::CurrencyExchangeService; -use crate::models::{ExchangeRate, NewSettings, Settings}; +use crate::models::{ExchangeRate, Settings}; use crate::settings::settings_service; use crate::AppState; use diesel::r2d2::ConnectionManager; @@ -27,30 +27,43 @@ pub fn get_settings(state: State) -> Result { } #[tauri::command] -pub fn update_settings(settings: NewSettings, state: State) -> Result { +pub fn update_settings(settings: Settings, state: State) -> Result { println!("Updating settings..."); // Log message let mut conn = get_connection(&state)?; let service = settings_service::SettingsService::new(); service .update_settings(&mut conn, &settings) .map_err(|e| format!("Failed to update settings: {}", e))?; + // Update the app state + let mut base_currency = state.base_currency.write().map_err(|e| e.to_string())?; + *base_currency = settings.base_currency; service .get_settings(&mut conn) .map_err(|e| format!("Failed to load settings: {}", e)) } -#[tauri::command] -pub fn update_currency(currency: String, state: State) -> Result { - println!("Updating base currency..."); // Log message - let mut conn = get_connection(&state)?; - let service = settings_service::SettingsService::new(); - service - .update_base_currency(&mut conn, ¤cy) - .map_err(|e| format!("Failed to update settings: {}", e))?; - service - .get_settings(&mut conn) - .map_err(|e| format!("Failed to load settings: {}", e)) -} +// #[tauri::command] +// pub async fn update_base_currency( +// state: tauri::State<'_, AppState>, +// new_currency: String, +// ) -> Result { +// let pool = &state.pool; +// let mut conn = pool.get().map_err(|e| e.to_string())?; + +// let settings_service = settings_service::SettingsService::new(); +// settings_service +// .update_base_currency(&mut conn, &new_currency) +// .map_err(|e| e.to_string())?; + +// // Update the app state +// let mut base_currency = state.base_currency.write().map_err(|e| e.to_string())?; +// *base_currency = new_currency; + +// // Return the updated settings +// settings_service +// .get_settings(&mut conn) +// .map_err(|e| format!("Failed to load updated settings: {}", e)) +// } #[tauri::command] pub fn update_exchange_rate( diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 4369073..fbfdd64 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -18,9 +18,7 @@ use commands::portfolio::{ calculate_historical_data, compute_holdings, get_account_history, get_accounts_summary, get_income_summary, recalculate_portfolio, }; -use commands::settings::{ - get_exchange_rates, get_settings, update_currency, update_exchange_rate, update_settings, -}; +use commands::settings::{get_exchange_rates, get_settings, update_exchange_rate, update_settings}; use wealthfolio_core::db; use wealthfolio_core::models; @@ -38,7 +36,7 @@ use wealthfolio_core::settings; use dotenvy::dotenv; use std::env; use std::path::Path; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use diesel::r2d2::{self, ConnectionManager}; use diesel::SqliteConnection; @@ -50,6 +48,7 @@ type DbPool = r2d2::Pool>; // AppState struct AppState { pool: Arc, + base_currency: Arc>, } fn main() { @@ -73,8 +72,18 @@ fn main() { .expect("Failed to create database connection pool"); let pool = Arc::new(pool); + // Get initial base_currency from settings + let mut conn = pool.get().expect("Failed to get database connection"); + let settings_service = settings::SettingsService::new(); + let base_currency = settings_service + .get_base_currency(&mut conn) + .unwrap_or_else(|_| "USD".to_string()); + // Initialize state - let state = AppState { pool: pool.clone() }; + let state = AppState { + pool: pool.clone(), + base_currency: Arc::new(RwLock::new(base_currency)), + }; app.manage(state); spawn_quote_sync(app_handle, pool); @@ -99,7 +108,6 @@ fn main() { synch_quotes, get_settings, update_settings, - update_currency, get_exchange_rates, update_exchange_rate, create_goal, @@ -141,7 +149,12 @@ fn handle_menu_event(event: tauri::WindowMenuEvent) { fn spawn_quote_sync(app_handle: tauri::AppHandle, pool: Arc) { spawn(async move { - let portfolio_service = portfolio::PortfolioService::new((*pool).clone()) + let base_currency = { + let state = app_handle.state::(); + let currency = state.base_currency.read().unwrap().clone(); + currency + }; + let portfolio_service = portfolio::PortfolioService::new((*pool).clone(), base_currency) .await .expect("Failed to create PortfolioService"); diff --git a/src/commands/setting.ts b/src/commands/settings.ts similarity index 100% rename from src/commands/setting.ts rename to src/commands/settings.ts diff --git a/src/lib/types.ts b/src/lib/types.ts index a5bb606..e886b76 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -223,7 +223,6 @@ export interface AssetData { } export interface Settings { - id: number; theme: string; font: string; baseCurrency: string; diff --git a/src/lib/useSettings.ts b/src/lib/useSettings.ts index c213afa..26cae62 100644 --- a/src/lib/useSettings.ts +++ b/src/lib/useSettings.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { Settings } from './types'; -import { getSettings } from '@/commands/setting'; +import { getSettings } from '@/commands/settings'; import { QueryKeys } from './query-keys'; export function useSettings() { diff --git a/src/lib/useSettingsMutation.ts b/src/lib/useSettingsMutation.ts index 111fcea..47ca6df 100644 --- a/src/lib/useSettingsMutation.ts +++ b/src/lib/useSettingsMutation.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from '@/components/ui/use-toast'; -import { saveSettings } from '@/commands/setting'; +import { saveSettings } from '@/commands/settings'; import { Settings } from './types'; import { useCalculateHistoryMutation } from '@/hooks/useCalculateHistory'; import { QueryKeys } from './query-keys'; @@ -31,7 +31,8 @@ export function useSettingsMutation( }); } }, - onError: () => { + onError: (error) => { + console.error('Error updating settings:', error); toast({ title: 'Uh oh! Something went wrong.', description: 'There was a problem updating your settings.', diff --git a/src/pages/settings/general/general-form.tsx b/src/pages/settings/general/general-form.tsx index 0615671..2823d55 100644 --- a/src/pages/settings/general/general-form.tsx +++ b/src/pages/settings/general/general-form.tsx @@ -45,7 +45,6 @@ export function GeneralSettingForm() { function onSubmit(data: GeneralSettingFormValues) { const updatedSettings = { - id: settings?.id || 1, theme: settings?.theme || 'light', font: settings?.font || 'font-mono', ...data, From 606e1669fa87239732e717445d95ed04cca4f3ce Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 22 Sep 2024 19:55:13 -0400 Subject: [PATCH 43/45] exchange rate conversion --- src-core/src/account/account_service.rs | 14 +-- src-core/src/activity/activity_service.rs | 37 +++--- src-core/src/fx/fx_repository.rs | 7 ++ src-core/src/fx/fx_service.rs | 63 ++++++++++- .../src/market_data/market_data_service.rs | 105 +++++++++++++++++- src-core/src/portfolio/portfolio_service.rs | 18 ++- src-core/src/providers/yahoo_provider.rs | 9 +- src-tauri/src/commands/account.rs | 12 +- src-tauri/src/commands/activity.rs | 44 ++++---- src-tauri/src/commands/goal.rs | 16 +-- src-tauri/src/commands/portfolio.rs | 7 +- src-tauri/src/commands/settings.rs | 36 ++---- 12 files changed, 259 insertions(+), 109 deletions(-) diff --git a/src-core/src/account/account_service.rs b/src-core/src/account/account_service.rs index 07d0df1..9165c91 100644 --- a/src-core/src/account/account_service.rs +++ b/src-core/src/account/account_service.rs @@ -1,6 +1,6 @@ use crate::account::AccountRepository; use crate::fx::fx_service::CurrencyExchangeService; -use crate::models::{Account, AccountUpdate, ExchangeRate, NewAccount}; +use crate::models::{Account, AccountUpdate, NewAccount}; use diesel::r2d2::{ConnectionManager, Pool}; use diesel::Connection; use diesel::SqliteConnection; @@ -44,16 +44,8 @@ impl AccountService { conn.transaction(|conn| { if new_account.currency != base_currency { let fx_service = CurrencyExchangeService::new(self.pool.clone()); - let exchange_rate = ExchangeRate { - id: format!("{}{}=X", base_currency, new_account.currency), - created_at: chrono::Utc::now().naive_utc(), - updated_at: chrono::Utc::now().naive_utc(), - from_currency: base_currency.clone(), - to_currency: new_account.currency.clone(), - source: "MANUAL".to_string(), - rate: 1.0, // Default rate, should be updated with actual rate - }; - fx_service.upsert_exchange_rate(conn, exchange_rate)?; + fx_service + .add_exchange_rate(base_currency.clone(), new_account.currency.clone())?; } // Insert new account diff --git a/src-core/src/activity/activity_service.rs b/src-core/src/activity/activity_service.rs index d938a9c..8494269 100644 --- a/src-core/src/activity/activity_service.rs +++ b/src-core/src/activity/activity_service.rs @@ -3,6 +3,7 @@ use std::fs::File; use crate::account::AccountService; use crate::activity::ActivityRepository; use crate::asset::asset_service::AssetService; +use crate::fx::fx_service::CurrencyExchangeService; use crate::models::{ Activity, ActivityImport, ActivitySearchResponse, ActivityUpdate, IncomeData, NewActivity, Sort, }; @@ -81,8 +82,8 @@ impl ActivityService { pub async fn create_activity( &self, mut activity: NewActivity, - ) -> Result { - let mut conn = self.pool.get().expect("Couldn't get db connection"); + ) -> Result> { + let mut conn = self.pool.get()?; let asset_id = activity.asset_id.clone(); let asset_service = AssetService::new(self.pool.clone()).await; let account_service = AccountService::new(self.pool.clone(), self.base_currency.clone()); @@ -103,16 +104,16 @@ impl ActivityService { activity.unit_price = 1.0; } + // Create exchange rate if asset currency is different from account currency + if activity.currency != account.currency { + let fx_service = CurrencyExchangeService::new(self.pool.clone()); + fx_service + .add_exchange_rate(account.currency.clone(), activity.currency.clone())?; + } + // Insert the new activity into the database let inserted_activity = self.repo.insert_new_activity(conn, activity)?; - // Create currency symbols if asset currency is different from account currency - if asset_profile.currency != account.currency { - asset_service - .create_exchange_rate_symbols(conn, &asset_profile.currency, &account.currency) - .map_err(|_e| diesel::result::Error::RollbackTransaction)?; - } - Ok(inserted_activity) }) } @@ -121,8 +122,8 @@ impl ActivityService { pub async fn update_activity( &self, mut activity: ActivityUpdate, - ) -> Result { - let mut conn = self.pool.get().expect("Couldn't get db connection"); + ) -> Result> { + let mut conn = self.pool.get()?; let asset_service = AssetService::new(self.pool.clone()).await; let account_service = AccountService::new(self.pool.clone(), self.base_currency.clone()); let asset_profile = asset_service @@ -142,16 +143,16 @@ impl ActivityService { activity.unit_price = 1.0; } + // Create exchange rate if asset currency is different from account currency + if activity.currency != account.currency { + let fx_service = CurrencyExchangeService::new(self.pool.clone()); + fx_service + .add_exchange_rate(account.currency.clone(), activity.currency.clone())?; + } + // Update the activity in the database let updated_activity = self.repo.update_activity(conn, activity)?; - // Create currency symbols if asset currency is different from account currency - if asset_profile.currency != account.currency { - asset_service - .create_exchange_rate_symbols(conn, &asset_profile.currency, &account.currency) - .map_err(|_e| diesel::result::Error::RollbackTransaction)?; - } - Ok(updated_activity) }) } diff --git a/src-core/src/fx/fx_repository.rs b/src-core/src/fx/fx_repository.rs index 5be0a92..a651f88 100644 --- a/src-core/src/fx/fx_repository.rs +++ b/src-core/src/fx/fx_repository.rs @@ -19,6 +19,13 @@ impl FxRepository { exchange_rates::table.find(id).first(conn).optional() } + pub fn get_exchange_rate_by_id( + conn: &mut SqliteConnection, + id: &str, + ) -> QueryResult> { + exchange_rates::table.find(id).first(conn).optional() + } + pub fn upsert_exchange_rate( conn: &mut SqliteConnection, new_rate: ExchangeRate, diff --git a/src-core/src/fx/fx_service.rs b/src-core/src/fx/fx_service.rs index 0803178..7fb507f 100644 --- a/src-core/src/fx/fx_service.rs +++ b/src-core/src/fx/fx_service.rs @@ -122,21 +122,40 @@ impl CurrencyExchangeService { } fn fetch_yahoo_rate(&self, from: &str, to: &str) -> Result> { + println!("Fetching Yahoo rate for {} to {}", from, to); + let direct_id = format!("{}{}=X", from, to); + println!("Attempting direct conversion with ID: {}", direct_id); if let Ok(rate) = self.fetch_rate_from_yahoo(&direct_id) { + println!("Direct conversion successful. Rate: {}", rate); return Ok(rate); } let inverse_id = format!("{}{}=X", to, from); + println!("Attempting inverse conversion with ID: {}", inverse_id); if let Ok(rate) = self.fetch_rate_from_yahoo(&inverse_id) { - return Ok(1.0 / rate); + let inverse_rate = 1.0 / rate; + println!("Inverse conversion successful. Rate: {}", inverse_rate); + return Ok(inverse_rate); } // If both direct and inverse fail, try USD conversion - let from_usd = self.fetch_rate_from_yahoo(&format!("USD{}=X", from))?; - let to_usd = self.fetch_rate_from_yahoo(&format!("USD{}=X", to))?; + println!("Direct and inverse conversions failed. Attempting USD conversion."); + let from_usd_id = format!("USD{}=X", from); + let to_usd_id = format!("USD{}=X", to); + + println!("Fetching USD to {} rate", from); + let from_usd = self.fetch_rate_from_yahoo(&from_usd_id)?; + println!("USD to {} rate: {}", from, from_usd); + + println!("Fetching USD to {} rate", to); + let to_usd = self.fetch_rate_from_yahoo(&to_usd_id)?; + println!("USD to {} rate: {}", to, to_usd); + + let final_rate = from_usd / to_usd; + println!("Final conversion rate: {}", final_rate); - Ok(from_usd / to_usd) + Ok(final_rate) } fn fetch_rate_from_yahoo(&self, symbol: &str) -> Result> { @@ -173,4 +192,40 @@ impl CurrencyExchangeService { self.cache_rate(&updated_rate.id, updated_rate.rate)?; Ok(updated_rate) } + + pub fn add_exchange_rate( + &self, + from: String, + to: String, + ) -> Result> { + let mut conn = self.pool.get()?; + + // Check for direct conversion + let direct_id = format!("{}{}=X", from, to); + if let Some(existing_rate) = FxRepository::get_exchange_rate_by_id(&mut conn, &direct_id)? { + return Ok(existing_rate); + } + + // Check for inverse conversion + let inverse_id = format!("{}{}=X", to, from); + if let Some(existing_rate) = FxRepository::get_exchange_rate_by_id(&mut conn, &inverse_id)? + { + return Ok(existing_rate); + } + + // If neither direct nor inverse rate exists, create a new rate + let exchange_rate = ExchangeRate { + id: direct_id, + from_currency: from, + to_currency: to, + rate: 1.0, // Default rate, should be updated with actual rate + source: "MANUAL".to_string(), + created_at: chrono::Utc::now().naive_utc(), + updated_at: chrono::Utc::now().naive_utc(), + }; + + let result = self.upsert_exchange_rate(&mut conn, exchange_rate)?; + self.cache_rate(&result.id, result.rate)?; + Ok(result) + } } diff --git a/src-core/src/market_data/market_data_service.rs b/src-core/src/market_data/market_data_service.rs index 96ba468..c3edc32 100644 --- a/src-core/src/market_data/market_data_service.rs +++ b/src-core/src/market_data/market_data_service.rs @@ -1,6 +1,6 @@ -use crate::models::{Asset, NewAsset, Quote, QuoteSummary}; +use crate::models::{Asset, ExchangeRate, NewAsset, Quote, QuoteSummary}; use crate::providers::yahoo_provider::YahooProvider; -use crate::schema::{activities, quotes}; +use crate::schema::{activities, exchange_rates, quotes}; use chrono::{Duration, NaiveDate, NaiveDateTime, TimeZone, Utc}; use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool}; @@ -119,8 +119,8 @@ impl MarketDataService { pub async fn initialize_and_sync_quotes(&self) -> Result<(), String> { use crate::schema::assets::dsl::*; - // self.initialize_provider().await?; let conn = &mut self.pool.get().map_err(|e| e.to_string())?; + self.sync_exchange_rates().await?; let asset_list: Vec = assets .load::(conn) .map_err(|e| format!("Failed to load assets: {}", e))?; @@ -131,12 +131,14 @@ impl MarketDataService { .map(|asset| asset.symbol.clone()) .collect::>(), ) - .await + .await?; + + Ok(()) } //self.initialize_provider().await?; pub async fn fetch_symbol_summary(&self, symbol: &str) -> Result { self.provider - .fetch_quote_summary(symbol) + .fetch_symbol_summary(symbol) .await .map_err(|e| e.to_string()) } @@ -156,4 +158,97 @@ impl MarketDataService { HashMap::new() }) } + + pub async fn sync_exchange_rates(&self) -> Result<(), String> { + println!("Syncing exchange rates..."); + let mut conn = self.pool.get().expect("Couldn't get db connection"); + + // Load existing exchange rates + let existing_rates: Vec = exchange_rates::table + .load::(&mut conn) + .map_err(|e| format!("Failed to load existing exchange rates: {}", e))?; + + let mut updated_rates = Vec::new(); + + for rate in existing_rates { + match self + .fetch_exchange_rate(&rate.from_currency, &rate.to_currency) + .await + { + Ok(new_rate) => { + if new_rate > 0.0 { + updated_rates.push(ExchangeRate { + id: rate.id, + from_currency: rate.from_currency, + to_currency: rate.to_currency, + rate: new_rate, + source: "YAHOO".to_string(), + created_at: rate.created_at, + updated_at: Utc::now().naive_utc(), + }); + } + } + Err(e) => { + eprintln!( + "Failed to fetch rate for {}-{}: {}. Skipping update.", + rate.from_currency, rate.to_currency, e + ); + } + } + } + + // Update rates in the database + diesel::replace_into(exchange_rates::table) + .values(&updated_rates) + .execute(&mut conn) + .map_err(|e| format!("Failed to update exchange rates: {}", e))?; + + Ok(()) + } + + async fn fetch_exchange_rate(&self, from: &str, to: &str) -> Result { + // Try direct conversion + let symbol = format!("{}{}=X", from, to); + if let Ok(quote) = self.provider.get_latest_quote(&symbol).await { + return Ok(quote.close); + } + + // Try reverse conversion + let reverse_symbol = format!("{}{}=X", to, from); + if let Ok(quote) = self.provider.get_latest_quote(&reverse_symbol).await { + return Ok(1.0 / quote.close); + } + + // Try conversion through USD + let from_usd_symbol = if from != "USD" { + format!("{}USD=X", from) + } else { + "".to_string() + }; + + let to_usd_symbol = if to != "USD" { + format!("{}USD=X", to) + } else { + "".to_string() + }; + let from_usd = if !from_usd_symbol.is_empty() { + match self.provider.get_latest_quote(&from_usd_symbol).await { + Ok(quote) => quote.close, + Err(_) => return Ok(-1.0), + } + } else { + -1.0 + }; + + let to_usd = if !to_usd_symbol.is_empty() { + match self.provider.get_latest_quote(&to_usd_symbol).await { + Ok(quote) => quote.close, + Err(_) => return Ok(-1.0), + } + } else { + 1.0 + }; + + Ok(from_usd / to_usd) + } } diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 090e601..98f3e99 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -49,21 +49,19 @@ impl PortfolioService { }) } - pub fn compute_holdings(&self) -> Result, Box> { - match self.holdings_service.compute_holdings() { - Ok(holdings) => Ok(holdings), - Err(e) => { - eprintln!("Error computing holdings: {}", e); - Err(Box::new(e) as Box) - } - } + pub async fn compute_holdings(&self) -> Result, Box> { + self.holdings_service + .compute_holdings() + .map_err(|e| Box::new(e) as Box) } - pub fn calculate_historical_data( + pub async fn calculate_historical_data( &self, account_ids: Option>, force_full_calculation: bool, ) -> Result, Box> { + // First, sync quotes + self.market_data_service.sync_exchange_rates().await?; let accounts = match &account_ids { Some(ids) => self.account_service.get_accounts_by_ids(ids)?, None => self.account_service.get_accounts()?, @@ -100,7 +98,7 @@ impl PortfolioService { .await?; // Then, calculate historical data - self.calculate_historical_data(None, false) + self.calculate_historical_data(None, false).await } pub fn get_account_history( diff --git a/src-core/src/providers/yahoo_provider.rs b/src-core/src/providers/yahoo_provider.rs index 09dd7ca..85c7f9f 100644 --- a/src-core/src/providers/yahoo_provider.rs +++ b/src-core/src/providers/yahoo_provider.rs @@ -68,6 +68,13 @@ impl YahooProvider { Ok(yahoo_provider) } + pub async fn get_latest_quote(&self, symbol: &str) -> Result { + let response = self.provider.get_latest_quotes(symbol, "1d").await?; + response + .last_quote() + .map_err(|_| yahoo::YahooError::EmptyDataSet) + } + fn yahoo_quote_to_model_quote(&self, symbol: String, yahoo_quote: yahoo::Quote) -> ModelQuote { let date = DateTime::::from_timestamp(yahoo_quote.timestamp as i64, 0) .unwrap_or_default() @@ -146,7 +153,7 @@ impl YahooProvider { Ok(asset_profiles) } - pub async fn fetch_quote_summary(&self, symbol: &str) -> Result { + pub async fn fetch_symbol_summary(&self, symbol: &str) -> Result { // Handle the cash asset case if let Some(currency) = symbol.strip_prefix("$CASH-") { return Ok(self.create_cash_asset(symbol, currency)); diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index e78abc2..9539d72 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -4,7 +4,7 @@ use crate::AppState; use tauri::State; #[tauri::command] -pub fn get_accounts(state: State) -> Result, String> { +pub async fn get_accounts(state: State<'_, AppState>) -> Result, String> { println!("Fetching active accounts..."); let base_currency = state.base_currency.read().unwrap().clone(); let service = AccountService::new((*state.pool).clone(), base_currency); @@ -28,7 +28,10 @@ pub async fn create_account( } #[tauri::command] -pub fn update_account(account: AccountUpdate, state: State) -> Result { +pub async fn update_account( + account: AccountUpdate, + state: State<'_, AppState>, +) -> Result { println!("Updating account..."); let base_currency = state.base_currency.read().unwrap().clone(); let service = AccountService::new((*state.pool).clone(), base_currency); @@ -38,7 +41,10 @@ pub fn update_account(account: AccountUpdate, state: State) -> Result< } #[tauri::command] -pub fn delete_account(account_id: String, state: State) -> Result { +pub async fn delete_account( + account_id: String, + state: State<'_, AppState>, +) -> Result { println!("Deleting account..."); let base_currency = state.base_currency.read().unwrap().clone(); let service = AccountService::new((*state.pool).clone(), base_currency); diff --git a/src-tauri/src/commands/activity.rs b/src-tauri/src/commands/activity.rs index 65fd0fc..5084c58 100644 --- a/src-tauri/src/commands/activity.rs +++ b/src-tauri/src/commands/activity.rs @@ -6,14 +6,14 @@ use crate::AppState; use tauri::State; #[tauri::command] -pub fn search_activities( +pub async fn search_activities( page: i64, // Page number, 1-based page_size: i64, // Number of items per page account_id_filter: Option>, // Optional account_id filter activity_type_filter: Option>, // Optional activity_type filter asset_id_keyword: Option, // Optional asset_id keyword for search sort: Option, - state: State, + state: State<'_, AppState>, ) -> Result { println!("Search activities... {}, {}", page, page_size); let base_currency = state.base_currency.read().unwrap().clone(); @@ -32,30 +32,31 @@ pub fn search_activities( } #[tauri::command] -pub fn create_activity(activity: NewActivity, state: State) -> Result { +pub async fn create_activity( + activity: NewActivity, + state: State<'_, AppState>, +) -> Result { println!("Adding new activity..."); let base_currency = state.base_currency.read().unwrap().clone(); - let result = tauri::async_runtime::block_on(async { - let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); - service.create_activity(activity).await - }); - - result.map_err(|e| format!("Failed to add new activity: {}", e)) + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); + service + .create_activity(activity) + .await + .map_err(|e| format!("Failed to add new activity: {}", e)) } #[tauri::command] -pub fn update_activity( +pub async fn update_activity( activity: ActivityUpdate, - state: State, + state: State<'_, AppState>, ) -> Result { println!("Updating activity..."); let base_currency = state.base_currency.read().unwrap().clone(); - let result = tauri::async_runtime::block_on(async { - let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); - service.update_activity(activity).await - }); - - result.map_err(|e| format!("Failed to update activity: {}", e)) + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); + service + .update_activity(activity) + .await + .map_err(|e| format!("Failed to update activity: {}", e)) } #[tauri::command] @@ -77,9 +78,9 @@ pub async fn check_activities_import( } #[tauri::command] -pub fn create_activities( +pub async fn create_activities( activities: Vec, - state: State, + state: State<'_, AppState>, ) -> Result { println!("Importing activities..."); let base_currency = state.base_currency.read().unwrap().clone(); @@ -90,7 +91,10 @@ pub fn create_activities( } #[tauri::command] -pub fn delete_activity(activity_id: String, state: State) -> Result { +pub async fn delete_activity( + activity_id: String, + state: State<'_, AppState>, +) -> Result { println!("Deleting activity..."); let base_currency = state.base_currency.read().unwrap().clone(); let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); diff --git a/src-tauri/src/commands/goal.rs b/src-tauri/src/commands/goal.rs index b39204e..f49ce3e 100644 --- a/src-tauri/src/commands/goal.rs +++ b/src-tauri/src/commands/goal.rs @@ -16,7 +16,7 @@ fn get_connection( } #[tauri::command] -pub fn get_goals(state: State) -> Result, String> { +pub async fn get_goals(state: State<'_, AppState>) -> Result, String> { println!("Fetching active goals..."); let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); @@ -26,7 +26,7 @@ pub fn get_goals(state: State) -> Result, String> { } #[tauri::command] -pub fn create_goal(goal: NewGoal, state: State) -> Result { +pub async fn create_goal(goal: NewGoal, state: State<'_, AppState>) -> Result { println!("Adding new goal..."); let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); @@ -36,7 +36,7 @@ pub fn create_goal(goal: NewGoal, state: State) -> Result) -> Result { +pub async fn update_goal(goal: Goal, state: State<'_, AppState>) -> Result { println!("Updating goal..."); let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); @@ -46,7 +46,7 @@ pub fn update_goal(goal: Goal, state: State) -> Result { } #[tauri::command] -pub fn delete_goal(goal_id: String, state: State) -> Result { +pub async fn delete_goal(goal_id: String, state: State<'_, AppState>) -> Result { println!("Deleting goal..."); let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); @@ -56,9 +56,9 @@ pub fn delete_goal(goal_id: String, state: State) -> Result, - state: State, + state: State<'_, AppState>, ) -> Result { println!("Updating goal allocations..."); let mut conn = get_connection(&state)?; @@ -69,7 +69,9 @@ pub fn update_goal_allocations( } #[tauri::command] -pub fn load_goals_allocations(state: State) -> Result, String> { +pub async fn load_goals_allocations( + state: State<'_, AppState>, +) -> Result, String> { println!("Loading goal allocations..."); let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); diff --git a/src-tauri/src/commands/portfolio.rs b/src-tauri/src/commands/portfolio.rs index 2f179c5..77a23e8 100644 --- a/src-tauri/src/commands/portfolio.rs +++ b/src-tauri/src/commands/portfolio.rs @@ -22,7 +22,8 @@ pub async fn calculate_historical_data( service .calculate_historical_data(account_ids, force_full_calculation) - .map_err(|e| format!("Failed to calculate historical data: {}", e)) + .await + .map_err(|e| e.to_string()) } #[tauri::command] @@ -32,7 +33,9 @@ pub async fn compute_holdings(state: State<'_, AppState>) -> Result service .compute_holdings() - .map_err(|e| format!("Failed to fetch activities: {}", e)) + .await + .map_err(|e| e.to_string()) + .map(|vec| Ok(vec))? } #[tauri::command] diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 5c2d508..e8541aa 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -17,7 +17,7 @@ fn get_connection( } #[tauri::command] -pub fn get_settings(state: State) -> Result { +pub async fn get_settings(state: State<'_, AppState>) -> Result { println!("Fetching active settings..."); let mut conn = get_connection(&state)?; let service = settings_service::SettingsService::new(); @@ -27,7 +27,10 @@ pub fn get_settings(state: State) -> Result { } #[tauri::command] -pub fn update_settings(settings: Settings, state: State) -> Result { +pub async fn update_settings( + settings: Settings, + state: State<'_, AppState>, +) -> Result { println!("Updating settings..."); // Log message let mut conn = get_connection(&state)?; let service = settings_service::SettingsService::new(); @@ -42,33 +45,10 @@ pub fn update_settings(settings: Settings, state: State) -> Result, -// new_currency: String, -// ) -> Result { -// let pool = &state.pool; -// let mut conn = pool.get().map_err(|e| e.to_string())?; - -// let settings_service = settings_service::SettingsService::new(); -// settings_service -// .update_base_currency(&mut conn, &new_currency) -// .map_err(|e| e.to_string())?; - -// // Update the app state -// let mut base_currency = state.base_currency.write().map_err(|e| e.to_string())?; -// *base_currency = new_currency; - -// // Return the updated settings -// settings_service -// .get_settings(&mut conn) -// .map_err(|e| format!("Failed to load updated settings: {}", e)) -// } - #[tauri::command] -pub fn update_exchange_rate( +pub async fn update_exchange_rate( rate: ExchangeRate, - state: State, + state: State<'_, AppState>, ) -> Result { println!("Updating exchange rate..."); let fx_service = CurrencyExchangeService::new((*state.pool).clone()); @@ -78,7 +58,7 @@ pub fn update_exchange_rate( } #[tauri::command] -pub fn get_exchange_rates(state: State) -> Result, String> { +pub async fn get_exchange_rates(state: State<'_, AppState>) -> Result, String> { println!("Fetching exchange rates..."); let fx_service = CurrencyExchangeService::new((*state.pool).clone()); fx_service From 595aa4bd3df26b507bcad1375348bc7a6037c79c Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 22 Sep 2024 20:05:27 -0400 Subject: [PATCH 44/45] fix fx echange --- src-core/src/fx/fx_service.rs | 70 ------------------- .../src/market_data/market_data_service.rs | 5 ++ 2 files changed, 5 insertions(+), 70 deletions(-) diff --git a/src-core/src/fx/fx_service.rs b/src-core/src/fx/fx_service.rs index 7fb507f..08c9b28 100644 --- a/src-core/src/fx/fx_service.rs +++ b/src-core/src/fx/fx_service.rs @@ -97,76 +97,6 @@ impl CurrencyExchangeService { Ok(amount * rate) } - pub fn sync_rates_from_yahoo(&self) -> Result<(), Box> { - let mut conn = self.pool.get()?; - let existing_rates = FxRepository::get_exchange_rates(&mut conn)?; - - for rate in existing_rates { - let (from, to) = (&rate.from_currency, &rate.to_currency); - let new_rate = self.fetch_yahoo_rate(from, to)?; - - let updated_rate = ExchangeRate { - id: format!("{}{}=X", from, to), - from_currency: from.to_string(), - to_currency: to.to_string(), - rate: new_rate, - source: "Yahoo Finance".to_string(), - created_at: rate.created_at, - updated_at: chrono::Utc::now().naive_utc(), - }; - - let updated_rate = FxRepository::upsert_exchange_rate(&mut conn, updated_rate)?; - self.cache_rate(&format!("{}{}=X", from, to), updated_rate.rate)?; - } - Ok(()) - } - - fn fetch_yahoo_rate(&self, from: &str, to: &str) -> Result> { - println!("Fetching Yahoo rate for {} to {}", from, to); - - let direct_id = format!("{}{}=X", from, to); - println!("Attempting direct conversion with ID: {}", direct_id); - if let Ok(rate) = self.fetch_rate_from_yahoo(&direct_id) { - println!("Direct conversion successful. Rate: {}", rate); - return Ok(rate); - } - - let inverse_id = format!("{}{}=X", to, from); - println!("Attempting inverse conversion with ID: {}", inverse_id); - if let Ok(rate) = self.fetch_rate_from_yahoo(&inverse_id) { - let inverse_rate = 1.0 / rate; - println!("Inverse conversion successful. Rate: {}", inverse_rate); - return Ok(inverse_rate); - } - - // If both direct and inverse fail, try USD conversion - println!("Direct and inverse conversions failed. Attempting USD conversion."); - let from_usd_id = format!("USD{}=X", from); - let to_usd_id = format!("USD{}=X", to); - - println!("Fetching USD to {} rate", from); - let from_usd = self.fetch_rate_from_yahoo(&from_usd_id)?; - println!("USD to {} rate: {}", from, from_usd); - - println!("Fetching USD to {} rate", to); - let to_usd = self.fetch_rate_from_yahoo(&to_usd_id)?; - println!("USD to {} rate: {}", to, to_usd); - - let final_rate = from_usd / to_usd; - println!("Final conversion rate: {}", final_rate); - - Ok(final_rate) - } - - fn fetch_rate_from_yahoo(&self, symbol: &str) -> Result> { - // Implement Yahoo Finance API call here - // Return the fetched rate - unimplemented!( - "Yahoo Finance API call not implemented for symbol: {}", - symbol - ) - } - pub fn update_exchange_rate( &self, rate: &ExchangeRate, diff --git a/src-core/src/market_data/market_data_service.rs b/src-core/src/market_data/market_data_service.rs index c3edc32..87fc741 100644 --- a/src-core/src/market_data/market_data_service.rs +++ b/src-core/src/market_data/market_data_service.rs @@ -207,6 +207,11 @@ impl MarketDataService { } async fn fetch_exchange_rate(&self, from: &str, to: &str) -> Result { + // Handle GBP and GBp case like manually + if from.to_lowercase() == to.to_lowercase() { + return Ok(-1.0); + } + // Try direct conversion let symbol = format!("{}{}=X", from, to); if let Ok(quote) = self.provider.get_latest_quote(&symbol).await { From 3e4efd0f01b3f55f9c94ffe1e25604e300594e40 Mon Sep 17 00:00:00 2001 From: Aziz FADIL Date: Sun, 22 Sep 2024 20:58:14 -0400 Subject: [PATCH 45/45] exchange rate migration --- .../down.sql | 0 .../up.sql | 0 .../down.sql | 0 .../up.sql | 44 ++++++++++++++++ src-core/src/activity/activity_service.rs | 19 +++++++ .../src/market_data/market_data_service.rs | 5 +- src-core/src/schema.rs | 16 +++--- .../holdings/components/income-dashboard.tsx | 52 ++++++++++++++++++- .../settings/appearance/appearance-form.tsx | 1 - 9 files changed, 126 insertions(+), 11 deletions(-) rename src-core/migrations/{2024-09-22-023605_settings_to_kv => 2024-09-21-023605_settings_to_kv}/down.sql (100%) rename src-core/migrations/{2024-09-22-023605_settings_to_kv => 2024-09-21-023605_settings_to_kv}/up.sql (100%) create mode 100644 src-core/migrations/2024-09-22-012202_init_exchange_rates/down.sql create mode 100644 src-core/migrations/2024-09-22-012202_init_exchange_rates/up.sql diff --git a/src-core/migrations/2024-09-22-023605_settings_to_kv/down.sql b/src-core/migrations/2024-09-21-023605_settings_to_kv/down.sql similarity index 100% rename from src-core/migrations/2024-09-22-023605_settings_to_kv/down.sql rename to src-core/migrations/2024-09-21-023605_settings_to_kv/down.sql diff --git a/src-core/migrations/2024-09-22-023605_settings_to_kv/up.sql b/src-core/migrations/2024-09-21-023605_settings_to_kv/up.sql similarity index 100% rename from src-core/migrations/2024-09-22-023605_settings_to_kv/up.sql rename to src-core/migrations/2024-09-21-023605_settings_to_kv/up.sql diff --git a/src-core/migrations/2024-09-22-012202_init_exchange_rates/down.sql b/src-core/migrations/2024-09-22-012202_init_exchange_rates/down.sql new file mode 100644 index 0000000..e69de29 diff --git a/src-core/migrations/2024-09-22-012202_init_exchange_rates/up.sql b/src-core/migrations/2024-09-22-012202_init_exchange_rates/up.sql new file mode 100644 index 0000000..fee0f1f --- /dev/null +++ b/src-core/migrations/2024-09-22-012202_init_exchange_rates/up.sql @@ -0,0 +1,44 @@ +-- Get the base currency from app_setting +WITH base_currency AS ( + SELECT setting_value AS currency + FROM app_settings + WHERE setting_key = 'base_currency' +) + +-- Insert exchange rates for accounts +INSERT OR IGNORE INTO exchange_rates (id, from_currency, to_currency, rate, source) +SELECT + base_currency.currency || accounts.currency || '=X' AS id, + base_currency.currency, + accounts.currency, + 1.0, -- Default rate, to be updated later + 'MANUAL' +FROM accounts +CROSS JOIN base_currency +WHERE accounts.currency != base_currency.currency + +UNION + +-- Insert exchange rates for activities +SELECT DISTINCT + accounts.currency || activities.currency || '=X' AS id, + accounts.currency, + activities.currency, + 1.0, -- Default rate, to be updated later + 'MANUAL' +FROM activities +JOIN accounts ON activities.account_id = accounts.id +WHERE activities.currency != accounts.currency + +UNION + +-- Insert exchange rates from base currency to activity currency +SELECT DISTINCT + base_currency.currency || activities.currency || '=X' AS id, + base_currency.currency, + activities.currency, + 1.0, -- Default rate, to be updated later + 'MANUAL' +FROM activities +CROSS JOIN base_currency +WHERE activities.currency != base_currency.currency; \ No newline at end of file diff --git a/src-core/src/activity/activity_service.rs b/src-core/src/activity/activity_service.rs index 8494269..af3c18f 100644 --- a/src-core/src/activity/activity_service.rs +++ b/src-core/src/activity/activity_service.rs @@ -165,6 +165,7 @@ impl ActivityService { ) -> Result, String> { let asset_service = AssetService::new(self.pool.clone()).await; let account_service = AccountService::new(self.pool.clone(), self.base_currency.clone()); + let fx_service = CurrencyExchangeService::new(self.pool.clone()); let account = account_service .get_account_by_id(&_account_id) .map_err(|e| e.to_string())?; @@ -191,6 +192,24 @@ impl ActivityService { Ok(profile) => { activity_import.symbol_name = profile.name; symbols_to_sync.push(activity_import.symbol.clone()); + + // Add exchange rate if the activity currency is different from the account currency + let currency = &activity_import.currency; + if currency != &account.currency { + match fx_service + .add_exchange_rate(account.currency.clone(), currency.clone()) + { + Ok(_) => (), + Err(e) => { + let error_msg = format!( + "Failed to add exchange rate for {}/{}. Error: {}. Line: {}", + &account.currency, currency, e, line_number + ); + return Err(error_msg); + } + } + } + (Some("true".to_string()), None) } Err(_) => { diff --git a/src-core/src/market_data/market_data_service.rs b/src-core/src/market_data/market_data_service.rs index 87fc741..d57f2df 100644 --- a/src-core/src/market_data/market_data_service.rs +++ b/src-core/src/market_data/market_data_service.rs @@ -208,9 +208,12 @@ impl MarketDataService { async fn fetch_exchange_rate(&self, from: &str, to: &str) -> Result { // Handle GBP and GBp case like manually - if from.to_lowercase() == to.to_lowercase() { + if from != from.to_uppercase() || to != to.to_uppercase() { return Ok(-1.0); } + if from == to { + return Ok(1.0); + } // Try direct conversion let symbol = format!("{}{}=X", from, to); diff --git a/src-core/src/schema.rs b/src-core/src/schema.rs index 02f9a3b..b45b356 100644 --- a/src-core/src/schema.rs +++ b/src-core/src/schema.rs @@ -33,6 +33,13 @@ diesel::table! { } } +diesel::table! { + app_settings (setting_key) { + setting_key -> Text, + setting_value -> Text, + } +} + diesel::table! { assets (id) { id -> Text, @@ -134,13 +141,6 @@ diesel::table! { } } -diesel::table! { - app_settings (setting_key) { - setting_key -> Text, - setting_value -> Text, - } -} - diesel::joinable!(accounts -> platforms (platform_id)); diesel::joinable!(activities -> accounts (account_id)); diesel::joinable!(activities -> assets (asset_id)); @@ -151,6 +151,7 @@ diesel::joinable!(quotes -> assets (symbol)); diesel::allow_tables_to_appear_in_same_query!( accounts, activities, + app_settings, assets, exchange_rates, goals, @@ -158,5 +159,4 @@ diesel::allow_tables_to_appear_in_same_query!( platforms, portfolio_history, quotes, - app_settings, ); diff --git a/src/pages/holdings/components/income-dashboard.tsx b/src/pages/holdings/components/income-dashboard.tsx index 776372b..4ddda59 100644 --- a/src/pages/holdings/components/income-dashboard.tsx +++ b/src/pages/holdings/components/income-dashboard.tsx @@ -14,6 +14,7 @@ import { getIncomeSummary } from '@/commands/portfolio'; import type { IncomeSummary } from '@/lib/types'; import { formatAmount } from '@/lib/utils'; import { QueryKeys } from '@/lib/query-keys'; +import { Skeleton } from '@/components/ui/skeleton'; export function IncomeDashboard() { const { @@ -26,7 +27,7 @@ export function IncomeDashboard() { }); if (isLoading) { - return
Loading...
; + return ; } if (error || !incomeSummary) { @@ -212,3 +213,52 @@ export function IncomeDashboard() {
); } + +function IncomeDashboardSkeleton() { + return ( +
+
+
+ {[...Array(3)].map((_, index) => ( + + + + + + + + + + + ))} +
+
+ + + + + + + + + + + + + + +
+ {[...Array(10)].map((_, index) => ( +
+ + +
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/src/pages/settings/appearance/appearance-form.tsx b/src/pages/settings/appearance/appearance-form.tsx index ec8f5af..826b532 100644 --- a/src/pages/settings/appearance/appearance-form.tsx +++ b/src/pages/settings/appearance/appearance-form.tsx @@ -42,7 +42,6 @@ export function AppearanceForm() { function onSubmit(data: AppearanceFormValues) { const updatedSettings = { - id: settings?.id || 1, baseCurrency: settings?.baseCurrency || 'USD', ...data, };