diff --git a/Cargo.toml b/Cargo.toml index aa2dca3..fda81a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,5 @@ members = [ "pyth-sdk", "pyth-sdk-solana", "pyth-sdk-solana/test-contract", - "pyth-sdk-cw", - "examples/cw-contract", "examples/sol-contract" ] diff --git a/pyth-sdk-solana/test-contract/rust-toolchain b/pyth-sdk-solana/test-contract/rust-toolchain new file mode 100644 index 0000000..ffa7dbf --- /dev/null +++ b/pyth-sdk-solana/test-contract/rust-toolchain @@ -0,0 +1,5 @@ + +# This is only used for tests +[toolchain] +channel = "1.66.1" +profile = "minimal" diff --git a/pyth-sdk/Cargo.toml b/pyth-sdk/Cargo.toml index 79b161c..580ca5c 100644 --- a/pyth-sdk/Cargo.toml +++ b/pyth-sdk/Cargo.toml @@ -19,6 +19,9 @@ borsh = "0.9" borsh-derive = "0.9.0" serde = { version = "1.0.136", features = ["derive"] } schemars = "0.8.8" +getrandom = { version = "0.2.2", features = ["custom"] } [dev-dependencies] serde_json = "1.0.79" +quickcheck = "1" +quickcheck_macros = "1" diff --git a/pyth-sdk/src/lib.rs b/pyth-sdk/src/lib.rs index c22d29f..d2aef87 100644 --- a/pyth-sdk/src/lib.rs +++ b/pyth-sdk/src/lib.rs @@ -1,3 +1,9 @@ +#[cfg(test)] +extern crate quickcheck; +#[cfg(test)] +#[macro_use(quickcheck)] +extern crate quickcheck_macros; + use borsh::{ BorshDeserialize, BorshSerialize, diff --git a/pyth-sdk/src/price.rs b/pyth-sdk/src/price.rs index d0232c0..26798fc 100644 --- a/pyth-sdk/src/price.rs +++ b/pyth-sdk/src/price.rs @@ -3,6 +3,8 @@ use borsh::{ BorshSerialize, }; +use std::convert::TryFrom; + use schemars::JsonSchema; use crate::{ @@ -89,6 +91,263 @@ impl Price { self.div(quote)?.scale_to_exponent(result_expo) } + /// Get the valuation of a collateral position according to: + /// 1. the net amount currently deposited (across the protocol) + /// 2. the deposits endpoint for the affine combination (across the protocol) + /// 3. the initial (at 0 deposits) and final (at the deposits endpoint) valuation discount rates + /// + /// We use a linear interpolation between the the initial and final discount rates, + /// scaled by the proportion of the deposits endpoint that has been deposited. + /// This essentially assumes a linear liquidity cumulative density function, + /// which has been shown to be a reasonable assumption for many crypto tokens in literature. + /// For more detail on this: https://pythnetwork.medium.com/improving-lending-protocols-with-liquidity-oracles-fd1ea4f96f37 + /// + /// If the assumptions of the liquidity curve hold true, we are obtaining a lower bound for the + /// net price at which one can sell the quantity of token specified by deposits in the open + /// markets. We value collateral according to the total deposits in the protocol due to the + /// present intractability of assessing collateral at risk by price range. + /// + /// Args + /// deposits: u64, quantity of token deposited in the protocol + /// deposits_endpoint: u64, deposits right endpoint for the affine combination + /// rate_discount_initial: u64, initial discounted rate at 0 deposits (units given by + /// discount_exponent) rate_discount_final: u64, final discounted rate at deposits_endpoint + /// deposits (units given by discount_exponent) discount_exponent: u64, the exponent to + /// apply to the discounts above (e.g. if discount_final is 10 but meant to express 0.1/10%, + /// exponent would be -2) note that if discount_initial is bigger than 100% per the discount + /// exponent scale, then the initial valuation of the collateral will be higher than the oracle + /// price + pub fn get_collateral_valuation_price( + &self, + deposits: u64, + deposits_endpoint: u64, + rate_discount_initial: u64, + rate_discount_final: u64, + discount_exponent: i32, + ) -> Option { + // valuation price should not increase as amount of collateral grows, so + // rate_discount_initial should >= rate_discount_final + if rate_discount_initial < rate_discount_final { + return None; + } + + // get price versions of discounts + let initial_percentage = Price { + price: i64::try_from(rate_discount_initial).ok()?, + conf: 0, + expo: discount_exponent, + publish_time: 0, + }; + let final_percentage = Price { + price: i64::try_from(rate_discount_final).ok()?, + conf: 0, + expo: discount_exponent, + publish_time: 0, + }; + + // get the interpolated discount as a price + let discount_interpolated = Price::affine_combination( + 0, + initial_percentage, + i64::try_from(deposits_endpoint).ok()?, + final_percentage, + i64::try_from(deposits).ok()?, + -9, + )?; + + let conf_orig = self.conf; + let expo_orig = self.expo; + + // get price discounted, convert back to the original exponents we received the price in + let price_discounted = self + .mul(&discount_interpolated)? + .scale_to_exponent(expo_orig)?; + + return Some(Price { + price: price_discounted.price, + conf: conf_orig, + expo: price_discounted.expo, + publish_time: self.publish_time, + }); + } + + /// Get the valuation of a borrow position according to: + /// 1. the net amount currently borrowed (across the protocol) + /// 2. the borrowed endpoint for the affine combination (across the protocol) + /// 3. the initial (at 0 borrows) and final (at the borrow endpoint) valuation premiums + /// + /// We use a linear interpolation between the the initial and final premiums, + /// scaled by the proportion of the borrows endpoint that has been borrowed out. + /// This essentially assumes a linear liquidity cumulative density function, + /// which has been shown to be a reasonable assumption for many crypto tokens in literature. + /// For more detail on this: https://pythnetwork.medium.com/improving-lending-protocols-with-liquidity-oracles-fd1ea4f96f37 + /// + /// If the assumptions of the liquidity curve hold true, we are obtaining an upper bound for the + /// net price at which one can buy the quantity of token specified by borrows in the open + /// markets. We value the borrows according to the total borrows out of the protocol due to + /// the present intractability of assessing collateral at risk and repayment likelihood by + /// price range. + /// + /// Args + /// borrows: u64, quantity of token borrowed from the protocol + /// borrows_endpoint: u64, borrows right endpoint for the affine combination + /// rate_premium_initial: u64, initial premium at 0 borrows (units given by premium_exponent) + /// rate_premium_final: u64, final premium at borrows_endpoint borrows (units given by + /// premium_exponent) premium_exponent: u64, the exponent to apply to the premiums above + /// (e.g. if premium_final is 50 but meant to express 0.05/5%, exponent would be -3) + /// note that if premium_initial is less than 100% per the premium exponent scale, then the + /// initial valuation of the borrow will be lower than the oracle price + pub fn get_borrow_valuation_price( + &self, + borrows: u64, + borrows_endpoint: u64, + rate_premium_initial: u64, + rate_premium_final: u64, + premium_exponent: i32, + ) -> Option { + // valuation price should not decrease as amount of borrow grows, so rate_premium_initial + // should <= rate_premium_final + if rate_premium_initial > rate_premium_final { + return None; + } + + // get price versions of premiums + let initial_percentage = Price { + price: i64::try_from(rate_premium_initial).ok()?, + conf: 0, + expo: premium_exponent, + publish_time: 0, + }; + let final_percentage = Price { + price: i64::try_from(rate_premium_final).ok()?, + conf: 0, + expo: premium_exponent, + publish_time: 0, + }; + + // get the interpolated premium as a price + let premium_interpolated = Price::affine_combination( + 0, + initial_percentage, + i64::try_from(borrows_endpoint).ok()?, + final_percentage, + i64::try_from(borrows).ok()?, + -9, + )?; + + let conf_orig = self.conf; + let expo_orig = self.expo; + + // get price premium, convert back to the original exponents we received the price in + let price_premium = self + .mul(&premium_interpolated)? + .scale_to_exponent(expo_orig)?; + + return Some(Price { + price: price_premium.price, + conf: conf_orig, + expo: price_premium.expo, + publish_time: self.publish_time, + }); + } + + /// affine_combination performs an affine combination of two prices located at x coordinates x1 + /// and x2, for query x coordinate x_query Takes in 2 points and a 3rd "query" x coordinate, + /// to compute the value at x_query Effectively draws a line between the 2 points and then + /// proceeds to interpolate/extrapolate to find the value at the query coordinate according + /// to that line + /// + /// affine_combination gives you the Price, scaled to a specified exponent, closest to y2 * + /// ((xq-x1)/(x2-x1)) + y1 * ((x2-x3)/(x2-x1)) If the numerators and denominators of the + /// fractions there are both representable within 8 digits of precision and the fraction + /// itself is also representable within 8 digits of precision, there is no loss due to taking + /// the fractions. If the prices are normalized, then there is no loss in taking the + /// products via mul. Otherwise, the prices will be converted to a form representable within + /// 8 digits of precision. The scaling to the specified expo pre_add_expo introduces a max + /// error of 2*10^pre_add_expo. If pre_add_expo is small enough relative to the products, + /// then there is no loss due to scaling. If the fractions are expressable within 8 digits + /// of precision, the ys are normalized, and the exponent is sufficiently small, + /// then you get an exact result. Otherwise, your error is bounded as given below. + /// + /// Args + /// x1: i64, the x coordinate of the first point + /// y1: Price, the y coordinate of the first point, represented as a Price struct + /// x2: i64, the x coordinate of the second point, must be greater than x1 + /// y2: Price, the y coordinate of the second point, represented as a Price struct + /// x_query: i64, the query x coordinate, at which we wish to impute a y value + /// pre_add_expo: i32, the exponent to scale to, before final addition; essentially the final + /// precision you want + /// + /// Logic + /// imputed y value = y2 * ((xq-x1)/(x2-x1)) + y1 * ((x2-x3)/(x2-x1)) + /// 1. compute A = xq-x1 + /// 2. compute B = x2-xq + /// 3. compute C = x2-x1 + /// 4. compute D = A/C + /// 5. compute E = B/C + /// 6. compute F = y2 * D + /// 7. compute G = y1 * E + /// 8. compute H = F + G + /// + /// Bounds due to precision loss + /// x = 10^(PD_EXPO+2) + /// fraction (due to normalization & division) incurs max loss of x + /// Thus, max loss here: Err(D), Err(E) <= x + /// If y1, y2 already normalized, no additional error. O/w, Err(y1), Err(y2) with normalization + /// <= x Err(F), Err(G) <= (1+x)^2 - 1 (in fractional terms) ~= 2x + /// Err(H) <= 2*2x = 4x, when PD_EXPO = -9 ==> Err(H) <= 4*10^-7 + /// + /// Scaling this back has error bounded by the expo (10^pre_add_expo). + /// This is because reverting a potentially finer expo to a coarser grid has the potential to be + /// off by the order of the atomic unit of the coarser grid. + /// This scaling error combines with the previous error additively: Err <= 4x + + /// 2*10^pre_add_expo But if pre_add_expo is reasonably small (<= -9), then other term will + /// dominate + /// + /// Note that if the ys are unnormalized due to the confidence but not the price, the + /// normalization could zero out the price fields. Based on this, it is recommended that + /// input prices are normalized, or at least do not contain huge discrepancies between price and + /// confidence. + pub fn affine_combination( + x1: i64, + y1: Price, + x2: i64, + y2: Price, + x_query: i64, + pre_add_expo: i32, + ) -> Option { + if x2 <= x1 { + return None; + } + + // get the deltas for the x coordinates + // 1. compute A = xq-x1 + let delta_q1 = x_query.checked_sub(x1)?; + // 2. compute B = x2-xq + let delta_2q = x2.checked_sub(x_query)?; + // 3. compute C = x2-x1 + let delta_21 = x2.checked_sub(x1)?; + + // get the relevant fractions of the deltas, with scaling + // 4. compute D = A/C, Err(D) <= x + let frac_q1 = Price::fraction(delta_q1, delta_21)?; + // 5. compute E = B/C, Err(E) <= x + let frac_2q = Price::fraction(delta_2q, delta_21)?; + + // calculate products for left and right + // 6. compute F = y2 * D, Err(F) <= (1+x)^2 - 1 ~= 2x + let mut left = y2.mul(&frac_q1)?; + // 7. compute G = y1 * E, Err(G) <= (1+x)^2 - 1 ~= 2x + let mut right = y1.mul(&frac_2q)?; + + // Err(scaling) += 2*10^pre_add_expo + left = left.scale_to_exponent(pre_add_expo)?; + right = right.scale_to_exponent(pre_add_expo)?; + + // 8. compute H = F + G, Err(H) ~= 4x + 2*10^pre_add_expo + return left.add(&right); + } + /// Get the price of a basket of currencies. /// /// Each entry in `amounts` is of the form `(price, qty, qty_expo)`, and the result is the sum @@ -145,6 +404,7 @@ impl Price { // Price is not guaranteed to store its price/confidence in normalized form. // Normalize them here to bound the range of price/conf, which is required to perform // arithmetic operations. + let base = self.normalize()?; let other = other.normalize()?; @@ -346,10 +606,46 @@ impl Price { (x as u64, 1) } } + + /// Helper function to create fraction + /// + /// fraction(x, y) gives you the unnormalized Price closest to x/y. + /// This output could have arbitrary exponent due to the div, so you may need to call + /// scale_to_exponent to scale to your desired expo. If you cannot represent x/y exactly + /// within 8 digits of precision, it may zero out the remainder. In particular, if x and/or + /// y cannot be represented within 8 digits of precision, potential for precision error. + /// If x and y can both be represented within 8 digits of precision AND x/y can be represented + /// within 8 digits, no precision loss. + /// + /// Error of normalizing x, y <= 10^(PD_EXPO+2) = 10^-7 + /// Inherits any bounded errors from normalization and div + fn fraction(x: i64, y: i64) -> Option { + // convert x and y to Prices + let x_as_price = Price { + price: x, + conf: 0, + expo: 0, + publish_time: 0, + }; + let y_as_price = Price { + price: y, + conf: 0, + expo: 0, + publish_time: 0, + }; + + // get the relevant fraction + let frac = x_as_price.div(&y_as_price)?; + + return Some(frac); + } } #[cfg(test)] mod test { + use quickcheck::TestResult; + use std::convert::TryFrom; + use crate::price::{ Price, MAX_PD_V_U64, @@ -380,6 +676,7 @@ mod test { .unwrap() } + #[test] fn test_normalize() { fn succeeds(price1: Price, expected: Price) { @@ -897,4 +1194,1060 @@ mod test { assert_eq!(p1.mul(&p2).unwrap().publish_time, 100); assert_eq!(p2.mul(&p1).unwrap().publish_time, 100); } + + #[test] + fn test_get_collateral_valuation_price() { + fn succeeds( + price: Price, + deposits: u64, + deposits_endpoint: u64, + discount_initial: u64, + discount_final: u64, + discount_exponent: i32, + expected: Price, + ) { + let price_collat = price + .get_collateral_valuation_price( + deposits, + deposits_endpoint, + discount_initial, + discount_final, + discount_exponent, + ) + .unwrap(); + + assert_eq!(price_collat, expected); + } + + fn fails( + price: Price, + deposits: u64, + deposits_endpoint: u64, + discount_initial: u64, + discount_final: u64, + discount_exponent: i32, + ) { + let result = price.get_collateral_valuation_price( + deposits, + deposits_endpoint, + discount_initial, + discount_final, + discount_exponent, + ); + assert_eq!(result, None); + } + + // 0 deposits + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 0, + 100, + 100, + 90, + -2, + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // half deposits + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 50, + 100, + 100, + 90, + -2, + pc(95 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // full deposits + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 100, + 100, + 100, + 90, + -2, + pc(90 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // 0 deposits, diff precision + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 0, + 100, + 1000, + 900, + -3, + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // half deposits, diff precision + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 50, + 100, + 1000, + 900, + -3, + pc(95 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // full deposits, diff precision + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 100, + 100, + 1000, + 900, + -3, + pc(90 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // beyond final endpoint deposits + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 150, + 100, + 100, + 90, + -2, + pc(85 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // 0 deposits, staggered initial discount + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 0, + 100, + 98, + 90, + -2, + pc(98 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // half deposits, staggered initial discount + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 50, + 100, + 98, + 90, + -2, + pc(94 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // full deposits, staggered initial discount + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 100, + 100, + 98, + 90, + -2, + pc(90 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // test precision limits + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 0, + 1_000_000_000_000_000_000, + 100, + 90, + -2, + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 1, + 1_000_000_000_000_000_000, + 100, + 90, + -2, + pc(100 * (PD_SCALE as i64) - 1000, 2 * PD_SCALE, -9), + ); + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 100_000_000, + 1_000_000_000_000_000_000, + 100, + 90, + -2, + pc(100 * (PD_SCALE as i64) - 1000, 2 * PD_SCALE, -9), + ); + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 1_000_000_000, + 1_000_000_000_000_000_000, + 100, + 90, + -2, + pc(100 * (PD_SCALE as i64) - 1000, 2 * PD_SCALE, -9), + ); + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 10_000_000_000, + 1_000_000_000_000_000_000, + 100, + 90, + -2, + pc(100 * (PD_SCALE as i64) - 1000, 2 * PD_SCALE, -9), + ); + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 100_000_000_000, + 1_000_000_000_000_000_000, + 100, + 90, + -2, + pc(100 * (PD_SCALE as i64) - 1000, 2 * PD_SCALE, -9), + ); + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 200_000_000_000, + 1_000_000_000_000_000_000, + 100, + 90, + -2, + pc(100 * (PD_SCALE as i64) - 2000, 2 * PD_SCALE, -9), + ); + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 1_000_000_000_000, + 1_000_000_000_000_000_000, + 100, + 90, + -2, + pc(100 * (PD_SCALE as i64) - 10000, 2 * PD_SCALE, -9), + ); + + // fails bc initial discount lower than final discount + fails( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 50, + 100, + 89, + 90, + -2, + ); + } + + #[test] + fn test_get_borrow_valuation_price() { + fn succeeds( + price: Price, + borrows: u64, + borrows_endpoint: u64, + premium_initial: u64, + premium_final: u64, + premium_exponent: i32, + expected: Price, + ) { + let price_borrow = price + .get_borrow_valuation_price( + borrows, + borrows_endpoint, + premium_initial, + premium_final, + premium_exponent, + ) + .unwrap(); + + assert_eq!(price_borrow, expected); + } + + fn fails( + price: Price, + borrows: u64, + borrows_endpoint: u64, + premium_initial: u64, + premium_final: u64, + premium_exponent: i32, + ) { + let result = price.get_borrow_valuation_price( + borrows, + borrows_endpoint, + premium_initial, + premium_final, + premium_exponent, + ); + assert_eq!(result, None); + } + + // 0 borrows + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 0, + 100, + 100, + 110, + -2, + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // half borrows + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 50, + 100, + 100, + 110, + -2, + pc(105 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // full borrows + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 100, + 100, + 100, + 110, + -2, + pc(110 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // 0 borrows, diff precision + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 0, + 100, + 1000, + 1100, + -3, + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // half borrows, diff precision + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 50, + 100, + 1000, + 1100, + -3, + pc(105 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // full borrows, diff precision + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 100, + 100, + 1000, + 1100, + -3, + pc(110 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // beyond final endpoint borrows + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 150, + 100, + 100, + 110, + -2, + pc(115 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // 0 borrows, staggered initial premium + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 0, + 100, + 102, + 110, + -2, + pc(102 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // half borrows, staggered initial premium + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 50, + 100, + 102, + 110, + -2, + pc(106 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // full borrows, staggered initial premium + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 100, + 100, + 102, + 110, + -2, + pc(110 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + + // test precision limits + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 1, + 1_000_000_000_000_000_000, + 100, + 110, + -2, + pc(100 * (PD_SCALE as i64 - 10), 2 * PD_SCALE, -9), + ); + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 100_000_000, + 1_000_000_000_000_000_000, + 100, + 110, + -2, + pc(100 * (PD_SCALE as i64 - 10), 2 * PD_SCALE, -9), + ); + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 1_000_000_000, + 1_000_000_000_000_000_000, + 100, + 110, + -2, + pc(100 * (PD_SCALE as i64 - 10), 2 * PD_SCALE, -9), + ); + // interpolation now doesn't lose precision, but normalize in final multiply loses precision + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 10_000_000_000, + 1_000_000_000_000_000_000, + 100, + 110, + -2, + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 20_000_000_000, + 1_000_000_000_000_000_000, + 100, + 110, + -2, + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + ); + // precision no longer lost + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 100_000_000_000, + 1_000_000_000_000_000_000, + 100, + 110, + -2, + pc(100 * (PD_SCALE as i64 + 10), 2 * PD_SCALE, -9), + ); + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 200_000_000_000, + 1_000_000_000_000_000_000, + 100, + 110, + -2, + pc(100 * (PD_SCALE as i64 + 20), 2 * PD_SCALE, -9), + ); + succeeds( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 1_000_000_000_000, + 1_000_000_000_000_000_000, + 100, + 110, + -2, + pc(100 * (PD_SCALE as i64 + 100), 2 * PD_SCALE, -9), + ); + + // fails bc initial premium exceeds final premium + fails( + pc(100 * (PD_SCALE as i64), 2 * PD_SCALE, -9), + 50, + 100, + 111, + 110, + -2, + ); + } + + #[test] + fn test_affine_combination() { + fn succeeds( + x1: i64, + y1: Price, + x2: i64, + y2: Price, + x_query: i64, + pre_add_expo: i32, + expected: Price, + ) { + let y_query = Price::affine_combination(x1, y1, x2, y2, x_query, pre_add_expo).unwrap(); + + assert_eq!(y_query, expected); + } + + fn fails(x1: i64, y1: Price, x2: i64, y2: Price, x_query: i64, pre_add_expo: i32) { + let result = Price::affine_combination(x1, y1, x2, y2, x_query, pre_add_expo); + assert_eq!(result, None); + } + + // constant, in the bounds [x1, x2] + succeeds( + 0, + pc(100, 10, -4), + 10, + pc(100, 10, -4), + 5, + -9, + pc(10_000_000, 1_000_000, -9), + ); + + // constant, outside the bounds + succeeds( + 0, + pc(100, 10, -4), + 10, + pc(100, 10, -4), + 15, + -9, + pc(10_000_000, 2_000_000, -9), + ); + + // increasing, in the bounds + succeeds( + 0, + pc(90, 9, -4), + 10, + pc(100, 10, -4), + 5, + -9, + pc(9_500_000, 950_000, -9), + ); + + // increasing, out of bounds + succeeds( + 0, + pc(90, 9, -4), + 10, + pc(100, 10, -4), + 15, + -9, + pc(10_500_000, 1_950_000, -9), + ); + + // decreasing, in the bounds + succeeds( + 0, + pc(100, 10, -4), + 10, + pc(80, 8, -4), + 5, + -9, + pc(9_000_000, 900_000, -9), + ); + + // decreasing, out of bounds + succeeds( + 0, + pc(100, 10, -4), + 10, + pc(80, 8, -4), + 15, + -9, + pc(7_000_000, 1_700_000, -9), + ); + + // test with different pre_add_expos than -9 + succeeds( + 0, + pc(100, 10, -2), + 100, + pc(8000, 800, -4), + 50, + -3, + pc(900, 90, -3), + ); + succeeds( + 100_000, + pc(200_000, 20_000, -6), + 200_000, + pc(-20_000_000_000, 2_000_000_000, -11), + 175_000, + -4, + pc(-1_000, 200, -4), + ); + succeeds( + 2000, + pc(75, 7, 3), + 10000, + pc(675_000_000, 67_500_000, -3), + 6000, + -2, + pc(37_500_000, 3_725_000, -2), + ); + succeeds( + 0, + pc(100, 10, 2), + 100, + pc(0, 0, -12), + 200, + -12, + pc(-10_000_000_000_000_000, 1_000_000_000_000_000, -12), + ); + succeeds( + 0, + pc(10, 1, 9), + 1000, + pc(2, 0, 10), + 6000, + 6, + pc(70_000, 5_000, 6), + ); + + // test loss due to scaling + // lose more bc scaling to higher expo + succeeds( + 0, + pc(0, 0, -2), + 13, + pc(10, 1, -2), + 1, + -8, + pc(769230, 76923, -8), + ); + // lose less bc scaling to lower expo + succeeds( + 0, + pc(0, 0, -2), + 13, + pc(10, 1, -2), + 1, + -9, + pc(7692307, 769230, -9), + ); + // lose more bc need to increment expo more in scaling from original inputs + succeeds( + 0, + pc(0, 0, -3), + 13, + pc(100, 10, -3), + 1, + -9, + pc(7692307, 769230, -9), + ); + // lose less bc need to increment expo less in scaling from original inputs + succeeds( + 0, + pc(0, 0, -2), + 13, + pc(100, 10, -2), + 1, + -9, + pc(76923076, 7692307, -9), + ); + + // Test with end range of possible inputs on endpoint xs + succeeds( + 0, + pc(100, 10, -9), + i64::MAX, + pc(0, 0, -9), + i64::MAX / 10, + -9, + pc(90, 9, -9), + ); + succeeds( + i64::MIN, + pc(100, 10, -9), + i64::MIN / 2, + pc(0, 0, -9), + (i64::MIN / 4) * 3, + -9, + pc(50, 5, -9), + ); + // test with xs that yield fractions with significantly different expos + succeeds( + 0, + pc(100_000_000, 10_000_000, -9), + 1_000_000_000_000_000, + pc(0, 0, -9), + 10_000_000, + -9, + pc(99_999_999, 9_999_999, -9), + ); + + // Test with end range of possible inputs in prices to identify precision inaccuracy + // precision inaccuracy due to loss in scaling + succeeds( + 0, + pc(MAX_PD_V_I64 - 10, 1000, -9), + 10, + pc(MAX_PD_V_I64, 997, -9), + 5, + -9, + pc(MAX_PD_V_I64 - 6, 998, -9), + ); + // precision inaccruacy due to loss in scaling + succeeds( + 0, + pc(MAX_PD_V_I64 - 1, 200, -9), + 10, + pc(MAX_PD_V_I64, 191, -9), + 9, + -9, + pc(MAX_PD_V_I64 - 1, 191, -9), + ); + // // test with max u64 in conf + // // normalization to first price causes loss of price; loss in conf precision, only + // preserve 8 digits of precision + succeeds( + 0, + pc(1000, u64::MAX, -9), + 1000, + pc(-1000, 0, -9), + 500, + -9, + pc(-500, 92_23_372_000_000_000_000, -9), + ); + // test with MAX_PD_V_U64 in conf--no loss in precision unlike above + succeeds( + 0, + pc(1000, MAX_PD_V_U64, -9), + 1000, + pc(-1000, 0, -9), + 500, + -9, + pc(0, MAX_PD_V_U64 / 2, -9), + ); + + + // Test with combinations of (in)exact fractions + (un)normalized ys; making pre_add_expo + // very small to abstract away scaling error exact fraction, normalized ys --> exact + // result + succeeds( + 0, + pc(0, 0, -9), + 512, + pc(MAX_PD_V_I64 - 511, 512, -9), + 1, + -18, + pc(524_287_000_000_000, 1_000_000_000, -18), + ); + // exact fraction, unnormalized ys, should be 524_289_000_000_000 exactly, but due to + // normalization lose <= 2*10^(PD_EXPO+2) we see the actual result is off by < + // 16_000_000, which corresponds to loss of ~= 1.6*10^-8 < 2*10^-7 as can be seen, + // the normalization also messes with the final confidence precision + succeeds( + 0, + pc(0, 0, -9), + 512, + pc(MAX_PD_V_I64 + 513, 512, -9), + 1, + -18, + pc(524_288_984_375_000, 996_093_750, -18), + ); + // inexact fraciton, normalized ys, should be 262_143_000_000_000 exactly, but due to + // fraction imprecision lose <= 2*10^(PD_EXPO+2) 1/1024 = 0.0009765625, but due to + // imprecision --> 0.00976562; similar for 1023/1024 we see the actual result is off + // by < 140_000_000, which corresponds to loss of 1.4*10^-7 < 2*10^-7 + // inexact fraction also messes with the final confidence precision + succeeds( + 0, + pc(0, 0, -9), + 1024, + pc(MAX_PD_V_I64 - 1023, 1024, -9), + 1, + -18, + pc(262_142_865_782_784, 999_999_488, -18), + ); + // inexact fraction, unnormalized ys, should be 262_145_000_000_000 exactly, but due to + // normalization and fraction imprecision lose <= 4*10^(PD_EXPO+2) 1/1024 and + // 1023/1024 precision losses described above + normalization of y2 actual result + // off by < 140_000_000, which corresponds to loss of 1.4*10^-7 < 2*10^-7 + succeeds( + 0, + pc(0, 0, -9), + 1024, + pc(MAX_PD_V_I64 + 1025, 1024, -9), + 1, + -18, + pc(262_144_865_781_760, 996_093_240, -18), + ); + // should be -267_912_190_000_000_000 exactly, but due to normalization and fraction + // imprecision lose <= 4^10^(PD_EXPO+2) actual result off by < 2_000_000_000, which + // corresponds to loss of 2*10^-7 < 4*10^-7 (counting figures from the start of the number) + succeeds( + 0, + pc(MIN_PD_V_I64 - 1025, 0, -9), + 1024, + pc(MAX_PD_V_I64 + 1025, 0, -9), + 1, + -18, + pc(-267_912_188_120_944_640, 0, -18), + ); + + + // test w confidence (same at both endpoints)--expect linear change btwn x1 and x2 and + // growth in conf as distance from interval [x1, x2] increases + succeeds( + 0, + pc(90, 10, -4), + 10, + pc(100, 10, -4), + 5, + -9, + pc(9_500_000, 1_000_000, -9), + ); + + // test w confidence (different at the endpoints) + succeeds( + 0, + pc(90, 10, -4), + 10, + pc(100, 15, -4), + 5, + -9, + pc(9_500_000, 1_250_000, -9), + ); + succeeds( + 0, + pc(90, 10, -4), + 10, + pc(100, 15, -4), + 8, + -9, + pc(9_800_000, 1_400_000, -9), + ); + succeeds( + 0, + pc(90, 10, -4), + 10, + pc(100, 15, -4), + 15, + -9, + pc(10_500_000, 2_750_000, -9), + ); + + // fails bc x1 > x2 + fails(20, pc(100, 10, -4), 10, pc(100, 20, -4), 15, -9); + // fails bc x1 is MIN, x2-x1 --> overflow in delta + fails(i64::MIN, pc(100, 20, -5), 10, pc(1000, 40, -5), 5, -9); + // fails bc x2 is MAX, x1 is negative --> overflow in delta + fails(-5, pc(100, 40, -4), i64::MAX, pc(1000, 10, -4), 5, -9); + // fails bc of overflow in the checked_sub for x2-x1 + fails( + i64::MIN / 2, + pc(100, 20, -4), + i64::MAX / 2 + 1, + pc(100, 30, -4), + 5, + -9, + ); + // fails bc output price too small to be realized, cannot be scaled to fit with specified + // pre_add_expo + fails(0, pc(100, 0, -4), 10, pc(5, 50, -4), i64::MAX - 100, -9); + // fails bc 0-i64::MIN > i64::MAX, so overflow + fails(i64::MIN, pc(100, 10, -9), 0, pc(0, 12, -9), 0, -9); + } + + pub fn construct_quickcheck_affine_combination_price(price: i64) -> Price { + return Price { + price: price, + conf: 0, + expo: -9, + publish_time: 0, + }; + } + + // quickcheck to confirm affine_combination introduces no error if normalization done + // explicitly on prices first this quickcheck calls affine_combination with two sets of + // almost identical inputs: the first set has potentially unnormalized prices, the second + // set simply has the normalized versions of those prices this set of checks should pass + // because normalization is automatically performed on the prices before they are + // multiplied this set of checks passing indicates that it doesn't matter whether the + // prices passed in are normalized + #[quickcheck] + fn quickcheck_affine_combination_normalize_prices( + x1_inp: i32, + p1: i32, + x2_inp: i32, + p2: i32, + x_query_inp: i32, + ) -> TestResult { + // generating xs and prices from i32 to limit the range to reasonable values and guard + // against overflow/bespoke constraint setting for quickcheck + let y1 = construct_quickcheck_affine_combination_price(i64::try_from(p1).ok().unwrap()); + let y2 = construct_quickcheck_affine_combination_price(i64::try_from(p2).ok().unwrap()); + + let x1 = i64::try_from(x1_inp).ok().unwrap(); + let x2 = i64::try_from(x2_inp).ok().unwrap(); + let x_query = i64::try_from(x_query_inp).ok().unwrap(); + + // stick with single expo for ease of testing and generation + let pre_add_expo = -9; + + // require x2 > x1, as needed for affine_combination + if x1 >= x2 { + return TestResult::discard(); + } + + // original result + let result_orig = Price::affine_combination(x1, y1, x2, y2, x_query, pre_add_expo).unwrap(); + + let y1_norm = y1.normalize().unwrap(); + let y2_norm = y2.normalize().unwrap(); + + // result with normalized price inputs + let result_norm = + Price::affine_combination(x1, y1_norm, x2, y2_norm, x_query, pre_add_expo).unwrap(); + + // results should match exactly + TestResult::from_bool(result_norm == result_orig) + } + + // quickcheck to confirm affine_combination introduces bounded error if close fraction x/y + // passed in first this quickcheck calls affine_combination with two sets of similar inputs: + // the first set has xs generated by the quickcheck generation process, leading to + // potentially inexact fractions that don't fit within 8 digits of precision the second + // set "normalizes" down to 8 digits of precision by setting x1 to 0, x2 to 100_000_000, + // and xquery proportionally based on the bounds described in the docstring of + // affine_combination, we expect error due to this to be leq 4*10^-7 + #[quickcheck] + fn quickcheck_affine_combination_normalize_fractions( + x1_inp: i32, + p1: i32, + x2_inp: i32, + p2: i32, + x_query_inp: i32, + ) -> TestResult { + // generating xs and prices from i32 to limit the range to reasonable values and guard + // against overflow/bespoke constraint setting for quickcheck + let y1 = construct_quickcheck_affine_combination_price(i64::try_from(p1).ok().unwrap()); + let y2 = construct_quickcheck_affine_combination_price(i64::try_from(p2).ok().unwrap()); + + let x1 = i64::try_from(x1_inp).ok().unwrap(); + let x2 = i64::try_from(x2_inp).ok().unwrap(); + let x_query = i64::try_from(x_query_inp).ok().unwrap(); + + // stick with single expo for ease of testing and generation + let pre_add_expo = -9; + + // require x2 > x1, as needed for affine_combination + if x1 >= x2 { + return TestResult::discard(); + } + + // constrain x_query to be within 5 interval lengths of x1 or x2 + if (x_query > x2 + 5 * (x2 - x1)) || (x_query < x1 - 5 * (x2 - x1)) { + return TestResult::discard(); + } + + // generate new xs based on scaling x_1 --> 0, x_2 --> 10^8 + let x1_new: i64; + let xq_new: i64; + let x2_new: i64; + + if x2 == 0 { + x1_new = x1; + xq_new = x_query; + x2_new = x2; + } else { + let mut frac_q2 = Price::fraction(x_query - x1, x2 - x1).unwrap(); + frac_q2 = frac_q2.scale_to_exponent(-8).unwrap(); + + x1_new = 0; + xq_new = frac_q2.price; + x2_new = 100_000_000 as i64; + } + + // original result + let result_orig = Price::affine_combination(x1, y1, x2, y2, x_query, pre_add_expo) + .unwrap() + .scale_to_exponent(-7) + .unwrap(); + + // xs "normalized" result + let result_norm = Price::affine_combination(x1_new, y1, x2_new, y2, xq_new, pre_add_expo) + .unwrap() + .scale_to_exponent(-7) + .unwrap(); + + // compute difference in prices + let price_diff = result_norm.add(&result_orig.cmul(-1, 0).unwrap()).unwrap(); + + // results should differ by less than 4*10^-7 + TestResult::from_bool((price_diff.price < 4) && (price_diff.price > -4)) + } + + #[test] + fn test_fraction() { + fn succeeds(x: i64, y: i64, expected: Price) { + let frac = Price::fraction(x, y).unwrap(); + + assert_eq!(frac, expected); + } + + fn fails(x: i64, y: i64) { + let result = Price::fraction(x, y); + + assert_eq!(result, None); + } + + // check basic tests of fraction division + succeeds(100, 1000, pc(100_000_000, 0, -9)); + succeeds(1, 1_000_000_000, pc(10, 0, -10)); + // when x and y and x/y can be represented in 8 digits, no loss + succeeds(10_000_001, 20_000_002, pc(500_000_000, 0, -9)); + succeeds(102, 3, pc(34_000_000_000, 0, -9)); + succeeds(11_111_111, 10_000_000, pc(1_111_111_100, 0, -9)); + + // test loss due to big numer (x cannot be represented in 8 digits)--only preserves 8 digits + // of precision + succeeds(3_000_000_021_123, 1, pc(30_000_000_000_000_000, 0, -4)); + + // test loss due to big denom (y cannot be represented in 8 digits) + succeeds(1, 10_000_000_011, pc(10, 0, -11)); + + // x and y representable within 8 digits, but x/y is not + succeeds(1, 7, pc(142_857_142, 0, -9)); + + // Test with big inputs where the output will lose precision. + // x not representable within 8 digits + succeeds(i64::MAX, 100, pc(922337200000000, 0, 2)); + succeeds(i64::MAX, 1, pc(92233720000000000, 0, 2)); + // Neither x nor y representable within 8 digits + succeeds( + i64::MAX - 10, + i64::MAX - 10_000_000_000, + pc(1000000000, 0, -9), + ); + // Neither x nor y representable within 8 digits, but this subtraction actually influences + // relevant digit for precision + succeeds( + i64::MAX - 10, + i64::MAX - 100_000_000_000, + pc(1_000_000_010, 0, -9), + ); + + // Test with end range of possible inputs where the output should not lose precision. + succeeds(MAX_PD_V_I64, MAX_PD_V_I64, pc(1_000_000_000, 0, -9)); + succeeds(MAX_PD_V_I64, 1, pc(MAX_PD_V_I64 * 1_000_000_000, 0, -9)); + succeeds(MAX_PD_V_I64, MIN_PD_V_I64, pc(-1_000_000_000, 0, -9)); + succeeds(MIN_PD_V_I64, 1, pc(MIN_PD_V_I64 * 1_000_000_000, 0, -9)); + // test cases near the boundary where output should lose precision + succeeds( + MAX_PD_V_I64 + 1, + 1, + pc(MAX_PD_V_I64 / 10 * 1_000_000_000, 0, -8), + ); + succeeds( + MAX_PD_V_I64 + 10, + 1, + pc((MAX_PD_V_I64 / 10 + 1) * 1_000_000_000, 0, -8), + ); + + // fails due to div by 0 + fails(100, 0); + } }