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}