Skip to content

Commit

Permalink
Added support for importing FTX CSV files
Browse files Browse the repository at this point in the history
  • Loading branch information
bjorn committed Dec 5, 2023
1 parent 8dfcbf6 commit 11cbdaf
Show file tree
Hide file tree
Showing 2 changed files with 194 additions and 3 deletions.
172 changes: 172 additions & 0 deletions src/ftx.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
use std::path::Path;

use anyhow::{Result, bail};
use chrono::{NaiveDateTime, FixedOffset, DateTime};
use rust_decimal::Decimal;
use serde::{Deserialize, Deserializer};

use crate::base::{Transaction, Amount};

// function for reading NaiveDateTime in the format "2/25/2021, 2:24:46 PM"
pub(crate) fn deserialize_date_time_mdy<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<NaiveDateTime, D::Error> {
let raw: &str = Deserialize::deserialize(d)?;
Ok(NaiveDateTime::parse_from_str(raw, "%m/%d/%Y, %I:%M:%S %p").unwrap())
}

/// FTX Deposit
// " ","Time","Coin","Amount","Status","Additional info","Transaction ID"
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct FtxDeposit {
// #[serde(rename = " ")]
// id: String,
time: DateTime<FixedOffset>,
coin: String,
amount: Decimal,
// status: String, // "complete" or "confirmed"
#[serde(rename = "Additional info")]
additional_info: String,
#[serde(rename = "Transaction ID")]
transaction_id: String,
}

impl From<FtxDeposit> for Transaction {
fn from(deposit: FtxDeposit) -> Self {
let blockchain = deposit.coin.clone();
let mut tx = Transaction::receive(deposit.time.naive_utc(), Amount::new(deposit.amount, deposit.coin));
tx.tx_hash = Some(deposit.transaction_id);
tx.blockchain = Some(blockchain);
tx.description = Some(deposit.additional_info);
tx
}
}

/// FTX Withdrawal
// " ","Time","Coin","Amount","Destination","Status","Transaction ID","fee"
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct FtxWithdrawal {
#[serde(rename = " ")]
id: String,
time: DateTime<FixedOffset>,
coin: String,
amount: Decimal,
// destination: String,
// status: String,
#[serde(rename = "Transaction ID")]
transaction_id: String,
#[serde(rename = "fee")]
fee: Decimal,
}

