fts_core/models/curve/
pwl.rs

1mod point;
2pub use point::Point;
3
4/// A representation of a piecewise-linear, weakly monotone decreasing demand curve
5///
6/// Demand curves define a bidder's willingness to pay for different quantities of a good.
7/// In flow trading, these curves must be:
8/// - Piecewise-linear (defined by a sequence of points)
9/// - Weakly monotone decreasing (price non-increasing as rate increases)
10/// - Include the point rate=0 in their domain (must allow zero trade)
11///
12/// Unlike a `ConstantCurve`, all values (rates and prices) must be finite.
13#[derive(Clone, Debug)]
14#[cfg_attr(
15    feature = "serde",
16    derive(serde::Serialize, serde::Deserialize),
17    serde(try_from = "PwlCurveDto", into = "PwlCurveDto")
18)]
19pub struct PwlCurve(Vec<Point>);
20
21impl PwlCurve {
22    /// Creates a new PwlCurve from a vector of points, validating all constraints
23    pub fn new(points: Vec<Point>) -> Result<Self, PwlCurveError> {
24        let dto = PwlCurveDto(points);
25        Self::try_from(dto)
26    }
27
28    /// Creates a new PwlCurve without validating the points
29    ///
30    /// # Safety
31    ///
32    /// This function bypasses all validation checks. The caller must guarantee that
33    /// the points satisfy all requirements validated by [`PwlCurve::try_from`].
34    /// Using invalid points can lead to incorrect behavior in downstream systems,
35    /// particularly in the solver which assumes valid monotone curves.
36    pub unsafe fn new_unchecked(points: Vec<Point>) -> Self {
37        Self(points)
38    }
39
40    /// Returns the domain of the demand curve (min and max rates)
41    ///
42    /// # Returns
43    ///
44    /// A tuple `(min_rate, max_rate)` where:
45    /// - `min_rate` is the rate of the first point (leftmost)
46    /// - `max_rate` is the rate of the last point (rightmost)
47    ///
48    /// # Panics
49    ///
50    /// Panics if the curve has no points (which should never happen for a valid curve).
51    pub fn domain(&self) -> (f64, f64) {
52        (self.0.first().unwrap().rate, self.0.last().unwrap().rate)
53    }
54
55    /// Converts the curve into its constituent points
56    ///
57    /// This consumes the curve and returns the underlying vector of points.
58    /// Useful for serialization or when the raw point data is needed.
59    pub fn points(self) -> Vec<Point> {
60        self.0
61    }
62}
63
64/// DTO to ensure that we always validate when we deserialize from an untrusted source
65#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
66#[cfg_attr(
67    feature = "serde",
68    derive(serde::Serialize, serde::Deserialize),
69    serde(transparent)
70)]
71#[derive(Debug)]
72pub struct PwlCurveDto(pub Vec<Point>);
73
74impl Into<PwlCurveDto> for PwlCurve {
75    fn into(self) -> PwlCurveDto {
76        PwlCurveDto(self.0)
77    }
78}
79
80impl TryFrom<PwlCurveDto> for PwlCurve {
81    type Error = PwlCurveError;
82
83    /// Attempts to create a PwlCurve from a DTO, validating all constraints
84    ///
85    /// # Validation
86    ///
87    /// This function validates that:
88    /// 1. The vector is not empty
89    /// 2. No coordinate values are NaN
90    /// 3. Points are ordered by ascending rate and descending price (monotonicity)
91    /// 4. The curve domain includes rate=0 (allows zero trade)
92    ///
93    /// # Errors
94    ///
95    /// Returns `PwlCurveError` if any validation fails.
96    fn try_from(value: PwlCurveDto) -> Result<Self, Self::Error> {
97        if value.0.is_empty() {
98            return Err(PwlCurveError::Empty);
99        }
100
101        let mut prev = Point {
102            rate: f64::NEG_INFINITY,
103            price: f64::INFINITY,
104        };
105
106        let mut negzero = false;
107        let mut poszero = false;
108
109        for point in value.0.iter() {
110            // Check for NaN values
111            if point.rate.is_nan() || point.price.is_nan() {
112                return Err(PwlCurveError::NaN);
113            }
114            if point.rate.is_infinite() || point.price.is_infinite() {
115                return Err(PwlCurveError::Infinity);
116            }
117
118            // Check monotonicity against previous point
119            // Note that we need the negation here, and cannot just use `point < prev` as condition:
120            // the comparison might yield None if the points are not comparable, which is quickly the case,
121            // especially in the non-monotone case.
122            if !(point >= &prev) {
123                return Err(PwlCurveError::NonMonotone);
124            }
125
126            // Track whether the domain includes rate=0
127            negzero = negzero || point.rate <= 0.0;
128            poszero = poszero || point.rate >= 0.0;
129
130            prev.rate = point.rate;
131            prev.price = point.price;
132        }
133
134        // Ensure the curve allows zero trade (domain includes 0)
135        if negzero && poszero {
136            Ok(Self(value.0))
137        } else {
138            Err(PwlCurveError::ZeroTrade)
139        }
140    }
141}
142
143/// Errors that can occur when creating or validating a PwlCurve
144#[derive(Debug, PartialEq, thiserror::Error)]
145pub enum PwlCurveError {
146    /// Error when any coordinate value is NaN
147    #[error("NaN value encountered")]
148    NaN,
149    /// Error when no points are provided
150    #[error("No points provided")]
151    Empty,
152    /// Error when points violate the monotonicity requirement
153    #[error("Points are not ordered by ascending rate, descending price")]
154    NonMonotone,
155    /// Error when the curve's domain does not include rate=0
156    #[error("Domain excludes rate=0")]
157    ZeroTrade,
158    /// Error when a point has infinite rate or price
159    #[error("Rates and prices cannot be infinite")]
160    Infinity,
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn test_empty_pwl_curve() {
169        assert_eq!(PwlCurve::new(vec![]).unwrap_err(), PwlCurveError::Empty);
170    }
171
172    #[test]
173    fn test_nan_values_in_points() {
174        // NaN in rate
175        assert_eq!(
176            PwlCurve::new(vec![
177                Point {
178                    rate: f64::NAN,
179                    price: 10.0,
180                },
181                Point {
182                    rate: 5.0,
183                    price: 5.0,
184                },
185            ])
186            .unwrap_err(),
187            PwlCurveError::NaN,
188        );
189
190        // NaN in price
191        assert_eq!(
192            PwlCurve::new(vec![
193                Point {
194                    rate: 0.0,
195                    price: f64::NAN,
196                },
197                Point {
198                    rate: 5.0,
199                    price: 5.0,
200                },
201            ])
202            .unwrap_err(),
203            PwlCurveError::NaN
204        );
205
206        // NaN in both
207        assert_eq!(
208            PwlCurve::new(vec![Point {
209                rate: f64::NAN,
210                price: f64::NAN,
211            }])
212            .unwrap_err(),
213            PwlCurveError::NaN
214        );
215    }
216
217    #[test]
218    fn test_non_monotone_rate() {
219        // Rates not in ascending order
220        assert_eq!(
221            PwlCurve::new(vec![
222                Point {
223                    rate: 5.0,
224                    price: 10.0,
225                },
226                Point {
227                    rate: 0.0,
228                    price: 8.0,
229                }, // rate goes backwards
230                Point {
231                    rate: 10.0,
232                    price: 5.0,
233                },
234            ])
235            .unwrap_err(),
236            PwlCurveError::NonMonotone
237        );
238    }
239
240    #[test]
241    fn test_non_monotone_prices() {
242        // Prices increasing when they should be non-increasing
243        assert_eq!(
244            PwlCurve::new(vec![
245                Point {
246                    rate: 0.0,
247                    price: 5.0,
248                },
249                Point {
250                    rate: 5.0,
251                    price: 6.0,
252                }, // price increases
253                Point {
254                    rate: 10.0,
255                    price: 2.0,
256                },
257            ])
258            .unwrap_err(),
259            PwlCurveError::NonMonotone
260        );
261    }
262
263    #[test]
264    fn test_combined_non_monotonicity() {
265        // Valid monotonicity: rates ascending, prices descending
266        assert_eq!(
267            PwlCurve::new(vec![
268                Point {
269                    rate: 0.0,
270                    price: 5.0,
271                },
272                Point {
273                    rate: 5.0,
274                    price: 8.0,
275                },
276                Point {
277                    rate: 10.0,
278                    price: 12.0,
279                },
280            ])
281            .unwrap_err(),
282            PwlCurveError::NonMonotone
283        );
284    }
285
286    #[test]
287    fn test_domain_excludes_zero() {
288        // All positive rates (no zero trade allowed)
289        assert_eq!(
290            PwlCurve::new(vec![
291                Point {
292                    rate: 1.0,
293                    price: 10.0,
294                },
295                Point {
296                    rate: 5.0,
297                    price: 8.0,
298                },
299                Point {
300                    rate: 10.0,
301                    price: 5.0,
302                },
303            ])
304            .unwrap_err(),
305            PwlCurveError::ZeroTrade
306        );
307
308        // All negative rates (no zero trade allowed)
309        assert_eq!(
310            PwlCurve::new(vec![
311                Point {
312                    rate: -10.0,
313                    price: 10.0,
314                },
315                Point {
316                    rate: -5.0,
317                    price: 8.0,
318                },
319                Point {
320                    rate: -1.0,
321                    price: 5.0,
322                },
323            ])
324            .unwrap_err(),
325            PwlCurveError::ZeroTrade
326        );
327    }
328
329    #[test]
330    fn test_edge_case_single_point_at_zero() {
331        // Single point at exactly zero should be valid
332        assert!(
333            PwlCurve::new(vec![Point {
334                rate: 0.0,
335                price: 10.0,
336            }])
337            .is_ok()
338        );
339    }
340
341    #[test]
342    fn test_edge_case_duplicate_rates() {
343        // Same rate with same price should be ok (though unusual)
344        let result = PwlCurve::new(vec![
345            Point {
346                rate: 0.0,
347                price: 10.0,
348            },
349            Point {
350                rate: 5.0,
351                price: 8.0,
352            },
353            Point {
354                rate: 5.0,
355                price: 8.0,
356            }, // duplicate
357            Point {
358                rate: 10.0,
359                price: 5.0,
360            },
361        ]);
362        assert!(result.is_ok());
363
364        // Same rate with different price, i.e. a step down
365        let result = PwlCurve::new(vec![
366            Point {
367                rate: 0.0,
368                price: 10.0,
369            },
370            Point {
371                rate: 5.0,
372                price: 8.0,
373            },
374            Point {
375                rate: 5.0,
376                price: 7.0,
377            }, // same rate, lower price
378            Point {
379                rate: 10.0,
380                price: 5.0,
381            },
382        ]);
383        assert!(result.is_ok());
384    }
385
386    #[test]
387    fn test_infinite_values() {
388        // Positive infinity in rate
389        assert_eq!(
390            PwlCurve::new(vec![
391                Point {
392                    rate: 0.0,
393                    price: 10.0,
394                },
395                Point {
396                    rate: f64::INFINITY,
397                    price: 0.0,
398                },
399            ])
400            .unwrap_err(),
401            PwlCurveError::Infinity
402        ); // Should be valid
403
404        // Negative infinity in rate
405        assert_eq!(
406            PwlCurve::new(vec![
407                Point {
408                    rate: f64::NEG_INFINITY,
409                    price: 10.0,
410                },
411                Point {
412                    rate: 0.0,
413                    price: 5.0,
414                },
415                Point {
416                    rate: 10.0,
417                    price: 0.0,
418                },
419            ])
420            .unwrap_err(),
421            PwlCurveError::Infinity
422        );
423
424        // Infinity in price
425        assert_eq!(
426            PwlCurve::new(vec![
427                Point {
428                    rate: 0.0,
429                    price: f64::INFINITY,
430                },
431                Point {
432                    rate: 10.0,
433                    price: 0.0,
434                },
435            ])
436            .unwrap_err(),
437            PwlCurveError::Infinity
438        );
439    }
440
441    #[test]
442    fn test_precision_edge_cases() {
443        // Very small but valid differences
444        let result = PwlCurve::new(vec![
445            Point {
446                rate: 0.0,
447                price: 10.0,
448            },
449            Point {
450                rate: f64::EPSILON,
451                price: 10.0 - f64::EPSILON,
452            },
453        ]);
454        assert!(result.is_ok());
455
456        // Test with very small rates around zero
457        let result = PwlCurve::new(vec![
458            Point {
459                rate: -f64::EPSILON,
460                price: 10.0,
461            },
462            Point {
463                rate: 0.0,
464                price: 10.0,
465            },
466            Point {
467                rate: f64::EPSILON,
468                price: 10.0,
469            },
470            Point {
471                rate: 1.0,
472                price: 0.0,
473            },
474        ]);
475        assert!(result.is_ok());
476    }
477}