fts_core/models/
cost.rs

1mod constant;
2mod curve;
3
4use crate::models::{AuthId, BidderId, uuid_wrapper};
5pub use constant::{Constant, RawConstant};
6pub use curve::{Curve, Point};
7use fxhash::FxBuildHasher;
8use indexmap::IndexMap;
9use serde::{Deserialize, Serialize};
10use std::hash::Hash;
11use thiserror::Error;
12use time::OffsetDateTime;
13use utoipa::ToSchema;
14
15// A simple newtype for a Uuid
16uuid_wrapper!(CostId);
17
18/// Controls whether cost group details should be included in API responses
19///
20/// Since cost groups can be large, this enum allows API endpoints to optionally
21/// exclude group details from responses to reduce payload size.
22#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
23#[serde(rename_all = "lowercase")]
24pub enum GroupDisplay {
25    /// Exclude group details from the response
26    Exclude,
27    /// Include group details in the response
28    Include,
29}
30
31impl Default for GroupDisplay {
32    fn default() -> Self {
33        Self::Exclude
34    }
35}
36
37/// The utility-specification of the cost
38///
39/// CostData represents either:
40/// - A non-increasing, piecewise-linear demand curve assigning a cost to each quantity in its domain, or
41/// - A simple, "flat" demand curve assining a constant cost to each quantity in its domain.
42///
43/// This is the core component that defines how a bidder values different trade outcomes.
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
45#[serde(untagged, try_from = "RawCostData", into = "RawCostData")]
46// TODO: Utoipa doesn't fully support all the Serde annotations,
47// so we injected `untagged` (which Serde will ignore given the presence of
48// of `try_from` and `into`), then inline the actual fields. This appears
49// to correctly generate the OpenAPI schema, but we should revisit.
50pub enum CostData {
51    /// A piecewise linear demand curve defined by points
52    Curve(#[schema(inline)] Curve),
53    /// A constant constraint enforcing a specific trade quantity at a price
54    Constant(#[schema(inline)] Constant),
55}
56
57/// An error type for the ways in which the provided utility function may be invalid.
58#[derive(Error, Debug)]
59pub enum ValidationError {
60    /// Error when a curve's definition is invalid
61    #[error("invalid demand curve: {0}")]
62    Curve(#[from] curve::ValidationError),
63    /// Error when a constant curve's definition is invalid
64    #[error("invalid constant curve: {0}")]
65    Constraint(#[from] constant::ValidationError),
66}
67
68/// The "DTO" type for the utility
69///
70/// This enum represents the raw data formats accepted in API requests for defining costs.
71#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
72#[serde(untagged)]
73pub enum RawCostData {
74    /// A sequence of points defining a piecewise linear demand curve
75    Curve(Vec<Point>),
76    /// A raw constant constraint definition
77    Constant(RawConstant),
78}
79
80impl TryFrom<RawCostData> for CostData {
81    type Error = ValidationError;
82
83    fn try_from(value: RawCostData) -> Result<Self, Self::Error> {
84        match value {
85            RawCostData::Curve(curve) => Ok(CostData::Curve(curve.try_into()?)),
86            RawCostData::Constant(constant) => Ok(CostData::Constant(constant.try_into()?)),
87        }
88    }
89}
90
91impl From<CostData> for RawCostData {
92    fn from(value: CostData) -> Self {
93        match value {
94            CostData::Curve(curve) => RawCostData::Curve(curve.into()),
95            CostData::Constant(constant) => RawCostData::Constant(constant.into()),
96        }
97    }
98}
99
100/// A record of the cost's data at the time it was updated or defined
101///
102/// This provides historical versioning of cost data, allowing the system
103/// to track changes to cost definitions over time.
104#[derive(Serialize, Deserialize, PartialEq, ToSchema, Debug)]
105pub struct CostHistoryRecord {
106    /// The cost data, or None if the cost was deactivated
107    pub data: Option<CostData>,
108    /// The timestamp when this version was created
109    #[serde(with = "time::serde::rfc3339")]
110    pub version: OffsetDateTime,
111}
112
113/// A full description of a cost
114///
115/// A CostRecord combines all the information needed to define a cost:
116/// - Who owns it (bidder_id)
117/// - Which auths it applies to (group)
118/// - The utility function (data)
119#[derive(Serialize, Deserialize, PartialEq, Debug, ToSchema)]
120pub struct CostRecord {
121    /// The responsible bidder's id
122    pub bidder_id: BidderId,
123
124    /// A unique id for the cost
125    pub cost_id: CostId,
126
127    /// The group associated to the cost. Because it is not always required, some endpoints may omit its definition.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    #[schema(value_type = Option<std::collections::HashMap<AuthId, f64>>)]
130    pub group: Option<Group>,
131
132    /// The utility for the cost
133    pub data: Option<CostData>,
134
135    /// The "last-modified-or-created" time as recorded by the system
136    #[serde(with = "time::serde::rfc3339")]
137    pub version: OffsetDateTime,
138}
139
140/// A group is a sparse collection of authorizations
141///
142/// Groups define which auths a particular cost applies to, with weights determining the
143/// relative contribution of each auth to the group's overall trade. This allows bidders
144/// to express substitution preferences between different portfolios.
145pub type Group = IndexMap<AuthId, f64, FxBuildHasher>;
146
147impl CostRecord {
148    /// Converts this cost record into a solver-compatible format.
149    ///
150    /// This method transforms the cost record into the appropriate solver structures,
151    /// applying the time scale to rate-based constraints as needed.
152    pub fn into_solver(self, scale: f64) -> Option<(fts_solver::Group<AuthId>, fts_solver::Cost)> {
153        let group = self.group.unwrap_or_default().into_iter().collect();
154
155        if let Some(data) = self.data {
156            Some((
157                group,
158                match data {
159                    CostData::Curve(curve) => {
160                        let curve = curve.as_solver(scale);
161                        if curve.points.len() == 1 {
162                            // We can convert a pathological curve into a constraint
163                            let point = curve.points.first().unwrap();
164                            // assert_eq!(point.quantity, 0.0);
165                            let constant = fts_solver::Constant {
166                                quantity: (point.quantity, point.quantity),
167                                price: point.price,
168                            };
169                            fts_solver::Cost::Constant(constant)
170                        } else {
171                            fts_solver::Cost::PiecewiseLinearCurve(curve)
172                        }
173                    }
174                    CostData::Constant(constant) => {
175                        fts_solver::Cost::Constant(constant.as_solver(scale))
176                    }
177                },
178            ))
179        } else {
180            None
181        }
182    }
183}