Skip to content

Introduce dynamic EMA parameter that evolves with pool liquidity #1563

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: devnet-ready
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion pallets/admin-utils/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1516,7 +1516,7 @@ pub mod pallet {
Ok(())
}

///
/// Change liquidity scale max for a given subnet.
///
/// # Arguments
/// * `origin` - The origin of the call, which must be the root account.
Expand Down Expand Up @@ -1671,6 +1671,36 @@ pub mod pallet {
);
Ok(())
}

/// Change liquidity scale max for a given subnet.
///
/// # Arguments
/// * `origin` - The origin of the call, which must be the root account.
/// * `netuid` - The unique identifier for the subnet.
/// * `liquidity_scale_max` - The new maximum liquidity scale value.
///
/// # Errors
/// * `BadOrigin` - If the caller is not the root account.
///
/// # Weight
/// Weight is handled by the `#[pallet::weight]` attribute.
#[pallet::call_index(70)]
#[pallet::weight((0, DispatchClass::Operational, Pays::No))]
pub fn sudo_set_liquidity_scale_max(
origin: OriginFor<T>,
netuid: u16,
liquidity_scale_max: u64,
) -> DispatchResult {
ensure_root(origin)?;
pallet_subtensor::LiquidityScaleMax::<T>::set(netuid, liquidity_scale_max);

log::debug!(
"LiquidityScaleMax( netuid: {:?}, liquidity_scale_max: {:?} )",
netuid,
liquidity_scale_max
);
Ok(())
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions pallets/admin-utils/src/tests/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ parameter_types! {
pub const InitialDissolveNetworkScheduleDuration: u64 = 5 * 24 * 60 * 60 / 12; // 5 days
pub const InitialTaoWeight: u64 = u64::MAX/10; // 10% global weight.
pub const InitialEmaPriceHalvingPeriod: u64 = 201_600_u64; // 4 weeks
pub const LiquidityScaleMax: u64 = 2000;
pub const DurationOfStartCall: u64 = 7 * 24 * 60 * 60 / 12; // 7 days
}

Expand Down Expand Up @@ -208,6 +209,7 @@ impl pallet_subtensor::Config for Test {
type InitialDissolveNetworkScheduleDuration = InitialDissolveNetworkScheduleDuration;
type InitialTaoWeight = InitialTaoWeight;
type InitialEmaPriceHalvingPeriod = InitialEmaPriceHalvingPeriod;
type LiquidityScaleMax = LiquidityScaleMax;
type DurationOfStartCall = DurationOfStartCall;
}

Expand Down
52 changes: 52 additions & 0 deletions pallets/admin-utils/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1780,3 +1780,55 @@ fn test_set_sn_owner_hotkey_root() {
assert_eq!(actual_hotkey, hotkey);
});
}