impl From<FtxWithdrawal> for Transaction {
// todo: add destination?
fn from(withdrawal: FtxWithdrawal) -> Self {
let fee_currency = withdrawal.coin.clone();
let blockchain = withdrawal.coin.clone();
let mut tx = Transaction::send(withdrawal.time.naive_utc(), Amount::new(withdrawal.amount, withdrawal.coin));
tx.fee = Some(Amount::new(withdrawal.fee, fee_currency));
tx.tx_hash = Some(withdrawal.transaction_id);
tx.blockchain = Some(blockchain);
tx.description = Some(withdrawal.id);
tx
}
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
enum Side {
Buy,
Sell,
}

/// FTX Trade
// "ID","Time","Market","Side","Order Type","Size","Price","Total","Fee","Fee Currency","TWAP"
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct FtxTrade {
#[serde(rename = "ID")]
id: String,
#[serde(deserialize_with = "deserialize_date_time_mdy")]
time: NaiveDateTime,
market: String,
side: Side,
// #[serde(rename = "Order Type")]
// order_type: String, // "OTC" or "Order" have been observed
size: Decimal,
// price: Decimal,
total: Decimal,
fee: Option<Decimal>,
#[serde(rename = "Fee Currency")]
fee_currency: String,
// /// Whether the trade was executed using Time-Weighted Average Price.
// #[serde(rename = "TWAP")]
// twap: bool,
}

// FTX trades report in USD even if USDC was deposited. This is likely not the
// right way to resolve this issue...
fn normalize_currency(currency: &str) -> &str {
match currency {
"USD" => "USDC",
_ => currency,
}
}

impl TryFrom<FtxTrade> for Transaction {
type Error = anyhow::Error;

fn try_from(trade: FtxTrade) -> Result<Self, Self::Error> {
let mut split = trade.market.split('/');
match (split.next(), split.next()) {
(Some(base_currency), Some(quote_currency)) => {
let base = Amount::new(trade.size, normalize_currency(base_currency).to_owned());
let quote = Amount::new(trade.total, normalize_currency(quote_currency).to_owned());
let mut tx = match trade.side {
Side::Buy => Transaction::trade(trade.time, base, quote),
Side::Sell => Transaction::trade(trade.time, quote, base),
};
tx.fee = trade.fee.map(|fee| Amount::new(fee, normalize_currency(&trade.fee_currency).to_owned()));
tx.description = Some(trade.id);
Ok(tx)
}
_ => bail!("Invalid market value, expected: '<base_currency>/<quote_currency>'"),
}
}
}

pub(crate) fn load_ftx_deposits_csv(input_path: &Path) -> Result<Vec<Transaction>> {
let mut rdr = csv::ReaderBuilder::new().from_path(input_path)?;
let mut transactions = Vec::new();

for result in rdr.deserialize() {
let record: FtxDeposit = result?;
transactions.push(record.into());
}

Ok(transactions)
}

pub(crate) fn load_ftx_withdrawals_csv(input_path: &Path) -> Result<Vec<Transaction>> {
let mut rdr = csv::ReaderBuilder::new().from_path(input_path)?;
let mut transactions = Vec::new();

for result in rdr.deserialize() {
let record: FtxWithdrawal = result?;
transactions.push(record.into());
}

Ok(transactions)
}

pub(crate) fn load_ftx_trades_csv(input_path: &Path) -> Result<Vec<Transaction>> {
let mut rdr = csv::ReaderBuilder::new().from_path(input_path)?;
let mut transactions = Vec::new();

for result in rdr.deserialize() {
let record: FtxTrade = result?;
transactions.push(record.try_into()?);
}

Ok(transactions)
}
25 changes: 22 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod electrum;
mod esplora;
mod etherscan;
mod fifo;
mod ftx;
mod horizon;
mod liquid;
mod mycelium;
Expand Down Expand Up @@ -56,6 +57,9 @@ enum TransactionsSourceType {
Json,
MyceliumCsv,
PeercoinCsv,
FtxDepositsCsv,
FtxWithdrawalsCsv,
FtxTradesCsv,
LiquidDepositsCsv,
LiquidTradesCsv,
LiquidWithdrawalsCsv,
Expand Down Expand Up @@ -143,6 +147,9 @@ impl TransactionsSourceType {
TransactionsSourceType::CtcImportCsv => &["Timestamp (UTC)", "Type", "Base Currency", "Base Amount", "Quote Currency (Optional)", "Quote Amount (Optional)", "Fee Currency (Optional)", "Fee Amount (Optional)", "From (Optional)", "To (Optional)", "Blockchain (Optional)", "ID (Optional)", "Description (Optional)", "Reference Price Per Unit (Optional)", "Reference Price Currency (Optional)"],
TransactionsSourceType::ElectrumCsv => &["transaction_hash", "label", "confirmations", "value", "fiat_value", "fee", "fiat_fee", "timestamp"],
TransactionsSourceType::MyceliumCsv => &["Account", "Transaction ID", "Destination Address", "Timestamp", "Value", "Currency", "Transaction Label"],
TransactionsSourceType::FtxDepositsCsv => &[" ", "Time", "Coin", "Amount", "Status", "Additional info", "Transaction ID"],
TransactionsSourceType::FtxWithdrawalsCsv => &[" ", "Time", "Coin", "Amount", "Destination", "Status", "Transaction ID", "fee"],
TransactionsSourceType::FtxTradesCsv => &["ID", "Time", "Market", "Side", "Order Type", "Size", "Price", "Total", "Fee", "Fee Currency", "TWAP"],
TransactionsSourceType::LiquidDepositsCsv => &["ID", "Type", "Amount", "Status", "Created (YY/MM/DD)", "Hash"],
TransactionsSourceType::LiquidTradesCsv => &["Quoted currency", "Base currency", "Qex/liquid", "Execution", "Type", "Date", "Open qty", "Price", "Fee", "Fee currency", "Amount", "Trade side"],
TransactionsSourceType::LiquidWithdrawalsCsv => &["ID", "Wallet label", "Amount", "Created On", "Transfer network", "Status", "Address", "Liquid Fee", "Network Fee", "Broadcasted At", "Hash"],
Expand Down Expand Up @@ -176,6 +183,9 @@ impl ToString for TransactionsSourceType {
TransactionsSourceType::CtcImportCsv => "CryptoTaxCalculator import (CSV)".to_owned(),
TransactionsSourceType::MyceliumCsv => "Mycelium (CSV)".to_owned(),
TransactionsSourceType::PeercoinCsv => "Peercoin Qt (CSV)".to_owned(),
TransactionsSourceType::FtxDepositsCsv => "FTX Deposits (CSV)".to_owned(),
TransactionsSourceType::FtxWithdrawalsCsv => "FTX Withdrawal (CSV)".to_owned(),
TransactionsSourceType::FtxTradesCsv => "FTX Trades (CSV)".to_owned(),
TransactionsSourceType::LiquidDepositsCsv => "Liquid Deposits (CSV)".to_owned(),
TransactionsSourceType::LiquidTradesCsv => "Liquid Trades (CSV)".to_owned(),
TransactionsSourceType::LiquidWithdrawalsCsv => "Liquid Withdrawals (CSV)".to_owned(),
Expand Down Expand Up @@ -630,6 +640,15 @@ fn load_transactions(wallets: &mut Vec<Wallet>, ignored_currencies: &Vec<String>
TransactionsSourceType::PeercoinCsv => {
bitcoin_core::load_peercoin_csv(&source.full_path)
}
TransactionsSourceType::FtxDepositsCsv => {
ftx::load_ftx_deposits_csv(&source.full_path)
}
TransactionsSourceType::FtxWithdrawalsCsv => {
ftx::load_ftx_withdrawals_csv(&source.full_path)
}
TransactionsSourceType::FtxTradesCsv => {
ftx::load_ftx_trades_csv(&source.full_path)
}
TransactionsSourceType::LiquidDepositsCsv => {
liquid::load_liquid_deposits_csv(&source.full_path)
}
Expand Down Expand Up @@ -1281,7 +1300,7 @@ fn ui_set_transactions(app: &App) {
from: from.unwrap_or_default(),
to: to.unwrap_or_default(),
date: timestamp.date().to_string().into(),
time: timestamp.time().to_string().into(),
time: timestamp.time().format("%H:%M:%S").to_string().into(),
tx_type,
received_cmc_id: received.map(Amount::cmc_id).unwrap_or(-1),
received: received.map_or_else(String::default, Amount::to_string).into(),
Expand Down Expand Up @@ -1323,9 +1342,9 @@ fn ui_set_reports(app: &App) {
UiCapitalGain {
currency_cmc_id: gain.amount.cmc_id(),
bought_date: bought.date().to_string().into(),
bought_time: bought.time().to_string().into(),
bought_time: bought.time().format("%H:%M:%S").to_string().into(),
sold_date: sold.date().to_string().into(),
sold_time: sold.time().to_string().into(),
sold_time: sold.time().format("%H:%M:%S").to_string().into(),
amount: gain.amount.to_string().into(),
// todo: something else than unwrap()?
cost: rounded_to_cent(gain.cost).try_into().unwrap(),
Expand Down

0 comments on commit 11cbdaf

Please sign in to comment.