openquant/combinatorial_optimization.rs
1//! AFML Chapter 21: brute-force and combinatorial optimization adapters.
2//!
3//! This module provides integer decision schemas, exact finite-set solvers,
4//! adapter traits for heuristic/external solvers, and trading-trajectory
5//! state-space utilities with path-dependent objective evaluation.
6//!
7//! AFML Chapter 21 (§21.2 Combinatorial Optimization, §21.3 The Objective Function,
8//! §21.5 An Integer Optimization Approach) argues that when the choices are discrete and
9//! finite, listing and scoring every one gives the global optimum of any objective, smooth
10//! or not. The chapter's own multi-asset problem (pigeonhole partitions and the Sharpe-ratio
11//! trajectory search of Snippets 21.1–21.3) lives in [`crate::dynamic_allocation`]; this
12//! module is the generic exact-enumeration baseline underneath it:
13//!
14//! - [`solve_exact`] visits every point of a box of integer grids ([`DecisionSchema`]) and
15//! scores it with an [`IntegerObjective`] you write, without storing the candidates.
16//! - [`enumerate_trading_paths`] and [`solve_trading_trajectory_exact`] list and score every
17//! inventory path of a **single** instrument ([`TradingTrajectorySchema`]).
18//! [`evaluate_trading_path`] is a ready-made, non-convex objective (linear impact plus a
19//! fixed ticket cost per trade), in the spirit of Garleanu and Pedersen (2013) and
20//! Rosenberg et al. (2016); it is not the chapter's square-root cost.
21//! - [`SolverAdapter`] and [`solve_with_adapter`] run a heuristic or external solver on a box
22//! of any size (`max_enumeration` does not apply) and check its answer;
23//! [`compare_exact_and_adapter`] scores one against the exact answer on instances small
24//! enough to enumerate.
25//!
26//! Conventions: decisions, trades and inventories are integers (units or lots). Candidates
27//! are visited in lexicographic order starting from the lower bounds, and ties go to the
28//! first candidate found (comparisons are strict). An objective cannot mark a candidate
29//! infeasible: an error, or a non-finite score
30//! ([`CombinatorialOptimizationError::ObjectiveNotFinite`]), aborts the whole search, so
31//! constraints the schema cannot express must be large finite penalties. The search space is
32//! exponential; hitting `max_enumeration` or `max_paths` is the cue for a heuristic, not for a
33//! higher cap. Nothing here runs in parallel.
34//!
35//! ```
36//! use openquant::combinatorial_optimization::{
37//! solve_exact, CombinatorialOptimizationError, DecisionSchema, IntegerObjective,
38//! IntegerVariable, ObjectiveSense,
39//! };
40//!
41//! // Maximise -(x - 3)^2 - (y + 1)^2 with x on the even grid 0..=10 and y in -2..=2.
42//! struct Bowl;
43//! impl IntegerObjective for Bowl {
44//! fn sense(&self) -> ObjectiveSense {
45//! ObjectiveSense::Maximize
46//! }
47//! fn evaluate(&self, d: &[i64]) -> Result<f64, CombinatorialOptimizationError> {
48//! Ok(-((d[0] - 3).pow(2) + (d[1] + 1).pow(2)) as f64)
49//! }
50//! }
51//!
52//! # fn main() -> Result<(), CombinatorialOptimizationError> {
53//! let schema = DecisionSchema {
54//! variables: vec![
55//! IntegerVariable { lower: 0, upper: 10, step: 2 },
56//! IntegerVariable { lower: -2, upper: 2, step: 1 },
57//! ],
58//! max_enumeration: 100,
59//! };
60//! let best = solve_exact(&schema, &Bowl)?;
61//! // x = 2 and x = 4 tie at -1; the first one visited wins.
62//! assert_eq!(best.best_decision, [2, -1]);
63//! assert_eq!(best.best_objective, -1.0);
64//! assert_eq!(best.evaluated_candidates, 30); // 6 x 5 grid points
65//! # Ok(())
66//! # }
67//! ```
68
69use std::fmt::{Display, Formatter};
70
71/// Whether an objective is to be minimised or maximised.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum ObjectiveSense {
74 /// Smaller objective values are better.
75 Minimize,
76 /// Larger objective values are better.
77 Maximize,
78}
79
80/// Errors returned by the combinatorial-optimization functions.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum CombinatorialOptimizationError {
83 /// An input is out of its domain; the message names the violated condition.
84 InvalidInput(&'static str),
85 /// A per-step input of [`evaluate_trading_path`] does not match the path's horizon.
86 DecisionLengthMismatch {
87 /// The required length (the path's horizon).
88 expected: usize,
89 /// The length supplied.
90 found: usize,
91 },
92 /// A path given to [`evaluate_trading_path`] is not self-consistent:
93 /// `inventory_path[step + 1]` is not `inventory_path[step] + trades[step]` (or that sum
94 /// overflows `i64`).
95 InconsistentInventoryPath {
96 /// The first trade index at which the inventory does not follow the trades.
97 step: usize,
98 },
99 /// The objective returned a NaN or infinite value; the search is aborted.
100 ObjectiveNotFinite,
101 /// The schema has no variables, or no trading steps.
102 EmptyDomain,
103 /// The search space holds more candidates than the configured cap.
104 EnumerationLimitExceeded {
105 /// The cap that was exceeded (`max_enumeration` or `max_paths`).
106 limit: usize,
107 },
108 /// No candidate satisfies the constraints (for example an unreachable terminal inventory).
109 NoFeasibleSolution,
110 /// A [`SolverAdapter`] returned a result that fails [`solve_with_adapter`]'s checks; the
111 /// message names the check.
112 InvalidAdapterResult(&'static str),
113}
114
115impl Display for CombinatorialOptimizationError {
116 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
117 match self {
118 Self::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
119 Self::DecisionLengthMismatch { expected, found } => {
120 write!(f, "decision length mismatch: expected {expected}, found {found}")
121 }
122 Self::InconsistentInventoryPath { step } => write!(
123 f,
124 "inventory_path is not the running sum of trades: step {step} does not add up"
125 ),
126 Self::ObjectiveNotFinite => write!(f, "objective evaluation returned non-finite value"),
127 Self::EmptyDomain => write!(f, "decision domain is empty"),
128 Self::EnumerationLimitExceeded { limit } => {
129 write!(f, "enumeration limit exceeded: more than {limit} candidates")
130 }
131 Self::NoFeasibleSolution => write!(f, "no feasible solution found"),
132 Self::InvalidAdapterResult(msg) => write!(f, "invalid adapter result: {msg}"),
133 }
134 }
135}
136
137impl std::error::Error for CombinatorialOptimizationError {}
138
139/// One integer decision variable: the arithmetic grid `lower, lower + step, ...` up to and
140/// including the last point `<= upper`.
141///
142/// `upper` itself is a candidate only when `upper - lower` is a multiple of `step`.
143/// [`DecisionSchema::validate`] rejects `step <= 0` and `lower > upper`.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct IntegerVariable {
146 /// Smallest value, the first grid point.
147 pub lower: i64,
148 /// Inclusive upper bound on the grid.
149 pub upper: i64,
150 /// Grid spacing; must be `> 0`.
151 pub step: i64,
152}
153
154impl IntegerVariable {
155 fn validate(self) -> Result<(), CombinatorialOptimizationError> {
156 if self.step <= 0 {
157 return Err(CombinatorialOptimizationError::InvalidInput("step must be > 0"));
158 }
159 if self.lower > self.upper {
160 return Err(CombinatorialOptimizationError::InvalidInput(
161 "variable lower bound must be <= upper bound",
162 ));
163 }
164 Ok(())
165 }
166
167 fn cardinality(self) -> Result<usize, CombinatorialOptimizationError> {
168 self.validate()?;
169 let span = i128::from(self.upper) - i128::from(self.lower);
170 let step = i128::from(self.step);
171 let count = span / step + 1;
172 usize::try_from(count).map_err(|_| {
173 CombinatorialOptimizationError::InvalidInput("variable cardinality does not fit usize")
174 })
175 }
176
177 fn values(self) -> Result<Vec<i64>, CombinatorialOptimizationError> {
178 self.validate()?;
179 let mut out = Vec::with_capacity(self.cardinality()?);
180 let mut current = self.lower;
181 while current <= self.upper {
182 out.push(current);
183 current = match current.checked_add(self.step) {
184 Some(next) => next,
185 None => break,
186 };
187 }
188 if out.is_empty() {
189 return Err(CombinatorialOptimizationError::EmptyDomain);
190 }
191 Ok(out)
192 }
193}
194
195/// A box of integer decisions: the Cartesian product of the variables' grids (AFML §21.5).
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct DecisionSchema {
198 /// The decision variables, in the order the objective receives them.
199 pub variables: Vec<IntegerVariable>,
200 /// Hard cap for exact finite-set enumeration.
201 ///
202 /// Counts every point in the box, feasible or not. Enforced by [`solve_exact`] (and so by
203 /// [`compare_exact_and_adapter`]); [`solve_with_adapter`] ignores it.
204 pub max_enumeration: usize,
205}
206
207impl DecisionSchema {
208 /// Checks the schema and that its box has at most `max_enumeration` points: everything
209 /// [`solve_exact`] needs.
210 ///
211 /// # Errors
212 ///
213 /// - [`CombinatorialOptimizationError::EmptyDomain`] if `variables` is empty.
214 /// - [`CombinatorialOptimizationError::InvalidInput`] if `max_enumeration == 0`, a
215 /// variable has `step <= 0` or `lower > upper`, or the box size overflows `usize`.
216 /// - [`CombinatorialOptimizationError::EnumerationLimitExceeded`] if the box has more
217 /// than `max_enumeration` points.
218 pub fn validate(&self) -> Result<(), CombinatorialOptimizationError> {
219 self.validate_variables()?;
220 if self.max_enumeration == 0 {
221 return Err(CombinatorialOptimizationError::InvalidInput(
222 "max_enumeration must be > 0",
223 ));
224 }
225 let size = self.decision_space_size()?;
226 if size > self.max_enumeration {
227 return Err(CombinatorialOptimizationError::EnumerationLimitExceeded {
228 limit: self.max_enumeration,
229 });
230 }
231 Ok(())
232 }
233
234 /// Checks the variables alone: at least one, each with `step > 0` and `lower <= upper`. The
235 /// box may be of any size, so this is the check for a [`SolverAdapter`], which does not
236 /// enumerate; `max_enumeration` is not read.
237 ///
238 /// # Errors
239 ///
240 /// - [`CombinatorialOptimizationError::EmptyDomain`] if `variables` is empty.
241 /// - [`CombinatorialOptimizationError::InvalidInput`] if a variable has `step <= 0` or
242 /// `lower > upper`.
243 pub fn validate_variables(&self) -> Result<(), CombinatorialOptimizationError> {
244 if self.variables.is_empty() {
245 return Err(CombinatorialOptimizationError::EmptyDomain);
246 }
247 for var in &self.variables {
248 var.validate()?;
249 }
250 Ok(())
251 }
252
253 /// Whether `decision` is a point of the box: one value per variable, each on its grid
254 /// (`lower <= v <= upper` and `v - lower` a multiple of `step`).
255 ///
256 /// ```
257 /// use openquant::combinatorial_optimization::{DecisionSchema, IntegerVariable};
258 ///
259 /// let schema = DecisionSchema {
260 /// variables: vec![IntegerVariable { lower: 0, upper: 10, step: 3 }],
261 /// max_enumeration: 1,
262 /// };
263 /// assert!(schema.contains(&[9]));
264 /// assert!(!schema.contains(&[10])); // off the grid
265 /// assert!(!schema.contains(&[9, 0])); // wrong length
266 /// ```
267 pub fn contains(&self, decision: &[i64]) -> bool {
268 decision.len() == self.variables.len()
269 && self.variables.iter().zip(decision).all(|(var, &v)| {
270 var.step > 0
271 && var.lower <= v
272 && v <= var.upper
273 && (i128::from(v) - i128::from(var.lower)) % i128::from(var.step) == 0
274 })
275 }
276
277 /// Number of points in the box: the product of each variable's grid size.
278 ///
279 /// Does not check `max_enumeration`.
280 ///
281 /// # Errors
282 ///
283 /// - [`CombinatorialOptimizationError::EmptyDomain`] if `variables` is empty.
284 /// - [`CombinatorialOptimizationError::InvalidInput`] if a variable has `step <= 0` or
285 /// `lower > upper`, or a grid size or the product overflows `usize`.
286 ///
287 /// ```
288 /// use openquant::combinatorial_optimization::{DecisionSchema, IntegerVariable};
289 ///
290 /// let schema = DecisionSchema {
291 /// variables: vec![
292 /// IntegerVariable { lower: 0, upper: 10, step: 3 }, // 0, 3, 6, 9
293 /// IntegerVariable { lower: -1, upper: 1, step: 1 }, // -1, 0, 1
294 /// ],
295 /// max_enumeration: 5,
296 /// };
297 /// assert_eq!(schema.decision_space_size(), Ok(12));
298 /// assert!(schema.validate().is_err()); // 12 > max_enumeration
299 /// ```
300 pub fn decision_space_size(&self) -> Result<usize, CombinatorialOptimizationError> {
301 if self.variables.is_empty() {
302 return Err(CombinatorialOptimizationError::EmptyDomain);
303 }
304 self.variables.iter().try_fold(1usize, |acc, var| {
305 let n = var.cardinality()?;
306 acc.checked_mul(n).ok_or(CombinatorialOptimizationError::InvalidInput(
307 "decision space cardinality overflowed usize",
308 ))
309 })
310 }
311}
312
313/// The best decision a solver found over a [`DecisionSchema`].
314#[derive(Debug, Clone, PartialEq)]
315pub struct OptimizationResult {
316 /// The best decision, one value per schema variable in schema order.
317 pub best_decision: Vec<i64>,
318 /// The objective value of `best_decision`.
319 pub best_objective: f64,
320 /// How many candidates the solver evaluated (the whole box for [`solve_exact`]).
321 pub evaluated_candidates: usize,
322}
323
324/// An objective over integer decisions, scored by [`solve_exact`] or a [`SolverAdapter`].
325pub trait IntegerObjective {
326 /// Whether larger or smaller values of [`IntegerObjective::evaluate`] are better.
327 fn sense(&self) -> ObjectiveSense;
328
329 /// Scores one decision, given as one value per schema variable in schema order.
330 ///
331 /// Returning an error aborts the whole search; it does not mark the candidate
332 /// infeasible. A non-finite value also aborts [`solve_exact`] with
333 /// [`CombinatorialOptimizationError::ObjectiveNotFinite`].
334 ///
335 /// # Errors
336 ///
337 /// Implementation-defined.
338 fn evaluate(&self, decision: &[i64]) -> Result<f64, CombinatorialOptimizationError>;
339}
340
341/// A heuristic or external solver to be run through [`solve_with_adapter`] and scored
342/// against [`solve_exact`] with [`compare_exact_and_adapter`].
343pub trait SolverAdapter {
344 /// Searches `schema` for a good decision under `objective`.
345 ///
346 /// Called directly, the result is whatever the implementation returns. Through
347 /// [`solve_with_adapter`] (and so [`compare_exact_and_adapter`]) it is checked: the
348 /// decision must be a point of the box and `best_objective` must match the objective at
349 /// that decision.
350 ///
351 /// # Errors
352 ///
353 /// Implementation-defined.
354 fn solve(
355 &self,
356 schema: &DecisionSchema,
357 objective: &dyn IntegerObjective,
358 ) -> Result<OptimizationResult, CombinatorialOptimizationError>;
359}
360
361/// The exact optimum and an adapter's answer on the same problem, from
362/// [`compare_exact_and_adapter`].
363#[derive(Debug, Clone, PartialEq)]
364pub struct AdapterComparison {
365 /// The global optimum from [`solve_exact`].
366 pub exact: OptimizationResult,
367 /// The adapter's result, as checked by [`solve_with_adapter`].
368 pub adapter: OptimizationResult,
369 /// How far short of the exact optimum the adapter fell, in objective units:
370 /// `exact - adapter` when maximising and `adapter - exact` when minimising.
371 ///
372 /// Not floored. It is `>= 0` because [`solve_with_adapter`] has checked that the adapter's
373 /// decision is in the box and its value is the objective there, and the exact optimum is
374 /// the best value over the whole box; 0 means the adapter found an optimum.
375 pub objective_gap_vs_exact: f64,
376}
377
378/// Finds the global optimum of `objective` by visiting every point of the schema's box
379/// (AFML §21.5).
380///
381/// Candidates are visited in lexicographic order from the lower bounds without being stored;
382/// the comparison is strict, so among tied values the first decision visited wins.
383///
384/// # Errors
385///
386/// - Any error of [`DecisionSchema::validate`], including
387/// [`CombinatorialOptimizationError::EnumerationLimitExceeded`] when the box has more than
388/// `max_enumeration` points.
389/// - [`CombinatorialOptimizationError::ObjectiveNotFinite`] if the objective returns NaN or
390/// an infinity for any candidate.
391/// - Any error returned by [`IntegerObjective::evaluate`], unchanged.
392///
393/// # Examples
394///
395/// See the [module documentation](self).
396pub fn solve_exact(
397 schema: &DecisionSchema,
398 objective: &dyn IntegerObjective,
399) -> Result<OptimizationResult, CombinatorialOptimizationError> {
400 schema.validate()?;
401 let values = schema
402 .variables
403 .iter()
404 .copied()
405 .map(IntegerVariable::values)
406 .collect::<Result<Vec<_>, _>>()?;
407 // Defensive: `validate` already rejects `lower > upper`, so every grid holds at least
408 // `lower`, and the non-empty box below always yields a best decision. `EmptyDomain` and
409 // `NoFeasibleSolution` stay (both are reachable elsewhere) instead of an `expect`.
410 if values.iter().any(Vec::is_empty) {
411 return Err(CombinatorialOptimizationError::EmptyDomain);
412 }
413
414 let mut current = vec![0_i64; schema.variables.len()];
415 let mut best_decision: Option<Vec<i64>> = None;
416 let mut best_objective = 0.0;
417 let mut evaluated = 0usize;
418
419 enumerate_decisions(&values, 0, &mut current, &mut |decision| {
420 let value = objective.evaluate(decision)?;
421 if !value.is_finite() {
422 return Err(CombinatorialOptimizationError::ObjectiveNotFinite);
423 }
424 if best_decision.is_none() || is_better(value, best_objective, objective.sense()) {
425 best_decision = Some(decision.to_vec());
426 best_objective = value;
427 }
428 evaluated = evaluated.saturating_add(1);
429 Ok(())
430 })?;
431
432 let best_decision = best_decision.ok_or(CombinatorialOptimizationError::NoFeasibleSolution)?;
433 Ok(OptimizationResult { best_decision, best_objective, evaluated_candidates: evaluated })
434}
435
436/// Runs `adapter` on `schema` and checks its answer.
437///
438/// The schema is checked with [`DecisionSchema::validate_variables`], so the box may be of
439/// any size: `max_enumeration` is an enumeration cap and does not apply to a solver that
440/// does not enumerate. The adapter's result is then checked: `best_decision` must be a point
441/// of the box ([`DecisionSchema::contains`]), and `best_objective` must equal the objective
442/// re-evaluated at `best_decision` to within `1e-9` relative (at least `1e-9` absolute). The
443/// returned `best_objective` is that re-evaluated value; `evaluated_candidates` is the
444/// adapter's own count.
445///
446/// # Errors
447///
448/// - Any error of [`DecisionSchema::validate_variables`].
449/// - Any error returned by [`SolverAdapter::solve`], unchanged.
450/// - [`CombinatorialOptimizationError::InvalidAdapterResult`] if the decision is not a point
451/// of the box, or the reported objective does not match the re-evaluated one.
452/// - [`CombinatorialOptimizationError::ObjectiveNotFinite`] if the objective at the decision
453/// is not finite, or an error of [`IntegerObjective::evaluate`], unchanged.
454pub fn solve_with_adapter(
455 schema: &DecisionSchema,
456 objective: &dyn IntegerObjective,
457 adapter: &dyn SolverAdapter,
458) -> Result<OptimizationResult, CombinatorialOptimizationError> {
459 schema.validate_variables()?;
460 let mut result = adapter.solve(schema, objective)?;
461 if !schema.contains(&result.best_decision) {
462 return Err(CombinatorialOptimizationError::InvalidAdapterResult(
463 "best_decision is not a point of the schema's box",
464 ));
465 }
466 let value = objective.evaluate(&result.best_decision)?;
467 if !value.is_finite() {
468 return Err(CombinatorialOptimizationError::ObjectiveNotFinite);
469 }
470 let tolerance = 1e-9 * value.abs().max(1.0);
471 let error = (result.best_objective - value).abs();
472 // A NaN report gives a NaN error, which must be rejected too.
473 if error.is_nan() || error > tolerance {
474 return Err(CombinatorialOptimizationError::InvalidAdapterResult(
475 "best_objective does not match the objective at best_decision",
476 ));
477 }
478 result.best_objective = value;
479 Ok(result)
480}
481
482/// Runs [`solve_exact`] and `adapter` on the same problem and reports how far short of the
483/// exact optimum the adapter fell. The box must fit `max_enumeration`, since it is enumerated.
484///
485/// # Errors
486///
487/// Any error of [`solve_exact`] (checked first) or of [`solve_with_adapter`].
488///
489/// ```
490/// use openquant::combinatorial_optimization::{
491/// compare_exact_and_adapter, CombinatorialOptimizationError, DecisionSchema,
492/// IntegerObjective, IntegerVariable, ObjectiveSense, OptimizationResult, SolverAdapter,
493/// };
494///
495/// // Minimise |x - 7| over 0..=9.
496/// struct Distance;
497/// impl IntegerObjective for Distance {
498/// fn sense(&self) -> ObjectiveSense {
499/// ObjectiveSense::Minimize
500/// }
501/// fn evaluate(&self, d: &[i64]) -> Result<f64, CombinatorialOptimizationError> {
502/// Ok((d[0] - 7).abs() as f64)
503/// }
504/// }
505///
506/// // A "solver" that always answers with the lower corner of the box.
507/// struct LowerCorner;
508/// impl SolverAdapter for LowerCorner {
509/// fn solve(
510/// &self,
511/// schema: &DecisionSchema,
512/// objective: &dyn IntegerObjective,
513/// ) -> Result<OptimizationResult, CombinatorialOptimizationError> {
514/// let x: Vec<i64> = schema.variables.iter().map(|v| v.lower).collect();
515/// let value = objective.evaluate(&x)?;
516/// Ok(OptimizationResult { best_decision: x, best_objective: value, evaluated_candidates: 1 })
517/// }
518/// }
519///
520/// # fn main() -> Result<(), CombinatorialOptimizationError> {
521/// let schema = DecisionSchema {
522/// variables: vec![IntegerVariable { lower: 0, upper: 9, step: 1 }],
523/// max_enumeration: 10,
524/// };
525/// let report = compare_exact_and_adapter(&schema, &Distance, &LowerCorner)?;
526/// assert_eq!(report.exact.best_decision, [7]);
527/// assert_eq!(report.adapter.best_decision, [0]);
528/// assert_eq!(report.objective_gap_vs_exact, 7.0);
529/// # Ok(())
530/// # }
531/// ```
532pub fn compare_exact_and_adapter(
533 schema: &DecisionSchema,
534 objective: &dyn IntegerObjective,
535 adapter: &dyn SolverAdapter,
536) -> Result<AdapterComparison, CombinatorialOptimizationError> {
537 let exact = solve_exact(schema, objective)?;
538 let adapter_result = solve_with_adapter(schema, objective, adapter)?;
539 let gap = match objective.sense() {
540 ObjectiveSense::Maximize => exact.best_objective - adapter_result.best_objective,
541 ObjectiveSense::Minimize => adapter_result.best_objective - exact.best_objective,
542 };
543 Ok(AdapterComparison { exact, adapter: adapter_result, objective_gap_vs_exact: gap })
544}
545
546/// The inclusive range of integer trades allowed at one step of a trading trajectory.
547///
548/// Every integer in `min_trade..=max_trade` is tried, so the range width drives the search
549/// cost directly, even when the inventory bounds rule most trades out.
550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub struct TradeBounds {
552 /// Smallest allowed trade (negative sells).
553 pub min_trade: i64,
554 /// Largest allowed trade; must be `>= min_trade`.
555 pub max_trade: i64,
556}
557
558impl TradeBounds {
559 fn validate(self) -> Result<(), CombinatorialOptimizationError> {
560 if self.min_trade > self.max_trade {
561 return Err(CombinatorialOptimizationError::InvalidInput(
562 "trade bounds must satisfy min_trade <= max_trade",
563 ));
564 }
565 Ok(())
566 }
567}
568
569/// The state space of a single instrument's inventory traded over a fixed number of steps.
570///
571/// A path is one integer trade per step; the inventory after every trade must stay within
572/// `[inventory_min, inventory_max]`, and `terminal_inventory`, if set, is a hard constraint
573/// on the final inventory.
574#[derive(Debug, Clone, PartialEq, Eq)]
575pub struct TradingTrajectorySchema {
576 /// Inventory before the first trade.
577 pub initial_inventory: i64,
578 /// Lowest inventory allowed after any trade (inclusive).
579 pub inventory_min: i64,
580 /// Highest inventory allowed after any trade (inclusive).
581 pub inventory_max: i64,
582 /// Allowed trades at each step; its length is the horizon.
583 pub step_trade_bounds: Vec<TradeBounds>,
584 /// Required final inventory, or `None` to leave it free. Paths ending elsewhere are
585 /// discarded, not penalised.
586 pub terminal_inventory: Option<i64>,
587 /// Cap on the number of **feasible** paths; a schema with exactly this many is accepted.
588 ///
589 /// Infeasible partial paths explored by the search do not count towards it.
590 pub max_paths: usize,
591}
592
593impl TradingTrajectorySchema {
594 /// Checks the bounds and constraints of the schema. Does not count paths.
595 ///
596 /// # Errors
597 ///
598 /// - [`CombinatorialOptimizationError::InvalidInput`] if `inventory_min > inventory_max`,
599 /// `initial_inventory` or `terminal_inventory` lies outside
600 /// `[inventory_min, inventory_max]`, `max_paths == 0`, or a step has
601 /// `min_trade > max_trade`.
602 /// - [`CombinatorialOptimizationError::EmptyDomain`] if `step_trade_bounds` is empty.
603 pub fn validate(&self) -> Result<(), CombinatorialOptimizationError> {
604 if self.inventory_min > self.inventory_max {
605 return Err(CombinatorialOptimizationError::InvalidInput(
606 "inventory_min must be <= inventory_max",
607 ));
608 }
609 if self.initial_inventory < self.inventory_min
610 || self.initial_inventory > self.inventory_max
611 {
612 return Err(CombinatorialOptimizationError::InvalidInput(
613 "initial_inventory must be inside [inventory_min, inventory_max]",
614 ));
615 }
616 if self.max_paths == 0 {
617 return Err(CombinatorialOptimizationError::InvalidInput("max_paths must be > 0"));
618 }
619 if self.step_trade_bounds.is_empty() {
620 return Err(CombinatorialOptimizationError::EmptyDomain);
621 }
622 for bounds in &self.step_trade_bounds {
623 bounds.validate()?;
624 }
625 if let Some(term) = self.terminal_inventory
626 && (term < self.inventory_min || term > self.inventory_max)
627 {
628 return Err(CombinatorialOptimizationError::InvalidInput(
629 "terminal_inventory must be inside [inventory_min, inventory_max]",
630 ));
631 }
632 Ok(())
633 }
634
635 /// Number of trading steps, `step_trade_bounds.len()`.
636 pub fn horizon(&self) -> usize {
637 self.step_trade_bounds.len()
638 }
639}
640
641/// One feasible trading path: the trades and the inventory they produce.
642#[derive(Debug, Clone, PartialEq, Eq)]
643pub struct TradingTrajectoryPath {
644 /// Trade at each step, in time order.
645 pub trades: Vec<i64>,
646 /// Inventory path includes initial inventory at index 0.
647 ///
648 /// Entry `t + 1` is the inventory after `trades[t]`, so the length is
649 /// `trades.len() + 1`.
650 pub inventory_path: Vec<i64>,
651}
652
653impl TradingTrajectoryPath {
654 /// Number of trading steps, `trades.len()`.
655 pub fn horizon(&self) -> usize {
656 self.trades.len()
657 }
658}
659
660/// An objective over whole trading paths, scored by [`solve_trading_trajectory_exact`].
661///
662/// Path dependence is allowed: the objective sees every trade and inventory. Wrap
663/// [`evaluate_trading_path`] for the built-in cost model.
664pub trait TradingTrajectoryObjective {
665 /// Whether larger or smaller values of [`TradingTrajectoryObjective::evaluate`] are
666 /// better.
667 fn sense(&self) -> ObjectiveSense;
668
669 /// Scores one path.
670 ///
671 /// Returning an error, or a non-finite value, aborts the whole search.
672 ///
673 /// # Errors
674 ///
675 /// Implementation-defined.
676 fn evaluate(&self, path: &TradingTrajectoryPath)
677 -> Result<f64, CombinatorialOptimizationError>;
678}
679
680/// The best path found by [`solve_trading_trajectory_exact`].
681#[derive(Debug, Clone, PartialEq)]
682pub struct TrajectoryOptimizationResult {
683 /// The optimal path; among ties, the first enumerated.
684 pub best_path: TradingTrajectoryPath,
685 /// The objective value of `best_path`.
686 pub best_objective: f64,
687 /// Number of feasible paths scored.
688 pub evaluated_paths: usize,
689}
690
691/// Coefficients of the built-in objective [`evaluate_trading_path`].
692///
693/// Per-step vectors are indexed by step and must have one entry per trade.
694#[derive(Debug, Clone, PartialEq)]
695pub struct TradingTrajectoryObjectiveConfig {
696 /// Expected return per unit of inventory at each step, earned on the inventory held
697 /// *after* that step's trade.
698 pub expected_returns: Vec<f64>,
699 /// Penalty on held inventory each step (path-dependent risk proxy).
700 ///
701 /// Charged as `risk_aversion * q_t^2` on the post-trade inventory `q_t`; must be finite
702 /// and `>= 0`.
703 pub risk_aversion: f64,
704 /// Per-step linear impact coefficient applied to |trade|.
705 ///
706 /// Not checked for sign or finiteness (a non-finite result is caught at the end).
707 pub impact_coefficients: Vec<f64>,
708 /// Fixed per-step ticket cost whenever trade != 0 (non-convex).
709 ///
710 /// Must be finite and `>= 0`.
711 pub fixed_ticket_cost: f64,
712 /// Final inventory the terminal penalty pulls towards.
713 pub terminal_inventory_target: i64,
714 /// Coefficient of the quadratic penalty on `final inventory - terminal_inventory_target`;
715 /// must be finite and `>= 0`. A soft counterpart of
716 /// [`TradingTrajectorySchema::terminal_inventory`], which is a hard constraint.
717 pub terminal_inventory_penalty: f64,
718}
719
720/// Lists every feasible path of `schema` by depth-first search.
721///
722/// Trades at each step are tried from `min_trade` upwards, so paths come out in
723/// lexicographic order of their trades. Every path is held in memory.
724///
725/// # Errors
726///
727/// - Any error of [`TradingTrajectorySchema::validate`].
728/// - [`CombinatorialOptimizationError::EnumerationLimitExceeded`] if there are more than
729/// `max_paths` feasible paths.
730/// - [`CombinatorialOptimizationError::NoFeasibleSolution`] if no path satisfies the
731/// inventory bounds and the terminal constraint.
732///
733/// ```
734/// use openquant::combinatorial_optimization::{
735/// enumerate_trading_paths, CombinatorialOptimizationError, TradeBounds,
736/// TradingTrajectorySchema,
737/// };
738///
739/// # fn main() -> Result<(), CombinatorialOptimizationError> {
740/// // Two steps, one unit either way, long-only up to 2, flat at both ends.
741/// let mut schema = TradingTrajectorySchema {
742/// initial_inventory: 0,
743/// inventory_min: 0,
744/// inventory_max: 2,
745/// step_trade_bounds: vec![TradeBounds { min_trade: -1, max_trade: 1 }; 2],
746/// terminal_inventory: Some(0),
747/// max_paths: 2,
748/// };
749/// let paths = enumerate_trading_paths(&schema)?;
750/// let trades: Vec<_> = paths.iter().map(|p| p.trades.clone()).collect();
751/// assert_eq!(trades, [vec![0, 0], vec![1, -1]]);
752/// assert_eq!(paths[1].inventory_path, [0, 1, 0]);
753///
754/// schema.max_paths = 1;
755/// assert_eq!(
756/// enumerate_trading_paths(&schema),
757/// Err(CombinatorialOptimizationError::EnumerationLimitExceeded { limit: 1 })
758/// );
759/// # Ok(())
760/// # }
761/// ```
762pub fn enumerate_trading_paths(
763 schema: &TradingTrajectorySchema,
764) -> Result<Vec<TradingTrajectoryPath>, CombinatorialOptimizationError> {
765 schema.validate()?;
766 let mut all_paths = Vec::new();
767 let mut trades = Vec::with_capacity(schema.horizon());
768 let mut inventory_path = Vec::with_capacity(schema.horizon() + 1);
769 inventory_path.push(schema.initial_inventory);
770 dfs_paths(schema, 0, &mut trades, &mut inventory_path, &mut all_paths)?;
771 if all_paths.is_empty() {
772 return Err(CombinatorialOptimizationError::NoFeasibleSolution);
773 }
774 Ok(all_paths)
775}
776
777/// Finds the optimal trading path by scoring every feasible path of `schema`.
778///
779/// Calls [`enumerate_trading_paths`], so memory grows with the number of feasible paths.
780/// The comparison is strict: among tied scores the first path enumerated (the one that
781/// trades lowest first) wins.
782///
783/// # Errors
784///
785/// - Any error of [`enumerate_trading_paths`].
786/// - [`CombinatorialOptimizationError::ObjectiveNotFinite`] if the objective returns NaN or
787/// an infinity for any path.
788/// - Any error returned by [`TradingTrajectoryObjective::evaluate`], unchanged.
789///
790/// ```
791/// use openquant::combinatorial_optimization::{
792/// evaluate_trading_path, solve_trading_trajectory_exact, CombinatorialOptimizationError,
793/// ObjectiveSense, TradeBounds, TradingTrajectoryObjective,
794/// TradingTrajectoryObjectiveConfig, TradingTrajectoryPath, TradingTrajectorySchema,
795/// };
796///
797/// struct NetPnl(TradingTrajectoryObjectiveConfig);
798/// impl TradingTrajectoryObjective for NetPnl {
799/// fn sense(&self) -> ObjectiveSense {
800/// ObjectiveSense::Maximize
801/// }
802/// fn evaluate(
803/// &self,
804/// path: &TradingTrajectoryPath,
805/// ) -> Result<f64, CombinatorialOptimizationError> {
806/// evaluate_trading_path(path, &self.0)
807/// }
808/// }
809///
810/// # fn main() -> Result<(), CombinatorialOptimizationError> {
811/// let schema = TradingTrajectorySchema {
812/// initial_inventory: 0,
813/// inventory_min: 0,
814/// inventory_max: 3,
815/// step_trade_bounds: vec![TradeBounds { min_trade: -3, max_trade: 3 }; 5],
816/// terminal_inventory: Some(0),
817/// max_paths: 10_000,
818/// };
819/// let solve = |fixed_ticket_cost| {
820/// let objective = NetPnl(TradingTrajectoryObjectiveConfig {
821/// expected_returns: vec![0.02, 0.02, -0.01, 0.02, 0.0],
822/// risk_aversion: 0.001,
823/// impact_coefficients: vec![0.002; 5],
824/// fixed_ticket_cost,
825/// terminal_inventory_target: 0,
826/// terminal_inventory_penalty: 0.0,
827/// });
828/// solve_trading_trajectory_exact(&schema, &objective)
829/// };
830///
831/// // Free to trade: sell out ahead of the dip and buy back after it.
832/// let free = solve(0.0)?;
833/// assert_eq!(free.best_path.trades, [3, 0, -3, 3, -3]);
834/// assert_eq!(free.evaluated_paths, 256);
835/// // A fixed ticket cost of 0.03 makes dodging the dip not worth two more tickets.
836/// assert_eq!(solve(0.03)?.best_path.trades, [3, 0, 0, 0, -3]);
837/// # Ok(())
838/// # }
839/// ```
840pub fn solve_trading_trajectory_exact(
841 schema: &TradingTrajectorySchema,
842 objective: &dyn TradingTrajectoryObjective,
843) -> Result<TrajectoryOptimizationResult, CombinatorialOptimizationError> {
844 let paths = enumerate_trading_paths(schema)?;
845 let mut best_idx = None::<usize>;
846 let mut best_value = 0.0;
847 for (idx, path) in paths.iter().enumerate() {
848 let value = objective.evaluate(path)?;
849 if !value.is_finite() {
850 return Err(CombinatorialOptimizationError::ObjectiveNotFinite);
851 }
852 if best_idx.is_none() || is_better(value, best_value, objective.sense()) {
853 best_idx = Some(idx);
854 best_value = value;
855 }
856 }
857 // Defensive: `enumerate_trading_paths` never returns an empty list, so a best path exists.
858 let idx = best_idx.ok_or(CombinatorialOptimizationError::NoFeasibleSolution)?;
859 Ok(TrajectoryOptimizationResult {
860 best_path: paths[idx].clone(),
861 best_objective: best_value,
862 evaluated_paths: paths.len(),
863 })
864}
865
866/// The built-in path objective: expected P&L net of inventory risk, linear impact and a
867/// fixed ticket cost, minus a quadratic terminal penalty.
868///
869/// ```text
870/// J = sum_t ( q_t r_t - lambda q_t^2 - c_t |dq_t| - kappa 1[dq_t != 0] ) - eta (q_T - q*)^2
871/// ```
872///
873/// where `q_t` is the inventory *after* trade `dq_t` at step `t`, `r_t` is
874/// `expected_returns[t]`, `lambda` is `risk_aversion`, `c_t` is `impact_coefficients[t]`,
875/// `kappa` is `fixed_ticket_cost` and `eta` is `terminal_inventory_penalty` around
876/// `q* = terminal_inventory_target`. Larger is better, so wrap it in a
877/// [`TradingTrajectoryObjective`] with [`ObjectiveSense::Maximize`]. The ticket cost `kappa`
878/// is what makes the problem non-convex. Impact is linear in `|dq_t|`, not the square-root
879/// cost of AFML §21.3.
880///
881/// `path` must be self-consistent: `inventory_path[0]` is the starting inventory and each
882/// later entry is the previous one plus that step's trade, as [`enumerate_trading_paths`]
883/// builds them.
884///
885/// # Errors
886///
887/// - [`CombinatorialOptimizationError::InvalidInput`] if `inventory_path.len() !=
888/// trades.len() + 1`, or `risk_aversion`, `fixed_ticket_cost`,
889/// `terminal_inventory_penalty` or an entry of `impact_coefficients` is negative or
890/// non-finite.
891/// - [`CombinatorialOptimizationError::DecisionLengthMismatch`] if `expected_returns` or
892/// `impact_coefficients` does not have one entry per trade.
893/// - [`CombinatorialOptimizationError::InconsistentInventoryPath`] if `inventory_path` is not
894/// the running sum of `trades`.
895/// - [`CombinatorialOptimizationError::ObjectiveNotFinite`] if the result is NaN or infinite.
896///
897/// `final inventory - terminal_inventory_target` is formed in `i128`, so it cannot overflow
898/// for any pair of `i64` values.
899///
900/// ```
901/// use openquant::combinatorial_optimization::{
902/// evaluate_trading_path, TradingTrajectoryObjectiveConfig, TradingTrajectoryPath,
903/// };
904///
905/// // Buy 2, sell 1: inventory 0 -> 2 -> 1.
906/// let path = TradingTrajectoryPath { trades: vec![2, -1], inventory_path: vec![0, 2, 1] };
907/// let cfg = TradingTrajectoryObjectiveConfig {
908/// expected_returns: vec![0.01, 0.02],
909/// risk_aversion: 0.001,
910/// impact_coefficients: vec![0.001, 0.002],
911/// fixed_ticket_cost: 0.005,
912/// terminal_inventory_target: 0,
913/// terminal_inventory_penalty: 0.01,
914/// };
915/// // Step 1: 2 * 0.01 - 0.001 * 4 - 0.001 * 2 - 0.005 = 0.009
916/// // Step 2: 1 * 0.02 - 0.001 * 1 - 0.002 * 1 - 0.005 = 0.012
917/// // Terminal: -0.01 * (1 - 0)^2 = -0.01
918/// let value = evaluate_trading_path(&path, &cfg).unwrap();
919/// assert!((value - 0.011).abs() < 1e-12);
920/// ```
921pub fn evaluate_trading_path(
922 path: &TradingTrajectoryPath,
923 cfg: &TradingTrajectoryObjectiveConfig,
924) -> Result<f64, CombinatorialOptimizationError> {
925 if path.inventory_path.len() != path.trades.len() + 1 {
926 return Err(CombinatorialOptimizationError::InvalidInput(
927 "inventory_path must have exactly trades.len() + 1 entries",
928 ));
929 }
930 if cfg.expected_returns.len() != path.horizon() {
931 return Err(CombinatorialOptimizationError::DecisionLengthMismatch {
932 expected: path.horizon(),
933 found: cfg.expected_returns.len(),
934 });
935 }
936 if cfg.impact_coefficients.len() != path.horizon() {
937 return Err(CombinatorialOptimizationError::DecisionLengthMismatch {
938 expected: path.horizon(),
939 found: cfg.impact_coefficients.len(),
940 });
941 }
942 if !cfg.risk_aversion.is_finite()
943 || !cfg.fixed_ticket_cost.is_finite()
944 || !cfg.terminal_inventory_penalty.is_finite()
945 || cfg.risk_aversion < 0.0
946 || cfg.fixed_ticket_cost < 0.0
947 || cfg.terminal_inventory_penalty < 0.0
948 {
949 return Err(CombinatorialOptimizationError::InvalidInput(
950 "risk/cost coefficients must be finite and >= 0",
951 ));
952 }
953 // A negative impact coefficient would pay the trader to trade.
954 if cfg.impact_coefficients.iter().any(|c| !(c.is_finite() && *c >= 0.0)) {
955 return Err(CombinatorialOptimizationError::InvalidInput(
956 "impact_coefficients must be finite and >= 0",
957 ));
958 }
959 for step in 0..path.horizon() {
960 let expected = path.inventory_path[step].checked_add(path.trades[step]);
961 if expected != Some(path.inventory_path[step + 1]) {
962 return Err(CombinatorialOptimizationError::InconsistentInventoryPath { step });
963 }
964 }
965
966 let mut objective = 0.0;
967 for step in 0..path.horizon() {
968 let trade = path.trades[step] as f64;
969 let inventory_after = path.inventory_path[step + 1] as f64;
970 let step_return = cfg.expected_returns[step];
971 let directional_pnl = inventory_after * step_return;
972 let risk_penalty = cfg.risk_aversion * inventory_after.powi(2);
973 let impact_cost = cfg.impact_coefficients[step] * trade.abs();
974 let fixed_cost = if path.trades[step] == 0 { 0.0 } else { cfg.fixed_ticket_cost };
975 objective += directional_pnl - risk_penalty - impact_cost - fixed_cost;
976 }
977
978 // Widened: the difference of two i64 values can need 65 bits.
979 let terminal_diff =
980 i128::from(path.inventory_path[path.horizon()]) - i128::from(cfg.terminal_inventory_target);
981 objective -= cfg.terminal_inventory_penalty * (terminal_diff as f64).powi(2);
982
983 if !objective.is_finite() {
984 return Err(CombinatorialOptimizationError::ObjectiveNotFinite);
985 }
986 Ok(objective)
987}
988
989fn dfs_paths(
990 schema: &TradingTrajectorySchema,
991 step: usize,
992 trades: &mut Vec<i64>,
993 inventory_path: &mut Vec<i64>,
994 out: &mut Vec<TradingTrajectoryPath>,
995) -> Result<(), CombinatorialOptimizationError> {
996 if step == schema.horizon() {
997 let inventory_final = *inventory_path.last().unwrap_or(&schema.initial_inventory);
998 if schema.terminal_inventory.is_some_and(|required| required != inventory_final) {
999 return Ok(());
1000 }
1001 if out.len() == schema.max_paths {
1002 return Err(CombinatorialOptimizationError::EnumerationLimitExceeded {
1003 limit: schema.max_paths,
1004 });
1005 }
1006 out.push(TradingTrajectoryPath {
1007 trades: trades.clone(),
1008 inventory_path: inventory_path.clone(),
1009 });
1010 return Ok(());
1011 }
1012
1013 let bounds = schema.step_trade_bounds[step];
1014 for trade in bounds.min_trade..=bounds.max_trade {
1015 let current = *inventory_path.last().unwrap_or(&schema.initial_inventory);
1016 let next = match current.checked_add(trade) {
1017 Some(v) => v,
1018 None => continue,
1019 };
1020 if next < schema.inventory_min || next > schema.inventory_max {
1021 continue;
1022 }
1023 trades.push(trade);
1024 inventory_path.push(next);
1025 dfs_paths(schema, step + 1, trades, inventory_path, out)?;
1026 inventory_path.pop();
1027 trades.pop();
1028 }
1029 Ok(())
1030}
1031
1032fn enumerate_decisions(
1033 values: &[Vec<i64>],
1034 depth: usize,
1035 current: &mut [i64],
1036 visit: &mut dyn FnMut(&[i64]) -> Result<(), CombinatorialOptimizationError>,
1037) -> Result<(), CombinatorialOptimizationError> {
1038 if depth == values.len() {
1039 return visit(current);
1040 }
1041 for value in &values[depth] {
1042 current[depth] = *value;
1043 enumerate_decisions(values, depth + 1, current, visit)?;
1044 }
1045 Ok(())
1046}
1047
1048fn is_better(candidate: f64, incumbent: f64, sense: ObjectiveSense) -> bool {
1049 match sense {
1050 ObjectiveSense::Maximize => candidate > incumbent,
1051 ObjectiveSense::Minimize => candidate < incumbent,
1052 }
1053}