fts_solver/impls/clarabel.rs
1use crate::{
2 AuctionOutcome, Auth, AuthOutcome, Constant, Point, ProductOutcome, Solver, Submission,
3};
4use crate::{Map, Set};
5use clarabel::{algebra::*, solver::*};
6use std::hash::Hash;
7
8/// A solver implementation that uses the Clarabel interior point method
9/// for quadratic programming to solve the market clearing problem.
10///
11/// This solver is generally more accurate but can be slower than ADMM-based
12/// solvers for large problems. It's a good choice when high precision is needed.
13pub struct ClarabelSolver(DefaultSettings<f64>);
14
15impl Default for ClarabelSolver {
16 fn default() -> Self {
17 let mut settings = DefaultSettings::default();
18 settings.verbose = false;
19 Self(settings)
20 }
21}
22
23impl Solver for ClarabelSolver {
24 type Settings = DefaultSettings<f64>;
25
26 fn new(settings: Self::Settings) -> Self {
27 Self(settings)
28 }
29
30 fn solve<AuthId: Eq + Hash + Clone + Ord, ProductId: Eq + Hash + Clone + Ord>(
31 &self,
32 auction: &[Submission<AuthId, ProductId>],
33 ) -> AuctionOutcome<AuthId, ProductId> {
34 if auction.is_empty() {
35 return AuctionOutcome {
36 auths: Default::default(),
37 products: Default::default(),
38 };
39 }
40
41 // In order to setup the the optimization program, we need to define
42 // up front the full space of products, as well as assign a canonical
43 // enumerative index to each of them.
44 let products = {
45 // Gather the set of products from every authorized portfolio
46 let mut products = auction
47 .iter()
48 .flat_map(|submission| {
49 submission
50 .auths
51 .values()
52 .flat_map(|auth| auth.portfolio.keys())
53 .map(|id| id.clone())
54 })
55 .collect::<Set<ProductId>>();
56
57 // Provide a canonical ordering to the product ids
58 products.sort_unstable();
59
60 // Build the index lookup
61 products
62 .into_iter()
63 .enumerate()
64 .map(|(a, b)| (b, a))
65 .collect::<Map<ProductId, usize>>()
66 };
67
68 // The trade and bid constraints are all (something) = 0, we need to
69 // know how many of these there are in order to handle the box
70 // constraints for each decision variable
71 let nzero = products.len()
72 + auction
73 .iter()
74 .map(|submission| submission.cost_curves.len() + submission.cost_constants.len())
75 .sum::<usize>();
76
77 // Our quadratic term is diagonal, so we build the matrix by defining its diagonal
78 let mut p = Vec::new();
79 // and these are the linear terms
80 let mut q = Vec::new();
81
82 // Clarabel handles constraints via a cone specification, e.g. Ax + s = b, where s is a cone.
83 // The first `nzero` of b and s are just =0, so we do that work upfront.
84 let mut b = vec![0.0; nzero];
85 let mut s = vec![ZeroConeT(nzero)];
86
87 // Clarabel's matrix input is in the form of CSC, so we handle the memory representation
88 // carefully.
89 let mut a_nzval = Vec::new();
90 let mut a_rowval = Vec::new();
91 let mut a_colptr = Vec::new();
92
93 // These will help us figure out the correct row index as we iterate.
94 let mut group_offset = products.len();
95
96 // We begin by setting up the portfolio variables
97 for submission in auction.iter() {
98 for (
99 auth_id,
100 Auth {
101 min_trade,
102 max_trade,
103 portfolio,
104 },
105 ) in submission.auths.iter()
106 {
107 // portfolio variables contribute nothing to the objective
108 p.push(0.0);
109 q.push(0.0);
110
111 // start a new column in the constraint matrix
112 a_colptr.push(a_nzval.len());
113
114 // We copy the portfolio definition into the matrix
115 for (product, &weight) in portfolio.iter() {
116 a_nzval.push(weight);
117 a_rowval.push(*products.get(product).unwrap());
118 }
119
120 // Now we embed the weights from each group. This loop is a little wonky;
121 // in matrix terms, the representation is transposed in the wrong way.
122 // However, we expect the number of groups to be fairly small, so simply
123 // searching every group for every portfolio in the submission is not so bad.
124 for group in submission
125 .cost_curves
126 .iter()
127 .map(|(group, _)| group)
128 .chain(submission.cost_constants.iter().map(|(group, _)| group))
129 {
130 if let Some(x) = group.get(auth_id) {
131 a_nzval.push(x);
132 a_rowval.push(group_offset);
133 group_offset += 1;
134 }
135 }
136
137 // Now we add the box constraints. Note that here, we are dynamically
138 // growing the constraint vector b and using that to track our row indices.
139 // The signs on the lower bound are wonky because we have to use s>=0 as
140 // the cone specification.
141 if min_trade.is_finite() {
142 a_nzval.push(-1.0);
143 a_rowval.push(b.len());
144 b.push(-min_trade);
145 }
146 if max_trade.is_finite() {
147 a_nzval.push(1.0);
148 a_rowval.push(b.len());
149 b.push(*max_trade)
150 }
151 }
152 }
153
154 // Now we setup the segment variables
155 group_offset = products.len();
156 for submission in auction.iter() {
157 for (_, curve) in submission.cost_curves.iter() {
158 for pair in curve.points.windows(2) {
159 // Extract the coordinates
160 let Point {
161 quantity: x0,
162 price: y0,
163 } = pair[0];
164 let Point {
165 quantity: x1,
166 price: y1,
167 } = pair[1];
168
169 // Slide the segment so that it abuts x=0
170 let (x0, x1) = {
171 let translate = x0.max(0.0) + x1.min(0.0);
172 (x0 - translate, x1 - translate)
173 };
174
175 let dx = x1 - x0;
176 let dy = y1 - y0;
177
178 // We ignore vertical segments
179 if dx == 0.0 {
180 continue;
181 }
182
183 // Setup the contributions to the objective
184 let quad = -dy / dx;
185 p.push(quad);
186 q.push(-(y0 + quad * x0));
187
188 // Insert a new column
189 a_colptr.push(a_nzval.len());
190
191 // Ensure it counts towards the group
192 a_nzval.push(-1.0);
193 a_rowval.push(group_offset);
194
195 // Setup the box constraints
196 // x0 <= y <= x1 ==> -y + s == -x0 and y + s == x1
197 a_nzval.push(-1.0);
198 a_rowval.push(b.len());
199 b.push(-x0);
200 a_nzval.push(1.0);
201 a_rowval.push(b.len());
202 b.push(x1);
203 }
204
205 // Advance the group offset for the next bid/constraint
206 group_offset += 1;
207 }
208
209 for (
210 _,
211 Constant {
212 quantity: (min, max),
213 price,
214 },
215 ) in submission.cost_constants.iter()
216 {
217 // Constant segments contribute nothing to the quadratic objective, but otherwise
218 // act a lot like a curve.
219 p.push(0.0);
220 q.push(-price);
221
222 // Insert a new column
223 a_colptr.push(a_nzval.len());
224
225 // Ensure it counts towards the group
226 a_nzval.push(-1.0);
227 a_rowval.push(group_offset);
228
229 // Also establish the relevant constraints
230 if min.is_finite() {
231 a_nzval.push(-1.0);
232 a_rowval.push(b.len());
233 b.push(-min);
234 }
235 if max.is_finite() {
236 a_nzval.push(1.0);
237 a_rowval.push(b.len());
238 b.push(*max);
239 }
240
241 // Advance the group offset for the next bid/constraint
242 group_offset += 1;
243 }
244 }
245
246 // We need to polish off the CSC matrix
247 a_colptr.push(a_nzval.len());
248
249 let a_matrix = CscMatrix {
250 m: b.len(),
251 n: p.len(),
252 colptr: a_colptr,
253 rowval: a_rowval,
254 nzval: a_nzval,
255 };
256
257 assert!(a_matrix.check_format().is_ok()); // TODO: maybe remove this
258
259 // We also need to cleanup the cone specification
260 s.push(NonnegativeConeT(b.len() - nzero));
261
262 // Finally, we need to convert our p spec into a csc matrix
263 let p_matrix = {
264 let m = p.len();
265 let n = p.len();
266
267 CscMatrix {
268 m,
269 n,
270 colptr: (0..=n).collect(),
271 rowval: (0..n).collect(),
272 nzval: p,
273 }
274 };
275
276 // Now we can solve!
277 let mut solver = DefaultSolver::new(&p_matrix, &q, &a_matrix, &b, &s, self.0.clone());
278 solver.solve();
279 assert_eq!(solver.solution.status, SolverStatus::Solved);
280
281 // We get the raw optimization output
282 // TODO: make a determination on whether the price should actually be None
283
284 let mut volume: Map<ProductId, f64> =
285 products.iter().map(|(id, _)| (id.clone(), 0.0)).collect();
286
287 let prices: Map<ProductId, f64> = products
288 .into_iter()
289 .zip(solver.solution.z.iter())
290 .map(|((p, _), x)| (p, *x))
291 .collect();
292
293 let auth_outcomes: Map<AuthId, AuthOutcome> = auction
294 .iter()
295 .flat_map(|submission| submission.auths.iter())
296 .zip(solver.solution.x.iter())
297 .map(|((id, auth), x)| {
298 let mut price = 0.0;
299 for (product_id, weight) in auth.portfolio.iter() {
300 // TODO: we're adding floats, which has a possibility of precision loss.
301 // This probably shouldn't matter, but if we learn that it does we can easily
302 // use a Kahan-style summation here instead.
303 volume[product_id] += (weight * x).abs();
304 price += weight * prices[product_id];
305 }
306 (id.clone(), AuthOutcome { price, trade: *x })
307 })
308 .collect();
309
310 // This whole bit is somewhat hacky for now. Computing the volume is easy:
311 // just add the absolute value of each trade (weighted by the portfolio weight)
312 // and ultimately divide by 2. If we had exact arithmetic, then if volume = 0
313 // we know the price should be None. However, as we are in floating point land,
314 // the solver will arrive at an arbitrary price. It would likely be better to either
315 // detect this and say the price is non-existent, OR choose a price according to some
316 // criteria that is compatible with no-trade. The latter would require a secondary
317 // solve that necessarily distinguishes between zero and non-zero dual variables from
318 // the first solve, which is also asking for a world of trouble. For now, we just take
319 // whatever the solver returns as the price without further analysis, though we should
320 // consider constructing an auxiliary optimization program to specifically force
321 // a particular price when is a freedom.
322 AuctionOutcome {
323 auths: auth_outcomes,
324 products: prices
325 .into_iter()
326 .zip(volume)
327 .map(|((product_id, price), (_, volume))| {
328 (
329 product_id,
330 ProductOutcome {
331 price,
332 volume: volume / 2.0,
333 },
334 )
335 })
336 .collect(),
337 }
338 }
339}