Commit alternative solution for pool inflation
This commit is contained in:
parent
87ba024a04
commit
381cb94295
5 changed files with 429 additions and 235 deletions
|
|
@ -401,6 +401,78 @@ async fn get_nearest_entries(
|
|||
Ok(pools)
|
||||
}
|
||||
|
||||
/// A capital injection event: both-side-positive delta on a pool_history_entry row.
|
||||
/// Carries the actual pool state immediately before and after the injection so callers
|
||||
/// can split a pool period into clean sub-periods without synthetic values.
|
||||
pub struct InjectionRecord {
|
||||
pub sats_before: u64,
|
||||
pub tokens_before: u64,
|
||||
pub sats_after: u64,
|
||||
pub tokens_after: u64,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// Returns injection events (both-side-positive deltas) for a set of pools within a time window.
|
||||
/// An injection is a pool state where both sats_delta >= 0 and token_delta >= 0, meaning
|
||||
/// capital was added rather than a normal trade occurring.
|
||||
/// Callers use the k_before/k_after ratio to neutralize the K jump in APY calculations.
|
||||
pub async fn get_injections_between(
|
||||
pool: &SqlitePool,
|
||||
pool_id_blobs: &[Vec<u8>],
|
||||
min_start_ts: i64,
|
||||
end_ts: i64,
|
||||
) -> Result<HashMap<String, Vec<InjectionRecord>>> {
|
||||
if pool_id_blobs.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let placeholders = (1..=pool_id_blobs.len())
|
||||
.map(|i| format!("?{i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let n = pool_id_blobs.len();
|
||||
let query = format!(
|
||||
"SELECT pool, sats, token_amount, sats_delta, token_delta, effective_timestamp
|
||||
FROM pool_history_entry
|
||||
WHERE pool IN ({placeholders})
|
||||
AND effective_timestamp > ?{start_bind}
|
||||
AND effective_timestamp <= ?{end_bind}
|
||||
AND sats_delta >= 0 AND token_delta >= 0
|
||||
AND (sats_delta > 0 OR token_delta > 0)",
|
||||
start_bind = n + 1,
|
||||
end_bind = n + 2,
|
||||
);
|
||||
|
||||
let mut query_builder = sqlx::query(&query);
|
||||
for blob in pool_id_blobs {
|
||||
query_builder = query_builder.bind(blob.clone());
|
||||
}
|
||||
query_builder = query_builder.bind(min_start_ts).bind(end_ts);
|
||||
|
||||
let rows = query_builder.fetch_all(pool).await?;
|
||||
let mut result: HashMap<String, Vec<InjectionRecord>> = HashMap::new();
|
||||
|
||||
for row in rows {
|
||||
let pool_blob: Vec<u8> = row.get(0);
|
||||
let pool_id = blob_to_display_hex::<PoolID>(&pool_blob)?;
|
||||
let sats: i64 = row.get(1);
|
||||
let token_amount: i64 = row.get(2);
|
||||
let sats_delta: i64 = row.get(3);
|
||||
let token_delta: i64 = row.get(4);
|
||||
let ts: i64 = row.get(5);
|
||||
|
||||
result.entry(pool_id).or_default().push(InjectionRecord {
|
||||
sats_before: (sats - sats_delta).max(0) as u64,
|
||||
tokens_before: (token_amount - token_delta).max(0) as u64,
|
||||
sats_after: sats.max(0) as u64,
|
||||
tokens_after: token_amount.max(0) as u64,
|
||||
timestamp: ts as u64,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Returns pool period snapshots filtered by token and/or owner PKH.
|
||||
pub async fn get_pool_period_snapshot(
|
||||
pool: &SqlitePool,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ use log::warn;
|
|||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::blob::display_hex_to_blob;
|
||||
use crate::db::cauldron::pool::get_pool_period_snapshot;
|
||||
use crate::db::cauldron::pool::{get_injections_between, get_pool_period_snapshot};
|
||||
use crate::db::oracle::get_closest;
|
||||
use crate::rpc::apy::apyaggregator::APYAggregator;
|
||||
use crate::rpc::apy::poolperiod::PoolPeriod;
|
||||
use crate::rpc::apy::poolperiod::split_at_injections;
|
||||
use malachite::base::num::arithmetic::traits::FloorSqrt;
|
||||
use malachite::Integer;
|
||||
|
||||
|
|
@ -226,6 +226,14 @@ pub async fn apy_30d_bp_for_token(
|
|||
let start = now - 30 * 86_400;
|
||||
|
||||
let pairs = get_pool_period_snapshot(cauldron_pool, Some(token_id), None, start, now).await?;
|
||||
|
||||
let pool_id_blobs: Vec<Vec<u8>> = pairs
|
||||
.iter()
|
||||
.filter_map(|(s, _)| display_hex_to_blob::<crate::def::PoolID>(&s.pool_id).ok())
|
||||
.collect();
|
||||
|
||||
let injections_map = get_injections_between(cauldron_pool, &pool_id_blobs, start, now).await?;
|
||||
|
||||
let periods = pairs
|
||||
.into_iter()
|
||||
.filter(|(s, e)| {
|
||||
|
|
@ -235,7 +243,18 @@ pub async fn apy_30d_bp_for_token(
|
|||
&& e.sats > 0
|
||||
&& e.token_amount > 0
|
||||
})
|
||||
.map(|(s, e)| PoolPeriod::new(s, e))
|
||||
.flat_map(|(s, e)| {
|
||||
let injections = injections_map
|
||||
.get(&s.pool_id)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.filter(|r| r.timestamp > s.timestamp && r.timestamp < e.timestamp)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
split_at_injections(s, e, injections)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
if periods.is_empty() {
|
||||
|
|
|
|||
|
|
@ -3,41 +3,76 @@
|
|||
// 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::Result;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use rust_decimal::Decimal;
|
||||
use rust_decimal::MathematicalOps;
|
||||
use rust_decimal_macros::dec;
|
||||
|
||||
use super::poolperiod::PoolPeriod;
|
||||
|
||||
const DAYS_IN_YEAR: Decimal = dec!(365.25);
|
||||
|
||||
pub struct APYAggregator;
|
||||
|
||||
impl APYAggregator {
|
||||
/// Aggregate APY across pools by computing a weighted average yield first,
|
||||
/// then annualizing once. This avoids per-pool overflow from (1+y)^(365/d)
|
||||
/// on short high-yield periods, and correctly dilutes extreme outlier pools.
|
||||
pub fn aggregate_apy<I>(pools: I, period_start: Option<u64>) -> Result<Decimal>
|
||||
where
|
||||
I: Iterator<Item = PoolPeriod>,
|
||||
{
|
||||
let mut weighted_apy_sum = Decimal::ZERO;
|
||||
let mut weighted_yield_sum = Decimal::ZERO;
|
||||
let mut weighted_duration_sum = Decimal::ZERO;
|
||||
let mut total_weight = Decimal::ZERO;
|
||||
|
||||
for pool in pools {
|
||||
let days_active = pool.duration_days(&period_start)?;
|
||||
|
||||
if !days_active.is_zero() {
|
||||
let (_pool_yield, apy) = pool.yield_and_apy(&period_start)?;
|
||||
|
||||
// we give larger pools more weight in the aggregated apy. They have more liquidity,
|
||||
// so the aggregated number will be more accurate.
|
||||
let pool_yield = pool.pool_yield(&period_start)?;
|
||||
// Weight larger pools more — they represent more real liquidity.
|
||||
let pool_size = pool.end_k_sqrt()?;
|
||||
let weighted_apy = apy * days_active * pool_size;
|
||||
let weight = days_active * pool_size;
|
||||
|
||||
weighted_apy_sum += weighted_apy;
|
||||
total_weight += days_active * pool_size;
|
||||
weighted_yield_sum += pool_yield * weight;
|
||||
weighted_duration_sum += days_active * weight;
|
||||
total_weight += weight;
|
||||
}
|
||||
}
|
||||
|
||||
if total_weight.is_zero() {
|
||||
Ok(Decimal::ZERO)
|
||||
} else {
|
||||
Ok(weighted_apy_sum / total_weight)
|
||||
}
|
||||
return Ok(Decimal::ZERO);
|
||||
}
|
||||
|
||||
let avg_yield = weighted_yield_sum / total_weight;
|
||||
let avg_duration_days = weighted_duration_sum / total_weight;
|
||||
|
||||
if avg_duration_days.is_zero() {
|
||||
return Ok(Decimal::ZERO);
|
||||
}
|
||||
|
||||
let years_elapsed = DAYS_IN_YEAR
|
||||
.checked_div(avg_duration_days)
|
||||
.ok_or_else(|| anyhow!("days over year div"))?;
|
||||
|
||||
if years_elapsed.is_zero() {
|
||||
return Ok(Decimal::ZERO);
|
||||
}
|
||||
|
||||
let one_plus = avg_yield
|
||||
.checked_div(Decimal::ONE_HUNDRED)
|
||||
.ok_or_else(|| anyhow!("div 100"))?
|
||||
.checked_add(Decimal::ONE)
|
||||
.ok_or_else(|| anyhow!("1 + yield"))?;
|
||||
|
||||
let powd = one_plus
|
||||
.checked_powd(years_elapsed)
|
||||
.context("powd overflow")?;
|
||||
|
||||
powd.checked_sub(Decimal::ONE)
|
||||
.ok_or_else(|| anyhow!("powd - 1"))?
|
||||
.checked_mul(Decimal::ONE_HUNDRED)
|
||||
.ok_or_else(|| anyhow!("* 100"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,13 @@ use serde_json::Value;
|
|||
|
||||
use crate::{
|
||||
db::{
|
||||
cauldron::pool::{get_pool_period_snapshot, get_pool_period_snapshot_by_pool_ids},
|
||||
blob::display_hex_to_blob,
|
||||
cauldron::pool::{
|
||||
get_injections_between, get_pool_period_snapshot, get_pool_period_snapshot_by_pool_ids,
|
||||
},
|
||||
DB,
|
||||
},
|
||||
def::PoolID,
|
||||
rpc::err::{bad_request, db_error, ApiErrorCode, CachedApiResult},
|
||||
rpc::response::{cached_ok, CACHE_AGGREGATE},
|
||||
timeutil::time_now,
|
||||
|
|
@ -104,14 +108,44 @@ pub async fn aggregate_apy(
|
|||
.map_err(db_error)?
|
||||
};
|
||||
|
||||
// Fetch injection events (capital additions) for all pools in the result.
|
||||
// Each pool period is split into sub-periods at injection boundaries so the
|
||||
// APY calculation only sees real fee growth, never injected capital.
|
||||
let pool_id_blobs: Vec<Vec<u8>> = pool_snapshots
|
||||
.iter()
|
||||
.filter_map(|(s, _)| display_hex_to_blob::<PoolID>(&s.pool_id).ok())
|
||||
.collect();
|
||||
|
||||
let min_start_ts = pool_snapshots
|
||||
.iter()
|
||||
.map(|(s, _)| s.timestamp as i64)
|
||||
.min()
|
||||
.unwrap_or(start);
|
||||
|
||||
let injections_map = get_injections_between(&db.cauldron_r, &pool_id_blobs, min_start_ts, end)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
|
||||
let pools_count = pool_snapshots.len();
|
||||
let pools: anyhow::Result<Vec<PoolPeriod>> = pool_snapshots
|
||||
.into_iter()
|
||||
.map(|(start, end)| PoolPeriod::new(start, end))
|
||||
.flat_map(|(start_snap, end_snap)| {
|
||||
let injections = injections_map
|
||||
.get(&start_snap.pool_id)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.timestamp > start_snap.timestamp && r.timestamp < end_snap.timestamp
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
poolperiod::split_at_injections(start_snap, end_snap, injections)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let pools = pools.map_err(db_error)?;
|
||||
|
||||
let pools_count = pools.len();
|
||||
let apy = apyaggregator::APYAggregator::aggregate_apy(pools.into_iter(), Some(start as u64))
|
||||
.map_err(db_error)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,21 +3,16 @@
|
|||
// 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::{anyhow, bail, Context, Result};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use malachite::base::num::arithmetic::traits::FloorSqrt;
|
||||
use malachite::Integer;
|
||||
use rust_decimal::MathematicalOps;
|
||||
use rust_decimal::{prelude::FromPrimitive, Decimal};
|
||||
use rust_decimal_macros::dec;
|
||||
|
||||
use super::PoolSnapshot;
|
||||
use crate::db::cauldron::pool::InjectionRecord;
|
||||
|
||||
const SECONDS_IN_DAY: Decimal = dec!(86400.0);
|
||||
const DAYS_IN_YEAR: Decimal = dec!(365.25);
|
||||
/// Maximum APY value (100,000%) to guard against overflow in edge cases where
|
||||
/// pools with very small initial liquidity or large gaps in activity could
|
||||
/// produce unrealistic annualized returns that exceed Decimal precision limits.
|
||||
const MAX_APY: Decimal = dec!(100000.0);
|
||||
|
||||
/// Linear interpolation between two Decimal values.
|
||||
/// Returns start + (end - start) * (elapsed / total).
|
||||
|
|
@ -31,6 +26,51 @@ fn interpolate_linear(start: Decimal, end: Decimal, elapsed: u64, total: u64) ->
|
|||
start.checked_add(delta.checked_mul(ratio)?)
|
||||
}
|
||||
|
||||
/// Split a pool's (start, end) snapshot pair into sub-periods at injection boundaries.
|
||||
/// Each sub-period uses only real DB values and contains no injected capital, so the
|
||||
/// APY calculation sees only fee growth within each sub-period.
|
||||
pub fn split_at_injections(
|
||||
start: PoolSnapshot,
|
||||
end: PoolSnapshot,
|
||||
mut injections: Vec<&InjectionRecord>,
|
||||
) -> Vec<Result<PoolPeriod>> {
|
||||
injections.sort_by_key(|r| r.timestamp);
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut current_start = start;
|
||||
|
||||
for inj in injections {
|
||||
// Skip degenerate injections (pool was empty before injection)
|
||||
if inj.sats_before == 0 || inj.tokens_before == 0 {
|
||||
current_start = PoolSnapshot {
|
||||
pool_id: current_start.pool_id,
|
||||
timestamp: inj.timestamp,
|
||||
sats: inj.sats_after,
|
||||
token_amount: inj.tokens_after,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
let snap_before = PoolSnapshot {
|
||||
pool_id: current_start.pool_id.clone(),
|
||||
timestamp: inj.timestamp,
|
||||
sats: inj.sats_before,
|
||||
token_amount: inj.tokens_before,
|
||||
};
|
||||
result.push(PoolPeriod::new(current_start, snap_before));
|
||||
|
||||
current_start = PoolSnapshot {
|
||||
pool_id: end.pool_id.clone(),
|
||||
timestamp: inj.timestamp,
|
||||
sats: inj.sats_after,
|
||||
token_amount: inj.tokens_after,
|
||||
};
|
||||
}
|
||||
|
||||
result.push(PoolPeriod::new(current_start, end));
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PoolPeriod {
|
||||
pub start: PoolSnapshot,
|
||||
|
|
@ -87,13 +127,6 @@ impl PoolPeriod {
|
|||
Ok(self.durationd(starting)? / SECONDS_IN_DAY)
|
||||
}
|
||||
|
||||
pub fn days_over_year(&self, starting: &Option<u64>) -> Result<Decimal> {
|
||||
// If duration_days is 0, checked_div returns None → default to 0
|
||||
Ok(DAYS_IN_YEAR
|
||||
.checked_div(self.duration_days(starting)?)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn sqrt_integer_as_decimal(k: &Integer) -> anyhow::Result<Decimal> {
|
||||
use std::str::FromStr;
|
||||
let s: Integer = k.clone().floor_sqrt();
|
||||
|
|
@ -152,42 +185,6 @@ impl PoolPeriod {
|
|||
.checked_mul(Decimal::ONE_HUNDRED)
|
||||
.ok_or_else(|| anyhow::anyhow!("mul"))
|
||||
}
|
||||
|
||||
pub fn yield_and_apy(&self, starting: &Option<u64>) -> Result<(Decimal, Decimal)> {
|
||||
let pool_yield = self.pool_yield(starting)?;
|
||||
let years_elapsed = self.days_over_year(starting)?; // > 0 means at least some duration
|
||||
|
||||
// avoid powd blowups for very short periods (< 6h)
|
||||
if self.duration(starting) < 6 * 3600 {
|
||||
return Ok((pool_yield, Decimal::ZERO));
|
||||
}
|
||||
|
||||
let apy = if years_elapsed.is_zero() {
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
// APY = ((1 + y)^years - 1) * 100, with overflow guards
|
||||
let one_plus = (pool_yield
|
||||
.checked_div(Decimal::ONE_HUNDRED)
|
||||
.ok_or_else(|| anyhow!("divide by 100 overflow"))?)
|
||||
.checked_add(Decimal::ONE)
|
||||
.ok_or_else(|| anyhow!("1 + yield overflow"))?;
|
||||
|
||||
let powd = one_plus
|
||||
.checked_powd(years_elapsed)
|
||||
.context("powd overflow")?;
|
||||
let minus_one = powd
|
||||
.checked_sub(Decimal::ONE)
|
||||
.ok_or_else(|| anyhow!("pow - 1 underflow"))?;
|
||||
let apy_raw = minus_one
|
||||
.checked_mul(Decimal::ONE_HUNDRED)
|
||||
.ok_or_else(|| anyhow!("apy * 100 overflow"))?;
|
||||
|
||||
// Safety cap to prevent extreme values
|
||||
apy_raw.min(MAX_APY)
|
||||
};
|
||||
|
||||
Ok((pool_yield, apy))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -195,6 +192,105 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
|
||||
fn make_injection(
|
||||
timestamp: u64,
|
||||
sats_before: u64,
|
||||
tokens_before: u64,
|
||||
sats_after: u64,
|
||||
tokens_after: u64,
|
||||
) -> InjectionRecord {
|
||||
InjectionRecord {
|
||||
sats_before,
|
||||
tokens_before,
|
||||
sats_after,
|
||||
tokens_after,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_no_injections() {
|
||||
let start = PoolSnapshot::dummy(1000, 1000, 500);
|
||||
let end = PoolSnapshot::dummy(2000, 1100, 490);
|
||||
let periods: Vec<PoolPeriod> = split_at_injections(start, end, vec![])
|
||||
.into_iter()
|
||||
.collect::<anyhow::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(periods.len(), 1);
|
||||
assert_eq!(periods[0].start.sats, 1000);
|
||||
assert_eq!(periods[0].end.sats, 1100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_one_injection() {
|
||||
let start = PoolSnapshot::dummy(1000, 1000, 500);
|
||||
let end = PoolSnapshot::dummy(3000, 2100, 1490);
|
||||
// injection at t=2000: sats 1000→2000, tokens 500→1500
|
||||
let inj = make_injection(2000, 1000, 500, 2000, 1500);
|
||||
|
||||
let periods: Vec<PoolPeriod> = split_at_injections(start, end, vec![&inj])
|
||||
.into_iter()
|
||||
.collect::<anyhow::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(periods.len(), 2);
|
||||
// Sub-period 1: start → state just before injection
|
||||
assert_eq!(periods[0].start.timestamp, 1000);
|
||||
assert_eq!(periods[0].end.timestamp, 2000);
|
||||
assert_eq!(periods[0].end.sats, 1000);
|
||||
assert_eq!(periods[0].end.token_amount, 500);
|
||||
// Sub-period 2: state just after injection → end
|
||||
assert_eq!(periods[1].start.timestamp, 2000);
|
||||
assert_eq!(periods[1].start.sats, 2000);
|
||||
assert_eq!(periods[1].start.token_amount, 1500);
|
||||
assert_eq!(periods[1].end.timestamp, 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_multiple_injections() {
|
||||
let start = PoolSnapshot::dummy(1000, 1000, 500);
|
||||
let end = PoolSnapshot::dummy(5000, 4100, 3490);
|
||||
let inj1 = make_injection(2000, 1000, 500, 2000, 1500);
|
||||
let inj2 = make_injection(3000, 2000, 1500, 3000, 2500);
|
||||
let inj3 = make_injection(4000, 3000, 2500, 4000, 3500);
|
||||
|
||||
let periods: Vec<PoolPeriod> = split_at_injections(start, end, vec![&inj1, &inj2, &inj3])
|
||||
.into_iter()
|
||||
.collect::<anyhow::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(periods.len(), 4);
|
||||
assert_eq!(periods[0].start.timestamp, 1000);
|
||||
assert_eq!(periods[0].end.timestamp, 2000);
|
||||
assert_eq!(periods[1].start.timestamp, 2000);
|
||||
assert_eq!(periods[1].end.timestamp, 3000);
|
||||
assert_eq!(periods[2].start.timestamp, 3000);
|
||||
assert_eq!(periods[2].end.timestamp, 4000);
|
||||
assert_eq!(periods[3].start.timestamp, 4000);
|
||||
assert_eq!(periods[3].end.timestamp, 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_degenerate_injection_skipped() {
|
||||
// An injection where sats_before=0 should be skipped (pool was empty before injection).
|
||||
// The current_start should jump forward to the post-injection state.
|
||||
let start = PoolSnapshot::dummy(1000, 0, 0);
|
||||
let end = PoolSnapshot::dummy(3000, 2100, 1490);
|
||||
let inj = make_injection(2000, 0, 0, 2000, 1500);
|
||||
|
||||
let periods: Vec<PoolPeriod> = split_at_injections(start, end, vec![&inj])
|
||||
.into_iter()
|
||||
.collect::<anyhow::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
|
||||
// Degenerate injection skipped; only the post-injection → end sub-period remains
|
||||
assert_eq!(periods.len(), 1);
|
||||
assert_eq!(periods[0].start.sats, 2000);
|
||||
assert_eq!(periods[0].start.timestamp, 2000);
|
||||
assert_eq!(periods[0].end.timestamp, 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interpolate_linear_basic() {
|
||||
// Basic interpolation at midpoint
|
||||
|
|
@ -253,32 +349,120 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_apy_and_yield() {
|
||||
fn test_yield_known_reference_values() {
|
||||
// Reference values from a real pool:
|
||||
// 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
|
||||
|
||||
// yield: 0.02206714530974295, APY: 0.2815061492818, period: 28.67 days
|
||||
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));
|
||||
let pool_yield = period.pool_yield(&None).unwrap();
|
||||
let expected_yield = dec!(0.02206714530974295);
|
||||
assert!(
|
||||
(expected_apy - pool_apy).abs() < dec!(1e-7),
|
||||
"expected {expected_apy} != actual {pool_apy}"
|
||||
(expected_yield - pool_yield).abs() < dec!(1e-12),
|
||||
"yield mismatch: expected {expected_yield}, got {pool_yield}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aggregate_apy_known_reference_values() {
|
||||
// Same pool as test_yield_known_reference_values.
|
||||
// Single-pool aggregation should annualize correctly: ~28.2% APY.
|
||||
use crate::rpc::apy::apyaggregator::APYAggregator;
|
||||
let start = PoolSnapshot::dummy(1727201259, 1648241, 116674414);
|
||||
let end = PoolSnapshot::dummy(1729678252, 1759576, 109340225);
|
||||
let period = PoolPeriod::new(start, end).unwrap();
|
||||
|
||||
let apy = APYAggregator::aggregate_apy(std::iter::once(period), None).unwrap();
|
||||
let expected_apy = dec!(0.2815061492818);
|
||||
assert!(
|
||||
(expected_apy - apy).abs() < dec!(1e-6),
|
||||
"apy mismatch: expected {expected_apy}, got {apy}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aggregate_apy_no_overflow_with_period_start() {
|
||||
// Regression: pool 0b36662c had start_k barely above zero (sats=294608, tokens=2)
|
||||
// and a 42.8-day period. With the old per-pool annualization, passing period_start
|
||||
// inside that window shrank the effective duration to ~10.9 days while keeping the
|
||||
// full-period yield (52.5%), causing (1.525)^33.4 = powd overflow.
|
||||
// The new aggregator averages yield and duration first, so this must not overflow.
|
||||
use crate::rpc::apy::apyaggregator::APYAggregator;
|
||||
let start = PoolSnapshot::dummy(1763661872, 294608, 2);
|
||||
let end = PoolSnapshot::dummy(1767359139, 2948, 465);
|
||||
let period = PoolPeriod::new(start, end).unwrap();
|
||||
|
||||
let period_start = Some(1766414517u64); // 30-day window starts inside this pool period
|
||||
let result = APYAggregator::aggregate_apy(std::iter::once(period), period_start);
|
||||
assert!(result.is_ok(), "unexpected overflow: {:?}", result.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aggregate_apy_token_d03a_no_overflow() {
|
||||
// Regression: real 30-day window for token d03a0d87... (Jan 21, 2026).
|
||||
// The aggregation previously failed with "powd overflow" due to pool 0b36662c.
|
||||
use crate::rpc::apy::apyaggregator::APYAggregator;
|
||||
let pools_data = vec![
|
||||
(
|
||||
1766272253u64,
|
||||
27996919u64,
|
||||
1174620u64,
|
||||
1768387876u64,
|
||||
16301236u64,
|
||||
2028343u64,
|
||||
),
|
||||
(1763661872, 294608, 2, 1767359139, 2948, 465),
|
||||
(1766272253, 14025779, 588453, 1768385423, 8166999, 1016090),
|
||||
(1766272253, 254947, 10697, 1768664725, 144935, 18923),
|
||||
(1766272253, 13957573, 585591, 1768385423, 8127341, 1011139),
|
||||
(1764336593, 3824, 187, 1767359139, 2135, 336),
|
||||
(1766272253, 38225, 1602, 1767377622, 23136, 2661),
|
||||
];
|
||||
let periods: Vec<PoolPeriod> = pools_data
|
||||
.into_iter()
|
||||
.map(|(s_ts, s_sats, s_tok, e_ts, e_sats, e_tok)| {
|
||||
PoolPeriod::new(
|
||||
PoolSnapshot::dummy(s_ts, s_sats, s_tok),
|
||||
PoolSnapshot::dummy(e_ts, e_sats, e_tok),
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let period_start = Some(1766414517u64);
|
||||
let result = APYAggregator::aggregate_apy(periods.into_iter(), period_start);
|
||||
assert!(result.is_ok(), "unexpected error: {:?}", result.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aggregate_apy_zero_duration_period_skipped() {
|
||||
// A period where start == end timestamp contributes zero duration_days.
|
||||
// The aggregator should skip it rather than panic or divide by zero.
|
||||
use crate::rpc::apy::apyaggregator::APYAggregator;
|
||||
let ts = 1727201259u64;
|
||||
let normal = PoolPeriod::new(
|
||||
PoolSnapshot::dummy(ts, 1648241, 116674414),
|
||||
PoolSnapshot::dummy(ts + 86400, 1659000, 115000000),
|
||||
)
|
||||
.unwrap();
|
||||
// Zero-duration period: start_ts == end_ts (same timestamp, validated as equal)
|
||||
// PoolPeriod::new allows equal timestamps (only rejects start > end).
|
||||
let zero_dur = PoolPeriod::new(
|
||||
PoolSnapshot::dummy(ts, 1648241, 116674414),
|
||||
PoolSnapshot::dummy(ts, 1648241, 116674414),
|
||||
)
|
||||
.unwrap();
|
||||
let result = APYAggregator::aggregate_apy(vec![normal, zero_dur].into_iter(), None);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"should not panic on zero-duration period: {:?}",
|
||||
result.err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Test APY calculation failure for token d03a0d876afba161101674e363398e33939cc2164bd7ea5baccd937497d6216f
|
||||
/// This test reproduces the APY calculation failure by testing the documented error conditions:
|
||||
/// - "start sqrt is zero; division by zero" when start_k (sats * token_amount) is zero
|
||||
/// - "powd overflow" when exponentiation in APY calculation overflows
|
||||
/// - "pow - 1 underflow" when arithmetic underflow occurs after power calculation
|
||||
#[test]
|
||||
fn test_apy_failure_start_sqrt_zero() {
|
||||
// Test case: start sats is zero, causing division by zero
|
||||
|
|
@ -317,156 +501,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apy_powd_overflow_with_period_start() {
|
||||
// BUG REPRODUCTION: Pool 0b36662c from token d03a0d87...
|
||||
// This pool has a large yield (52.5%) over a long period (42.8 days).
|
||||
// When yield_and_apy() is called with period_start that is AFTER the pool's
|
||||
// start timestamp, the duration shrinks but yield stays the same.
|
||||
// This causes the APY calculation to overflow because:
|
||||
// - yield = 52.5% (calculated from full period k values)
|
||||
// - effective duration = ~10.9 days (from period_start to end)
|
||||
// - years_elapsed = 365.25 / 10.9 = 33.4
|
||||
// - APY = (1.525^33.4 - 1) * 100 = OVERFLOW!
|
||||
//
|
||||
// Real data from cauldron.db:
|
||||
// Pool: 0b36662c82f7ff3c36f2aad7fd0c06c99735dcce22fffde940b1d0731334c3f5
|
||||
// Start: ts=1763661872, sats=294608, tokens=2
|
||||
// End: ts=1767359139, sats=2948, tokens=465
|
||||
let start = PoolSnapshot::dummy(1763661872, 294608, 2);
|
||||
let end = PoolSnapshot::dummy(1767359139, 2948, 465);
|
||||
|
||||
let period = PoolPeriod::new(start, end).unwrap();
|
||||
|
||||
// Without period_start, it works (42.8 days)
|
||||
let result_none = period.yield_and_apy(&None);
|
||||
assert!(
|
||||
result_none.is_ok(),
|
||||
"Should work without period_start: {:?}",
|
||||
result_none.err()
|
||||
);
|
||||
let (yield_val, apy_val) = result_none.unwrap();
|
||||
println!("Without period_start: yield={}, apy={}", yield_val, apy_val);
|
||||
|
||||
// With period_start = API's (NOW - 30 days), duration shrinks to ~10.9 days
|
||||
// but yield stays at 52.5%, causing overflow
|
||||
//
|
||||
// BUG: Currently fails with "powd overflow" because yield is calculated from
|
||||
// full period but duration uses period_start, creating (1.525)^33.4 overflow.
|
||||
// This test should pass once the bug is fixed.
|
||||
let period_start = Some(1766414517u64); // NOW - 30 days when API was called
|
||||
let result_with_start = period.yield_and_apy(&period_start);
|
||||
|
||||
assert!(
|
||||
result_with_start.is_ok(),
|
||||
"BUG: Should not overflow - got error: {:?}",
|
||||
result_with_start.err()
|
||||
);
|
||||
let (yield_val, apy_val) = result_with_start.unwrap();
|
||||
println!("With period_start: yield={}, apy={}", yield_val, apy_val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apy_token_d03a_current_window() {
|
||||
// Test case for token d03a0d876afba161101674e363398e33939cc2164bd7ea5baccd937497d6216f
|
||||
// Real pool periods from current 30-day API window (as of Jan 21, 2026)
|
||||
// These are the actual periods that cause "powd overflow" error
|
||||
|
||||
use crate::rpc::apy::apyaggregator::APYAggregator;
|
||||
|
||||
let pools_data = vec![
|
||||
// pool, start_ts, start_sats, start_tokens, end_ts, end_sats, end_tokens
|
||||
(
|
||||
"0a6f7bad",
|
||||
1766272253u64,
|
||||
27996919u64,
|
||||
1174620u64,
|
||||
1768387876u64,
|
||||
16301236u64,
|
||||
2028343u64,
|
||||
),
|
||||
("0b36662c", 1763661872, 294608, 2, 1767359139, 2948, 465), // Very low start tokens!
|
||||
(
|
||||
"22ddbf2e", 1766272253, 14025779, 588453, 1768385423, 8166999, 1016090,
|
||||
),
|
||||
(
|
||||
"b6ac601f", 1766272253, 254947, 10697, 1768664725, 144935, 18923,
|
||||
),
|
||||
(
|
||||
"c1517c3a", 1766272253, 13957573, 585591, 1768385423, 8127341, 1011139,
|
||||
),
|
||||
("fc9d23e1", 1764336593, 3824, 187, 1767359139, 2135, 336),
|
||||
("fd77e2da", 1766272253, 38225, 1602, 1767377622, 23136, 2661),
|
||||
];
|
||||
|
||||
// Test each pool individually first
|
||||
let mut periods = Vec::new();
|
||||
for (pool_name, start_ts, start_sats, start_tokens, end_ts, end_sats, end_tokens) in
|
||||
&pools_data
|
||||
{
|
||||
let start = PoolSnapshot::dummy(*start_ts, *start_sats, *start_tokens);
|
||||
let end = PoolSnapshot::dummy(*end_ts, *end_sats, *end_tokens);
|
||||
|
||||
let period = PoolPeriod::new(start, end).unwrap();
|
||||
let duration_days = (*end_ts - *start_ts) as f64 / 86400.0;
|
||||
|
||||
match period.yield_and_apy(&None) {
|
||||
Ok((pool_yield, apy)) => {
|
||||
println!(
|
||||
"Pool {} ({:.1} days): yield={}, apy={}",
|
||||
pool_name, duration_days, pool_yield, apy
|
||||
);
|
||||
periods.push(period);
|
||||
}
|
||||
Err(e) => {
|
||||
panic!(
|
||||
"Pool {} ({:.1} days) FAILED: {} (start: sats={}, tokens={}, end: sats={}, tokens={})",
|
||||
pool_name, duration_days, e, start_sats, start_tokens, end_sats, end_tokens
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now test aggregation - this is where the real error occurs
|
||||
// BUG: The aggregation fails because pool 0b36662c has:
|
||||
// - yield = 52.5% (from full period)
|
||||
// - but duration_days uses period_start, making it ~10.9 days
|
||||
// - years_elapsed = 365.25 / 10.9 = 33.4
|
||||
// - APY = (1.525^33.4 - 1) * 100 = OVERFLOW!
|
||||
//
|
||||
// This test should pass once the bug is fixed.
|
||||
let period_start = Some(1766414517u64); // NOW - 30 days
|
||||
let result = APYAggregator::aggregate_apy(periods.into_iter(), period_start);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"BUG: Aggregation should not overflow - got error: {:?}",
|
||||
result.err()
|
||||
);
|
||||
let apy = result.unwrap();
|
||||
println!("Aggregated APY: {}", apy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apy_short_period_returns_zero_apy() {
|
||||
// Periods shorter than 6 hours should return zero APY (to avoid powd blowups)
|
||||
let start = PoolSnapshot::dummy(1727201259, 1648241, 116674414);
|
||||
let end = PoolSnapshot::dummy(1727201259 + 3600, 1659000, 115000000); // 1 hour later
|
||||
|
||||
let period = PoolPeriod::new(start, end).unwrap();
|
||||
let (pool_yield, pool_apy) = period.yield_and_apy(&None).unwrap();
|
||||
|
||||
// Pool yield should be calculated
|
||||
assert!(!pool_yield.is_zero() || pool_yield.is_zero()); // yield is calculated
|
||||
|
||||
// APY should be zero for short periods
|
||||
assert!(
|
||||
pool_apy.is_zero(),
|
||||
"APY should be zero for periods < 6 hours, got: {}",
|
||||
pool_apy
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apy_timestamp_order_validation() {
|
||||
// Test that start timestamp > end timestamp is rejected
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue