diff --git a/crates/subgraph/src/apy.rs b/crates/subgraph/src/apy.rs new file mode 100644 index 000000000..56d3b7c9a --- /dev/null +++ b/crates/subgraph/src/apy.rs @@ -0,0 +1,353 @@ +use crate::{ + types::common::{Erc20, Trade}, + utils::{annual_rate, one_18, to_18_decimals}, + vol::VaultVolume, + OrderbookSubgraphClientError, +}; +use alloy::primitives::{utils::ParseUnits, I256, U256}; +use chrono::TimeDelta; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use typeshare::typeshare; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare] +pub struct VaultAPY { + pub id: String, + pub token: Erc20, + #[typeshare(typescript(type = "number"))] + pub start_time: u64, + #[typeshare(typescript(type = "number"))] + pub end_time: u64, + #[typeshare(typescript(type = "string"))] + pub net_vol: I256, + #[typeshare(typescript(type = "string"))] + pub capital: I256, + #[typeshare(typescript(type = "string"))] + pub apy: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare] +pub struct TokenPair { + pub input: Erc20, + pub output: Erc20, +} + +/// Calculates each token vault apy at the given timeframe +/// Trades must be sorted indesc order by timestamp, this is +/// the case if queried from subgraph using this lib functionalities +pub fn get_vaults_apy( + trades: &[Trade], + vols: &[VaultVolume], + start_timestamp: Option, + end_timestamp: Option, +) -> Result, OrderbookSubgraphClientError> { + let mut token_vaults_apy: Vec = vec![]; + for vol in vols { + let vol = vol.to_18_decimals()?; + // this token vault trades in desc order by timestamp + let vault_trades = trades + .iter() + .filter(|v| { + (v.input_vault_balance_change.vault.vault_id.0 == vol.id + && v.input_vault_balance_change.vault.token == vol.token) + || (v.output_vault_balance_change.vault.vault_id.0 == vol.id + && v.output_vault_balance_change.vault.token == vol.token) + }) + .collect::>(); + + // this token vault first trade, indictaes the start time + // to find the end of the first day to find the starting capital + let first_trade = vault_trades[vault_trades.len() - 1]; + let first_day_last_trade = vault_trades + .iter() + .filter(|v| { + u64::from_str(&v.timestamp.0) + .ok() + .zip(u64::from_str(&first_trade.timestamp.0).ok()) + .is_some_and(|(trade_time, first_trade_time)| { + trade_time <= first_trade_time + TimeDelta::days(1).num_seconds() as u64 + }) + }) + .collect::>()[0]; + + // vaults starting capital at end of first day of its first ever trade + // as 18 point decimals + let vault_balance_change = if first_day_last_trade + .input_vault_balance_change + .vault + .vault_id + .0 + == vol.id + && first_day_last_trade.input_vault_balance_change.vault.token == vol.token + { + &first_day_last_trade.input_vault_balance_change + } else { + &first_day_last_trade.output_vault_balance_change + }; + let starting_capital = U256::from_str(&vault_balance_change.new_vault_balance.0) + .ok() + .and_then(|amount| { + to_18_decimals( + ParseUnits::U256(amount), + vault_balance_change + .vault + .token + .decimals + .as_ref() + .map(|v| v.0.as_str()) + .unwrap_or("18"), + ) + .ok() + }); + + // the time range for this token vault + let mut start = u64::from_str(&first_trade.timestamp.0)?; + start_timestamp.inspect(|t| { + if start > *t { + start = *t; + } + }); + let end = end_timestamp.unwrap_or(chrono::Utc::now().timestamp() as u64); + + // this token vault apy in 18 decimals point + let apy = starting_capital.and_then(|starting_capital| { + (!starting_capital.is_zero()) + .then_some( + vol.net_vol + .saturating_mul(one_18().get_signed()) + .saturating_div(starting_capital.get_signed()) + .saturating_mul(one_18().get_signed()) + .checked_div(annual_rate(start, end)), + ) + .flatten() + }); + + // this token vault apy + token_vaults_apy.push(VaultAPY { + id: vol.id.clone(), + token: vol.token.clone(), + start_time: start, + end_time: end, + apy, + net_vol: vol.net_vol, + capital: starting_capital + .unwrap_or(ParseUnits::I256(I256::ZERO)) + .get_signed(), + }); + } + + Ok(token_vaults_apy) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::types::common::{ + BigInt, Bytes, Orderbook, TradeEvent, TradeStructPartialOrder, TradeVaultBalanceChange, + Transaction, VaultBalanceChangeVault, + }; + use alloy::primitives::{Address, B256}; + + #[test] + fn test_get_vaults_apy() { + let trades = get_trades(); + let [token1, token2] = get_tokens(); + let [vault1, vault2] = get_vault_ids(); + let vault_vol1 = VaultVolume { + id: vault1.to_string(), + token: token1.clone(), + total_in: U256::ZERO, + total_out: U256::ZERO, + total_vol: U256::ZERO, + net_vol: I256::from_str("1000000000000000000").unwrap(), + }; + let vault_vol2 = VaultVolume { + id: vault2.to_string(), + token: token2.clone(), + total_in: U256::ZERO, + total_out: U256::ZERO, + total_vol: U256::ZERO, + net_vol: I256::from_str("2000000000000000000").unwrap(), + }; + let result = + get_vaults_apy(&trades, &[vault_vol1, vault_vol2], Some(1), Some(10000001)).unwrap(); + let expected = vec![ + VaultAPY { + id: vault1.to_string(), + token: token1.clone(), + start_time: 1, + end_time: 10000001, + net_vol: I256::from_str("1000000000000000000").unwrap(), + capital: I256::from_str("5000000000000000000").unwrap(), + // (1/5) / (10000001_end - 1_start / 31_536_00_year) + apy: Some(I256::from_str("630720000000000000").unwrap()), + }, + VaultAPY { + id: vault2.to_string(), + token: token2.clone(), + start_time: 1, + end_time: 10000001, + net_vol: I256::from_str("2000000000000000000").unwrap(), + capital: I256::from_str("5000000000000000000").unwrap(), + // (2/5) / ((10000001_end - 1_start) / 31_536_00_year) + apy: Some(I256::from_str("1261440000000000000").unwrap()), + }, + ]; + + assert_eq!(result, expected); + } + + fn get_vault_ids() -> [B256; 2] { + [ + B256::from_slice(&[0x11u8; 32]), + B256::from_slice(&[0x22u8; 32]), + ] + } + fn get_tokens() -> [Erc20; 2] { + let token1_address = Address::from_slice(&[0x11u8; 20]); + let token2_address = Address::from_slice(&[0x22u8; 20]); + let token1 = Erc20 { + id: Bytes(token1_address.to_string()), + address: Bytes(token1_address.to_string()), + name: Some("Token1".to_string()), + symbol: Some("Token1".to_string()), + decimals: Some(BigInt(18.to_string())), + }; + let token2 = Erc20 { + id: Bytes(token2_address.to_string()), + address: Bytes(token2_address.to_string()), + name: Some("Token2".to_string()), + symbol: Some("Token2".to_string()), + decimals: Some(BigInt(18.to_string())), + }; + [token1, token2] + } + + fn get_trades() -> Vec { + let bytes = Bytes("".to_string()); + let bigint = BigInt("".to_string()); + let [vault_id1, vault_id2] = get_vault_ids(); + let [token1, token2] = get_tokens(); + let trade1 = Trade { + id: bytes.clone(), + order: TradeStructPartialOrder { + id: bytes.clone(), + order_hash: bytes.clone(), + }, + trade_event: TradeEvent { + sender: bytes.clone(), + transaction: Transaction { + id: bytes.clone(), + from: bytes.clone(), + block_number: bigint.clone(), + timestamp: bigint.clone(), + }, + }, + timestamp: BigInt("1".to_string()), + orderbook: Orderbook { id: bytes.clone() }, + output_vault_balance_change: TradeVaultBalanceChange { + id: bytes.clone(), + __typename: "TradeVaultBalanceChange".to_string(), + amount: BigInt("-2000000000000000000".to_string()), + new_vault_balance: BigInt("2000000000000000000".to_string()), + old_vault_balance: bigint.clone(), + vault: VaultBalanceChangeVault { + id: bytes.clone(), + token: token1.clone(), + vault_id: BigInt(vault_id1.to_string()), + }, + timestamp: BigInt("1".to_string()), + transaction: Transaction { + id: bytes.clone(), + from: bytes.clone(), + block_number: bigint.clone(), + timestamp: BigInt("1".to_string()), + }, + orderbook: Orderbook { id: bytes.clone() }, + }, + input_vault_balance_change: TradeVaultBalanceChange { + id: bytes.clone(), + __typename: "TradeVaultBalanceChange".to_string(), + amount: BigInt("5000000000000000000".to_string()), + new_vault_balance: BigInt("2000000000000000000".to_string()), + old_vault_balance: bigint.clone(), + vault: VaultBalanceChangeVault { + id: bytes.clone(), + token: token2.clone(), + vault_id: BigInt(vault_id2.to_string()), + }, + timestamp: BigInt("1".to_string()), + transaction: Transaction { + id: bytes.clone(), + from: bytes.clone(), + block_number: bigint.clone(), + timestamp: BigInt("1".to_string()), + }, + orderbook: Orderbook { id: bytes.clone() }, + }, + }; + let trade2 = Trade { + id: bytes.clone(), + order: TradeStructPartialOrder { + id: bytes.clone(), + order_hash: bytes.clone(), + }, + trade_event: TradeEvent { + sender: bytes.clone(), + transaction: Transaction { + id: bytes.clone(), + from: bytes.clone(), + block_number: bigint.clone(), + timestamp: bigint.clone(), + }, + }, + timestamp: BigInt("2".to_string()), + orderbook: Orderbook { id: bytes.clone() }, + output_vault_balance_change: TradeVaultBalanceChange { + id: bytes.clone(), + __typename: "TradeVaultBalanceChange".to_string(), + amount: BigInt("-2000000000000000000".to_string()), + new_vault_balance: BigInt("5000000000000000000".to_string()), + old_vault_balance: bigint.clone(), + vault: VaultBalanceChangeVault { + id: bytes.clone(), + token: token2.clone(), + vault_id: BigInt(vault_id2.to_string()), + }, + timestamp: BigInt("2".to_string()), + transaction: Transaction { + id: bytes.clone(), + from: bytes.clone(), + block_number: bigint.clone(), + timestamp: BigInt("1".to_string()), + }, + orderbook: Orderbook { id: bytes.clone() }, + }, + input_vault_balance_change: TradeVaultBalanceChange { + id: bytes.clone(), + __typename: "TradeVaultBalanceChange".to_string(), + amount: BigInt("7000000000000000000".to_string()), + new_vault_balance: BigInt("5000000000000000000".to_string()), + old_vault_balance: bigint.clone(), + vault: VaultBalanceChangeVault { + id: bytes.clone(), + token: token1.clone(), + vault_id: BigInt(vault_id1.to_string()), + }, + timestamp: BigInt("2".to_string()), + transaction: Transaction { + id: bytes.clone(), + from: bytes.clone(), + block_number: bigint.clone(), + timestamp: BigInt("1".to_string()), + }, + orderbook: Orderbook { id: bytes.clone() }, + }, + }; + vec![trade2, trade1] + } +} diff --git a/crates/subgraph/src/error.rs b/crates/subgraph/src/error.rs new file mode 100644 index 000000000..cc146e7ce --- /dev/null +++ b/crates/subgraph/src/error.rs @@ -0,0 +1,65 @@ +use crate::{cynic_client::CynicClientError, pagination::PaginationClientError}; +use alloy::primitives::{ + ruint::ParseError, utils::UnitsError, BigIntConversionError, ParseSignedError, +}; +use std::num::{ParseFloatError, ParseIntError}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum OrderbookSubgraphClientError { + #[error(transparent)] + CynicClientError(#[from] CynicClientError), + #[error("Subgraph query returned no data")] + Empty, + #[error(transparent)] + PaginationClientError(#[from] PaginationClientError), + #[error(transparent)] + ParseNumberError(#[from] crate::error::ParseNumberError), +} + +#[derive(Error, Debug)] +pub enum ParseNumberError { + #[error(transparent)] + UnitsError(#[from] UnitsError), + #[error(transparent)] + ParseUnsignedError(#[from] ParseError), + #[error(transparent)] + ParseSignedError(#[from] ParseSignedError), + #[error(transparent)] + BigIntConversionError(#[from] BigIntConversionError), + #[error(transparent)] + ParseIntError(#[from] ParseIntError), + #[error(transparent)] + ParseFloatError(#[from] ParseFloatError), +} + +impl From for OrderbookSubgraphClientError { + fn from(value: UnitsError) -> Self { + ParseNumberError::from(value).into() + } +} +impl From for OrderbookSubgraphClientError { + fn from(value: ParseError) -> Self { + ParseNumberError::from(value).into() + } +} +impl From for OrderbookSubgraphClientError { + fn from(value: ParseSignedError) -> Self { + ParseNumberError::from(value).into() + } +} +impl From for OrderbookSubgraphClientError { + fn from(value: BigIntConversionError) -> Self { + ParseNumberError::from(value).into() + } +} +impl From for OrderbookSubgraphClientError { + fn from(value: ParseIntError) -> Self { + ParseNumberError::from(value).into() + } +} +impl From for OrderbookSubgraphClientError { + fn from(value: ParseFloatError) -> Self { + ParseNumberError::from(value).into() + } +} diff --git a/crates/subgraph/src/lib.rs b/crates/subgraph/src/lib.rs index 2ca4cc6f5..c257fab1b 100644 --- a/crates/subgraph/src/lib.rs +++ b/crates/subgraph/src/lib.rs @@ -1,4 +1,6 @@ +pub mod apy; mod cynic_client; +pub mod error; mod orderbook_client; mod pagination; pub mod types; @@ -10,5 +12,6 @@ pub mod vol; #[cynic::schema("orderbook")] pub mod schema {} -pub use orderbook_client::{OrderbookSubgraphClient, OrderbookSubgraphClientError}; +pub use error::*; +pub use orderbook_client::OrderbookSubgraphClient; pub use pagination::{PageQueryClient, PaginationArgs}; diff --git a/crates/subgraph/src/orderbook_client.rs b/crates/subgraph/src/orderbook_client.rs index 4663f8ce7..9612fa1b1 100644 --- a/crates/subgraph/src/orderbook_client.rs +++ b/crates/subgraph/src/orderbook_client.rs @@ -1,9 +1,10 @@ -use crate::cynic_client::{CynicClient, CynicClientError}; -use crate::pagination::{PaginationArgs, PaginationClient, PaginationClientError}; +use crate::cynic_client::CynicClient; +use crate::error::OrderbookSubgraphClientError; +use crate::pagination::{PaginationArgs, PaginationClient}; use crate::types::common::*; use crate::types::order::{ BatchOrderDetailQuery, BatchOrderDetailQueryVariables, OrderDetailQuery, OrderIdList, - OrdersListQuery, + OrderPerformance, OrdersListQuery, }; use crate::types::order_trade::{OrderTradeDetailQuery, OrderTradesListQuery}; use crate::types::vault::{VaultDetailQuery, VaultsListQuery}; @@ -11,22 +12,9 @@ use crate::vault_balance_changes_query::VaultBalanceChangesListPageQueryClient; use crate::vol::{get_vaults_vol, VaultVolume}; use cynic::Id; use reqwest::Url; -use thiserror::Error; const ALL_PAGES_QUERY_PAGE_SIZE: u16 = 200; -#[derive(Error, Debug)] -pub enum OrderbookSubgraphClientError { - #[error(transparent)] - CynicClientError(#[from] CynicClientError), - #[error("Subgraph query returned no data")] - Empty, - #[error(transparent)] - PaginationClientError(#[from] PaginationClientError), - #[error(transparent)] - ParseError(#[from] alloy::primitives::ruint::ParseError), -} - pub struct OrderbookSubgraphClient { url: Url, } @@ -216,6 +204,20 @@ impl OrderbookSubgraphClient { Ok(get_vaults_vol(&trades)?) } + /// Fetches order data and measures an order's detailed performance (apy and vol) + pub async fn order_performance( + &self, + order_id: cynic::Id, + start_timestamp: Option, + end_timestamp: Option, + ) -> Result { + let order = self.order_detail(order_id.clone()).await?; + let trades = self + .order_trades_list_all(order_id, start_timestamp, end_timestamp) + .await?; + OrderPerformance::measure(&order, &trades, start_timestamp, end_timestamp) + } + /// Fetch single vault pub async fn vault_detail(&self, id: Id) -> Result { let data = self diff --git a/crates/subgraph/src/types/common.rs b/crates/subgraph/src/types/common.rs index bef02ad12..0489778a3 100644 --- a/crates/subgraph/src/types/common.rs +++ b/crates/subgraph/src/types/common.rs @@ -278,7 +278,7 @@ pub struct OrderStructPartialTrade { pub id: Bytes, } -#[derive(cynic::QueryFragment, Debug, Serialize, Clone, PartialEq)] +#[derive(cynic::QueryFragment, Debug, Serialize, Clone, PartialEq, Eq, Hash)] #[cynic(graphql_type = "ERC20")] #[typeshare] pub struct Erc20 { @@ -305,11 +305,11 @@ pub struct AddOrder { pub transaction: Transaction, } -#[derive(cynic::Scalar, Debug, Clone, PartialEq)] +#[derive(cynic::Scalar, Debug, Clone, PartialEq, Eq, Hash)] #[typeshare] pub struct BigInt(pub String); -#[derive(cynic::Scalar, Debug, Clone, PartialEq)] +#[derive(cynic::Scalar, Debug, Clone, PartialEq, Eq, Hash)] #[typeshare] pub struct Bytes(pub String); diff --git a/crates/subgraph/src/types/impls.rs b/crates/subgraph/src/types/impls.rs new file mode 100644 index 000000000..7ecb055e5 --- /dev/null +++ b/crates/subgraph/src/types/impls.rs @@ -0,0 +1,178 @@ +use super::common::*; +use crate::{ + error::ParseNumberError, + utils::{one_18, to_18_decimals}, +}; +use alloy::primitives::{utils::ParseUnits, I256, U256}; +use std::str::FromStr; + +impl Trade { + /// Converts this trade's input to 18 point decimals in U256/I256 + pub fn input_as_18_decimals(&self) -> Result { + Ok(to_18_decimals( + ParseUnits::U256(U256::from_str(&self.input_vault_balance_change.amount.0)?), + self.input_vault_balance_change + .vault + .token + .decimals + .as_ref() + .map(|v| v.0.as_str()) + .unwrap_or("18"), + )?) + } + + /// Converts this trade's output to 18 point decimals in U256/I256 + pub fn output_as_18_decimals(&self) -> Result { + Ok(to_18_decimals( + ParseUnits::I256(I256::from_str(&self.output_vault_balance_change.amount.0)?), + self.output_vault_balance_change + .vault + .token + .decimals + .as_ref() + .map(|v| v.0.as_str()) + .unwrap_or("18"), + )?) + } + + /// Calculates the trade's I/O ratio + pub fn ratio(&self) -> Result { + Ok(self + .input_as_18_decimals()? + .get_absolute() + .saturating_mul(one_18().get_absolute()) + .checked_div( + self.output_as_18_decimals()? + .get_signed() + .saturating_neg() + .try_into()?, + ) + .unwrap_or(U256::MAX)) + } + + /// Calculates the trade's O/I ratio (inverse) + pub fn inverse_ratio(&self) -> Result { + Ok( + TryInto::::try_into(self.output_as_18_decimals()?.get_signed().saturating_neg())? + .saturating_mul(one_18().get_absolute()) + .checked_div(self.input_as_18_decimals()?.get_absolute()) + .unwrap_or(U256::MAX), + ) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::types::common::{ + BigInt, Bytes, Orderbook, TradeEvent, TradeStructPartialOrder, TradeVaultBalanceChange, + Transaction, VaultBalanceChangeVault, + }; + use alloy::primitives::Address; + + #[test] + fn test_input_to_18_decimals() { + let result = get_trade().input_as_18_decimals().unwrap(); + let expected = U256::from_str("3000000000000000000").unwrap(); + assert_eq!(result.get_absolute(), expected); + } + + #[test] + fn test_output_to_18_decimals() { + let result = get_trade().output_as_18_decimals().unwrap(); + let expected = I256::from_str("-6000000000000000000").unwrap(); + assert_eq!(result.get_signed(), expected); + } + + #[test] + fn test_ratio() { + let result = get_trade().ratio().unwrap(); + let expected = U256::from_str("500000000000000000").unwrap(); + assert_eq!(result, expected); + } + + #[test] + fn test_inverse_ratio() { + let result = get_trade().inverse_ratio().unwrap(); + let expected = U256::from_str("2000000000000000000").unwrap(); + assert_eq!(result, expected); + } + + // helper to get trade struct + fn get_trade() -> Trade { + let token_address = Address::from_slice(&[0x11u8; 20]); + let token = Erc20 { + id: Bytes(token_address.to_string()), + address: Bytes(token_address.to_string()), + name: Some("Token1".to_string()), + symbol: Some("Token1".to_string()), + decimals: Some(BigInt(6.to_string())), + }; + let input_trade_vault_balance_change = TradeVaultBalanceChange { + id: Bytes("".to_string()), + __typename: "".to_string(), + amount: BigInt("3000000".to_string()), + new_vault_balance: BigInt("".to_string()), + old_vault_balance: BigInt("".to_string()), + vault: VaultBalanceChangeVault { + id: Bytes("".to_string()), + vault_id: BigInt("".to_string()), + token: token.clone(), + }, + timestamp: BigInt("".to_string()), + transaction: Transaction { + id: Bytes("".to_string()), + from: Bytes("".to_string()), + block_number: BigInt("".to_string()), + timestamp: BigInt("".to_string()), + }, + orderbook: Orderbook { + id: Bytes("".to_string()), + }, + }; + let output_trade_vault_balance_change = TradeVaultBalanceChange { + id: Bytes("".to_string()), + __typename: "".to_string(), + amount: BigInt("-6000000".to_string()), + new_vault_balance: BigInt("".to_string()), + old_vault_balance: BigInt("".to_string()), + vault: VaultBalanceChangeVault { + id: Bytes("".to_string()), + vault_id: BigInt("".to_string()), + token: token.clone(), + }, + timestamp: BigInt("".to_string()), + transaction: Transaction { + id: Bytes("".to_string()), + from: Bytes("".to_string()), + block_number: BigInt("".to_string()), + timestamp: BigInt("".to_string()), + }, + orderbook: Orderbook { + id: Bytes("".to_string()), + }, + }; + Trade { + id: Bytes("".to_string()), + trade_event: TradeEvent { + transaction: Transaction { + id: Bytes("".to_string()), + from: Bytes("".to_string()), + block_number: BigInt("".to_string()), + timestamp: BigInt("".to_string()), + }, + sender: Bytes("".to_string()), + }, + output_vault_balance_change: output_trade_vault_balance_change, + input_vault_balance_change: input_trade_vault_balance_change, + order: TradeStructPartialOrder { + id: Bytes("".to_string()), + order_hash: Bytes("".to_string()), + }, + timestamp: BigInt("".to_string()), + orderbook: Orderbook { + id: Bytes("".to_string()), + }, + } + } +} diff --git a/crates/subgraph/src/types/mod.rs b/crates/subgraph/src/types/mod.rs index 88b092954..7e27af4e4 100644 --- a/crates/subgraph/src/types/mod.rs +++ b/crates/subgraph/src/types/mod.rs @@ -1,4 +1,5 @@ pub mod common; +pub mod impls; pub mod order; pub mod order_detail_traits; pub mod order_trade; diff --git a/crates/subgraph/src/types/order.rs b/crates/subgraph/src/types/order.rs index 233c566a9..8c7924657 100644 --- a/crates/subgraph/src/types/order.rs +++ b/crates/subgraph/src/types/order.rs @@ -1,6 +1,16 @@ use super::common::*; +use crate::apy::{get_vaults_apy, TokenPair}; use crate::schema; -use serde::Serialize; +use crate::utils::annual_rate; +use crate::{ + types::common::{Erc20, Order, Trade}, + utils::one_18, + vol::get_vaults_vol, + OrderbookSubgraphClientError, +}; +use alloy::primitives::{I256, U256}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; use typeshare::typeshare; #[derive(cynic::QueryVariables, Debug)] @@ -41,3 +51,604 @@ pub struct OrderDetailQuery { #[arguments(id: $id)] pub order: Option, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +#[serde(rename_all = "camelCase")] +#[typeshare] +pub struct VaultPerformance { + /// vault id + pub id: String, + /// vault token + pub token: Erc20, + #[typeshare(typescript(type = "number"))] + pub start_time: u64, + #[typeshare(typescript(type = "number"))] + pub end_time: u64, + + // vol segment + #[typeshare(typescript(type = "string"))] + pub total_in_vol: U256, + #[typeshare(typescript(type = "string"))] + pub total_out_vol: U256, + #[typeshare(typescript(type = "string"))] + pub total_vol: U256, + #[typeshare(typescript(type = "string"))] + pub net_vol: I256, + + // apy segment + #[typeshare(typescript(type = "string"))] + pub starting_capital: I256, + #[typeshare(typescript(type = "string"))] + pub apy: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare] +pub struct DenominatedPerformance { + #[typeshare(typescript(type = "string"))] + pub apy: I256, + #[typeshare(typescript(type = "string"))] + pub net_vol: I256, + #[typeshare(typescript(type = "string"))] + pub starting_capital: I256, + pub token: Erc20, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[typeshare] +pub struct OrderPerformance { + /// Order subgraph id + pub order_id: String, + /// Order hash + pub order_hash: String, + /// Order's orderbook + pub orderbook: String, + /// Order's measured performance as a whole + pub denominated_performance: Option, + /// Start timestamp of the performance measring timeframe + #[typeshare(typescript(type = "number"))] + pub start_time: u64, + /// End timestamp of the performance measuring timeframe + #[typeshare(typescript(type = "number"))] + pub end_time: u64, + /// Ordder's input vaults isolated performance + pub inputs_vaults: Vec, + /// Ordder's output vaults isolated performance + pub outputs_vaults: Vec, +} + +impl OrderPerformance { + /// Given an order and its trades and optionally a timeframe, will calculates + /// the order performance, (apy and volume) + /// Trades must be sorted indesc order by timestamp, this is the case if + /// queried from subgraph using this lib functionalities + pub fn measure( + order: &Order, + trades: &[Trade], + start_timestamp: Option, + end_timestamp: Option, + ) -> Result { + if trades.is_empty() { + return Ok(OrderPerformance { + order_id: order.id.0.clone(), + order_hash: order.order_hash.0.clone(), + orderbook: order.orderbook.id.0.clone(), + start_time: start_timestamp.unwrap_or(0), + end_time: end_timestamp.unwrap_or(chrono::Utc::now().timestamp() as u64), + inputs_vaults: vec![], + outputs_vaults: vec![], + denominated_performance: None, + }); + } + let vols = get_vaults_vol(trades)?; + let vaults_apy = get_vaults_apy(trades, &vols, start_timestamp, end_timestamp)?; + + // build an OrderPerformance struct + let mut start_time = u64::MAX; + let mut end_time = 0_u64; + let mut inputs: Vec = vec![]; + let mut outputs: Vec = vec![]; + for (vault_apy, vault_vol) in vaults_apy.iter().zip(vols) { + if vault_apy.start_time < start_time { + start_time = vault_apy.start_time; + } + if vault_apy.end_time > end_time { + end_time = vault_apy.end_time; + } + if order + .inputs + .iter() + .any(|v| v.vault_id.0 == vault_apy.id && v.token == vault_apy.token) + { + inputs.push(VaultPerformance { + id: vault_apy.id.clone(), + token: vault_apy.token.clone(), + total_in_vol: vault_vol.total_in, + total_out_vol: vault_vol.total_out, + total_vol: vault_vol.total_vol, + net_vol: vault_vol.net_vol, + start_time: vault_apy.start_time, + end_time: vault_apy.end_time, + starting_capital: vault_apy.capital, + apy: vault_apy.apy, + }); + } + if order + .outputs + .iter() + .any(|v| v.vault_id.0 == vault_apy.id && v.token == vault_apy.token) + { + outputs.push(VaultPerformance { + id: vault_apy.id.clone(), + token: vault_apy.token.clone(), + total_in_vol: vault_vol.total_in, + total_out_vol: vault_vol.total_out, + total_vol: vault_vol.total_vol, + net_vol: vault_vol.net_vol, + start_time: vault_apy.start_time, + end_time: vault_apy.end_time, + starting_capital: vault_apy.capital, + apy: vault_apy.apy, + }); + } + } + let mut order_performance = OrderPerformance { + order_id: order.id.0.clone(), + order_hash: order.order_hash.0.clone(), + orderbook: order.orderbook.id.0.clone(), + start_time, + end_time, + inputs_vaults: inputs, + outputs_vaults: outputs, + denominated_performance: None, + }; + + // get pairs ratios + let pair_ratio_map = get_order_pairs_ratio(order, trades); + + // try to calculate all vaults capital and volume denominated into each of + // the order's tokens by checking if there is direct ratio between the tokens, + // multi path ratios are ignored currently and results in None for the APY. + // if there is a success for any of the denomination tokens, gather it in order + // of its net vol and pick the one with highest net vol. + // if there was no success with any of the order's tokens, simply return None + // for the APY. + let mut ordered_token_net_vol_map = BTreeMap::new(); + let mut full_apy_in_distinct_token_denominations = vec![]; + for token in &vaults_apy { + let mut noway = false; + let mut combined_capital = I256::ZERO; + let mut combined_net_vol = I256::ZERO; + let mut combined_annual_rate_vol = I256::ZERO; + let mut token_net_vol_map_converted_in_current_denomination = BTreeMap::new(); + for token_vault in &vaults_apy { + // time to year ratio + let annual_rate = annual_rate(token_vault.start_time, token_vault.end_time); + + // sum up all token vaults' capitals and vols in the current's iteration + // token denomination by using the direct ratio between the tokens + if token_vault.token == token.token { + combined_capital += token_vault.capital; + combined_net_vol += token_vault.net_vol; + combined_annual_rate_vol += token_vault + .net_vol + .saturating_mul(one_18().get_signed()) + .saturating_div(annual_rate); + token_net_vol_map_converted_in_current_denomination + .insert(token_vault.net_vol, &token.token); + } else { + let pair = TokenPair { + input: token.token.clone(), + output: token_vault.token.clone(), + }; + // convert to current denomination by the direct pair ratio if exists + if let Some(Some(ratio)) = pair_ratio_map.get(&pair) { + combined_capital += token_vault + .capital + .saturating_mul(*ratio) + .saturating_div(one_18().get_signed()); + combined_net_vol += token_vault + .net_vol + .saturating_mul(*ratio) + .saturating_div(one_18().get_signed()); + combined_annual_rate_vol += token_vault + .net_vol + .saturating_mul(*ratio) + .saturating_div(one_18().get_signed()) + .saturating_mul(one_18().get_signed()) + .saturating_div(annual_rate); + token_net_vol_map_converted_in_current_denomination.insert( + token_vault + .net_vol + .saturating_mul(*ratio) + .saturating_div(one_18().get_signed()), + &token_vault.token, + ); + } else { + noway = true; + break; + } + } + } + + // for every success apy calc in a token denomination, gather them in BTreeMap + // this means at the end we have all the successful apy calculated in each of + // the order's io tokens in order from highest to lowest. + if !noway { + if let Some(apy) = combined_annual_rate_vol + .saturating_mul(one_18().get_signed()) + .checked_div(combined_capital) + { + full_apy_in_distinct_token_denominations.push(DenominatedPerformance { + apy, + token: token.token.clone(), + starting_capital: combined_capital, + net_vol: combined_net_vol, + }); + } + } else { + token_net_vol_map_converted_in_current_denomination.clear(); + } + + // if we already have ordered token net vol in a denomination + // we dont need them in other denominations in order to pick + // the highest vol token as settelement denomination + if ordered_token_net_vol_map.is_empty() { + ordered_token_net_vol_map + .extend(token_net_vol_map_converted_in_current_denomination); + } + } + + // pick the denomination with highest net vol by iterating over tokens with + // highest vol to lowest and pick the first matching matching one + for (_, &token) in ordered_token_net_vol_map.iter().rev() { + if let Some(denominated_apy) = full_apy_in_distinct_token_denominations + .iter() + .find(|&v| &v.token == token) + { + order_performance.denominated_performance = Some(denominated_apy.clone()); + // return early as soon as a match is found + return Ok(order_performance); + } + } + + Ok(order_performance) + } +} + +/// Calculates an order's pairs' ratios from their last trades in a given list of trades +/// Trades must be sorted indesc order by timestamp, this is the case if queried from subgraph +/// using this lib functionalities +pub fn get_order_pairs_ratio(order: &Order, trades: &[Trade]) -> HashMap> { + let mut pair_ratio_map: HashMap> = HashMap::new(); + for input in &order.inputs { + for output in &order.outputs { + let pair_as_key = TokenPair { + input: input.token.clone(), + output: output.token.clone(), + }; + let inverse_pair_as_key = TokenPair { + input: output.token.clone(), + output: input.token.clone(), + }; + // if not same io token and ratio map doesnt already include them + if input.token != output.token + && !(pair_ratio_map.contains_key(&pair_as_key) + || pair_ratio_map.contains_key(&inverse_pair_as_key)) + { + // find this pairs(io or oi) latest tradetrades from list of order's + // trades, the calculate the pair ratio (in amount/out amount) and + // its inverse from the latest trade that involes these 2 tokens. + let ratio = trades + .iter() + .find(|v| { + (v.input_vault_balance_change.vault.token == input.token + && v.output_vault_balance_change.vault.token == output.token) + || (v.output_vault_balance_change.vault.token == input.token + && v.input_vault_balance_change.vault.token == output.token) + }) + .and_then(|latest_trade| { + // convert input and output amounts to 18 decimals point + // and then calculate the pair ratio + latest_trade + .ratio() + .ok() + .zip(latest_trade.inverse_ratio().ok()) + .map(|(ratio, inverse_ratio)| { + [I256::from_raw(ratio), I256::from_raw(inverse_ratio)] + }) + }); + + // io + pair_ratio_map.insert(pair_as_key, ratio.map(|v| v[0])); + // oi + pair_ratio_map.insert(inverse_pair_as_key, ratio.map(|v| v[1])); + } + } + } + + pair_ratio_map +} + +#[cfg(test)] +mod test { + use super::*; + use crate::types::common::{ + BigInt, Bytes, Order, Orderbook, TradeEvent, TradeStructPartialOrder, + TradeVaultBalanceChange, Transaction, Vault, VaultBalanceChangeVault, + }; + use alloy::primitives::{Address, B256}; + use std::str::FromStr; + + #[test] + fn test_get_pairs_ratio() { + let trades = get_trades(); + let [token1, token2] = get_tokens(); + let result = get_order_pairs_ratio(&get_order(), &trades); + let mut expected = HashMap::new(); + expected.insert( + TokenPair { + input: token2.clone(), + output: token1.clone(), + }, + Some(I256::from_str("285714285714285714").unwrap()), + ); + expected.insert( + TokenPair { + input: token1.clone(), + output: token2.clone(), + }, + Some(I256::from_str("3500000000000000000").unwrap()), + ); + + assert_eq!(result, expected); + } + + #[test] + fn test_get_order_performance() { + let order = get_order(); + let trades = get_trades(); + let [token1, token2] = get_tokens(); + let [vault1, vault2] = get_vault_ids(); + let token1_perf = VaultPerformance { + id: vault1.to_string(), + token: token1.clone(), + start_time: 1, + end_time: 10000001, + net_vol: I256::from_str("5000000000000000000").unwrap(), + starting_capital: I256::from_str("5000000000000000000").unwrap(), + apy: Some(I256::from_str("3153600000000000000").unwrap()), + total_in_vol: U256::from_str("7000000000000000000").unwrap(), + total_out_vol: U256::from_str("2000000000000000000").unwrap(), + total_vol: U256::from_str("9000000000000000000").unwrap(), + }; + let token2_perf = VaultPerformance { + id: vault2.to_string(), + token: token2.clone(), + start_time: 1, + end_time: 10000001, + net_vol: I256::from_str("3000000000000000000").unwrap(), + starting_capital: I256::from_str("5000000000000000000").unwrap(), + apy: Some(I256::from_str("1892160000000000000").unwrap()), + total_in_vol: U256::from_str("5000000000000000000").unwrap(), + total_out_vol: U256::from_str("2000000000000000000").unwrap(), + total_vol: U256::from_str("7000000000000000000").unwrap(), + }; + let result = OrderPerformance::measure(&order, &trades, Some(1), Some(10000001)).unwrap(); + let expected = OrderPerformance { + order_id: "order-id".to_string(), + order_hash: "".to_string(), + orderbook: "".to_string(), + start_time: 1, + end_time: 10000001, + inputs_vaults: vec![token1_perf.clone(), token2_perf.clone()], + outputs_vaults: vec![token1_perf.clone(), token2_perf.clone()], + denominated_performance: Some(DenominatedPerformance { + apy: I256::from_str("2172479999999999999").unwrap(), + token: token2, + net_vol: I256::from_str("4428571428571428570").unwrap(), + starting_capital: I256::from_str("6428571428571428570").unwrap(), + }), + }; + + assert_eq!(result, expected); + } + + fn get_vault_ids() -> [B256; 2] { + [ + B256::from_slice(&[0x11u8; 32]), + B256::from_slice(&[0x22u8; 32]), + ] + } + fn get_tokens() -> [Erc20; 2] { + let token1_address = Address::from_slice(&[0x11u8; 20]); + let token2_address = Address::from_slice(&[0x22u8; 20]); + let token1 = Erc20 { + id: Bytes(token1_address.to_string()), + address: Bytes(token1_address.to_string()), + name: Some("Token1".to_string()), + symbol: Some("Token1".to_string()), + decimals: Some(BigInt(18.to_string())), + }; + let token2 = Erc20 { + id: Bytes(token2_address.to_string()), + address: Bytes(token2_address.to_string()), + name: Some("Token2".to_string()), + symbol: Some("Token2".to_string()), + decimals: Some(BigInt(18.to_string())), + }; + [token1, token2] + } + fn get_order() -> Order { + let [vault_id1, vault_id2] = get_vault_ids(); + let [token1, token2] = get_tokens(); + let vault1 = Vault { + id: Bytes("".to_string()), + owner: Bytes("".to_string()), + vault_id: BigInt(vault_id1.to_string()), + balance: BigInt("".to_string()), + token: token1, + orderbook: Orderbook { + id: Bytes("".to_string()), + }, + orders_as_output: vec![], + orders_as_input: vec![], + balance_changes: vec![], + }; + let vault2 = Vault { + id: Bytes("".to_string()), + owner: Bytes("".to_string()), + vault_id: BigInt(vault_id2.to_string()), + balance: BigInt("".to_string()), + token: token2, + orderbook: Orderbook { + id: Bytes("".to_string()), + }, + orders_as_output: vec![], + orders_as_input: vec![], + balance_changes: vec![], + }; + Order { + id: Bytes("order-id".to_string()), + order_bytes: Bytes("".to_string()), + order_hash: Bytes("".to_string()), + owner: Bytes("".to_string()), + outputs: vec![vault1.clone(), vault2.clone()], + inputs: vec![vault1, vault2], + orderbook: Orderbook { + id: Bytes("".to_string()), + }, + active: true, + timestamp_added: BigInt("".to_string()), + meta: None, + add_events: vec![], + trades: vec![], + } + } + + fn get_trades() -> Vec { + let bytes = Bytes("".to_string()); + let bigint = BigInt("".to_string()); + let [vault_id1, vault_id2] = get_vault_ids(); + let [token1, token2] = get_tokens(); + let trade1 = Trade { + id: bytes.clone(), + order: TradeStructPartialOrder { + id: bytes.clone(), + order_hash: bytes.clone(), + }, + trade_event: TradeEvent { + sender: bytes.clone(), + transaction: Transaction { + id: bytes.clone(), + from: bytes.clone(), + block_number: bigint.clone(), + timestamp: bigint.clone(), + }, + }, + timestamp: BigInt("1".to_string()), + orderbook: Orderbook { id: bytes.clone() }, + output_vault_balance_change: TradeVaultBalanceChange { + id: bytes.clone(), + __typename: "TradeVaultBalanceChange".to_string(), + amount: BigInt("-2000000000000000000".to_string()), + new_vault_balance: BigInt("2000000000000000000".to_string()), + old_vault_balance: bigint.clone(), + vault: VaultBalanceChangeVault { + id: bytes.clone(), + token: token1.clone(), + vault_id: BigInt(vault_id1.to_string()), + }, + timestamp: BigInt("1".to_string()), + transaction: Transaction { + id: bytes.clone(), + from: bytes.clone(), + block_number: bigint.clone(), + timestamp: BigInt("1".to_string()), + }, + orderbook: Orderbook { id: bytes.clone() }, + }, + input_vault_balance_change: TradeVaultBalanceChange { + id: bytes.clone(), + __typename: "TradeVaultBalanceChange".to_string(), + amount: BigInt("5000000000000000000".to_string()), + new_vault_balance: BigInt("2000000000000000000".to_string()), + old_vault_balance: bigint.clone(), + vault: VaultBalanceChangeVault { + id: bytes.clone(), + token: token2.clone(), + vault_id: BigInt(vault_id2.to_string()), + }, + timestamp: BigInt("1".to_string()), + transaction: Transaction { + id: bytes.clone(), + from: bytes.clone(), + block_number: bigint.clone(), + timestamp: BigInt("1".to_string()), + }, + orderbook: Orderbook { id: bytes.clone() }, + }, + }; + let trade2 = Trade { + id: bytes.clone(), + order: TradeStructPartialOrder { + id: bytes.clone(), + order_hash: bytes.clone(), + }, + trade_event: TradeEvent { + sender: bytes.clone(), + transaction: Transaction { + id: bytes.clone(), + from: bytes.clone(), + block_number: bigint.clone(), + timestamp: bigint.clone(), + }, + }, + timestamp: BigInt("2".to_string()), + orderbook: Orderbook { id: bytes.clone() }, + output_vault_balance_change: TradeVaultBalanceChange { + id: bytes.clone(), + __typename: "TradeVaultBalanceChange".to_string(), + amount: BigInt("-2000000000000000000".to_string()), + new_vault_balance: BigInt("5000000000000000000".to_string()), + old_vault_balance: bigint.clone(), + vault: VaultBalanceChangeVault { + id: bytes.clone(), + token: token2.clone(), + vault_id: BigInt(vault_id2.to_string()), + }, + timestamp: BigInt("2".to_string()), + transaction: Transaction { + id: bytes.clone(), + from: bytes.clone(), + block_number: bigint.clone(), + timestamp: BigInt("1".to_string()), + }, + orderbook: Orderbook { id: bytes.clone() }, + }, + input_vault_balance_change: TradeVaultBalanceChange { + id: bytes.clone(), + __typename: "TradeVaultBalanceChange".to_string(), + amount: BigInt("7000000000000000000".to_string()), + new_vault_balance: BigInt("5000000000000000000".to_string()), + old_vault_balance: bigint.clone(), + vault: VaultBalanceChangeVault { + id: bytes.clone(), + token: token1.clone(), + vault_id: BigInt(vault_id1.to_string()), + }, + timestamp: BigInt("2".to_string()), + transaction: Transaction { + id: bytes.clone(), + from: bytes.clone(), + block_number: bigint.clone(), + timestamp: BigInt("1".to_string()), + }, + orderbook: Orderbook { id: bytes.clone() }, + }, + }; + vec![trade2, trade1] + } +} diff --git a/crates/subgraph/src/utils/mod.rs b/crates/subgraph/src/utils/mod.rs index 145715232..9cc9633d8 100644 --- a/crates/subgraph/src/utils/mod.rs +++ b/crates/subgraph/src/utils/mod.rs @@ -1,5 +1,79 @@ +use alloy::primitives::{ + utils::{format_units, parse_units, ParseUnits, Unit, UnitsError}, + I256, U256, +}; +use chrono::TimeDelta; + mod order_id; mod slice_list; pub use order_id::*; pub use slice_list::*; + +/// Returns 18 point decimals 1 as I256/U256 +pub fn one_18() -> ParseUnits { + ParseUnits::U256(U256::from(1_000_000_000_000_000_000_u64)) +} + +/// Returns YEAR as 18 point decimals as I256/U256 +pub fn year_18() -> ParseUnits { + ParseUnits::U256( + U256::from(TimeDelta::days(365).num_seconds()).saturating_mul(one_18().get_absolute()), + ) +} + +/// Converts a U256/I256 value to a 18 fixed point U256/I256 given the decimals point +pub fn to_18_decimals>( + amount: ParseUnits, + decimals: T, +) -> Result { + parse_units(&format_units(amount, decimals)?, 18) +} + +/// Returns annual rate as 18 point decimals as I256 +pub fn annual_rate(start: u64, end: u64) -> I256 { + I256::from_raw(U256::from(end - start).saturating_mul(one_18().get_absolute())) + .saturating_mul(one_18().get_signed()) + .saturating_div(year_18().get_signed()) +} + +#[cfg(test)] +mod test { + use super::*; + use alloy::primitives::{I256, U256}; + use std::str::FromStr; + + #[test] + fn test_one() { + let result = one_18(); + let expected_signed = I256::from_str("1_000_000_000_000_000_000").unwrap(); + let expected_absolute = U256::from_str("1_000_000_000_000_000_000").unwrap(); + assert_eq!(result.get_signed(), expected_signed); + assert_eq!(result.get_absolute(), expected_absolute); + } + + #[test] + fn test_year_18_decimals() { + const YEAR: u64 = 60 * 60 * 24 * 365; + let result = year_18(); + let expected_signed = I256::try_from(YEAR) + .unwrap() + .saturating_mul(one_18().get_signed()); + let expected_absolute = U256::from(YEAR).saturating_mul(one_18().get_absolute()); + assert_eq!(result.get_signed(), expected_signed); + assert_eq!(result.get_absolute(), expected_absolute); + } + + #[test] + fn test_to_18_decimals() { + let value = ParseUnits::I256(I256::from_str("-123456789").unwrap()); + let result = to_18_decimals(value, 5).unwrap(); + let expected = ParseUnits::I256(I256::from_str("-1234567890000000000000").unwrap()); + assert_eq!(result, expected); + + let value = ParseUnits::U256(U256::from_str("123456789").unwrap()); + let result = to_18_decimals(value, 12).unwrap(); + let expected = ParseUnits::U256(U256::from_str("123456789000000").unwrap()); + assert_eq!(result, expected); + } +} diff --git a/crates/subgraph/src/vol.rs b/crates/subgraph/src/vol.rs index a88798249..fa897ffa9 100644 --- a/crates/subgraph/src/vol.rs +++ b/crates/subgraph/src/vol.rs @@ -1,26 +1,30 @@ -use crate::types::common::{Erc20, Trade}; -use alloy::primitives::{ruint::ParseError, I256, U256}; +use crate::{ + error::ParseNumberError, + types::common::{Erc20, Trade}, + utils::to_18_decimals, +}; +use alloy::primitives::{ruint::ParseError, utils::ParseUnits, I256, U256}; use serde::{Deserialize, Serialize}; use std::str::FromStr; use typeshare::typeshare; -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] #[serde(rename_all = "camelCase")] #[typeshare] pub struct VaultVolume { - id: String, - token: Erc20, + pub id: String, + pub token: Erc20, #[typeshare(typescript(type = "string"))] - total_in: U256, + pub total_in: U256, #[typeshare(typescript(type = "string"))] - total_out: U256, + pub total_out: U256, #[typeshare(typescript(type = "string"))] - total_vol: U256, + pub total_vol: U256, #[typeshare(typescript(type = "string"))] - net_vol: I256, + pub net_vol: I256, } -/// Get the vaults volume from array of trades +/// Get the vaults volume from array of trades of an owner pub fn get_vaults_vol(trades: &[Trade]) -> Result, ParseError> { let mut vaults_vol: Vec = vec![]; for trade in trades { @@ -108,6 +112,29 @@ pub fn get_vaults_vol(trades: &[Trade]) -> Result, ParseError> Ok(vaults_vol) } +impl VaultVolume { + /// Creates a new instance of self with all volume values as 18 decimals point + pub fn to_18_decimals(&self) -> Result { + let token_decimals = self + .token + .decimals + .as_ref() + .map(|v| v.0.as_str()) + .unwrap_or("18"); + Ok(VaultVolume { + id: self.id.clone(), + token: self.token.clone(), + total_in: to_18_decimals(ParseUnits::U256(self.total_in), token_decimals)? + .get_absolute(), + total_out: to_18_decimals(ParseUnits::U256(self.total_out), token_decimals)? + .get_absolute(), + total_vol: to_18_decimals(ParseUnits::U256(self.total_vol), token_decimals)? + .get_absolute(), + net_vol: to_18_decimals(ParseUnits::I256(self.net_vol), token_decimals)?.get_signed(), + }) + } +} + #[cfg(test)] mod test { use super::*; @@ -118,7 +145,7 @@ mod test { use alloy::primitives::{Address, B256}; #[test] - fn test_get_vaults_vol() { + fn test_vaults_vol() { let bytes = Bytes("".to_string()); let bigint = BigInt("".to_string()); let token1_address = Address::random(); @@ -278,4 +305,36 @@ mod test { assert_eq!(result, expected); } + + #[test] + fn test_to_18_decimals() { + let token_address = Address::random(); + let token = Erc20 { + id: Bytes(token_address.to_string()), + address: Bytes(token_address.to_string()), + name: Some("Token".to_string()), + symbol: Some("Token".to_string()), + decimals: Some(BigInt(6.to_string())), + }; + let vault_vol = VaultVolume { + id: "vault-id".to_string(), + token: token.clone(), + total_in: U256::from(20_500_000), + total_out: U256::from(30_000_000), + total_vol: U256::from(50_500_000), + net_vol: I256::from_str("-9_500_000").unwrap(), + }; + + let result = vault_vol.to_18_decimals().unwrap(); + let expected = VaultVolume { + id: "vault-id".to_string(), + token, + total_in: U256::from_str("20_500_000_000_000_000_000").unwrap(), + total_out: U256::from_str("30_000_000_000_000_000_000").unwrap(), + total_vol: U256::from_str("50_500_000_000_000_000_000").unwrap(), + net_vol: I256::from_str("-9_500_000_000_000_000_000").unwrap(), + }; + + assert_eq!(result, expected); + } } diff --git a/flake.nix b/flake.nix index d206affc4..065e711e0 100644 --- a/flake.nix +++ b/flake.nix @@ -63,7 +63,7 @@ cargo install --git https://github.com/tomjw64/typeshare --rev 556b44aafd5304eedf17206800f69834e3820b7c export PATH=$PATH:$CARGO_HOME/bin - typeshare crates/subgraph/src/types/common.rs crates/subgraph/src/types/order.rs crates/subgraph/src/types/vault.rs crates/subgraph/src/types/order_trade.rs crates/common/src/types/order_detail_extended.rs crates/subgraph/src/vol.rs --lang=typescript --output-file=tauri-app/src/lib/typeshare/subgraphTypes.ts; + typeshare crates/subgraph/src/types/common.rs crates/subgraph/src/types/order.rs crates/subgraph/src/types/vault.rs crates/subgraph/src/types/order_trade.rs crates/common/src/types/order_detail_extended.rs crates/subgraph/src/vol.rs crates/subgraph/src/apy.rs --lang=typescript --output-file=tauri-app/src/lib/typeshare/subgraphTypes.ts; typeshare crates/settings/src/parse.rs --lang=typescript --output-file=tauri-app/src/lib/typeshare/appSettings.ts; typeshare lib/rain.interpreter/crates/eval/src/trace.rs crates/common/src/fuzz/mod.rs crates/settings/src/config_source.rs crates/settings/src/config.rs crates/settings/src/plot_source.rs crates/settings/src/chart.rs crates/settings/src/deployer.rs crates/settings/src/network.rs crates/settings/src/order.rs crates/settings/src/orderbook.rs crates/settings/src/scenario.rs crates/settings/src/blocks.rs crates/settings/src/token.rs crates/settings/src/deployment.rs --lang=typescript --output-file=tauri-app/src/lib/typeshare/config.ts; diff --git a/tauri-app/src-tauri/src/commands/order_take.rs b/tauri-app/src-tauri/src/commands/order_take.rs index 4b47506ec..ffe4eee0c 100644 --- a/tauri-app/src-tauri/src/commands/order_take.rs +++ b/tauri-app/src-tauri/src/commands/order_take.rs @@ -2,6 +2,8 @@ use crate::error::CommandResult; use rain_orderbook_common::{ csv::TryIntoCsv, subgraph::SubgraphArgs, types::FlattenError, types::OrderTakeFlattened, }; + +use rain_orderbook_subgraph_client::types::order::OrderPerformance; use rain_orderbook_subgraph_client::vol::VaultVolume; use rain_orderbook_subgraph_client::{types::common::*, PaginationArgs}; use std::fs; @@ -79,3 +81,16 @@ pub async fn order_trades_count( .await? .len()) } + +#[tauri::command] +pub async fn order_performance( + order_id: String, + subgraph_args: SubgraphArgs, + start_timestamp: Option, + end_timestamp: Option, +) -> CommandResult { + let client = subgraph_args.to_subgraph_client().await?; + Ok(client + .order_performance(order_id.into(), start_timestamp, end_timestamp) + .await?) +} diff --git a/tauri-app/src-tauri/src/main.rs b/tauri-app/src-tauri/src/main.rs index bb237152b..5d9f26598 100644 --- a/tauri-app/src-tauri/src/main.rs +++ b/tauri-app/src-tauri/src/main.rs @@ -19,7 +19,8 @@ use commands::order::{ }; use commands::order_quote::{batch_order_quotes, debug_order_quote}; use commands::order_take::{ - order_trades_count, order_trades_list, order_trades_list_write_csv, order_vaults_volume, + order_performance, order_trades_count, order_trades_list, order_trades_list_write_csv, + order_vaults_volume, }; use commands::trade_debug::debug_trade; use commands::vault::{ @@ -82,7 +83,8 @@ fn run_tauri_app() { get_app_commit_sha, validate_raindex_version, order_vaults_volume, - order_trades_count + order_trades_count, + order_performance, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/tauri-app/src/lib/components/charts/APYTimeFilters.svelte b/tauri-app/src/lib/components/charts/APYTimeFilters.svelte new file mode 100644 index 000000000..103fd0d79 --- /dev/null +++ b/tauri-app/src/lib/components/charts/APYTimeFilters.svelte @@ -0,0 +1,51 @@ + + + + { + setNow(); + timeDelta = undefined; + startTimestamp = undefined; + endTimestamp = undefined; + }} + active={timeDelta === undefined} + size="xs" + class="px-2 py-1">All Time + { + setNow(); + timeDelta = TIME_DELTA_1_YEAR; + startTimestamp = now - TIME_DELTA_1_YEAR; + endTimestamp = now; + }} + active={timeDelta === TIME_DELTA_1_YEAR} + size="xs" + class="px-2 py-1">Last Year + { + setNow(); + timeDelta = TIME_DELTA_30_DAYS; + startTimestamp = now - TIME_DELTA_30_DAYS; + endTimestamp = now; + }} + active={timeDelta === TIME_DELTA_30_DAYS} + size="xs" + class="px-2 py-1">Last Month + diff --git a/tauri-app/src/lib/components/charts/ChartTimeFilters.svelte b/tauri-app/src/lib/components/charts/ChartTimeFilters.svelte index bf111800c..176299e8a 100644 --- a/tauri-app/src/lib/components/charts/ChartTimeFilters.svelte +++ b/tauri-app/src/lib/components/charts/ChartTimeFilters.svelte @@ -1,11 +1,12 @@ diff --git a/tauri-app/src/lib/components/charts/ChartTimeFilters.test.ts b/tauri-app/src/lib/components/charts/ChartTimeFilters.test.ts index 9efecbe92..acfc1b91a 100644 --- a/tauri-app/src/lib/components/charts/ChartTimeFilters.test.ts +++ b/tauri-app/src/lib/components/charts/ChartTimeFilters.test.ts @@ -2,11 +2,12 @@ import { render, fireEvent, screen } from '@testing-library/svelte'; import { get, writable } from 'svelte/store'; import { test, expect } from 'vitest'; import ChartTimeFiltersTest from './ChartTimeFilters.test.svelte'; - -const TIME_DELTA_24_HOURS = 60 * 60 * 24; -const TIME_DELTA_7_DAYS = TIME_DELTA_24_HOURS * 7; -const TIME_DELTA_30_DAYS = TIME_DELTA_24_HOURS * 30; -const TIME_DELTA_1_YEAR = TIME_DELTA_24_HOURS * 365; +import { + TIME_DELTA_1_YEAR, + TIME_DELTA_24_HOURS, + TIME_DELTA_30_DAYS, + TIME_DELTA_7_DAYS, +} from '$lib/services/time'; test('initial timeDelta is set to 1 year', async () => { const timeDeltaStore = writable(TIME_DELTA_1_YEAR); diff --git a/tauri-app/src/lib/components/charts/TableTimeFilters.svelte b/tauri-app/src/lib/components/charts/TableTimeFilters.svelte index 8439f7355..6ac185bd2 100644 --- a/tauri-app/src/lib/components/charts/TableTimeFilters.svelte +++ b/tauri-app/src/lib/components/charts/TableTimeFilters.svelte @@ -1,15 +1,13 @@ + + + + + + + APY + + + + + {item.denominatedPerformance + ? bigintStringToPercentage(item.denominatedPerformance.apy, 18, 5) + + '% in ' + + (item.denominatedPerformance.token.symbol ?? + item.denominatedPerformance.token.name ?? + item.denominatedPerformance.token.address) + : 'Unavailable APY'} + + + diff --git a/tauri-app/src/lib/components/tables/OrderAPY.test.ts b/tauri-app/src/lib/components/tables/OrderAPY.test.ts new file mode 100644 index 000000000..8100c8940 --- /dev/null +++ b/tauri-app/src/lib/components/tables/OrderAPY.test.ts @@ -0,0 +1,83 @@ +import { render, screen, waitFor } from '@testing-library/svelte'; +import { test, vi } from 'vitest'; +import { expect } from '$lib/test/matchers'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import type { OrderPerformance } from '$lib/typeshare/subgraphTypes'; +import { QueryClient } from '@tanstack/svelte-query'; +import OrderApy from './OrderAPY.svelte'; +import { bigintStringToPercentage } from '$lib/utils/number'; + +vi.mock('$lib/stores/settings', async (importOriginal) => { + const { writable } = await import('svelte/store'); + const { mockSettingsStore } = await import('$lib/mocks/settings'); + + const _activeOrderbook = writable(); + + return { + ...((await importOriginal()) as object), + settings: mockSettingsStore, + subgraphUrl: writable('https://example.com'), + activeOrderbook: { + ..._activeOrderbook, + load: vi.fn(() => _activeOrderbook.set(true)), + }, + }; +}); + +vi.mock('$lib/services/modal', async () => { + return { + handleDepositGenericModal: vi.fn(), + handleDepositModal: vi.fn(), + handleWithdrawModal: vi.fn(), + }; +}); + +const mockOrderApy: OrderPerformance[] = [ + { + orderId: '1', + orderHash: '1', + orderbook: '1', + denominatedPerformance: { + apy: '1200000000000000000', + token: { + id: 'output_token', + address: 'output_token', + name: 'output_token', + symbol: 'output_token', + decimals: '0', + }, + netVol: '0', + startingCapital: '0', + }, + startTime: 1, + endTime: 2, + inputsVaults: [], + outputsVaults: [], + }, +]; + +test('renders table with correct data', async () => { + const queryClient = new QueryClient(); + + mockIPC((cmd) => { + if (cmd === 'order_apy') { + return mockOrderApy[0]; + } + }); + + render(OrderApy, { + context: new Map([['$$_queryClient', queryClient]]), + props: { id: '1' }, + }); + + await waitFor(async () => { + // get apy row + const rows = screen.getAllByTestId('apy-field'); + + // checking + for (let i = 0; i < mockOrderApy.length; i++) { + const display = bigintStringToPercentage(mockOrderApy[i].denominatedPerformance!.apy, 18, 5); + expect(rows[i]).toHaveTextContent(display); + } + }); +}); diff --git a/tauri-app/src/lib/queries/keys.ts b/tauri-app/src/lib/queries/keys.ts index ab88165c0..1580e284e 100644 --- a/tauri-app/src/lib/queries/keys.ts +++ b/tauri-app/src/lib/queries/keys.ts @@ -6,3 +6,4 @@ export const QKEY_ORDER = 'order'; export const QKEY_ORDER_TRADES_LIST = 'orderTradesList'; export const QKEY_ORDER_QUOTE = 'orderQuote'; export const QKEY_VAULTS_VOL_LIST = 'orderVaultsVolumeList'; +export const QKEY_ORDER_APY = 'orderApy'; diff --git a/tauri-app/src/lib/queries/orderTradesList.ts b/tauri-app/src/lib/queries/orderTradesList.ts index 4272a8dc4..802a6136b 100644 --- a/tauri-app/src/lib/queries/orderTradesList.ts +++ b/tauri-app/src/lib/queries/orderTradesList.ts @@ -1,4 +1,4 @@ -import type { Trade, VaultVolume } from '$lib/typeshare/subgraphTypes'; +import type { OrderPerformance, Trade, VaultVolume } from '$lib/typeshare/subgraphTypes'; import { invoke } from '@tauri-apps/api'; import { DEFAULT_PAGE_SIZE } from './constants'; import { prepareHistoricalOrderChartData } from '$lib/services/historicalOrderCharts'; @@ -92,3 +92,22 @@ export const orderTradesCount = async ( endTimestamp, } as OrderTradesListArgs); }; + +export const getOrderApy = async ( + id: string, + url: string | undefined, + startTimestamp?: number, + endTimestamp?: number, +) => { + if (!url) { + return []; + } + return [ + await invoke('order_apy', { + orderId: id, + subgraphArgs: { url }, + startTimestamp, + endTimestamp, + } as OrderTradesListArgs), + ]; +}; diff --git a/tauri-app/src/lib/services/time.ts b/tauri-app/src/lib/services/time.ts new file mode 100644 index 000000000..8ba7f5913 --- /dev/null +++ b/tauri-app/src/lib/services/time.ts @@ -0,0 +1,9 @@ +export const TIME_DELTA_24_HOURS = 60 * 60 * 24; +export const TIME_DELTA_48_HOURS = TIME_DELTA_24_HOURS * 2; +export const TIME_DELTA_7_DAYS = TIME_DELTA_24_HOURS * 7; +export const TIME_DELTA_30_DAYS = TIME_DELTA_24_HOURS * 30; +export const TIME_DELTA_1_YEAR = TIME_DELTA_24_HOURS * 365; + +export function nowTimestamp(): number { + return Math.floor(new Date().getTime() / 1000); +} diff --git a/tauri-app/src/lib/utils/number.ts b/tauri-app/src/lib/utils/number.ts index 79417b775..084a7e554 100644 --- a/tauri-app/src/lib/utils/number.ts +++ b/tauri-app/src/lib/utils/number.ts @@ -3,3 +3,24 @@ import { formatUnits } from 'viem'; export function bigintToFloat(value: bigint, decimals: number) { return parseFloat(formatUnits(value, decimals)); } + +/** + * Converts a bigint string value to a percentage with optionally given number of decimal points + * @param value - The bigint string value + * @param valueDecimals - The bigint string value decimals point + * @param decimalPoint - (optional) The number of digits to keep after "." in final result, defaults to valueDecimals + */ +export function bigintStringToPercentage( + value: string, + valueDecimals: number, + finalDecimalsDigits?: number, +): string { + const finalDecimals = + typeof finalDecimalsDigits !== 'undefined' ? finalDecimalsDigits : valueDecimals; + let valueString = formatUnits(BigInt(value) * 100n, valueDecimals); + const index = valueString.indexOf('.'); + if (index > -1) { + valueString = valueString.substring(0, finalDecimals === 0 ? index : index + finalDecimals + 1); + } + return valueString; +}