From 9e06a1bf9dbbd92eb9b6d79d3f643f69e29d745e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thorbj=C3=B8rn=20Lindeijer?= Date: Tue, 24 Oct 2023 09:50:37 +0200 Subject: [PATCH] Added "Update Price History" button This change is not entirely polished. The price history is now stored inside the portfolio, so that it is persistent. However, it consumed way too much space there since way more price points are being downloaded than necessary. Probably the needed price points should just be stored on each transaction, which implies storing all transactions in the portfolio after importing them from a CSV file. --- raccoin_ui/ui/global.slint | 2 + raccoin_ui/ui/portfolio.slint | 4 ++ src/base.rs | 41 +++++++++++++- src/coinmarketcap.rs | 44 ++++++++++++++- src/main.rs | 102 +++++++++++++++++++++++++++++++--- 5 files changed, 180 insertions(+), 13 deletions(-) diff --git a/raccoin_ui/ui/global.slint b/raccoin_ui/ui/global.slint index 3a5ddcb..f5f7a39 100644 --- a/raccoin_ui/ui/global.slint +++ b/raccoin_ui/ui/global.slint @@ -41,6 +41,8 @@ export global Facade { callback add-source(int); callback remove-source(int,int); + callback update-price-history(); + callback ignore-currency(string); // params: (blockchain, tx_hash) diff --git a/raccoin_ui/ui/portfolio.slint b/raccoin_ui/ui/portfolio.slint index ceb3adf..f3b6def 100644 --- a/raccoin_ui/ui/portfolio.slint +++ b/raccoin_ui/ui/portfolio.slint @@ -51,6 +51,10 @@ export component Portfolio inherits Rectangle { text: "Close"; clicked => { Facade.close-portfolio(); } } + Button { + text: "Update Price History"; + clicked => { Facade.update-price-history(); } + } height: self.preferred-height; width: self.preferred-width; } diff --git a/src/base.rs b/src/base.rs index 9cac22c..c0bf383 100644 --- a/src/base.rs +++ b/src/base.rs @@ -485,12 +485,25 @@ pub(crate) fn load_transactions_from_json(input_path: &Path) -> Result Option { + Some(self.cmp(other)) + } +} + +impl Ord for PricePoint { + fn cmp(&self, other: &Self) -> Ordering { + self.timestamp.cmp(&other.timestamp) + } +} + +#[derive(Clone, Default, Serialize, Deserialize)] pub(crate) struct PriceHistory { prices: HashMap>, } @@ -506,10 +519,23 @@ impl PriceHistory { Self { prices } } - pub(crate) fn insert_price_points(&mut self, currency: String, price_points: Vec) { + pub(crate) fn empty() -> Self { + Self { + prices: HashMap::new(), + } + } + + pub(crate) fn set_price_points(&mut self, currency: String, price_points: Vec) { self.prices.insert(currency, price_points); } + pub(crate) fn add_price_points(&mut self, currency: String, price_points: Vec) { + let points = self.prices.entry(currency).or_default(); + for price_point in price_points { + points.insert(points.partition_point(|&p| p < price_point), price_point); + } + } + // todo: would be nice to expose the accuracy in the UI pub(crate) fn estimate_price(&self, timestamp: NaiveDateTime, currency: &str) -> Option { match currency { @@ -522,6 +548,17 @@ impl PriceHistory { } } + pub(crate) fn estimate_price_with_accuracy(&self, timestamp: NaiveDateTime, currency: &str) -> Option<(Decimal, Duration)> { + match currency { + "EUR" => Some((Decimal::ONE, Duration::zero())), + _ => { + self.prices.get(currency).and_then(|price_points| { + estimate_price(timestamp, price_points) + }) + } + } + } + pub(crate) fn estimate_value(&self, timestamp: NaiveDateTime, amount: &Amount) -> Option { self.estimate_price(timestamp, &amount.currency).map(|price| Amount::new(price * amount.quantity, "EUR".to_owned())) } diff --git a/src/coinmarketcap.rs b/src/coinmarketcap.rs index 88706e7..7371fa8 100644 --- a/src/coinmarketcap.rs +++ b/src/coinmarketcap.rs @@ -1,5 +1,5 @@ -use anyhow::Result; -use chrono::{DateTime, FixedOffset}; +use anyhow::{Result, bail}; +use chrono::{DateTime, FixedOffset, NaiveDateTime}; use rust_decimal::Decimal; use serde::Deserialize; @@ -55,6 +55,45 @@ struct Quote { // timestamp: DateTime, } +#[allow(dead_code)] +pub(crate) async fn download_price_points(time_end: NaiveDateTime, currency: &str) -> Result> { + let id = cmc_id(currency); + if id == -1 { + bail!("Unsupported currency (cmc id not known): {}", currency); + } + + let convert_id = cmc_id("EUR"); + let time_end: i64 = time_end.timestamp(); + let url = format!("https://api.coinmarketcap.com/data-api/v3.1/cryptocurrency/historical?id={}&convertId={}&timeEnd={}&interval=1h", id, convert_id, time_end); + println!("Downloading {}", url); + + let response: CmcHistoricalDataResponse = reqwest::Client::builder() + .user_agent("Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0") + .build()?.get(url.clone()).send().await?.json().await?; + + let prices: Vec = response.data.quotes + .into_iter() + .map(|quote| PricePoint { + timestamp: quote.time_open.naive_utc(), + price: quote.quote.open, + }) + .collect(); + + // Fill the prices with hourly dummy data spanning two weeks before time_end + // let mut prices: Vec = Vec::new(); + // let time_start = time_end - 2 * 7 * 24 * 3600; + // for hour in 0..(2 * 7 * 24) { + // let time = time_start + hour * 3600; + // prices.push(PricePoint { + // timestamp: NaiveDateTime::from_timestamp_opt(time, 0).unwrap(), + // price: Decimal::ZERO, + // }); + // } + + println!("Loaded {} price points from {}", prices.len(), url); + Ok(prices) +} + #[allow(dead_code)] pub(crate) async fn download_price_history(currency: &str) -> Result<()> { let id = cmc_id(currency); @@ -71,6 +110,7 @@ pub(crate) async fn download_price_history(currency: &str) -> Result<()> { let time_start = DateTime::parse_from_rfc3339(&format!("{}-01-01T00:00:00+00:00", year)).unwrap().timestamp(); let time_end = DateTime::parse_from_rfc3339(&format!("{}-12-31T23:59:59+00:00", year)).unwrap().timestamp(); let url = format!("https://api.coinmarketcap.com/data-api/v3.1/cryptocurrency/historical?id={}&convertId={}&timeStart={}&timeEnd={}&interval=1d", id, convert_id, time_start, time_end); + println!("Downloading {}", url); let response: CmcHistoricalDataResponse = reqwest::Client::builder() .user_agent("Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0") diff --git a/src/main.rs b/src/main.rs index 709daeb..822e100 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,7 @@ mod trezor; use anyhow::{Context, Result, anyhow}; use base::{Operation, Amount, Transaction, cmc_id, PriceHistory}; -use chrono::{Duration, Datelike, Utc, TimeZone, Local}; +use chrono::{Duration, Datelike, Utc, TimeZone, Local, NaiveDateTime}; use directories::ProjectDirs; use raccoin_ui::*; use fifo::{FIFO, CapitalGain}; @@ -268,6 +268,8 @@ struct Portfolio { #[serde(default)] wallets: Vec, #[serde(default)] + price_history: PriceHistory, + #[serde(default)] ignored_currencies: Vec, #[serde(default)] merge_consecutive_trades: bool, @@ -376,7 +378,6 @@ struct App { portfolio: Portfolio, transactions: Vec, reports: Vec, - price_history: PriceHistory, transaction_filters: Vec, @@ -385,8 +386,6 @@ struct App { impl App { fn new() -> Self { - let mut price_history = PriceHistory::new(); - let project_dirs = ProjectDirs::from("org", "raccoin", "Raccoin"); let state = project_dirs.as_ref().and_then(|dirs| { let config_dir = dirs.config_local_dir(); @@ -401,7 +400,6 @@ impl App { portfolio: Portfolio::default(), transactions: Vec::new(), reports: Vec::new(), - price_history, transaction_filters: Vec::default(), @@ -476,7 +474,7 @@ impl App { } fn refresh_transactions(&mut self) { - self.transactions = load_transactions(&mut self.portfolio, &self.price_history).unwrap_or_default(); + self.transactions = load_transactions(&mut self.portfolio).unwrap_or_default(); self.reports = calculate_tax_reports(&mut self.transactions); } @@ -624,6 +622,7 @@ pub(crate) fn export_all_to(app: &App, output_path: &Path) -> Result<()> { cost: rounded_to_cent(report.short_term_cost), gains: rounded_to_cent(report.short_term_proceeds - report.short_term_cost), })?; + let year = if report.year == 0 { "all_time".to_owned() } else { report.year.to_string() }; let path = output_path.join(format!("{}_report_summary.csv", year)); save_summary_to_csv(report, &path)?; @@ -634,7 +633,7 @@ pub(crate) fn export_all_to(app: &App, output_path: &Path) -> Result<()> { Ok(()) } -fn load_transactions(portfolio: &mut Portfolio, price_history: &PriceHistory) -> Result> { +fn load_transactions(portfolio: &mut Portfolio) -> Result> { let (wallets, ignored_currencies) = (&mut portfolio.wallets, &portfolio.ignored_currencies); let mut transactions = Vec::new(); @@ -809,7 +808,7 @@ fn load_transactions(portfolio: &mut Portfolio, price_history: &PriceHistory) -> } match_send_receive(&mut transactions); - estimate_transaction_values(&mut transactions, price_history); + estimate_transaction_values(&mut transactions, &portfolio.price_history); Ok(transactions) } @@ -1037,6 +1036,58 @@ fn estimate_transaction_values(transactions: &mut Vec, price_histor transactions.iter_mut().for_each(estimate_transaction_value); } +async fn download_price_history(transactions: Vec) -> Result { + let mut price_history = PriceHistory::empty(); + + async fn ensure_price_point(price_history: &mut PriceHistory, timestamp: NaiveDateTime, currency: &str) { + // If there is already a price point within 1 hour, we don't need to download new price points + if let Some((_, accuracy)) = price_history.estimate_price_with_accuracy(timestamp, currency) { + if accuracy <= Duration::hours(1) { + return; + } + } + + let price_points = coinmarketcap::download_price_points(timestamp, currency).await; + match price_points { + Ok(price_points) => { + price_history.add_price_points(currency.into(), price_points); + } + Err(e) => { + println!("warning: failed to download price points for {:}: {:?}", currency, e); + } + } + } + + for tx in transactions.iter().rev() { + match tx.incoming_outgoing() { + (Some(incoming), Some(outgoing)) => { + if !incoming.is_fiat() { + ensure_price_point(&mut price_history, tx.timestamp, &incoming.currency).await; + } + if !outgoing.is_fiat() { + ensure_price_point(&mut price_history, tx.timestamp, &outgoing.currency).await; + } + } + (Some(amount), None) | + (None, Some(amount)) => { + if !amount.is_fiat() { + ensure_price_point(&mut price_history, tx.timestamp, &amount.currency).await; + } + } + (None, None) => {} + }; + + match &tx.fee { + Some(amount) => { + ensure_price_point(&mut price_history, tx.timestamp, &amount.currency).await; + } + None => {} + }; + } + + Ok(price_history) +} + fn calculate_tax_reports(transactions: &mut Vec) -> Vec { let mut currencies = Vec::::new(); @@ -1501,7 +1552,7 @@ fn ui_set_portfolio(app: &App) { return None } - let current_price = app.price_history.estimate_price(now, ¤cy.currency); + let current_price = app.portfolio.price_history.estimate_price(now, ¤cy.currency); let current_value = current_price.map(|price| currency.balance_end * price).unwrap_or(Decimal::ZERO); let unrealized_gain = current_value - currency.cost_end; let roi = if currency.cost_end > Decimal::ZERO { Some(unrealized_gain / currency.cost_end * Decimal::ONE_HUNDRED) } else { None }; @@ -1542,6 +1593,9 @@ fn ui_set_portfolio(app: &App) { #[tokio::main] async fn main() -> Result<()> { + // coinmarketcap::download_price_history("ETH").await?; + // coinmarketcap::download_price_history("BTC").await?; + let mut app = App::new(); // Load portfolio from command-line or from previous application state @@ -1873,6 +1927,36 @@ async fn main() -> Result<()> { } }); + facade.on_update_price_history({ + let app = app.clone(); + + move || { + let app_for_future = app.clone(); + let transactions = app.lock().unwrap().transactions.clone(); + + tokio::task::spawn(async move { + let price_history = download_price_history(transactions).await; + + slint::invoke_from_event_loop(move || { + let mut app = app_for_future.lock().unwrap(); + + match price_history { + Ok(price_history) => { + app.portfolio.price_history = price_history; + app.refresh_transactions(); + app.refresh_ui(); + app.save_portfolio(None); + } + Err(e) => { + // todo: show error in UI + println!("Error updating price history: {}", e); + }, + } + }).unwrap(); + }); + } + }); + fn save_csv_file(title: &str, starting_file_name: &str) -> Option { let dialog = rfd::FileDialog::new() .set_title(title)