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}