#[test]
fn test_sudo_set_liquidity_scale_max() {
new_test_ext().execute_with(|| {
let netuid: u16 = 1;
let to_be_set: u64 = 1_234;
// install a subnet with owner = 10
add_network(netuid, 10);

// read the default
let before: u64 = pallet_subtensor::LiquidityScaleMax::<Test>::get(netuid);

// 1) wrong signed account → BadOrigin, no change
assert_eq!(
AdminUtils::sudo_set_liquidity_scale_max(
<<Test as Config>::RuntimeOrigin>::signed(U256::from(1)),
netuid,
to_be_set
),
Err(DispatchError::BadOrigin)
);
assert_eq!(
pallet_subtensor::LiquidityScaleMax::<Test>::get(netuid),
before
);

// 2) subnet owner but not root → still BadOrigin
let owner = U256::from(10);
pallet_subtensor::SubnetOwner::<Test>::insert(netuid, owner);
assert_eq!(
AdminUtils::sudo_set_liquidity_scale_max(
<<Test as Config>::RuntimeOrigin>::signed(owner),
netuid,
to_be_set
),
Err(DispatchError::BadOrigin)
);
assert_eq!(
pallet_subtensor::LiquidityScaleMax::<Test>::get(netuid),
before
);

// 3) root origin → Ok, value updated
assert_ok!(AdminUtils::sudo_set_liquidity_scale_max(
<<Test as Config>::RuntimeOrigin>::root(),
netuid,
to_be_set
));
let after: u64 = pallet_subtensor::LiquidityScaleMax::<Test>::get(netuid);
assert_eq!(after, to_be_set);
});
}
9 changes: 9 additions & 0 deletions pallets/subtensor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,11 @@ pub mod pallet {
T::InitialEmaPriceHalvingPeriod::get()
}
#[pallet::type_value]
/// Default Liquidity Scale Max factor for which to retain ema
pub fn DefaultLiquidityScaleMax<T: Config>() -> u64 {
T::LiquidityScaleMax::get()
}
#[pallet::type_value]
/// Default registrations this block.
pub fn DefaultBurn<T: Config>() -> u64 {
T::InitialBurn::get()
Expand Down Expand Up @@ -1449,6 +1454,10 @@ pub mod pallet {
pub type EMAPriceHalvingBlocks<T> =
StorageMap<_, Identity, u16, u64, ValueQuery, DefaultEMAPriceMovingBlocks<T>>;
#[pallet::storage]
/// --- MAP ( netuid) ) --> Liquidity level at which to remove EMA
pub type LiquidityScaleMax<T> =
StorageMap<_, Identity, u16, u64, ValueQuery, DefaultLiquidityScaleMax<T>>;
#[pallet::storage]
/// --- MAP ( netuid ) --> global_RAO_recycled_for_registration
pub type RAORecycledForRegistration<T> =
StorageMap<_, Identity, u16, u64, ValueQuery, DefaultRAORecycledForRegistration<T>>;
Expand Down
3 changes: 3 additions & 0 deletions pallets/subtensor/src/macros/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ mod config {
/// Initial EMA price halving period
#[pallet::constant]
type InitialEmaPriceHalvingPeriod: Get<u64>;
/// Maximum liquidity scale after which to drop the EMA
#[pallet::constant]
type LiquidityScaleMax: Get<u64>;
/// Block number after a new subnet accept the start call extrinsic.
#[pallet::constant]
type DurationOfStartCall: Get<u64>;
Expand Down
120 changes: 93 additions & 27 deletions pallets/subtensor/src/staking/stake_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ impl<T: Config> Pallet<T> {
.unwrap_or(U96F32::saturating_from_num(0))
}
}

pub fn get_moving_alpha_price(netuid: u16) -> U96F32 {
let one = U96F32::saturating_from_num(1.0);
if netuid == Self::get_root_netuid() {
Expand All @@ -60,36 +61,101 @@ impl<T: Config> Pallet<T> {
}
}

/// Computes the smoothing factor α for the exponential moving average (EMA)
/// based on current pool liquidity.
///
/// This function implements a custom curve:
/// 1. If `l >= liquidity_scale_max`, returns α = 1.
/// 2. Otherwise:
/// - Normalize `x = 2·l / liquidity_scale_max − 1`.
/// - Compute a cubic polynomial
/// `f(x) = (((7/2·x³ − 1)·x³ + 3/2)·x − 4)`.
/// - Take `|f(x)|`, ceiling it to an integer `exp_int`, and set
/// α = 10^(−exp_int).
///
/// # Arguments
/// * `l` – Current liquidity measure (√(TAO·α) after scaling).
/// * `liquidity_scale_max` – Liquidity level at which α saturates to 1.
///
/// # Returns
/// * `U96F32` – The EMA weight α in the range [0, 1].
pub fn compute_alpha_for_ema(l: U96F32, liquidity_scale_max: U96F32) -> U96F32 {
if l >= liquidity_scale_max {
return U96F32::saturating_from_num(1);
}

let i_l_max = I96F32::saturating_from_num(liquidity_scale_max);
let i_l = I96F32::saturating_from_num(l);
let neg_one = I96F32::saturating_from_num(-1);
let two = I96F32::saturating_from_num(2);
let a = I96F32::saturating_from_num(7).safe_div(two);
let b = neg_one;
let c = I96F32::saturating_from_num(3).safe_div(two);
let d = neg_one.saturating_mul(I96F32::saturating_from_num(4));
let x = (two.saturating_mul(i_l).safe_div(i_l_max)).saturating_add(neg_one);

let x_cubed = x.saturating_mul(x).saturating_mul(x);
let f_x = ((a.saturating_mul(x_cubed).saturating_add(b))
.saturating_mul(x_cubed)
.saturating_add(c))
.saturating_mul(x)
.saturating_add(d);

let abs_f_x = f_x.saturating_abs();
let exp = abs_f_x.ceil();

let exp_int = exp.to_num::<u32>();
let mut alpha = I96F32::saturating_from_num(1);
let ten = I96F32::saturating_from_num(10);

for _ in 0..exp_int {
alpha = alpha.safe_div(ten);
}

U96F32::saturating_from_num(alpha)
}

/// Updates the stored “moving” alpha price for a subnet using a dynamic EMA.
///
/// Steps performed:
/// 1. Load raw TAO and α reserves (`SubnetTAO`, `SubnetAlphaIn`) and down-scale by 1e9 (to TAO units)
/// 2. Compute the constant-product k = TAO_reserves·α_reserves, then
/// l = √k (with minimal epsilon).
/// 3. Call `compute_alpha_for_ema(l, liquidity_scale_max)` to obtain α.
/// 4. Blend current price (`get_alpha_price`) and previous moving price
/// (`get_moving_alpha_price`) as
/// `α·current + (1−α)·moving`.
/// 5. Clamp the result to ≤ current price and write into `SubnetMovingPrice`.
///
/// # Arguments
/// * `netuid` – The subnet identifier whose price to update.
///
/// # Effects
/// * Writes a new `I96F32` into storage map `SubnetMovingPrice::<T>::insert(netuid, …)`.
pub fn update_moving_price(netuid: u16) {
let blocks_since_start_call = U96F32::saturating_from_num({
// We expect FirstEmissionBlockNumber to be set earlier, and we take the block when
// `start_call` was called (first block before FirstEmissionBlockNumber).
let start_call_block = FirstEmissionBlockNumber::<T>::get(netuid)
.unwrap_or_default()
.saturating_sub(1);

Self::get_current_block_as_u64().saturating_sub(start_call_block)
});
let tao_reserves_rao = U96F32::saturating_from_num(SubnetTAO::<T>::get(netuid));
let alpha_reserves_rao = U96F32::saturating_from_num(SubnetAlphaIn::<T>::get(netuid));
let tao_reserves = tao_reserves_rao.safe_div(U96F32::saturating_from_num(1_000_000_000));
let alpha_reserves =
alpha_reserves_rao.safe_div(U96F32::saturating_from_num(1_000_000_000));

let k = tao_reserves.saturating_mul(alpha_reserves);
let epsilon: U96F32 = U96F32::saturating_from_num(0.0000001);
let l = checked_sqrt(k, epsilon).unwrap_or(U96F32::saturating_from_num(0));
let liquidity_scale_max = U96F32::saturating_from_num(LiquidityScaleMax::<T>::get(netuid));
let alpha = Self::compute_alpha_for_ema(l, liquidity_scale_max);

// Use halving time hyperparameter. The meaning of this parameter can be best explained under
// the assumption of a constant price and SubnetMovingAlpha == 0.5: It is how many blocks it
// will take in order for the distance between current EMA of price and current price to shorten
// by half.
let halving_time = EMAPriceHalvingBlocks::<T>::get(netuid);
let current_ma_unsigned = U96F32::saturating_from_num(SubnetMovingAlpha::<T>::get());
let alpha: U96F32 = current_ma_unsigned.saturating_mul(blocks_since_start_call.safe_div(
blocks_since_start_call.saturating_add(U96F32::saturating_from_num(halving_time)),
));
// Because alpha = b / (b + h), where b and h > 0, alpha < 1, so 1 - alpha > 0.
// We can use unsigned type here: U96F32
let one_minus_alpha: U96F32 = U96F32::saturating_from_num(1.0).saturating_sub(alpha);
let current_price: U96F32 = alpha
.saturating_mul(Self::get_alpha_price(netuid).min(U96F32::saturating_from_num(1.0)));
let current_moving: U96F32 =
one_minus_alpha.saturating_mul(Self::get_moving_alpha_price(netuid));
// Convert batch to signed I96F32 to avoid migration of SubnetMovingPrice for now``
let new_moving: I96F32 =
I96F32::saturating_from_num(current_price.saturating_add(current_moving));
let moving_price = Self::get_moving_alpha_price(netuid);
let current_price = Self::get_alpha_price(netuid);
let weighted_current_price: U96F32 = alpha.saturating_mul(current_price);
let weighted_current_moving: U96F32 = one_minus_alpha.saturating_mul(moving_price);

let mut new_moving: I96F32 = I96F32::saturating_from_num(
weighted_current_price.saturating_add(weighted_current_moving),
);

new_moving = new_moving.min(I96F32::saturating_from_num(current_price));
SubnetMovingPrice::<T>::insert(netuid, new_moving);
}

Expand Down
Loading