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}