diff --git a/crates/openquant/src/combinatorial_optimization.rs b/crates/openquant/src/combinatorial_optimization.rs new file mode 100644 index 0000000..41f36b3 --- /dev/null +++ b/crates/openquant/src/combinatorial_optimization.rs @@ -0,0 +1,484 @@ +//! AFML Chapter 21: brute-force and combinatorial optimization adapters. +//! +//! This module provides integer decision schemas, exact finite-set solvers, +//! adapter traits for heuristic/external solvers, and trading-trajectory +//! state-space utilities with path-dependent objective evaluation. + +use std::fmt::{Display, Formatter}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObjectiveSense { + Minimize, + Maximize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CombinatorialOptimizationError { + InvalidInput(&'static str), + DecisionLengthMismatch { expected: usize, found: usize }, + ObjectiveNotFinite, + EmptyDomain, + EnumerationLimitExceeded { limit: usize }, + NoFeasibleSolution, +} + +impl Display for CombinatorialOptimizationError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidInput(msg) => write!(f, "invalid input: {msg}"), + Self::DecisionLengthMismatch { expected, found } => { + write!(f, "decision length mismatch: expected {expected}, found {found}") + } + Self::ObjectiveNotFinite => write!(f, "objective evaluation returned non-finite value"), + Self::EmptyDomain => write!(f, "decision domain is empty"), + Self::EnumerationLimitExceeded { limit } => { + write!(f, "enumeration limit exceeded: more than {limit} candidates") + } + Self::NoFeasibleSolution => write!(f, "no feasible solution found"), + } + } +} + +impl std::error::Error for CombinatorialOptimizationError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IntegerVariable { + pub lower: i64, + pub upper: i64, + pub step: i64, +} + +impl IntegerVariable { + fn validate(self) -> Result<(), CombinatorialOptimizationError> { + if self.step <= 0 { + return Err(CombinatorialOptimizationError::InvalidInput("step must be > 0")); + } + if self.lower > self.upper { + return Err(CombinatorialOptimizationError::InvalidInput( + "variable lower bound must be <= upper bound", + )); + } + Ok(()) + } + + fn cardinality(self) -> Result { + self.validate()?; + let span = i128::from(self.upper) - i128::from(self.lower); + let step = i128::from(self.step); + let count = span / step + 1; + usize::try_from(count).map_err(|_| { + CombinatorialOptimizationError::InvalidInput("variable cardinality does not fit usize") + }) + } + + fn values(self) -> Result, CombinatorialOptimizationError> { + self.validate()?; + let mut out = Vec::with_capacity(self.cardinality()?); + let mut current = self.lower; + while current <= self.upper { + out.push(current); + current = match current.checked_add(self.step) { + Some(next) => next, + None => break, + }; + } + if out.is_empty() { + return Err(CombinatorialOptimizationError::EmptyDomain); + } + Ok(out) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecisionSchema { + pub variables: Vec, + /// Hard cap for exact finite-set enumeration. + pub max_enumeration: usize, +} + +impl DecisionSchema { + pub fn validate(&self) -> Result<(), CombinatorialOptimizationError> { + if self.variables.is_empty() { + return Err(CombinatorialOptimizationError::EmptyDomain); + } + if self.max_enumeration == 0 { + return Err(CombinatorialOptimizationError::InvalidInput( + "max_enumeration must be > 0", + )); + } + for var in &self.variables { + var.validate()?; + } + let size = self.decision_space_size()?; + if size > self.max_enumeration { + return Err(CombinatorialOptimizationError::EnumerationLimitExceeded { + limit: self.max_enumeration, + }); + } + Ok(()) + } + + pub fn decision_space_size(&self) -> Result { + if self.variables.is_empty() { + return Err(CombinatorialOptimizationError::EmptyDomain); + } + self.variables.iter().try_fold(1usize, |acc, var| { + let n = var.cardinality()?; + acc.checked_mul(n).ok_or(CombinatorialOptimizationError::InvalidInput( + "decision space cardinality overflowed usize", + )) + }) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct OptimizationResult { + pub best_decision: Vec, + pub best_objective: f64, + pub evaluated_candidates: usize, +} + +pub trait IntegerObjective { + fn sense(&self) -> ObjectiveSense; + fn evaluate(&self, decision: &[i64]) -> Result; +} + +pub trait SolverAdapter { + fn solve( + &self, + schema: &DecisionSchema, + objective: &dyn IntegerObjective, + ) -> Result; +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AdapterComparison { + pub exact: OptimizationResult, + pub adapter: OptimizationResult, + /// Non-negative gap in objective space relative to the exact optimum. + pub objective_gap_vs_exact: f64, +} + +pub fn solve_exact( + schema: &DecisionSchema, + objective: &dyn IntegerObjective, +) -> Result { + schema.validate()?; + let values = schema + .variables + .iter() + .copied() + .map(IntegerVariable::values) + .collect::, _>>()?; + if values.iter().any(Vec::is_empty) { + return Err(CombinatorialOptimizationError::EmptyDomain); + } + + let mut current = vec![0_i64; schema.variables.len()]; + let mut best_decision: Option> = None; + let mut best_objective = 0.0; + let mut evaluated = 0usize; + + enumerate_decisions(&values, 0, &mut current, &mut |decision| { + let value = objective.evaluate(decision)?; + if !value.is_finite() { + return Err(CombinatorialOptimizationError::ObjectiveNotFinite); + } + if best_decision.is_none() || is_better(value, best_objective, objective.sense()) { + best_decision = Some(decision.to_vec()); + best_objective = value; + } + evaluated = evaluated.saturating_add(1); + Ok(()) + })?; + + let best_decision = best_decision.ok_or(CombinatorialOptimizationError::NoFeasibleSolution)?; + Ok(OptimizationResult { best_decision, best_objective, evaluated_candidates: evaluated }) +} + +pub fn solve_with_adapter( + schema: &DecisionSchema, + objective: &dyn IntegerObjective, + adapter: &dyn SolverAdapter, +) -> Result { + schema.validate()?; + adapter.solve(schema, objective) +} + +pub fn compare_exact_and_adapter( + schema: &DecisionSchema, + objective: &dyn IntegerObjective, + adapter: &dyn SolverAdapter, +) -> Result { + let exact = solve_exact(schema, objective)?; + let adapter_result = solve_with_adapter(schema, objective, adapter)?; + let gap = match objective.sense() { + ObjectiveSense::Maximize => (exact.best_objective - adapter_result.best_objective).max(0.0), + ObjectiveSense::Minimize => (adapter_result.best_objective - exact.best_objective).max(0.0), + }; + Ok(AdapterComparison { exact, adapter: adapter_result, objective_gap_vs_exact: gap }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TradeBounds { + pub min_trade: i64, + pub max_trade: i64, +} + +impl TradeBounds { + fn validate(self) -> Result<(), CombinatorialOptimizationError> { + if self.min_trade > self.max_trade { + return Err(CombinatorialOptimizationError::InvalidInput( + "trade bounds must satisfy min_trade <= max_trade", + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TradingTrajectorySchema { + pub initial_inventory: i64, + pub inventory_min: i64, + pub inventory_max: i64, + pub step_trade_bounds: Vec, + pub terminal_inventory: Option, + pub max_paths: usize, +} + +impl TradingTrajectorySchema { + pub fn validate(&self) -> Result<(), CombinatorialOptimizationError> { + if self.inventory_min > self.inventory_max { + return Err(CombinatorialOptimizationError::InvalidInput( + "inventory_min must be <= inventory_max", + )); + } + if self.initial_inventory < self.inventory_min + || self.initial_inventory > self.inventory_max + { + return Err(CombinatorialOptimizationError::InvalidInput( + "initial_inventory must be inside [inventory_min, inventory_max]", + )); + } + if self.max_paths == 0 { + return Err(CombinatorialOptimizationError::InvalidInput("max_paths must be > 0")); + } + if self.step_trade_bounds.is_empty() { + return Err(CombinatorialOptimizationError::EmptyDomain); + } + for bounds in &self.step_trade_bounds { + bounds.validate()?; + } + if let Some(term) = self.terminal_inventory { + if term < self.inventory_min || term > self.inventory_max { + return Err(CombinatorialOptimizationError::InvalidInput( + "terminal_inventory must be inside [inventory_min, inventory_max]", + )); + } + } + Ok(()) + } + + pub fn horizon(&self) -> usize { + self.step_trade_bounds.len() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TradingTrajectoryPath { + pub trades: Vec, + /// Inventory path includes initial inventory at index 0. + pub inventory_path: Vec, +} + +impl TradingTrajectoryPath { + pub fn horizon(&self) -> usize { + self.trades.len() + } +} + +pub trait TradingTrajectoryObjective { + fn sense(&self) -> ObjectiveSense; + fn evaluate(&self, path: &TradingTrajectoryPath) + -> Result; +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TrajectoryOptimizationResult { + pub best_path: TradingTrajectoryPath, + pub best_objective: f64, + pub evaluated_paths: usize, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TradingTrajectoryObjectiveConfig { + pub expected_returns: Vec, + /// Penalty on held inventory each step (path-dependent risk proxy). + pub risk_aversion: f64, + /// Per-step linear impact coefficient applied to |trade|. + pub impact_coefficients: Vec, + /// Fixed per-step ticket cost whenever trade != 0 (non-convex). + pub fixed_ticket_cost: f64, + pub terminal_inventory_target: i64, + pub terminal_inventory_penalty: f64, +} + +pub fn enumerate_trading_paths( + schema: &TradingTrajectorySchema, +) -> Result, CombinatorialOptimizationError> { + schema.validate()?; + let mut all_paths = Vec::new(); + let mut trades = Vec::with_capacity(schema.horizon()); + let mut inventory_path = Vec::with_capacity(schema.horizon() + 1); + inventory_path.push(schema.initial_inventory); + dfs_paths(schema, 0, &mut trades, &mut inventory_path, &mut all_paths)?; + if all_paths.is_empty() { + return Err(CombinatorialOptimizationError::NoFeasibleSolution); + } + Ok(all_paths) +} + +pub fn solve_trading_trajectory_exact( + schema: &TradingTrajectorySchema, + objective: &dyn TradingTrajectoryObjective, +) -> Result { + let paths = enumerate_trading_paths(schema)?; + let mut best_idx = None::; + let mut best_value = 0.0; + for (idx, path) in paths.iter().enumerate() { + let value = objective.evaluate(path)?; + if !value.is_finite() { + return Err(CombinatorialOptimizationError::ObjectiveNotFinite); + } + if best_idx.is_none() || is_better(value, best_value, objective.sense()) { + best_idx = Some(idx); + best_value = value; + } + } + let idx = best_idx.ok_or(CombinatorialOptimizationError::NoFeasibleSolution)?; + Ok(TrajectoryOptimizationResult { + best_path: paths[idx].clone(), + best_objective: best_value, + evaluated_paths: paths.len(), + }) +} + +pub fn evaluate_trading_path( + path: &TradingTrajectoryPath, + cfg: &TradingTrajectoryObjectiveConfig, +) -> Result { + if path.inventory_path.len() != path.trades.len() + 1 { + return Err(CombinatorialOptimizationError::InvalidInput( + "inventory_path must have exactly trades.len() + 1 entries", + )); + } + if cfg.expected_returns.len() != path.horizon() { + return Err(CombinatorialOptimizationError::DecisionLengthMismatch { + expected: path.horizon(), + found: cfg.expected_returns.len(), + }); + } + if cfg.impact_coefficients.len() != path.horizon() { + return Err(CombinatorialOptimizationError::DecisionLengthMismatch { + expected: path.horizon(), + found: cfg.impact_coefficients.len(), + }); + } + if !cfg.risk_aversion.is_finite() + || !cfg.fixed_ticket_cost.is_finite() + || !cfg.terminal_inventory_penalty.is_finite() + || cfg.risk_aversion < 0.0 + || cfg.fixed_ticket_cost < 0.0 + || cfg.terminal_inventory_penalty < 0.0 + { + return Err(CombinatorialOptimizationError::InvalidInput( + "risk/cost coefficients must be finite and >= 0", + )); + } + + let mut objective = 0.0; + for step in 0..path.horizon() { + let trade = path.trades[step] as f64; + let inventory_after = path.inventory_path[step + 1] as f64; + let step_return = cfg.expected_returns[step]; + let directional_pnl = inventory_after * step_return; + let risk_penalty = cfg.risk_aversion * inventory_after.powi(2); + let impact_cost = cfg.impact_coefficients[step] * trade.abs(); + let fixed_cost = if path.trades[step] == 0 { 0.0 } else { cfg.fixed_ticket_cost }; + objective += directional_pnl - risk_penalty - impact_cost - fixed_cost; + } + + let terminal_diff = path.inventory_path[path.horizon()] - cfg.terminal_inventory_target; + objective -= cfg.terminal_inventory_penalty * (terminal_diff as f64).powi(2); + + if !objective.is_finite() { + return Err(CombinatorialOptimizationError::ObjectiveNotFinite); + } + Ok(objective) +} + +fn dfs_paths( + schema: &TradingTrajectorySchema, + step: usize, + trades: &mut Vec, + inventory_path: &mut Vec, + out: &mut Vec, +) -> Result<(), CombinatorialOptimizationError> { + if out.len() >= schema.max_paths { + return Err(CombinatorialOptimizationError::EnumerationLimitExceeded { + limit: schema.max_paths, + }); + } + if step == schema.horizon() { + let inventory_final = *inventory_path.last().unwrap_or(&schema.initial_inventory); + if schema.terminal_inventory.is_some_and(|required| required != inventory_final) { + return Ok(()); + } + out.push(TradingTrajectoryPath { + trades: trades.clone(), + inventory_path: inventory_path.clone(), + }); + return Ok(()); + } + + let bounds = schema.step_trade_bounds[step]; + for trade in bounds.min_trade..=bounds.max_trade { + let current = *inventory_path.last().unwrap_or(&schema.initial_inventory); + let next = match current.checked_add(trade) { + Some(v) => v, + None => continue, + }; + if next < schema.inventory_min || next > schema.inventory_max { + continue; + } + trades.push(trade); + inventory_path.push(next); + dfs_paths(schema, step + 1, trades, inventory_path, out)?; + inventory_path.pop(); + trades.pop(); + } + Ok(()) +} + +fn enumerate_decisions( + values: &[Vec], + depth: usize, + current: &mut [i64], + visit: &mut dyn FnMut(&[i64]) -> Result<(), CombinatorialOptimizationError>, +) -> Result<(), CombinatorialOptimizationError> { + if depth == values.len() { + return visit(current); + } + for value in &values[depth] { + current[depth] = *value; + enumerate_decisions(values, depth + 1, current, visit)?; + } + Ok(()) +} + +fn is_better(candidate: f64, incumbent: f64, sense: ObjectiveSense) -> bool { + match sense { + ObjectiveSense::Maximize => candidate > incumbent, + ObjectiveSense::Minimize => candidate < incumbent, + } +} diff --git a/crates/openquant/src/lib.rs b/crates/openquant/src/lib.rs index 4b2dd6c..54e2acc 100644 --- a/crates/openquant/src/lib.rs +++ b/crates/openquant/src/lib.rs @@ -3,6 +3,7 @@ pub mod backtesting_engine; pub mod bet_sizing; pub mod cla; pub mod codependence; +pub mod combinatorial_optimization; pub mod cross_validation; pub mod data_structures; pub mod ef3m; diff --git a/crates/openquant/tests/combinatorial_optimization.rs b/crates/openquant/tests/combinatorial_optimization.rs new file mode 100644 index 0000000..ea3bda8 --- /dev/null +++ b/crates/openquant/tests/combinatorial_optimization.rs @@ -0,0 +1,180 @@ +use openquant::combinatorial_optimization::{ + compare_exact_and_adapter, enumerate_trading_paths, evaluate_trading_path, solve_exact, + solve_trading_trajectory_exact, CombinatorialOptimizationError, DecisionSchema, + IntegerObjective, IntegerVariable, ObjectiveSense, OptimizationResult, SolverAdapter, + TradeBounds, TradingTrajectoryObjective, TradingTrajectoryObjectiveConfig, + TradingTrajectoryPath, TradingTrajectorySchema, +}; + +struct NonConvexIntegerObjective; + +impl IntegerObjective for NonConvexIntegerObjective { + fn sense(&self) -> ObjectiveSense { + ObjectiveSense::Maximize + } + + fn evaluate(&self, decision: &[i64]) -> Result { + if decision.len() != 2 { + return Err(CombinatorialOptimizationError::DecisionLengthMismatch { + expected: 2, + found: decision.len(), + }); + } + let x = decision[0] as f64; + let y = decision[1] as f64; + let smooth = -((x - 1.0).powi(2) + (y + 1.0).powi(2)); + let fixed = + if decision[0] != 0 { 1.5 } else { 0.0 } + if decision[1] != 0 { 0.5 } else { 0.0 }; + let discrete_bonus = if decision[0] * decision[1] == -1 { 3.0 } else { 0.0 }; + Ok(smooth - fixed + discrete_bonus) + } +} + +struct ZeroVectorAdapter; + +impl SolverAdapter for ZeroVectorAdapter { + fn solve( + &self, + schema: &DecisionSchema, + objective: &dyn IntegerObjective, + ) -> Result { + schema.validate()?; + let decision = vec![0_i64; schema.variables.len()]; + let value = objective.evaluate(&decision)?; + Ok(OptimizationResult { + best_decision: decision, + best_objective: value, + evaluated_candidates: 1, + }) + } +} + +struct TrajectoryObjective { + cfg: TradingTrajectoryObjectiveConfig, +} + +impl TradingTrajectoryObjective for TrajectoryObjective { + fn sense(&self) -> ObjectiveSense { + ObjectiveSense::Maximize + } + + fn evaluate( + &self, + path: &TradingTrajectoryPath, + ) -> Result { + evaluate_trading_path(path, &self.cfg) + } +} + +#[test] +fn exact_solver_finds_best_non_convex_integer_solution() { + let schema = DecisionSchema { + variables: vec![ + IntegerVariable { lower: -2, upper: 2, step: 1 }, + IntegerVariable { lower: -2, upper: 2, step: 1 }, + ], + max_enumeration: 100, + }; + let objective = NonConvexIntegerObjective; + let result = solve_exact(&schema, &objective).expect("exact solve should succeed"); + + assert_eq!(result.best_decision, vec![1, -1]); + assert_eq!(result.evaluated_candidates, 25); + assert!((result.best_objective - 1.0).abs() < 1e-12); +} + +#[test] +fn adapter_comparison_reports_gap_vs_exact() { + let schema = DecisionSchema { + variables: vec![ + IntegerVariable { lower: -2, upper: 2, step: 1 }, + IntegerVariable { lower: -2, upper: 2, step: 1 }, + ], + max_enumeration: 100, + }; + let objective = NonConvexIntegerObjective; + let adapter = ZeroVectorAdapter; + let comparison = + compare_exact_and_adapter(&schema, &objective, &adapter).expect("comparison should run"); + + assert_eq!(comparison.exact.best_decision, vec![1, -1]); + assert_eq!(comparison.adapter.best_decision, vec![0, 0]); + assert!(comparison.objective_gap_vs_exact > 0.0); +} + +#[test] +fn trajectory_objective_captures_non_convex_ticket_cost() { + let path = TradingTrajectoryPath { trades: vec![1, -1, 0], inventory_path: vec![0, 1, 0, 0] }; + let mut cfg = TradingTrajectoryObjectiveConfig { + expected_returns: vec![0.01, -0.02, 0.015], + risk_aversion: 0.001, + impact_coefficients: vec![0.0005, 0.0005, 0.0005], + fixed_ticket_cost: 0.002, + terminal_inventory_target: 0, + terminal_inventory_penalty: 0.1, + }; + + let with_ticket = evaluate_trading_path(&path, &cfg).expect("evaluation should succeed"); + cfg.fixed_ticket_cost = 0.0; + let without_ticket = evaluate_trading_path(&path, &cfg).expect("evaluation should succeed"); + + assert!(without_ticket > with_ticket); + assert!(((without_ticket - with_ticket) - 0.004).abs() < 1e-12); +} + +#[test] +fn trajectory_enumeration_and_exact_solver_match_manual_best() { + let schema = TradingTrajectorySchema { + initial_inventory: 0, + inventory_min: -2, + inventory_max: 2, + step_trade_bounds: vec![ + TradeBounds { min_trade: -1, max_trade: 1 }, + TradeBounds { min_trade: -1, max_trade: 1 }, + TradeBounds { min_trade: -1, max_trade: 1 }, + TradeBounds { min_trade: -1, max_trade: 1 }, + ], + terminal_inventory: Some(0), + max_paths: 10_000, + }; + let cfg = TradingTrajectoryObjectiveConfig { + expected_returns: vec![0.015, -0.01, 0.02, -0.005], + risk_aversion: 0.001, + impact_coefficients: vec![0.0005, 0.001, 0.0005, 0.001], + fixed_ticket_cost: 0.0015, + terminal_inventory_target: 0, + terminal_inventory_penalty: 0.05, + }; + let objective = TrajectoryObjective { cfg: cfg.clone() }; + let paths = enumerate_trading_paths(&schema).expect("path enumeration should succeed"); + let manual_best = paths + .iter() + .map(|path| evaluate_trading_path(path, &cfg).expect("manual objective should evaluate")) + .fold(f64::NEG_INFINITY, f64::max); + + let result = + solve_trading_trajectory_exact(&schema, &objective).expect("exact solve should succeed"); + + assert_eq!(result.evaluated_paths, paths.len()); + assert_eq!(result.best_path.inventory_path.first().copied(), Some(schema.initial_inventory)); + assert_eq!(result.best_path.inventory_path.last().copied(), schema.terminal_inventory); + assert!((result.best_objective - manual_best).abs() < 1e-12); +} + +#[test] +fn trajectory_path_limit_guard_triggers() { + let schema = TradingTrajectorySchema { + initial_inventory: 0, + inventory_min: -2, + inventory_max: 2, + step_trade_bounds: vec![ + TradeBounds { min_trade: -1, max_trade: 1 }, + TradeBounds { min_trade: -1, max_trade: 1 }, + TradeBounds { min_trade: -1, max_trade: 1 }, + ], + terminal_inventory: None, + max_paths: 5, + }; + let err = enumerate_trading_paths(&schema).expect_err("limit should be exceeded"); + assert!(matches!(err, CombinatorialOptimizationError::EnumerationLimitExceeded { limit: 5 })); +} diff --git a/docs-site/src/data/afmlDocsState.ts b/docs-site/src/data/afmlDocsState.ts index 559bc06..d91f4ca 100644 --- a/docs-site/src/data/afmlDocsState.ts +++ b/docs-site/src/data/afmlDocsState.ts @@ -345,6 +345,20 @@ export const afmlDocsState = { "status": "done" } ] + }, + { + "chapter": "CHAPTER 21", + "theme": "Brute force and quantum computers", + "status": "done", + "chunkCount": 0, + "sections": [ + { + "id": "chapter-21-combinatorial_optimization", + "module": "combinatorial_optimization", + "slug": "combinatorial-optimization", + "status": "done" + } + ] } ] } as const; diff --git a/docs-site/src/data/moduleDocs.ts b/docs-site/src/data/moduleDocs.ts index 624a5f2..b66e7d2 100644 --- a/docs-site/src/data/moduleDocs.ts +++ b/docs-site/src/data/moduleDocs.ts @@ -734,6 +734,55 @@ export const moduleDocs: ModuleDoc[] = [ "If per-atom cost rises with atom index (e.g., expanding windows), nested partitioning can reduce tail stragglers versus linear chunking.", ], }, + { + slug: "combinatorial-optimization", + module: "combinatorial_optimization", + subject: "Scaling, HPC and Infrastructure", + summary: + "AFML Chapter 21 integer-encoded optimization and trajectory state-space tooling with exact baselines and solver adapters.", + whyItExists: + "Many trading/search problems are discrete and path-dependent; this module keeps integer structure explicit and provides exact small-instance baselines before scaling to heuristics.", + keyApis: [ + "DecisionSchema", + "IntegerVariable", + "IntegerObjective", + "solve_exact", + "SolverAdapter", + "solve_with_adapter", + "compare_exact_and_adapter", + "TradingTrajectorySchema", + "enumerate_trading_paths", + "evaluate_trading_path", + "solve_trading_trajectory_exact", + ], + formulas: [ + { + label: "Finite Integer Program", + latex: "x^*=\\arg\\max_{x\\in\\mathcal X\\subset\\mathbb Z^d} f(x),\\quad |\\mathcal X|<\\infty", + }, + { + label: "Path-Dependent Objective", + latex: + "J(\\tau)=\\sum_{t=1}^{T}\\left(q_t r_t-\\lambda q_t^2-c_t|\\Delta q_t|-\\kappa\\,\\mathbf 1_{\\Delta q_t\\ne0}\\right)-\\eta(q_T-q^*)^2", + }, + { + label: "Adapter Gap vs Exact", + latex: + "\\Delta_{alg}=\\begin{cases}f(x^*)-f(\\hat x) & \\text{maximize}\\\\f(\\hat x)-f(x^*) & \\text{minimize}\\end{cases}", + }, + ], + examples: [ + { + title: "Exact trajectory search with fixed ticket costs", + language: "rust", + code: `use openquant::combinatorial_optimization::{\n TradeBounds, TradingTrajectoryObjectiveConfig, TradingTrajectoryPath, TradingTrajectorySchema,\n enumerate_trading_paths, evaluate_trading_path,\n};\n\nlet schema = TradingTrajectorySchema {\n initial_inventory: 0,\n inventory_min: -2,\n inventory_max: 2,\n step_trade_bounds: vec![\n TradeBounds { min_trade: -1, max_trade: 1 },\n TradeBounds { min_trade: -1, max_trade: 1 },\n TradeBounds { min_trade: -1, max_trade: 1 },\n ],\n terminal_inventory: Some(0),\n max_paths: 50_000,\n};\nlet cfg = TradingTrajectoryObjectiveConfig {\n expected_returns: vec![0.01, -0.015, 0.012],\n risk_aversion: 0.001,\n impact_coefficients: vec![0.0005, 0.0005, 0.0005],\n fixed_ticket_cost: 0.002,\n terminal_inventory_target: 0,\n terminal_inventory_penalty: 0.05,\n};\n\nlet best = enumerate_trading_paths(&schema)?\n .into_iter()\n .map(|path| {\n let score = evaluate_trading_path(&path, &cfg)?;\n Ok::<(TradingTrajectoryPath, f64), openquant::combinatorial_optimization::CombinatorialOptimizationError>((path, score))\n })\n .collect::, _>>()?\n .into_iter()\n .max_by(|a, b| a.1.total_cmp(&b.1))\n .expect(\"at least one feasible path\");\n\nprintln!(\"best objective: {:.6}\", best.1);\nprintln!(\"trades: {:?}\", best.0.trades);`, + }, + ], + notes: [ + "Exact enumeration scales exponentially in decision dimension/horizon; treat it as a correctness baseline and regression oracle.", + "Use adapter interfaces to compare heuristic/external solvers against exact solutions on small calibration instances before production deployment.", + ], + }, { slug: "sample-weights", module: "sample_weights",