Skip to content

Commit 30c3bb3

Browse files
feat: oracle health monitoring, rate history, pool optimizer, priority liquidation (#774)
- #680: Add monitor_oracle_health() with auto circuit breaker trigger, consecutive failure tracking, incident timeline, and recovery - #681: Add rate history snapshots, simulate_rate_at_utilization(), average rate calculation, and rate change tracking - #682: Add optimize_allocation() with utilization-based recommendations, rebalance threshold detection, and yield improvement estimates - #683: Add priority ordering to batch_liquidate() with insertion sort by profit potential and calculate_priority_score() Closes #680, #681, #682, #683
1 parent 214cb1c commit 30c3bb3

4 files changed

Lines changed: 621 additions & 2 deletions

File tree

stellar-lend/contracts/hello-world/src/amm.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,3 +386,156 @@ pub fn get_il_snapshot(env: &Env, asset: &Address) -> Option<IlSnapshot> {
386386
.persistent()
387387
.get(&AmmLendingKey::IlTracking(asset.clone()))
388388
}
389+
390+
// ─── Pool Allocation Optimizer (#682) ─────────────────────────────────────────
391+
392+
/// Target utilization for optimal capital efficiency (80%)
393+
const OPTIMAL_UTILIZATION_BPS: i128 = 8000;
394+
395+
/// Minimum rebalance threshold (5% difference to trigger rebalance)
396+
const REBALANCE_THRESHOLD_BPS: i128 = 500;
397+
398+
/// Pool allocation recommendation
399+
#[contracttype]
400+
#[derive(Clone, Debug, PartialEq)]
401+
pub struct AllocationRecommendation {
402+
pub asset: Address,
403+
pub current_utilization_bps: i128,
404+
pub recommended_allocation_bps: i128,
405+
pub action: AllocationAction,
406+
pub amount: i128,
407+
}
408+
409+
/// Action to take for allocation optimization
410+
#[contracttype]
411+
#[derive(Clone, Debug, PartialEq)]
412+
pub enum AllocationAction {
413+
/// Move funds into this pool (under-utilized)
414+
Increase,
415+
/// Move funds out of this pool (over-utilized)
416+
Decrease,
417+
/// No change needed
418+
NoChange,
419+
}
420+
421+
/// Result of an optimization pass
422+
#[contracttype]
423+
#[derive(Clone, Debug, PartialEq)]
424+
pub struct OptimizationResult {
425+
pub recommendations: Vec<AllocationRecommendation>,
426+
pub total_capital_efficiency_bps: i128,
427+
pub estimated_yield_improvement_bps: i128,
428+
}
429+
430+
/// Analyze pool utilization and recommend allocation changes.
431+
///
432+
/// Examines all tracked pools and recommends rebalancing actions to
433+
/// maximize capital efficiency while maintaining safety buffers.
434+
pub fn optimize_allocation(
435+
env: &Env,
436+
pools: &Vec<Address>,
437+
) -> Result<OptimizationResult, AmmError> {
438+
let mut recommendations: Vec<AllocationRecommendation> = Vec::new(env);
439+
let mut total_utilization: i128 = 0;
440+
let mut pool_count: i128 = 0;
441+
442+
for pool in pools.iter() {
443+
let utilization_key = AmmLendingKey::PoolUtilization(pool.clone());
444+
let current_utilization: i128 = env
445+
.storage()
446+
.persistent()
447+
.get::<AmmLendingKey, i128>(&utilization_key)
448+
.unwrap_or(0);
449+
450+
total_utilization = total_utilization.saturating_add(current_utilization);
451+
pool_count = pool_count.saturating_add(1);
452+
453+
let deviation = if current_utilization > OPTIMAL_UTILIZATION_BPS {
454+
current_utilization - OPTIMAL_UTILIZATION_BPS
455+
} else {
456+
OPTIMAL_UTILIZATION_BPS - current_utilization
457+
};
458+
459+
let (action, amount) = if deviation < REBALANCE_THRESHOLD_BPS {
460+
(AllocationAction::NoChange, 0)
461+
} else if current_utilization < OPTIMAL_UTILIZATION_BPS {
462+
// Under-utilized — increase allocation
463+
let buffer_key = AmmLendingKey::WithdrawalBufferBps(pool.clone());
464+
let buffer_bps: i128 = env
465+
.storage()
466+
.persistent()
467+
.get::<AmmLendingKey, i128>(&buffer_key)
468+
.unwrap_or(DEFAULT_WITHDRAWAL_BUFFER_BPS);
469+
let available = BPS_SCALE.saturating_sub(buffer_bps);
470+
let increase_amount = available
471+
.saturating_mul(OPTIMAL_UTILIZATION_BPS - current_utilization)
472+
.checked_div(BPS_SCALE)
473+
.unwrap_or(0);
474+
(AllocationAction::Increase, increase_amount)
475+
} else {
476+
// Over-utilized — decrease allocation
477+
let excess = current_utilization - OPTIMAL_UTILIZATION_BPS;
478+
let decrease_amount = excess
479+
.saturating_mul(current_utilization)
480+
.checked_div(BPS_SCALE)
481+
.unwrap_or(0);
482+
(AllocationAction::Decrease, decrease_amount)
483+
};
484+
485+
recommendations.push_back(AllocationRecommendation {
486+
asset: pool.clone(),
487+
current_utilization_bps: current_utilization,
488+
recommended_allocation_bps: OPTIMAL_UTILIZATION_BPS,
489+
action,
490+
amount,
491+
});
492+
}
493+
494+
let avg_utilization = if pool_count > 0 {
495+
total_utilization.checked_div(pool_count).unwrap_or(0)
496+
} else {
497+
0
498+
};
499+
500+
// Estimate yield improvement: closer to optimal = better yield
501+
let efficiency = if avg_utilization <= OPTIMAL_UTILIZATION_BPS {
502+
avg_utilization
503+
} else {
504+
// Over-utilization means higher rates but more risk
505+
OPTIMAL_UTILIZATION_BPS
506+
};
507+
508+
// Yield improvement estimate: moving from current to optimal
509+
let yield_improvement = if avg_utilization < OPTIMAL_UTILIZATION_BPS {
510+
(OPTIMAL_UTILIZATION_BPS - avg_utilization).checked_div(100).unwrap_or(0)
511+
} else {
512+
0
513+
};
514+
515+
Ok(OptimizationResult {
516+
recommendations,
517+
total_capital_efficiency_bps: efficiency,
518+
estimated_yield_improvement_bps: yield_improvement,
519+
})
520+
}
521+
522+
/// Update the utilization snapshot for a pool.
523+
///
524+
/// Should be called whenever deposits/withdrawals/borrows change pool state.
525+
pub fn update_pool_utilization(
526+
env: &Env,
527+
asset: &Address,
528+
utilization_bps: i128,
529+
) {
530+
let key = AmmLendingKey::PoolUtilization(asset.clone());
531+
env.storage().persistent().set(&key, &utilization_bps);
532+
}
533+
534+
/// Get the current utilization snapshot for a pool.
535+
pub fn get_pool_utilization(env: &Env, asset: &Address) -> i128 {
536+
let key = AmmLendingKey::PoolUtilization(asset.clone());
537+
env.storage()
538+
.persistent()
539+
.get::<AmmLendingKey, i128>(&key)
540+
.unwrap_or(0)
541+
}

stellar-lend/contracts/hello-world/src/interest_rate.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,3 +568,182 @@ pub fn compute_index_interest(
568568
.ok_or(InterestRateError::DivisionByZero)?;
569569
Ok(interest)
570570
}
571+
572+
// ── Rate History & Dynamic Adjustment (#681) ───────────────────────────────
573+
574+
/// Maximum number of rate history entries to keep per asset
575+
const MAX_RATE_HISTORY: u32 = 100;
576+
577+
/// A snapshot of the interest rate at a point in time
578+
#[contracttype]
579+
#[derive(Clone, Debug, PartialEq)]
580+
pub struct RateHistoryEntry {
581+
/// Borrow rate at this point (bps)
582+
pub borrow_rate_bps: i128,
583+
/// Supply rate at this point (bps)
584+
pub supply_rate_bps: i128,
585+
/// Utilization at this point (bps)
586+
pub utilization_bps: i128,
587+
/// Timestamp of the snapshot
588+
pub timestamp: u64,
589+
}
590+
591+
/// Storage keys for rate history
592+
#[contracttype]
593+
#[derive(Clone)]
594+
pub enum RateHistoryKey {
595+
/// Vec<RateHistoryEntry> — rolling window of rate snapshots
596+
RateHistory,
597+
/// RateHistoryEntry — the last recorded snapshot
598+
LastSnapshot,
599+
}
600+
601+
/// Record a rate snapshot in the history.
602+
///
603+
/// Should be called whenever interest is accrued or rates are updated.
604+
pub fn record_rate_snapshot(env: &Env) -> Result<(), InterestRateError> {
605+
let borrow_rate = calculate_borrow_rate(env)?;
606+
let supply_rate = calculate_supply_rate(env)?;
607+
let utilization = calculate_utilization(env)?;
608+
let now = env.ledger().timestamp();
609+
610+
let entry = RateHistoryEntry {
611+
borrow_rate_bps: borrow_rate,
612+
supply_rate_bps: supply_rate,
613+
utilization_bps: utilization,
614+
timestamp: now,
615+
};
616+
617+
// Store as last snapshot
618+
env.storage()
619+
.persistent()
620+
.set(&RateHistoryKey::LastSnapshot, &entry);
621+
622+
// Append to rolling history
623+
let history_key = RateHistoryKey::RateHistory;
624+
let mut history: Vec<RateHistoryEntry> = env
625+
.storage()
626+
.persistent()
627+
.get::<RateHistoryKey, Vec<RateHistoryEntry>>(&history_key)
628+
.unwrap_or_else(|| Vec::new(env));
629+
630+
history.push_back(entry);
631+
632+
// Trim to max size
633+
let mut trimmed: Vec<RateHistoryEntry> = Vec::new(env);
634+
let start = if history.len() as u32 > MAX_RATE_HISTORY {
635+
history.len() - MAX_RATE_HISTORY
636+
} else {
637+
0
638+
};
639+
for i in start..history.len() {
640+
trimmed.push_back(history.get(i).unwrap());
641+
}
642+
643+
env.storage().persistent().set(&history_key, &trimmed);
644+
645+
Ok(())
646+
}
647+
648+
/// Get the rate history.
649+
pub fn get_rate_history(env: &Env) -> Vec<RateHistoryEntry> {
650+
let key = RateHistoryKey::RateHistory;
651+
env.storage()
652+
.persistent()
653+
.get::<RateHistoryKey, Vec<RateHistoryEntry>>(&key)
654+
.unwrap_or_else(|| Vec::new(env))
655+
}
656+
657+
/// Get the last recorded rate snapshot.
658+
pub fn get_last_rate_snapshot(env: &Env) -> Option<RateHistoryEntry> {
659+
env.storage()
660+
.persistent()
661+
.get::<RateHistoryKey, RateHistoryEntry>(&RateHistoryKey::LastSnapshot)
662+
}
663+
664+
/// Simulate what the borrow rate would be at a given utilization level.
665+
///
666+
/// Does not modify state — purely a read-only calculation.
667+
///
668+
/// # Arguments
669+
/// * `env` - The Soroban environment
670+
/// * `target_utilization_bps` - The utilization to simulate (0-10000)
671+
///
672+
/// # Returns
673+
/// The simulated borrow rate in basis points
674+
pub fn simulate_rate_at_utilization(
675+
env: &Env,
676+
target_utilization_bps: i128,
677+
) -> Result<i128, InterestRateError> {
678+
let config = get_interest_rate_config(env).ok_or(InterestRateError::InvalidParameter)?;
679+
680+
if target_utilization_bps < 0 || target_utilization_bps > BASIS_POINTS_SCALE {
681+
return Err(InterestRateError::InvalidParameter);
682+
}
683+
684+
let mut rate = config.base_rate_bps;
685+
686+
if target_utilization_bps <= config.kink_utilization_bps {
687+
if config.kink_utilization_bps > 0 {
688+
let rate_increase = target_utilization_bps
689+
.checked_mul(config.multiplier_bps)
690+
.ok_or(InterestRateError::Overflow)?
691+
.checked_div(config.kink_utilization_bps)
692+
.ok_or(InterestRateError::DivisionByZero)?;
693+
rate = rate
694+
.checked_add(rate_increase)
695+
.ok_or(InterestRateError::Overflow)?;
696+
}
697+
} else {
698+
let rate_at_kink = config
699+
.base_rate_bps
700+
.checked_add(config.multiplier_bps)
701+
.ok_or(InterestRateError::Overflow)?;
702+
703+
let above_kink = target_utilization_bps
704+
.checked_sub(config.kink_utilization_bps)
705+
.ok_or(InterestRateError::Overflow)?;
706+
707+
let max_above = BASIS_POINTS_SCALE
708+
.checked_sub(config.kink_utilization_bps)
709+
.ok_or(InterestRateError::Overflow)?;
710+
711+
if max_above > 0 {
712+
let additional = above_kink
713+
.checked_mul(config.jump_multiplier_bps)
714+
.ok_or(InterestRateError::Overflow)?
715+
.checked_div(max_above)
716+
.ok_or(InterestRateError::DivisionByZero)?;
717+
rate = rate_at_kink
718+
.checked_add(additional)
719+
.ok_or(InterestRateError::Overflow)?;
720+
} else {
721+
rate = rate_at_kink;
722+
}
723+
}
724+
725+
rate = rate
726+
.checked_add(config.emergency_adjustment_bps)
727+
.ok_or(InterestRateError::Overflow)?;
728+
729+
Ok(rate.max(config.rate_floor_bps).min(config.rate_ceiling_bps))
730+
}
731+
732+
/// Get the average borrow rate over the recorded history.
733+
pub fn get_average_borrow_rate(env: &Env) -> Result<i128, InterestRateError> {
734+
let history = get_rate_history(env);
735+
if history.is_empty() {
736+
return Ok(0);
737+
}
738+
739+
let mut total: i128 = 0;
740+
for entry in history.iter() {
741+
total = total
742+
.checked_add(entry.borrow_rate_bps)
743+
.ok_or(InterestRateError::Overflow)?;
744+
}
745+
746+
total
747+
.checked_div(history.len() as i128)
748+
.ok_or(InterestRateError::DivisionByZero)
749+
}

0 commit comments

Comments
 (0)