fts_core/models/cost/curve.rs
1use serde::{Deserialize, Serialize};
2use std::ops::Index;
3use thiserror::Error;
4use utoipa::ToSchema;
5
6/// A representation of a piecewise-linear, weakly monotone decreasing demand curve
7///
8/// Demand curves define a bidder's willingness to pay for different quantities of a good.
9/// In flow trading, these curves must be:
10/// - Piecewise-linear (defined by a sequence of points)
11/// - Weakly monotone decreasing (price non-increasing as rate increases)
12/// - Include the point rate=0 in their domain (must allow zero trade)
13#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
14#[serde(try_from = "Vec::<Point>", into = "Vec::<Point>")]
15pub struct Curve(Vec<Point>);
16
17impl Curve {
18 /// Creates a new demand curve from a sequence of points
19 ///
20 /// Validates that the points:
21 /// - Are not empty
22 /// - Have valid (non-NaN, non-infinite) coordinates
23 /// - Are ordered by ascending rate and descending price
24 /// - Include rate=0 in their domain (first point rate ≤ 0, last point rate ≥ 0)
25 ///
26 /// Returns a ValidationError if any of these conditions are not met.
27 pub fn new(points: Vec<Point>) -> Result<Self, ValidationError> {
28 if let Some(point) = points.first() {
29 validate_point(point)?;
30 } else {
31 return Err(ValidationError::EMPTY);
32 }
33
34 for pair in points.windows(2) {
35 // We've already checked pair[0], so we just need to check pair[1]
36 validate_point(pair.index(1))?;
37
38 let Point {
39 rate: r0,
40 price: p0,
41 } = pair[0];
42
43 let Point {
44 rate: r1,
45 price: p1,
46 } = pair[1];
47
48 if r1 < r0 || p0 < p1 {
49 return Err(ValidationError::NONMONOTONE);
50 }
51 }
52
53 // These unwraps will not panic, as we would have already returned from this function otherwise
54 let r0 = points.first().unwrap().rate;
55 let r1 = points.last().unwrap().rate;
56
57 if r0 > 0.0 || r1 < 0.0 {
58 Err(ValidationError::NOZERO)
59 } else {
60 Ok(Self(points))
61 }
62 }
63
64 /// Creates a new Curve without validating the points
65 ///
66 /// # Safety
67 ///
68 /// This function should only be used when the caller can guarantee that
69 /// the points satisfy all the requirements that `new` would check.
70 /// Using invalid points can lead to incorrect behavior in downstream systems.
71 pub unsafe fn new_unchecked(points: Vec<Point>) -> Self {
72 Self(points)
73 }
74
75 /// Converts the curve into an iterator over its points
76 pub fn into_iter(self) -> impl Iterator<Item = Point> {
77 self.0.into_iter()
78 }
79
80 /// Scales the rate component of each point by the given factor
81 ///
82 /// Returns a vector of (scaled_rate, price) tuples. This is useful when
83 /// converting between rate-based and quantity-based representations.
84 pub fn scale(&self, x: f64) -> Vec<(f64, f64)> {
85 self.0
86 .iter()
87 .map(|point| (point.rate * x, point.price))
88 .collect()
89 }
90}
91
92impl TryFrom<Vec<Point>> for Curve {
93 type Error = ValidationError;
94
95 /// Attempts to create a Curve from a vector of points
96 ///
97 /// Delegates to `Curve::new` for validation.
98 fn try_from(value: Vec<Point>) -> Result<Self, Self::Error> {
99 Curve::new(value)
100 }
101}
102
103impl Into<Vec<Point>> for Curve {
104 /// Converts the Curve back into a vector of Points
105 fn into(self) -> Vec<Point> {
106 self.0
107 }
108}
109
110/// The various ways in which a curve can be invalid
111#[derive(Debug, Error)]
112pub enum ValidationError {
113 /// Error when any coordinate value is NaN
114 #[error("NaN value encountered")]
115 NAN,
116 /// Error when any coordinate value is infinite
117 #[error("Infinite value encountered")]
118 INFINITY,
119 /// Error when no points are provided
120 #[error("No points provided")]
121 EMPTY,
122 /// Error when the curve's domain does not include rate=0
123 #[error("Domain excludes rate=0")]
124 NOZERO,
125 /// Error when points violate the monotonicity requirement
126 #[error("Points are not ordered by ascending rate, descending price")]
127 NONMONOTONE,
128}
129
130/// Validates that a point has finite coordinates
131fn validate_point(point: &Point) -> Result<(), ValidationError> {
132 let Point { rate, price } = point;
133 if rate.is_nan() || price.is_nan() {
134 return Err(ValidationError::NAN);
135 } else if rate.is_infinite() || price.is_infinite() {
136 return Err(ValidationError::INFINITY);
137 } else {
138 Ok(())
139 }
140}
141
142/// A representation of a point for use in defining piecewise-linear curves
143///
144/// Each point consists of:
145/// - A rate (quantity per time unit)
146/// - A price (value per unit)
147///
148/// Points are used to define the vertices of piecewise-linear demand curves.
149#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
150pub struct Point {
151 /// The rate (quantity per time) coordinate
152 pub rate: f64,
153 /// The price (value per unit) coordinate
154 pub price: f64,
155}
156
157impl Point {
158 /// Converts this point to a solver-compatible format, applying rate scaling
159 ///
160 /// The rate component is scaled by the given factor, typically representing
161 /// a time interval duration, to convert from rate to quantity.
162 pub fn as_solver(&self, scale: f64) -> fts_solver::Point {
163 fts_solver::Point {
164 quantity: self.rate * scale,
165 price: self.price,
166 }
167 }
168}
169
170impl Curve {
171 /// Removes any collinearities and scales by the provided value
172 ///
173 /// This optimizes the curve representation by:
174 /// 1. Removing intermediate points that lie on the same line segment
175 /// 2. Scaling rates by the provided factor to convert to quantities
176 ///
177 /// The resulting curve is suitable for use with the solver library.
178 pub fn as_solver(&self, scale: f64) -> fts_solver::PiecewiseLinearCurve {
179 // Start from the first point
180 let mut reduced = vec![self.0.first().unwrap().as_solver(scale)];
181
182 for i in 1..(self.0.len() - 1) {
183 let x0 = reduced.last().unwrap();
184 let x1 = unsafe { self.0.get_unchecked(i) }.as_solver(scale);
185 let x2 = unsafe { self.0.get_unchecked(i + 1) }.as_solver(scale);
186 // Going from the last accepted point, compare against the current point and the one after that.
187 // If collinear, the slopes are the same. (This includes vertical, horizontal, and degenerate cases)
188 if (x2.quantity - x0.quantity) * (x1.price - x0.price)
189 != (x1.quantity - x0.quantity) * (x2.price - x0.price)
190 {
191 reduced.push(x1);
192 }
193 }
194
195 let lst = self.0.last().unwrap().as_solver(scale);
196 if lst != *reduced.last().unwrap() {
197 reduced.push(lst);
198 }
199
200 fts_solver::PiecewiseLinearCurve { points: reduced }
201 }
202}