// Copyright (C) 2024 Riften Labs AS // // This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later. // A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html use anyhow::{bail, Context, Result}; use rust_decimal::MathematicalOps; use rust_decimal::{prelude::FromPrimitive, Decimal}; use rust_decimal_macros::dec; use super::PoolSnapshot; const SECONDS_IN_DAY: Decimal = dec!(86400.0); const DAYS_IN_YEAR: Decimal = dec!(365.25); #[derive(Debug, Clone)] pub struct PoolPeriod { pub start: PoolSnapshot, pub end: PoolSnapshot, start_k: Decimal, end_k: Decimal, } impl PoolPeriod { pub fn new(start: PoolSnapshot, end: PoolSnapshot) -> Result { if start.timestamp > end.timestamp { bail!( "start timestamp ({}) > end timestamp ({})", start.timestamp, end.timestamp ) } let start_k = Decimal::from_u64(start.sats * start.token_amount) .context("failed to convert inital_k to decimal")?; let end_k = Decimal::from_u64(end.sats * end.token_amount) .context("failed to convert final_k to decimal")?; Ok(Self { start, end, start_k, end_k, }) } /// Duration in seconds of this pool period /// starting: If provided; we assume that when the real starting position was BEFORE this timestamp, /// then this was also the state of the pool at this time. pub fn duration(&self, starting: &Option) -> u64 { let start = match *starting { Some(starting) => { if starting > self.start.timestamp { starting } else { self.start.timestamp } } None => self.start.timestamp, }; self.end.timestamp.saturating_sub(start) } pub fn durationd(&self, starting: &Option) -> Result { Decimal::from_u64(self.duration(starting)).context("duration to decimal") } pub fn duration_days(&self, starting: &Option) -> Result { Ok(self.durationd(starting)? / SECONDS_IN_DAY) } pub fn days_over_year(&self, starting: &Option) -> Result { Ok(DAYS_IN_YEAR .checked_div(self.duration_days(starting)?) .unwrap_or_default()) } pub fn end_k_sqrt(&self) -> Result { self.end_k.sqrt().context("failed to sqrt end") } pub fn pool_yield(&self) -> Result { let start_k_sr = self.start_k.sqrt().context("failed to sqrt start")?; let end_k_sr = self.end_k.sqrt().context("failed to sqrt end")?; Ok(((end_k_sr - start_k_sr) / start_k_sr) * Decimal::ONE_HUNDRED) } pub fn yield_and_apy(&self, starting: &Option) -> Result<(Decimal, Decimal)> { let pool_yield = self.pool_yield()?; let years_elapsed = self.days_over_year(starting)?; // to avoid powd overflow; don't calculate for pools < 6 hour old if self.duration(starting) < 3600 * 6 { return Ok((pool_yield, Decimal::ZERO)); } let apy = if years_elapsed.is_zero() { Decimal::ZERO } else { (((pool_yield / Decimal::ONE_HUNDRED) + Decimal::ONE) .checked_powd(years_elapsed) .context("powd overflow")? - Decimal::ONE) * Decimal::ONE_HUNDRED }; Ok((pool_yield, apy)) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_apy_and_yield() { // http://localhost:3000/position/f67ff489fb3b8efaf5db1a2cf9e3faa07fdfd7903079262a534c921e7e7d0d2c17 // APY: 0.2815061492818 numberOfDaysInPeriod: 28.668907708332494 first timestamp 1727201259 last trade 1729678252.626 first sats 1648241n first tokens 116674414n last sats 1759576n last tokens 109340225n yield 0.02206714530974295 // compare numbers to our existing implementation let start = PoolSnapshot::dummy(1727201259, 1648241, 116674414); let end = PoolSnapshot::dummy(1729678252, 1759576, 109340225); let expected_yield = dec!(0.02206714530974295); let expected_apy = dec!(0.2815061492818); let period = PoolPeriod::new(start, end).unwrap(); let (pool_yield, pool_apy) = period.yield_and_apy(&None).unwrap(); assert!((expected_yield - pool_yield).abs() < dec!(1e-12)); assert!( (expected_apy - pool_apy).abs() < dec!(1e-7), "expected {expected_apy} != actual {pool_apy}" ); } }