use anyhow::Result;
use fix::prelude::*;
use hylo_core::earn_pool_math::lp_token_nav;
use hylo_core::yields::{HarvestCache, YieldHarvestConfig};
use crate::earn_pool_yield_math::{
apply_drawdown_offset, epoch_yield_rate, projected_borrow_inflow,
projected_lst_inflow, EPOCHS_PER_YEAR,
};
use crate::error::StatsError::{
ProjectedExoInflowOverflow, ProjectedInflowOverflow,
ProjectedLstInflowOverflow, RealizedYieldOverflow,
};
use crate::types::{
EarnPoolStats, ExoSnapshot, ExoStats, LstPosition, RealizedHarvest,
StatsInputs,
};
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn annualize_with(per_epoch_rate: UFix64<N9>, epochs_per_year: f64) -> f64 {
let rate = per_epoch_rate.bits as f64 * 1e-9;
(1.0 + rate).powf(epochs_per_year) - 1.0
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn annualize(per_epoch_rate: UFix64<N9>) -> f64 {
annualize_with(per_epoch_rate, EPOCHS_PER_YEAR as f64)
}
fn realized(
harvest_cache: &HarvestCache,
current_epoch: u64,
) -> Result<RealizedHarvest> {
Ok(RealizedHarvest {
epoch: harvest_cache.epoch,
hyusd_to_pool: harvest_cache.stablecoin_to_pool.try_into()?,
is_stale: harvest_cache.is_stale(current_epoch),
})
}
fn exo_snapshot_stats(
snapshot: &ExoSnapshot,
current_epoch: u64,
) -> Result<ExoStats> {
Ok(ExoStats {
collateral_mint: snapshot.collateral_mint,
harvest: realized(&snapshot.harvest_cache, current_epoch)?,
projected_inflow: projected_borrow_inflow(
snapshot.levercoin_market_cap,
&snapshot.borrow_rate_config,
)?,
})
}
fn realized_yield_rate(
lst_harvest: &RealizedHarvest,
exo_stats: &[ExoStats],
pool_balance: UFix64<N6>,
) -> Result<UFix64<N9>> {
let last_harvest_epoch = exo_stats
.iter()
.map(|stats| stats.harvest.epoch)
.fold(lst_harvest.epoch, u64::max);
let realized_total = std::iter::once(lst_harvest)
.chain(exo_stats.iter().map(|stats| &stats.harvest))
.filter(|harvest| harvest.epoch == last_harvest_epoch)
.try_fold(UFix64::zero(), |acc: UFix64<N6>, harvest| {
acc.checked_add(&harvest.hyusd_to_pool)
})
.ok_or(RealizedYieldOverflow)?;
epoch_yield_rate(realized_total, pool_balance)
}
fn projected_lst_total(
lst_positions: &[LstPosition],
sol_usd_spot: UFix64<N9>,
harvest_config: &YieldHarvestConfig,
) -> Result<UFix64<N6>> {
lst_positions.iter().try_fold(
UFix64::zero(),
|acc: UFix64<N6>, position| -> Result<UFix64<N6>> {
let inflow = projected_lst_inflow(
position.sol_value,
position.epoch_growth,
sol_usd_spot,
harvest_config,
)?;
Ok(acc.checked_add(&inflow).ok_or(ProjectedLstInflowOverflow)?)
},
)
}
fn projected_exo_total(exo_stats: &[ExoStats]) -> Result<UFix64<N6>> {
Ok(
exo_stats
.iter()
.try_fold(UFix64::zero(), |acc: UFix64<N6>, stats| {
acc.checked_add(&stats.projected_inflow)
})
.ok_or(ProjectedExoInflowOverflow)?,
)
}
fn projected_rate(
projected_lst: UFix64<N6>,
projected_exo: UFix64<N6>,
outstanding_drawdown: UFix64<N6>,
pool_balance: UFix64<N6>,
) -> Result<UFix64<N9>> {
let gross = projected_lst
.checked_add(&projected_exo)
.ok_or(ProjectedInflowOverflow)?;
let net = apply_drawdown_offset(gross, outstanding_drawdown);
epoch_yield_rate(net, pool_balance)
}
pub fn compute_stats(inputs: &StatsInputs) -> Result<EarnPoolStats> {
let nav = lp_token_nav(inputs.pool_balance, inputs.shyusd_supply)?;
let lst_harvest = realized(&inputs.lst_harvest_cache, inputs.current_epoch)?;
let exo_stats = inputs
.exo_snapshots
.iter()
.map(|snapshot| exo_snapshot_stats(snapshot, inputs.current_epoch))
.collect::<Result<Vec<ExoStats>>>()?;
let last_epoch_yield_rate =
realized_yield_rate(&lst_harvest, &exo_stats, inputs.pool_balance)?;
let projected_lst_inflow = projected_lst_total(
&inputs.lst_positions,
inputs.sol_usd_spot,
&inputs.harvest_config,
)?;
let projected_exo_inflow = projected_exo_total(&exo_stats)?;
let projected_epoch_rate = projected_rate(
projected_lst_inflow,
projected_exo_inflow,
inputs.outstanding_drawdown,
inputs.pool_balance,
)?;
Ok(EarnPoolStats {
nav,
pool_balance: inputs.pool_balance,
shyusd_supply: inputs.shyusd_supply,
current_epoch: inputs.current_epoch,
epochs_per_year: inputs.epochs_per_year,
lst_harvest,
exo_stats,
last_epoch_yield_rate,
naive_apy: annualize_with(last_epoch_yield_rate, inputs.epochs_per_year),
projected_lst_inflow,
projected_exo_inflow,
outstanding_drawdown: inputs.outstanding_drawdown,
projected_epoch_rate,
projected_apy: annualize_with(projected_epoch_rate, inputs.epochs_per_year),
})
}
#[cfg(test)]
mod tests {
use anchor_lang::prelude::Pubkey;
use hylo_core::borrow_rate::BorrowRateConfig;
use hylo_core::yields::YieldHarvestConfig;
use super::*;
use crate::types::{ExoSnapshot, LstPosition};
fn cache(epoch: u64, to_pool_bits: u64) -> HarvestCache {
HarvestCache {
epoch,
stability_pool_cap: UFix64::<N6>::zero().into(),
stablecoin_to_pool: UFix64::<N6>::new(to_pool_bits).into(),
}
}
fn exo_snapshot(
harvest_cache: HarvestCache,
levercoin_market_cap: UFix64<N9>,
) -> ExoSnapshot {
ExoSnapshot {
collateral_mint: Pubkey::new_unique(),
harvest_cache,
borrow_rate_config: BorrowRateConfig::new(
UFix64::<N9>::new(384_620).into(),
UFix64::<N4>::new(500).into(),
),
levercoin_market_cap,
}
}
fn inputs() -> StatsInputs {
StatsInputs {
current_epoch: 800,
pool_balance: UFix64::<N6>::new(1_000_000_000_000),
shyusd_supply: UFix64::<N6>::new(950_000_000_000),
lst_harvest_cache: cache(800, 1_000_000_000),
harvest_config: YieldHarvestConfig {
allocation: UFix64::<N4>::new(10_000).into(),
fee: UFix64::<N4>::new(1_000).into(),
},
lst_positions: vec![LstPosition {
sol_value: UFix64::<N9>::new(100_000_000_000_000),
epoch_growth: UFix64::<N9>::new(500_000),
}],
exo_snapshots: vec![exo_snapshot(
cache(800, 200_000_000),
UFix64::<N9>::new(1_000_000_000_000_000),
)],
sol_usd_spot: UFix64::<N9>::new(150_000_000_000),
outstanding_drawdown: UFix64::zero(),
epochs_per_year: 182.0,
}
}
#[test]
fn annualize_compounds() {
let apy = annualize(UFix64::<N9>::new(1_000_000));
assert!((apy - 0.1995).abs() < 1e-3, "apy = {apy}");
}
#[test]
fn annualize_zero_rate() {
let apy = annualize(UFix64::zero());
assert!(apy.abs() < f64::EPSILON);
}
#[test]
fn annualize_with_matches_convention() {
let rate = UFix64::<N9>::new(1_000_000);
let diff = (annualize_with(rate, 182.0) - annualize(rate)).abs();
assert!(diff < f64::EPSILON);
}
#[test]
fn annualize_with_lower_basis_lowers_apy() {
let rate = UFix64::<N9>::new(2_000_000);
let low = annualize_with(rate, 166.0);
let high = annualize_with(rate, 182.0);
assert!(low < high, "low = {low}, high = {high}");
let expected = (1.002f64).powf(166.0) - 1.0;
assert!((low - expected).abs() < 1e-12, "low = {low}");
}
#[test]
fn compute_stats_uses_given_basis() -> Result<()> {
let mut input = inputs();
input.epochs_per_year = 100.0;
let stats = compute_stats(&input)?;
assert!((stats.epochs_per_year - 100.0).abs() < f64::EPSILON);
let expected = annualize_with(stats.last_epoch_yield_rate, 100.0);
assert!((stats.naive_apy - expected).abs() < f64::EPSILON);
Ok(())
}
#[test]
fn compute_stats_realized_sums_matching_epochs() -> Result<()> {
let stats = compute_stats(&inputs())?;
assert_eq!(stats.last_epoch_yield_rate, UFix64::<N9>::new(1_200_000));
assert!(!stats.lst_harvest.is_stale);
assert!(!stats.exo_stats[0].harvest.is_stale);
Ok(())
}
#[test]
fn compute_stats_ignores_older_stream_epoch() -> Result<()> {
let mut input = inputs();
input.exo_snapshots[0].harvest_cache = cache(799, 200_000_000);
let stats = compute_stats(&input)?;
assert_eq!(stats.last_epoch_yield_rate, UFix64::<N9>::new(1_000_000));
assert!(stats.exo_stats[0].harvest.is_stale);
Ok(())
}
#[test]
fn compute_stats_two_exo_snapshots() -> Result<()> {
let mut input = inputs();
input.exo_snapshots = vec![
exo_snapshot(
cache(800, 200_000_000),
UFix64::<N9>::new(1_000_000_000_000_000),
),
exo_snapshot(
cache(799, 999_000_000),
UFix64::<N9>::new(500_000_000_000_000),
),
];
let stats = compute_stats(&input)?;
assert_eq!(stats.last_epoch_yield_rate, UFix64::<N9>::new(1_200_000));
assert!(stats.exo_stats[1].harvest.is_stale);
let expected_exo = stats.exo_stats[0]
.projected_inflow
.checked_add(&stats.exo_stats[1].projected_inflow)
.ok_or(ProjectedExoInflowOverflow)?;
assert_eq!(stats.projected_exo_inflow, expected_exo);
Ok(())
}
#[test]
fn compute_stats_projection_pipeline() -> Result<()> {
let stats = compute_stats(&inputs())?;
assert_eq!(stats.projected_lst_inflow, UFix64::<N6>::new(6_750_000_000));
assert_eq!(
stats.exo_stats[0].projected_inflow,
UFix64::<N6>::new(365_389_000)
);
assert_eq!(stats.projected_exo_inflow, UFix64::<N6>::new(365_389_000));
assert_eq!(stats.projected_epoch_rate, UFix64::<N9>::new(7_115_389));
assert!(stats.projected_apy > stats.naive_apy);
Ok(())
}
#[test]
fn compute_stats_drawdown_reduces_projection() -> Result<()> {
let mut input = inputs();
input.outstanding_drawdown = UFix64::<N6>::new(7_115_389_000);
let stats = compute_stats(&input)?;
assert_eq!(stats.projected_epoch_rate, UFix64::zero());
assert!(stats.projected_apy.abs() < f64::EPSILON);
Ok(())
}
#[test]
fn compute_stats_nav() -> Result<()> {
let stats = compute_stats(&inputs())?;
assert_eq!(stats.nav, UFix64::<N6>::new(1_052_632));
Ok(())
}
}