Skip to main content

ledge_core/
sequence.rs

1//! Rolling rebalance sequences over a fixed portfolio structure (roadmap 2.5).
2//!
3//! A [`PortfolioSequence`] is the high-level entry point for the workload
4//! Ledge is built around: solve the same factor portfolio every date with new
5//! expected returns, a new turnover anchor, or new right-hand sides. It wraps
6//! a [`Workspace`](crate::Workspace) — the equilibration and the SMW-reduced
7//! factorizations are built once and reused — and chains full primal/dual
8//! warm starts from one solve into the next automatically.
9//!
10//! Per-date data changes are described by a [`RebalanceStep`]. Only data that
11//! preserves the cached factorizations may change: expected returns, the
12//! previous-weights turnover anchor, and the tracking benchmark move the
13//! linear cost, and budget / right-hand-side updates move constraint
14//! targets. Structural changes
15//! (covariance, constraint matrices, bounds, the turnover penalty itself)
16//! require a new sequence, because they change the factored system.
17//!
18//! Steps are applied atomically: a rejected step leaves the sequence exactly
19//! as it was, so a caller can drop one bad date and keep rolling.
20//!
21//! ```
22//! use ledge_core::{FactorCovariance, Matrix, PortfolioProblem, RebalanceStep};
23//!
24//! let problem = PortfolioProblem::new(
25//!     Matrix::new(3, 1, vec![1.0, -0.5, 0.25])?,
26//!     FactorCovariance::Diagonal(vec![0.1]),
27//!     vec![0.2, 0.3, 0.25],
28//!     vec![0.08, 0.04, 0.06],
29//! )?;
30//! let mut sequence = problem.sequence()?;
31//! let first = sequence.solve_next(&RebalanceStep::default())?;
32//! let second = sequence.solve_next(&RebalanceStep {
33//!     expected_returns: Some(vec![0.07, 0.05, 0.06]),
34//!     ..RebalanceStep::default()
35//! })?;
36//! assert_eq!(second.status, ledge_core::SolveStatus::Solved);
37//! # let _ = first;
38//! # Ok::<(), ledge_core::PortfolioError>(())
39//! ```
40
41use crate::{
42    portfolio::{
43        append_certificate_hints, validate_vector, FinitePolicy, PortfolioError, PortfolioProblem,
44        PortfolioSemantics,
45    },
46    problem::{FactorQuad, ProblemError},
47    solver::{Solution, SolveStatus, Solver, SolverSettings, WarmStart},
48    workspace::Workspace,
49};
50
51/// Per-date data changes for one [`PortfolioSequence::solve_next`] call.
52///
53/// Every field is optional; `None` keeps the current value. The default value
54/// changes nothing, so `RebalanceStep::default()` re-solves the current data
55/// (useful for the first solve of a sequence).
56///
57/// Only factorization-preserving updates are expressible. Anything structural
58/// — covariance, constraint matrices, bounds, the turnover penalty — needs a
59/// new [`PortfolioProblem`] and a new sequence.
60#[derive(Clone, Debug, Default, PartialEq)]
61pub struct RebalanceStep {
62    /// New expected returns (length must match the number of assets).
63    pub expected_returns: Option<Vec<f64>>,
64    /// New turnover anchor. Requires the base problem to have been built
65    /// with [`PortfolioProblem::with_quadratic_turnover`] and/or
66    /// [`PortfolioProblem::with_l1_turnover`]; both terms share this single
67    /// anchor. The L2 penalty and the L1 costs themselves stay fixed for
68    /// the whole sequence.
69    pub previous_weights: Option<Vec<f64>>,
70    /// New tracking benchmark. Requires the base problem to have been built
71    /// with [`PortfolioProblem::with_tracking_benchmark`]. The benchmark
72    /// only shifts the linear cost, so cached factorizations survive.
73    pub benchmark_weights: Option<Vec<f64>>,
74    /// New budget. Requires the base problem to have a budget constraint.
75    pub budget: Option<f64>,
76    /// New right-hand side for the user-supplied equality constraints
77    /// (excluding the budget row).
78    pub equality_rhs: Option<Vec<f64>>,
79    /// New right-hand side for the inequality constraints.
80    pub inequality_rhs: Option<Vec<f64>>,
81}
82
83impl RebalanceStep {
84    const fn is_empty(&self) -> bool {
85        self.expected_returns.is_none()
86            && self.previous_weights.is_none()
87            && self.benchmark_weights.is_none()
88            && self.budget.is_none()
89            && self.equality_rhs.is_none()
90            && self.inequality_rhs.is_none()
91    }
92}
93
94/// A rolling solve sequence over a fixed portfolio structure.
95///
96/// Built by [`PortfolioProblem::sequence`] or
97/// [`PortfolioProblem::sequence_with`]. Owns a
98/// [`Workspace`](crate::Workspace), so equilibration and SMW-reduced
99/// factorizations persist across dates, and manages warm starts internally:
100/// each solve starts from the previous solution's full primal/dual iterate.
101///
102/// [`Solution::solve_time`] on sequence solves covers iteration only; the
103/// one-time setup was paid when the sequence was constructed.
104pub struct PortfolioSequence {
105    workspace: Workspace,
106    expected_returns: Vec<f64>,
107    previous_weights: Option<Vec<f64>>,
108    turnover_penalty: f64,
109    has_l1_turnover: bool,
110    /// Needed to recompute the linear-cost shift `-risk_aversion * Σ b`
111    /// when a step moves the tracking benchmark.
112    covariance: FactorQuad,
113    risk_aversion: f64,
114    benchmark_weights: Option<Vec<f64>>,
115    budget: Option<f64>,
116    user_equality_rhs: Vec<f64>,
117    /// Bounds are fixed for the life of the sequence, so budget reachability
118    /// reduces to two precomputed sums.
119    minimum_budget: f64,
120    maximum_budget: f64,
121    warm_start: Option<WarmStart>,
122}
123
124impl PortfolioSequence {
125    pub(crate) fn new(problem: &PortfolioProblem, solver: &Solver) -> Result<Self, PortfolioError> {
126        let qp = problem.to_qp()?;
127        let workspace = solver.workspace(&qp)?;
128        Ok(Self {
129            workspace,
130            expected_returns: problem.expected_returns().to_vec(),
131            previous_weights: problem.previous_weights().map(<[f64]>::to_vec),
132            turnover_penalty: problem.turnover_penalty(),
133            has_l1_turnover: problem.has_l1_turnover(),
134            covariance: problem.covariance().clone(),
135            risk_aversion: problem.risk_aversion(),
136            benchmark_weights: problem.benchmark_weights().map(<[f64]>::to_vec),
137            budget: problem.budget(),
138            user_equality_rhs: problem.user_equality_rhs().to_vec(),
139            minimum_budget: problem.lower_bounds().iter().sum(),
140            maximum_budget: problem.upper_bounds().iter().sum(),
141            warm_start: None,
142        })
143    }
144
145    /// Number of portfolio weights.
146    #[must_use]
147    pub fn dimension(&self) -> usize {
148        self.expected_returns.len()
149    }
150
151    /// Settings every solve in this sequence iterates with.
152    #[must_use]
153    pub const fn settings(&self) -> &SolverSettings {
154        self.workspace.settings()
155    }
156
157    /// Number of reduced factorizations built since construction (including
158    /// the initial one). A stable count across rolling dates demonstrates
159    /// factorization reuse.
160    #[must_use]
161    pub const fn factorizations(&self) -> usize {
162        self.workspace.factorizations()
163    }
164
165    /// Applies one step's data changes, then solves, warm-started from the
166    /// previous solution.
167    ///
168    /// The step is validated in full before any state changes, so an `Err`
169    /// leaves the sequence exactly as it was. The first solve of a sequence
170    /// is cold; every later solve chains the previous full primal/dual
171    /// iterate (unless the previous solve failed numerically, which would
172    /// make a non-finite start).
173    ///
174    /// # Errors
175    ///
176    /// Returns [`PortfolioError`] when the step data has wrong dimensions or
177    /// non-finite values, updates a constraint the base problem does not
178    /// have (budget / turnover anchor), or moves the budget outside what the
179    /// box constraints can reach.
180    pub fn solve_next(&mut self, step: &RebalanceStep) -> Result<Solution, PortfolioError> {
181        self.apply(step)?;
182        let mut solution = self.workspace.solve(self.warm_start.as_ref())?;
183        append_certificate_hints(
184            &mut solution,
185            &PortfolioSemantics {
186                budget: self.budget,
187                user_equality_count: self.user_equality_rhs.len(),
188            },
189        );
190        // A NumericalFailure iterate contains non-finite values and would be
191        // rejected as a warm start; an infeasible date's duals diverge along
192        // the certificate ray and would poison the next date. Both restart
193        // the following solve cold.
194        self.warm_start = matches!(
195            solution.status,
196            SolveStatus::Solved | SolveStatus::MaxIterations
197        )
198        .then(|| solution.warm_start());
199        Ok(solution)
200    }
201
202    /// Validates every field of `step`, then applies all of them. No state
203    /// (sequence bookkeeping or workspace data) changes on any error.
204    fn apply(&mut self, step: &RebalanceStep) -> Result<(), PortfolioError> {
205        if step.is_empty() {
206            return Ok(());
207        }
208        let dimension = self.dimension();
209        if let Some(expected_returns) = &step.expected_returns {
210            validate_vector(
211                "expected_returns",
212                expected_returns,
213                dimension,
214                FinitePolicy::Finite,
215            )?;
216        }
217        if let Some(previous_weights) = &step.previous_weights {
218            if self.previous_weights.is_none() {
219                return Err(PortfolioError::InvalidParameter(
220                    "previous_weights updates require a base problem built with \
221                     with_quadratic_turnover and/or with_l1_turnover; the L2 penalty \
222                     and the L1 costs are part of the problem structure and stay \
223                     fixed for the whole sequence",
224                ));
225            }
226            validate_vector(
227                "previous_weights",
228                previous_weights,
229                dimension,
230                FinitePolicy::Finite,
231            )?;
232        }
233        if let Some(benchmark_weights) = &step.benchmark_weights {
234            if self.benchmark_weights.is_none() {
235                return Err(PortfolioError::InvalidParameter(
236                    "benchmark_weights updates require a base problem built with \
237                     with_tracking_benchmark; switching between absolute-risk and \
238                     tracking objectives changes what the sequence is solving",
239                ));
240            }
241            validate_vector(
242                "benchmark_weights",
243                benchmark_weights,
244                dimension,
245                FinitePolicy::Finite,
246            )?;
247        }
248        if let Some(budget) = step.budget {
249            if self.budget.is_none() {
250                return Err(PortfolioError::InvalidParameter(
251                    "budget updates require a base problem with a budget constraint; \
252                     adding or removing the budget row changes the factored system",
253                ));
254            }
255            if !budget.is_finite() {
256                return Err(PortfolioError::InvalidParameter(
257                    "budget must be finite when provided",
258                ));
259            }
260            if budget < self.minimum_budget || budget > self.maximum_budget {
261                return Err(PortfolioError::BudgetOutsideBounds {
262                    budget,
263                    minimum: self.minimum_budget,
264                    maximum: self.maximum_budget,
265                });
266            }
267        }
268        if let Some(equality_rhs) = &step.equality_rhs {
269            validate_vector(
270                "equality_rhs",
271                equality_rhs,
272                self.user_equality_rhs.len(),
273                FinitePolicy::Finite,
274            )?;
275        }
276        if let Some(inequality_rhs) = &step.inequality_rhs {
277            validate_vector(
278                "inequality_rhs",
279                inequality_rhs,
280                self.workspace.problem().inequalities.len(),
281                FinitePolicy::Finite,
282            )?;
283        }
284
285        // Prebuild the derived vectors so their (pathological) overflow is
286        // caught before the workspace mutates.
287        let linear = if step.expected_returns.is_some()
288            || step.previous_weights.is_some()
289            || step.benchmark_weights.is_some()
290        {
291            let expected_returns = step
292                .expected_returns
293                .as_deref()
294                .unwrap_or(&self.expected_returns);
295            let mut linear: Vec<f64> = expected_returns.iter().map(|value| -value).collect();
296            if let Some(previous_weights) = step
297                .previous_weights
298                .as_deref()
299                .or(self.previous_weights.as_deref())
300            {
301                for (value, previous) in linear.iter_mut().zip(previous_weights) {
302                    *value -= self.turnover_penalty * previous;
303                }
304            }
305            if let Some(benchmark_weights) = step
306                .benchmark_weights
307                .as_deref()
308                .or(self.benchmark_weights.as_deref())
309            {
310                let covariance_times_benchmark = self.covariance.apply(benchmark_weights);
311                for (value, product) in linear.iter_mut().zip(&covariance_times_benchmark) {
312                    *value -= self.risk_aversion * product;
313                }
314            }
315            if linear.iter().any(|value| !value.is_finite()) {
316                return Err(ProblemError::NonFinite("linear").into());
317            }
318            Some(linear)
319        } else {
320            None
321        };
322        let combined_equality_rhs = if step.budget.is_some() || step.equality_rhs.is_some() {
323            let mut combined = Vec::with_capacity(
324                usize::from(self.budget.is_some()) + self.user_equality_rhs.len(),
325            );
326            if let Some(budget) = step.budget.or(self.budget) {
327                combined.push(budget);
328            }
329            combined.extend_from_slice(
330                step.equality_rhs
331                    .as_deref()
332                    .unwrap_or(&self.user_equality_rhs),
333            );
334            Some(combined)
335        } else {
336            None
337        };
338
339        // Everything is validated; apply. The workspace re-validates each
340        // vector but can no longer fail, keeping the step atomic.
341        if let Some(linear) = &linear {
342            self.workspace.update_linear(linear)?;
343        }
344        if let (Some(previous_weights), true) = (&step.previous_weights, self.has_l1_turnover) {
345            self.workspace.update_l1_anchor(previous_weights)?;
346        }
347        if let Some(combined) = &combined_equality_rhs {
348            self.workspace.update_equality_rhs(combined)?;
349        }
350        if let Some(inequality_rhs) = &step.inequality_rhs {
351            self.workspace.update_inequality_rhs(inequality_rhs)?;
352        }
353        if let Some(expected_returns) = &step.expected_returns {
354            self.expected_returns.clone_from(expected_returns);
355        }
356        if let Some(previous_weights) = &step.previous_weights {
357            self.previous_weights = Some(previous_weights.clone());
358        }
359        if let Some(benchmark_weights) = &step.benchmark_weights {
360            self.benchmark_weights = Some(benchmark_weights.clone());
361        }
362        if let Some(budget) = step.budget {
363            self.budget = Some(budget);
364        }
365        if let Some(equality_rhs) = &step.equality_rhs {
366            self.user_equality_rhs.clone_from(equality_rhs);
367        }
368        Ok(())
369    }
370}
371
372/// Solves a whole rolling sequence in one call: one [`Solution`] per step,
373/// in order (roadmap 2.5).
374///
375/// The first step is applied to the base problem before its solve, so pass
376/// [`RebalanceStep::default()`] first to solve the base data as-is. Warm
377/// starts and factorization reuse are managed internally; for streaming
378/// workloads where dates arrive one at a time, hold a [`PortfolioSequence`]
379/// (via [`PortfolioProblem::sequence`]) and call
380/// [`PortfolioSequence::solve_next`] instead.
381///
382/// A solve that stops at `MaxIterations` does not abort the sequence; inspect
383/// each returned [`SolveStatus`](crate::SolveStatus).
384///
385/// # Errors
386///
387/// Returns [`PortfolioError`] on invalid base data or on the first invalid
388/// step, together with the index-free context of that step's validation
389/// error. Solutions for the steps before the failure are dropped.
390pub fn solve_sequence(
391    problem: &PortfolioProblem,
392    settings: Option<SolverSettings>,
393    steps: &[RebalanceStep],
394) -> Result<Vec<Solution>, PortfolioError> {
395    let solver = Solver::new(settings.unwrap_or_default());
396    let mut sequence = problem.sequence_with(&solver)?;
397    steps.iter().map(|step| sequence.solve_next(step)).collect()
398}