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}