Skip to main content

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}