pezpallet-broker 0.6.0

Brokerage tool for managing Pezkuwi Core scheduling
Documentation
// This file is part of Bizinikiwi.

// Copyright (C) Parity Technologies (UK) Ltd. and Dijital Kurdistan Tech Institute
// SPDX-License-Identifier: Apache-2.0

// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// 	http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#![deny(missing_docs)]

use crate::{CoreIndex, SaleInfoRecord};
use pezsp_arithmetic::{traits::One, FixedU64};
use pezsp_core::{Get, RuntimeDebug};
use pezsp_runtime::{FixedPointNumber, FixedPointOperand, Saturating};

/// Performance of a past sale.
#[derive(Copy, Clone)]
pub struct SalePerformance<Balance> {
	/// The price at which the last core was sold.
	///
	/// Will be `None` if no cores have been offered.
	pub sellout_price: Option<Balance>,

	/// The minimum price that was achieved in this sale.
	pub end_price: Balance,

	/// The number of cores we want to sell, ideally.
	pub ideal_cores_sold: CoreIndex,

	/// Number of cores which are/have been offered for sale.
	pub cores_offered: CoreIndex,

	/// Number of cores which have been sold; never more than cores_offered.
	pub cores_sold: CoreIndex,
}

/// Result of `AdaptPrice::adapt_price`.
#[derive(Copy, Clone, RuntimeDebug, Eq, PartialEq)]
pub struct AdaptedPrices<Balance> {
	/// New minimum price to use.
	pub end_price: Balance,

	/// Price the controller is optimizing for.
	///
	/// This is the price "expected" by the controller based on the previous sale. We assume that
	/// sales in this period will be around this price, assuming stable market conditions.
	///
	/// Think of it as the expected market price. This can be used for determining what to charge
	/// for renewals, that don't yet have any price information for example. E.g. for expired
	/// legacy leases.
	pub target_price: Balance,
}

impl<Balance: Copy> SalePerformance<Balance> {
	/// Construct performance via data from a `SaleInfoRecord`.
	pub fn from_sale<BlockNumber>(record: &SaleInfoRecord<Balance, BlockNumber>) -> Self {
		Self {
			sellout_price: record.sellout_price,
			end_price: record.end_price,
			ideal_cores_sold: record.ideal_cores_sold,
			cores_offered: record.cores_offered,
			cores_sold: record.cores_sold,
		}
	}

	#[cfg(test)]
	fn new(sellout_price: Option<Balance>, end_price: Balance) -> Self {
		Self { sellout_price, end_price, ideal_cores_sold: 0, cores_offered: 0, cores_sold: 0 }
	}
}

/// Type for determining how to set price.
pub trait AdaptPrice<Balance> {
	/// Return the factor by which the regular price must be multiplied during the leadin period.
	///
	/// - `when`: The amount through the leadin period; between zero and one.
	fn leadin_factor_at(when: FixedU64) -> FixedU64;

	/// Return adapted prices for next sale.
	///
	/// Based on the previous sale's performance.
	fn adapt_price(performance: SalePerformance<Balance>) -> AdaptedPrices<Balance>;
}

impl<Balance: Copy> AdaptPrice<Balance> for () {
	fn leadin_factor_at(_: FixedU64) -> FixedU64 {
		FixedU64::one()
	}
	fn adapt_price(performance: SalePerformance<Balance>) -> AdaptedPrices<Balance> {
		let price = performance.sellout_price.unwrap_or(performance.end_price);
		AdaptedPrices { end_price: price, target_price: price }
	}
}

/// Simple implementation of `AdaptPrice` with two linear phases.
///
/// One steep one downwards to the target price, which is 1/10 of the maximum price and a more flat
/// one down to the minimum price, which is 1/100 of the maximum price.
pub struct CenterTargetPrice<Balance>(core::marker::PhantomData<Balance>);

impl<Balance: FixedPointOperand> AdaptPrice<Balance> for CenterTargetPrice<Balance> {
	fn leadin_factor_at(when: FixedU64) -> FixedU64 {
		if when <= FixedU64::from_rational(1, 2) {
			FixedU64::from(100).saturating_sub(when.saturating_mul(180.into()))
		} else {
			FixedU64::from(19).saturating_sub(when.saturating_mul(18.into()))
		}
	}

	fn adapt_price(performance: SalePerformance<Balance>) -> AdaptedPrices<Balance> {
		let Some(sellout_price) = performance.sellout_price else {
			return AdaptedPrices {
				end_price: performance.end_price,
				target_price: FixedU64::from(10).saturating_mul_int(performance.end_price),
			};
		};

		let price = FixedU64::from_rational(1, 10).saturating_mul_int(sellout_price);
		let price = if price == Balance::zero() {
			// We could not recover from a price equal 0 ever.
			sellout_price
		} else {
			price
		};

		AdaptedPrices { end_price: price, target_price: sellout_price }
	}
}

/// `AdaptPrice` like `CenterTargetPrice`, but with a minimum price.
///
/// This price adapter behaves exactly like `CenterTargetPrice`, except that it takes a minimum
/// price and makes sure that the returned `end_price` is never lower than that.
///
/// Target price will also get adjusted if necessary (it will never be less than the end_price).
pub struct MinimumPrice<Balance, MinPrice>(core::marker::PhantomData<(Balance, MinPrice)>);

