fts_solver/
export.rs

1use crate::{HashSet, Segment, disaggregate};
2use fts_core::models::{DemandCurve, DemandGroup, Map, ProductGroup};
3use std::fmt::Display;
4use std::hash::Hash;
5use std::io::Write;
6
7/// Convert a set of flow trading submissions to a quadratic program and export
8/// this program to `.mps` format.
9pub fn export_mps<
10    DemandId: Display + Eq + Hash + Clone,
11    PortfolioId: Display + Eq + Hash + Clone,
12    ProductId: Display + Eq + Hash + Clone + Ord,
13>(
14    demand_curves: Map<DemandId, DemandCurve>,
15    portfolios: Map<PortfolioId, (DemandGroup<DemandId>, ProductGroup<ProductId>)>,
16    buffer: &mut impl Write,
17) -> Result<(), std::io::Error> {
18    // MPS is a somewhat archaic format, but is easy enough to generate.
19    // https://www.ibm.com/docs/en/icos/22.1.2?topic=standard-records-in-mps-format
20    // is a good reference.
21
22    // This prepare method canonicalizes the input in an appropriate manner
23    let (demand_curves, portfolios, _, products) = super::prepare(demand_curves, portfolios);
24
25    writeln!(buffer, "NAME flow_trade_qp")?;
26    writeln!(buffer, "ROWS")?;
27
28    // Our objective is gains-from-trade ("gft")
29    writeln!(buffer, " N    gft")?;
30
31    // We will have one row per product, set equal to zero, which will give us the shadow prices
32    for product_id in products.keys() {
33        // The product dual variables are named `p_{product_id}`
34        writeln!(buffer, " E    p_{product_id}")?;
35    }
36
37    // We will also have one row per demand curve, set equal to zero.
38    for demand_id in demand_curves.keys() {
39        writeln!(buffer, " E    d_{demand_id}")?;
40    }
41
42    // We have two sets of variables: the per-product allocations `x`, and
43    // the demand curve segment fills `y`, related such that Ax - Σy = 0,
44    // where the rows of Σ are disjoint 1 vectors.
45    writeln!(buffer, "COLUMNS")?;
46
47    let mut zeroed = HashSet::default();
48
49    // We begin with this first set x. Notably, these variables *do not* appear in the objective.
50    for (portfolio_id, (demand_weights, product_weights)) in portfolios.iter() {
51        for (product_id, weight) in product_weights.iter() {
52            writeln!(buffer, "    x_{portfolio_id}    p_{product_id}    {weight}",)?;
53        }
54        for (demand_id, weight) in demand_weights.iter() {
55            writeln!(buffer, "    x_{portfolio_id}    d_{demand_id}    {weight}",)?;
56        }
57        if product_weights.len() == 0 || demand_weights.len() == 0 {
58            zeroed.insert(portfolio_id);
59        }
60    }
61
62    // Now the second set y.
63    let mut all_segments = Map::<(DemandId, usize), Segment>::default();
64    for (demand_id, demand_curve) in demand_curves.into_iter() {
65        let (min, max) = demand_curve.domain();
66        let points = demand_curve.points();
67
68        let segments = disaggregate(points.into_iter(), min, max).expect("empty demand curve");
69        for (idx, segment) in segments.enumerate() {
70            // TODO: propagate the error upwards
71            let segment = segment.unwrap();
72
73            // MPS defaults to minimization. Further, the quadratic terms are specified in a
74            // well-supported extension, so we only do the linear terms here.
75            let (_, b) = segment.slope_intercept();
76            writeln!(
77                buffer,
78                "    y_{demand_id}_{idx}    gft    {term}    d_{demand_id}    -1",
79                term = -b
80            )?;
81
82            // keep track of the segment
83            all_segments.insert((demand_id.clone(), idx), segment);
84        }
85    }
86
87    // Now we specify the domains for each variable.
88    writeln!(buffer, "BOUNDS")?;
89    for portfolio_id in portfolios.keys() {
90        if zeroed.contains(portfolio_id) {
91            writeln!(buffer, " FX BND    x_{portfolio_id} 0")?;
92        } else {
93            // The allocation variables are unconstrained typically.
94            writeln!(buffer, " FR BND    x_{portfolio_id}")?;
95        }
96    }
97
98    for ((demand_id, idx), segment) in all_segments.iter() {
99        let min = segment.q0;
100        let max = segment.q1;
101
102        // The segment variables are bounded above and below (unless infinite)
103        if min.is_finite() {
104            writeln!(buffer, " LO BND    y_{demand_id}_{idx}    {min}",)?;
105        } else {
106            writeln!(buffer, " MI BND    y_{demand_id}_{idx}",)?;
107        }
108        if max.is_finite() {
109            writeln!(buffer, " UP BND    y_{demand_id}_{idx}    {max}",)?;
110        } else {
111            writeln!(buffer, " PL BND    y_{demand_id}_{idx}",)?;
112        }
113    }
114
115    // Finally, we leverage the quadratic extension
116    writeln!(buffer, "QUADOBJ")?;
117    for ((demand_id, idx), segment) in all_segments {
118        let (m, _) = segment.slope_intercept();
119        writeln!(
120            buffer,
121            "    y_{demand_id}_{idx}    y_{demand_id}_{idx}    {term}",
122            term = -m
123        )?;
124    }
125
126    writeln!(buffer, "ENDATA")?;
127    Ok(())
128}
129
130/// Convert a set of flow trading submissions to a quadratic program and export
131/// this program to `.lp` format.
132pub fn export_lp<
133    DemandId: Display + Eq + Hash + Clone,
134    PortfolioId: Display + Eq + Hash + Clone,
135    ProductId: Display + Eq + Hash + Clone + Ord,
136>(
137    demand_curves: Map<DemandId, DemandCurve>,
138    portfolios: Map<PortfolioId, (DemandGroup<DemandId>, ProductGroup<ProductId>)>,
139    buffer: &mut impl Write,
140) -> Result<(), std::io::Error> {
141    // This prepare method canonicalizes the input in an appropriate manner
142    let (demand_curves, portfolios, _, products) = super::prepare(demand_curves, portfolios);
143    let mut all_segments = Map::<(DemandId, usize), Segment>::default();
144
145    // Start with the objective section - maximize gains from trade
146    writeln!(buffer, "Maximize")?;
147
148    // Start the objective function (gft)
149    write!(buffer, "  gft: ")?;
150
151    // Flag to track if we've written any terms yet
152    let mut first_term = true;
153    let mut has_quadratic_terms = false;
154
155    // Add linear terms from the y variables
156    for (demand_id, demand_curve) in demand_curves.iter() {
157        let (min, max) = demand_curve.domain();
158        let points = demand_curve.clone().points();
159
160        let segments = disaggregate(points.into_iter(), min, max).expect("empty demand curve");
161        for (idx, segment) in segments.enumerate() {
162            // TODO: propagate the error upwards
163            let segment = segment.unwrap();
164
165            let (m, b) = segment.slope_intercept();
166            has_quadratic_terms = has_quadratic_terms || m != 0.0;
167            if first_term {
168                write!(buffer, "{b} y_{demand_id}_{idx}")?;
169                first_term = false;
170            } else {
171                write!(buffer, " + {b} y_{demand_id}_{idx}")?;
172            }
173
174            // keep track of the segment
175            all_segments.insert((demand_id.clone(), idx), segment);
176        }
177    }
178
179    // If no linear terms were written, add a 0
180    if first_term {
181        write!(buffer, "0")?;
182    }
183
184    if has_quadratic_terms {
185        write!(buffer, " + [ ")?;
186
187        // Reset first_term flag for quadratic terms
188        first_term = true;
189
190        for ((demand_id, idx), segment) in all_segments.iter() {
191            let (m, _) = segment.slope_intercept();
192            if first_term {
193                write!(buffer, "{m} y_{demand_id}_{idx} ^ 2")?;
194                first_term = false;
195            } else {
196                write!(buffer, " + {m} y_{demand_id}_{idx} ^ 2",)?;
197            }
198        }
199
200        write!(buffer, " ] / 2")?;
201    }
202
203    // End the objective line
204    writeln!(buffer)?;
205
206    // Constraints section
207    writeln!(buffer, "Subject To")?;
208
209    // Product constraints (all products must sum to zero)
210    for product_id in products.keys() {
211        // Start the constraint
212        write!(buffer, "  p_{product_id}: ")?;
213
214        // Flag to track if we've written any terms
215        let mut first_term = true;
216
217        // Collect all terms related to this product
218        for (portfolio_id, (_, product_weights)) in portfolios.iter() {
219            if let Some(&weight) = product_weights.get(product_id) {
220                if first_term {
221                    write!(buffer, "{weight} x_{portfolio_id}")?;
222                    first_term = false;
223                } else {
224                    write!(buffer, " + {weight} x_{portfolio_id}",)?;
225                }
226            }
227        }
228
229        // If no terms were written, add a 0
230        if first_term {
231            write!(buffer, "0")?;
232        }
233
234        // Finish the constraint: = 0
235        writeln!(buffer, " = 0")?;
236    }
237
238    // Demand curve constraints
239    for demand_id in demand_curves.keys() {
240        // Start the constraint
241        write!(buffer, "  d_{demand_id}: ")?;
242
243        // Flag to track if we've written any terms
244        let mut first_term = true;
245
246        for (portfolio_id, (demand_weights, _)) in portfolios.iter() {
247            if let Some(&weight) = demand_weights.get(demand_id) {
248                if first_term {
249                    write!(buffer, "{weight} x_{portfolio_id}")?;
250                    first_term = false;
251                } else {
252                    write!(buffer, " + {weight} x_{portfolio_id}",)?;
253                }
254            }
255        }
256
257        // Assertion: first_term = false, since groups are non empty
258
259        let mut key = (demand_id.clone(), 0);
260        while all_segments.contains_key(&key) {
261            write!(buffer, " - y_{demand_id}_{idx}", idx = key.1)?;
262            key.1 += 1;
263        }
264
265        // Finish the constraint: = 0
266        writeln!(buffer, " = 0")?;
267    }
268
269    // Bounds section
270    writeln!(buffer, "Bounds")?;
271
272    // The x variables are (typically) unconstrained (free)
273    for (portfolio_id, (a, b)) in portfolios.iter() {
274        if a.len() == 0 || b.len() == 0 {
275            writeln!(buffer, "  x_{portfolio_id} = 0")?;
276        } else {
277            writeln!(buffer, "  x_{portfolio_id} free")?;
278        }
279    }
280
281    // The y variables have specific bounds
282    for ((demand_id, idx), segment) in all_segments {
283        match (segment.q0.is_finite(), segment.q1.is_finite()) {
284            (true, true) => {
285                writeln!(
286                    buffer,
287                    "  {min} <= y_{demand_id}_{idx} <= {max}",
288                    min = segment.q0,
289                    max = segment.q1
290                )?;
291            }
292            (true, false) => {
293                writeln!(buffer, "  y_{demand_id}_{idx} >= {min}", min = segment.q0)?;
294            }
295            (false, true) => {
296                writeln!(buffer, "  y_{demand_id}_{idx} <= {max}", max = segment.q1)?;
297            }
298            (false, false) => {
299                writeln!(buffer, "  y_{demand_id}_{idx} free")?;
300            }
301        }
302    }
303
304    // End the LP file
305    writeln!(buffer, "End")?;
306
307    Ok(())
308}