Skip to content

Commit

Permalink
Added "Update Price History" button
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
bjorn committed Apr 23, 2024
1 parent 3e08876 commit 9e06a1b
Show file tree
Hide file tree
Showing 5 changed files with 180 additions and 13 deletions.
2 changes: 2 additions & 0 deletions raccoin_ui/ui/global.slint
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions raccoin_ui/ui/portfolio.slint
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
41 changes: 39 additions & 2 deletions src/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,12 +485,25 @@ pub(crate) fn load_transactions_from_json(input_path: &Path) -> Result<Vec<Trans
Ok(transactions)
}

#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, Copy)]
pub(crate) struct PricePoint {
pub timestamp: NaiveDateTime,
pub price: Decimal,
}

impl PartialOrd for PricePoint {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
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<String, Vec<PricePoint>>,
}
Expand All @@ -506,10 +519,23 @@ impl PriceHistory {
Self { prices }
}

pub(crate) fn insert_price_points(&mut self, currency: String, price_points: Vec<PricePoint>) {
pub(crate) fn empty() -> Self {
Self {
prices: HashMap::new(),
}
}

pub(crate) fn set_price_points(&mut self, currency: String, price_points: Vec<PricePoint>) {
self.prices.insert(currency, price_points);
}

pub(crate) fn add_price_points(&mut self, currency: String, price_points: Vec<PricePoint>) {
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<Decimal> {
match currency {
Expand All @@ -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<Amount> {
self.estimate_price(timestamp, &amount.currency).map(|price| Amount::new(price * amount.quantity, "EUR".to_owned()))
}
Expand Down
44 changes: 42 additions & 2 deletions src/coinmarketcap.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -55,6 +55,45 @@ struct Quote {
// timestamp: DateTime<FixedOffset>,
}

#[allow(dead_code)]
pub(crate) async fn download_price_points(time_end: NaiveDateTime, currency: &str) -> Result<Vec<PricePoint>> {
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<PricePoint> = 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<PricePoint> = 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);
Expand All @@ -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")
Expand Down
102 changes: 93 additions & 9 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -268,6 +268,8 @@ struct Portfolio {
#[serde(default)]
wallets: Vec<Wallet>,
#[serde(default)]
price_history: PriceHistory,
#[serde(default)]
ignored_currencies: Vec<String>,
#[serde(default)]
merge_consecutive_trades: bool,
Expand Down Expand Up @@ -376,7 +378,6 @@ struct App {
portfolio: Portfolio,
transactions: Vec<Transaction>,
reports: Vec<TaxReport>,
price_history: PriceHistory,

transaction_filters: Vec<TransactionFilter>,

Expand All @@ -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();
Expand All @@ -401,7 +400,6 @@ impl App {
portfolio: Portfolio::default(),
transactions: Vec::new(),
reports: Vec::new(),
price_history,

transaction_filters: Vec::default(),

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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)?;
Expand All @@ -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<Vec<Transaction>> {
fn load_transactions(portfolio: &mut Portfolio) -> Result<Vec<Transaction>> {
let (wallets, ignored_currencies) = (&mut portfolio.wallets, &portfolio.ignored_currencies);
let mut transactions = Vec::new();

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -1037,6 +1036,58 @@ fn estimate_transaction_values(transactions: &mut Vec<Transaction>, price_histor
transactions.iter_mut().for_each(estimate_transaction_value);
}

async fn download_price_history(transactions: Vec<Transaction>) -> Result<PriceHistory> {
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<Transaction>) -> Vec<TaxReport> {
let mut currencies = Vec::<CurrencySummary>::new();

Expand Down Expand Up @@ -1501,7 +1552,7 @@ fn ui_set_portfolio(app: &App) {
return None
}

let current_price = app.price_history.estimate_price(now, &currency.currency);
let current_price = app.portfolio.price_history.estimate_price(now, &currency.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 };
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<PathBuf> {
let dialog = rfd::FileDialog::new()
.set_title(title)
Expand Down

0 comments on commit 9e06a1b

Please sign in to comment.