impl<Balance: FixedPointOperand, MinPrice: Get<Balance>> AdaptPrice<Balance>
	for MinimumPrice<Balance, MinPrice>
{
	fn leadin_factor_at(when: FixedU64) -> FixedU64 {
		CenterTargetPrice::<Balance>::leadin_factor_at(when)
	}

	fn adapt_price(performance: SalePerformance<Balance>) -> AdaptedPrices<Balance> {
		let mut proposal = CenterTargetPrice::<Balance>::adapt_price(performance);
		let min_price = MinPrice::get();
		if proposal.end_price < min_price {
			proposal.end_price = min_price;
		}
		// Fix target price if necessary:
		if proposal.target_price < proposal.end_price {
			proposal.target_price = proposal.end_price;
		}
		proposal
	}
}

#[cfg(test)]
mod tests {
	use pezsp_core::ConstU64;

	use super::*;

	#[test]
	fn linear_no_panic() {
		for sellout in 0..11 {
			for price in 0..10 {
				let sellout_price = if sellout == 11 { None } else { Some(sellout) };
				CenterTargetPrice::adapt_price(SalePerformance::new(sellout_price, price));
			}
		}
	}

	#[test]
	fn leadin_price_bound_check() {
		assert_eq!(
			CenterTargetPrice::<u64>::leadin_factor_at(FixedU64::from(0)),
			FixedU64::from(100)
		);
		assert_eq!(
			CenterTargetPrice::<u64>::leadin_factor_at(FixedU64::from_rational(1, 4)),
			FixedU64::from(55)
		);

		assert_eq!(
			CenterTargetPrice::<u64>::leadin_factor_at(FixedU64::from_float(0.5)),
			FixedU64::from(10)
		);

		assert_eq!(
			CenterTargetPrice::<u64>::leadin_factor_at(FixedU64::from_rational(3, 4)),
			FixedU64::from_float(5.5)
		);
		assert_eq!(CenterTargetPrice::<u64>::leadin_factor_at(FixedU64::one()), FixedU64::one());
	}

	#[test]
	fn no_op_sale_is_good() {
		let prices = CenterTargetPrice::adapt_price(SalePerformance::new(None, 1));
		assert_eq!(prices.target_price, 10);
		assert_eq!(prices.end_price, 1);
	}

	#[test]
	fn price_stays_stable_on_optimal_sale() {
		// Check price stays stable if sold at the optimal price:
		let mut performance = SalePerformance::new(Some(1000), 100);
		for _ in 0..10 {
			let prices = CenterTargetPrice::adapt_price(performance);
			performance.sellout_price = Some(1000);
			performance.end_price = prices.end_price;

			assert!(prices.end_price <= 101);
			assert!(prices.end_price >= 99);
			assert!(prices.target_price <= 1001);
			assert!(prices.target_price >= 999);
		}
	}

	#[test]
	fn price_adjusts_correctly_upwards() {
		let performance = SalePerformance::new(Some(10_000), 100);
		let prices = CenterTargetPrice::adapt_price(performance);
		assert_eq!(prices.target_price, 10_000);
		assert_eq!(prices.end_price, 1000);
	}

	#[test]
	fn price_adjusts_correctly_downwards() {
		let performance = SalePerformance::new(Some(100), 100);
		let prices = CenterTargetPrice::adapt_price(performance);
		assert_eq!(prices.target_price, 100);
		assert_eq!(prices.end_price, 10);
	}

	#[test]
	fn price_never_goes_to_zero_and_recovers() {
		// Check price stays stable if sold at the optimal price:
		let sellout_price = 1;
		let mut performance = SalePerformance::new(Some(sellout_price), 1);
		for _ in 0..11 {
			let prices = CenterTargetPrice::adapt_price(performance);
			performance.sellout_price = Some(sellout_price);
			performance.end_price = prices.end_price;

			assert!(prices.end_price <= sellout_price);
			assert!(prices.end_price > 0);
		}
	}

	#[test]
	fn renewal_price_is_correct_on_no_sale() {
		let performance = SalePerformance::new(None, 100);
		let prices = CenterTargetPrice::adapt_price(performance);
		assert_eq!(prices.target_price, 1000);
		assert_eq!(prices.end_price, 100);
	}

	#[test]
	fn renewal_price_is_sell_out() {
		let performance = SalePerformance::new(Some(1000), 100);
		let prices = CenterTargetPrice::adapt_price(performance);
		assert_eq!(prices.target_price, 1000);
	}

	#[test]
	fn minimum_price_works() {
		let performance = SalePerformance::new(Some(10), 10);
		let prices = MinimumPrice::<u64, ConstU64<10>>::adapt_price(performance);
		assert_eq!(prices.end_price, 10);
		assert_eq!(prices.target_price, 10);
	}

	#[test]
	fn minimum_price_does_not_affect_valid_target_price() {
		let performance = SalePerformance::new(Some(12), 10);
		let prices = MinimumPrice::<u64, ConstU64<10>>::adapt_price(performance);
		assert_eq!(prices.end_price, 10);
		assert_eq!(prices.target_price, 12);
	}

	#[test]
	fn no_minimum_price_works_as_center_target_price() {
		let performances = [
			(Some(100), 10),
			(None, 20),
			(Some(1000), 10),
			(Some(10), 10),
			(Some(1), 1),
			(Some(0), 10),
		];
		for (sellout, end) in performances {
			let performance = SalePerformance::new(sellout, end);
			let prices_minimum = MinimumPrice::<u64, ConstU64<0>>::adapt_price(performance);
			let prices = CenterTargetPrice::adapt_price(performance);
			assert_eq!(prices, prices_minimum);
		}
	}
}