wb_cache/test/simulation/scriptwriter/model/
product.rs

1use anyhow::Result;
2use fieldx::fxstruct;
3use rand_distr::Distribution;
4use rand_distr::LogNormal;
5use rand_distr::SkewNormal;
6use statrs::distribution::ContinuousCDF;
7use statrs::distribution::LogNormal as StatLN;
8
9use crate::test::simulation::scriptwriter::math::bisect;
10
11/// Implementation of the LogNormal price distribution model. The model takes the following expectations:
12/// - the most popular price value
13/// - the most popular price range
14/// - the expected share of the popular price range of all prices (normally - 2/3 of all)
15///
16/// The model then finds the parameters of the LogNormal distribution that best fit the given expectations and uses
17/// these to sample prices for modeled products.
18#[derive(Debug)]
19#[fxstruct(sync, builder(post_build), rc, get(copy))]
20pub struct ProductModel {
21    /// The most popular price value
22    #[fieldx(default(20.0))]
23    top_price: f64,
24    /// Lower bound of the most popular price range
25    #[fieldx(default(1.0))]
26    top_low:   f64,
27    /// Upper bound of the most popular price range
28    #[fieldx(default(100.0))]
29    top_high:  f64,
30    /// The expected share of the popular price range of all prices (normally - 2/3 of all)
31    #[fieldx(default(2.0/3.0))]
32    top_share: f64,
33    #[fieldx(default(0.0001))]
34    tolerance: f64,
35
36    // Customer interest in the product
37    /// The median price at which customer losts 1/2 of interest in the product, compared to the maximum.
38    #[fieldx(default(300.))]
39    pivot_price:        f64,
40    /// The higher the value – the steeper the interest loss curve around the pivot price.
41    #[fieldx(default(0.005))]
42    interest_loss_rate: f64,
43    /// This constant defines the point up to which customer interest remains stable with no significant loss.
44    /// Empirically, for prices <120 it can be estimated as (price / (2 * π))^2. For higher values the discrepancy
45    /// between the expected price and where the graph starts to fall becomes significant enough. The current default
46    /// value is 162, which corresponds to the price of 80.
47    #[fieldx(private, default(162.))]
48    interest_stability: f64,
49
50    #[fieldx(lock, private, set, builder(off))]
51    mu:    f64,
52    #[fieldx(lock, private, set, builder(off))]
53    sigma: f64,
54}
55
56impl ProductModel {
57    fn post_build(self) -> Self {
58        self.find_sigma();
59        self
60    }
61
62    fn find_sigma(&self) {
63        bisect(0.01, 5.0, 0.0, self.tolerance(), |sigma| {
64            self.set_sigma(sigma);
65            self.probability_difference()
66        })
67        .expect("failed to bisect sigma parameter for price model");
68    }
69
70    fn probability_difference(&self) -> f64 {
71        let sigma = self.sigma();
72        let mu = self.top_price().ln() + sigma * sigma;
73        self.set_mu(mu);
74        let dist = StatLN::new(mu, sigma).expect("bad parameters for LogNormal distribution");
75        self.top_share() - (dist.cdf(self.top_high()) - dist.cdf(self.top_low()))
76    }
77
78    pub fn next_price(&self) -> f64 {
79        let dist = LogNormal::new(self.mu(), self.sigma()).expect("bad parameters for LogNormal distribution");
80        let mut rng = rand::rng();
81        dist.sample(&mut rng)
82    }
83
84    pub fn customer_interest(&self, price: f64) -> f64 {
85        if price < 0. {
86            panic!("Price cannot be negative");
87        }
88
89        // The interest loss rate is actually an arctangent over the price. This way we can get a clear range of
90        // customer interest from 0 to 1. The higher the price, the lower the interest. Since arctangent itself doesn't
91        // represent empirical expectations well we need to transform the price coordinate to get a better fit. The
92        // final function "implements" the curve where customer interest is close to 100% up to some psychological point
93        // (we set it as 80 in the defaults) and then starts to fall down. The pivot price is the point where the
94        // interest loss rate is 50% of the maximum. The further decline of interest is less dramatic and it goes down
95        // to ~10% at around 1000.
96
97        let pivot = self.pivot_price();
98        // This is actually a coordinate transformation of the interest loss curve.
99        let price = price - pivot / ((self.interest_stability() * (price - pivot)) / (pivot + price.powi(2))).exp();
100
101        0.5 - (self.interest_loss_rate() * price).atan() / std::f64::consts::PI
102    }
103
104    pub fn supply_distribution(
105        &self,
106        expected_mean: f64,
107        supplier_inaccuracy: f64,
108        supplier_tardiness: f64,
109    ) -> Result<SkewNormal<f64>> {
110        let scale = supplier_inaccuracy * expected_mean / 1.6448536;
111        let shape = (std::f64::consts::PI * (supplier_tardiness - 0.5)).tan();
112
113        SkewNormal::new(expected_mean, scale, shape)
114            .map_err(|_| anyhow::anyhow!("bad parameters for SkewNormal distribution"))
115    }
116}
117
118#[cfg(test)]
119mod test {
120    use super::*;
121
122    #[test]
123    fn test_product_price() {
124        let pp = ProductModel::builder()
125            .top_price(15.0)
126            .top_low(1.0)
127            .top_high(100.0)
128            .top_share(2.0 / 3.0)
129            .tolerance(0.000001)
130            .build()
131            .unwrap();
132        assert_eq!((pp.sigma() * 100000.).round(), 117844.);
133        assert_eq!((pp.mu() * 100000.).round(), 409676.);
134    }
135}