Skip to main content

ledge_core/
portfolio.rs

1//! Ergonomic factor-model portfolio problem construction.
2
3use thiserror::Error;
4
5use crate::{
6    certificate::{Certificate, DualCertificate, PrimalCertificate},
7    problem::L1Term,
8    sequence::PortfolioSequence,
9    FactorCovariance, FactorQuad, LinearConstraints, Matrix, MatrixError, ProblemError, QpProblem,
10    Solution, Solver, SolverError, SolverSettings, WarmStart,
11};
12
13/// A mean-variance portfolio problem backed by a factor covariance.
14///
15/// The objective is
16///
17/// ```text
18/// risk_aversion / 2 * (w - b)' Σ (w - b) - expected_returns' w
19/// + turnover_penalty / 2 * ||w - previous_weights||²
20/// + l1_turnover_costs' |w - previous_weights|
21/// ```
22///
23/// where the benchmark `b` defaults to zero (absolute risk) and is set by
24/// [`PortfolioProblem::with_tracking_benchmark`] for tracking-error
25/// problems. The benchmark only shifts the linear term — the QP structure
26/// is unchanged — and the constant `risk_aversion / 2 * b' Σ b` is dropped
27/// from reported objective values, as is conventional for QP solvers.
28///
29/// The quadratic turnover term is an L2 approximation useful when a smooth
30/// preference for stable weights is acceptable
31/// ([`PortfolioProblem::with_quadratic_turnover`]). The L1 term models
32/// proportional transaction costs exactly
33/// ([`PortfolioProblem::with_l1_turnover`]); it is handled by a dedicated
34/// proximal block inside the solver, so it never grows the reduced system.
35/// Both may be combined; they share one previous portfolio.
36#[derive(Clone, Debug, PartialEq)]
37#[cfg_attr(
38    feature = "serde",
39    derive(serde::Serialize, serde::Deserialize),
40    serde(try_from = "PortfolioProblemData", into = "PortfolioProblemData")
41)]
42pub struct PortfolioProblem {
43    covariance: FactorQuad,
44    expected_returns: Vec<f64>,
45    risk_aversion: f64,
46    budget: Option<f64>,
47    equalities: LinearConstraints,
48    inequalities: LinearConstraints,
49    lower_bounds: Vec<f64>,
50    upper_bounds: Vec<f64>,
51    previous_weights: Option<Vec<f64>>,
52    turnover_penalty: f64,
53    l1_turnover_costs: Option<Vec<f64>>,
54    benchmark_weights: Option<Vec<f64>>,
55}
56
57/// Wire format for [`PortfolioProblem`] (roadmap 3.3): the builder inputs,
58/// replayed through the builder methods on deserialization so a dump can
59/// never smuggle in data that construction would have rejected.
60#[cfg(feature = "serde")]
61#[derive(serde::Serialize, serde::Deserialize)]
62struct PortfolioProblemData {
63    covariance: FactorQuad,
64    expected_returns: Vec<f64>,
65    risk_aversion: f64,
66    budget: Option<f64>,
67    equalities: LinearConstraints,
68    inequalities: LinearConstraints,
69    #[serde(with = "crate::serde_support::lower_bounds")]
70    lower_bounds: Vec<f64>,
71    #[serde(with = "crate::serde_support::upper_bounds")]
72    upper_bounds: Vec<f64>,
73    previous_weights: Option<Vec<f64>>,
74    turnover_penalty: f64,
75    l1_turnover_costs: Option<Vec<f64>>,
76    benchmark_weights: Option<Vec<f64>>,
77}
78
79#[cfg(feature = "serde")]
80impl TryFrom<PortfolioProblemData> for PortfolioProblem {
81    type Error = PortfolioError;
82
83    fn try_from(data: PortfolioProblemData) -> Result<Self, Self::Error> {
84        let mut problem = Self::new(
85            data.covariance.factors,
86            data.covariance.omega,
87            data.covariance.diagonal,
88            data.expected_returns,
89        )?
90        .with_risk_aversion(data.risk_aversion)?
91        .with_budget(data.budget)?
92        .with_bounds(data.lower_bounds, data.upper_bounds)?
93        .with_equalities(data.equalities.matrix, data.equalities.rhs)?
94        .with_inequalities(data.inequalities.matrix, data.inequalities.rhs)?;
95        if let Some(previous_weights) = data.previous_weights {
96            problem =
97                problem.with_quadratic_turnover(previous_weights.clone(), data.turnover_penalty)?;
98            if let Some(costs) = data.l1_turnover_costs {
99                problem = problem.with_l1_turnover(previous_weights, costs)?;
100            }
101        } else if data.turnover_penalty != 0.0 || data.l1_turnover_costs.is_some() {
102            return Err(PortfolioError::InvalidParameter(
103                "turnover terms require previous_weights",
104            ));
105        }
106        if let Some(benchmark_weights) = data.benchmark_weights {
107            problem = problem.with_tracking_benchmark(benchmark_weights)?;
108        }
109        Ok(problem)
110    }
111}
112
113#[cfg(feature = "serde")]
114impl From<PortfolioProblem> for PortfolioProblemData {
115    fn from(problem: PortfolioProblem) -> Self {
116        Self {
117            covariance: problem.covariance,
118            expected_returns: problem.expected_returns,
119            risk_aversion: problem.risk_aversion,
120            budget: problem.budget,
121            equalities: problem.equalities,
122            inequalities: problem.inequalities,
123            lower_bounds: problem.lower_bounds,
124            upper_bounds: problem.upper_bounds,
125            previous_weights: problem.previous_weights,
126            turnover_penalty: problem.turnover_penalty,
127            l1_turnover_costs: problem.l1_turnover_costs,
128            benchmark_weights: problem.benchmark_weights,
129        }
130    }
131}
132
133impl PortfolioProblem {
134    /// Creates a long-only, fully invested mean-variance problem.
135    ///
136    /// Defaults are risk aversion `1`, budget `1`, and bounds `[0, 1]`.
137    ///
138    /// # Errors
139    ///
140    /// Returns an error when covariance data or expected-return dimensions are
141    /// invalid.
142    pub fn new(
143        factors: Matrix,
144        omega: FactorCovariance,
145        specific_variance: Vec<f64>,
146        expected_returns: Vec<f64>,
147    ) -> Result<Self, PortfolioError> {
148        let covariance = FactorQuad::new(factors, omega, specific_variance)?;
149        let dimension = covariance.dimension();
150        validate_vector(
151            "expected_returns",
152            &expected_returns,
153            dimension,
154            FinitePolicy::Finite,
155        )?;
156        Ok(Self {
157            covariance,
158            expected_returns,
159            risk_aversion: 1.0,
160            budget: Some(1.0),
161            equalities: LinearConstraints::empty(dimension),
162            inequalities: LinearConstraints::empty(dimension),
163            lower_bounds: vec![0.0; dimension],
164            upper_bounds: vec![1.0; dimension],
165            previous_weights: None,
166            turnover_penalty: 0.0,
167            l1_turnover_costs: None,
168            benchmark_weights: None,
169        })
170    }
171
172    /// Number of portfolio weights.
173    #[must_use]
174    pub fn dimension(&self) -> usize {
175        self.covariance.dimension()
176    }
177
178    /// Sets the positive multiplier on portfolio variance.
179    ///
180    /// # Errors
181    ///
182    /// Returns an error unless `risk_aversion` is finite and positive.
183    pub fn with_risk_aversion(mut self, risk_aversion: f64) -> Result<Self, PortfolioError> {
184        if !risk_aversion.is_finite() || risk_aversion <= 0.0 {
185            return Err(PortfolioError::InvalidParameter(
186                "risk_aversion must be finite and positive",
187            ));
188        }
189        self.risk_aversion = risk_aversion;
190        Ok(self)
191    }
192
193    /// Sets the budget equality, or removes it with `None`.
194    ///
195    /// # Errors
196    ///
197    /// Returns an error when a supplied budget is not finite.
198    pub fn with_budget(mut self, budget: Option<f64>) -> Result<Self, PortfolioError> {
199        if budget.is_some_and(|value| !value.is_finite()) {
200            return Err(PortfolioError::InvalidParameter(
201                "budget must be finite when provided",
202            ));
203        }
204        self.budget = budget;
205        Ok(self)
206    }
207
208    /// Replaces the per-asset box constraints.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error for wrong dimensions, NaNs, or lower bounds above
213    /// upper bounds.
214    pub fn with_bounds(
215        mut self,
216        lower_bounds: Vec<f64>,
217        upper_bounds: Vec<f64>,
218    ) -> Result<Self, PortfolioError> {
219        let dimension = self.dimension();
220        validate_vector(
221            "lower_bounds",
222            &lower_bounds,
223            dimension,
224            FinitePolicy::AllowInfinity,
225        )?;
226        validate_vector(
227            "upper_bounds",
228            &upper_bounds,
229            dimension,
230            FinitePolicy::AllowInfinity,
231        )?;
232        for index in 0..dimension {
233            if lower_bounds[index] > upper_bounds[index] {
234                return Err(ProblemError::InvalidBounds {
235                    index,
236                    lower: lower_bounds[index],
237                    upper: upper_bounds[index],
238                }
239                .into());
240            }
241        }
242        self.lower_bounds = lower_bounds;
243        self.upper_bounds = upper_bounds;
244        Ok(self)
245    }
246
247    /// Adds user-supplied equality constraints alongside the optional budget.
248    ///
249    /// # Errors
250    ///
251    /// Returns an error when dimensions or coefficients are invalid.
252    pub fn with_equalities(
253        mut self,
254        matrix: Matrix,
255        rhs: Vec<f64>,
256    ) -> Result<Self, PortfolioError> {
257        self.equalities = validated_constraints("equalities", matrix, rhs, self.dimension())?;
258        Ok(self)
259    }
260
261    /// Adds upper-form linear constraints `matrix * weights <= rhs`.
262    ///
263    /// # Errors
264    ///
265    /// Returns an error when dimensions or coefficients are invalid.
266    pub fn with_inequalities(
267        mut self,
268        matrix: Matrix,
269        rhs: Vec<f64>,
270    ) -> Result<Self, PortfolioError> {
271        self.inequalities = validated_constraints("inequalities", matrix, rhs, self.dimension())?;
272        Ok(self)
273    }
274
275    /// Adds an L2 penalty around the previous portfolio.
276    ///
277    /// This is not proportional transaction cost — use
278    /// [`PortfolioProblem::with_l1_turnover`] for that. The problem models a
279    /// single previous portfolio: calling this after `with_l1_turnover`
280    /// replaces the shared anchor.
281    ///
282    /// # Errors
283    ///
284    /// Returns an error for a wrong-length/non-finite previous portfolio or a
285    /// negative/non-finite penalty.
286    pub fn with_quadratic_turnover(
287        mut self,
288        previous_weights: Vec<f64>,
289        turnover_penalty: f64,
290    ) -> Result<Self, PortfolioError> {
291        validate_vector(
292            "previous_weights",
293            &previous_weights,
294            self.dimension(),
295            FinitePolicy::Finite,
296        )?;
297        if !turnover_penalty.is_finite() || turnover_penalty < 0.0 {
298            return Err(PortfolioError::InvalidParameter(
299                "turnover_penalty must be finite and non-negative",
300            ));
301        }
302        self.previous_weights = Some(previous_weights);
303        self.turnover_penalty = turnover_penalty;
304        Ok(self)
305    }
306
307    /// Adds exact proportional transaction costs
308    /// `costs' |w - previous_weights|` around the previous portfolio
309    /// (roadmap 2.1).
310    ///
311    /// Unlike the L2 approximation, this is the real trading-cost model: a
312    /// per-asset charge proportional to the traded amount, with a genuine
313    /// no-trade region. The term is handled by a dedicated soft-threshold
314    /// proximal block, so the reduced factorization keeps its
315    /// `factors + constraints` dimension — an epigraph reformulation would
316    /// add `2n` constraint rows instead.
317    ///
318    /// May be combined with [`PortfolioProblem::with_quadratic_turnover`];
319    /// the two terms share a single previous portfolio, and calling either
320    /// method replaces that shared anchor.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error for wrong-length or non-finite previous weights, or
325    /// costs that are not finite and non-negative.
326    pub fn with_l1_turnover(
327        mut self,
328        previous_weights: Vec<f64>,
329        costs: Vec<f64>,
330    ) -> Result<Self, PortfolioError> {
331        validate_vector(
332            "previous_weights",
333            &previous_weights,
334            self.dimension(),
335            FinitePolicy::Finite,
336        )?;
337        validate_vector(
338            "l1_turnover_costs",
339            &costs,
340            self.dimension(),
341            FinitePolicy::Finite,
342        )?;
343        if costs.iter().any(|value| *value < 0.0) {
344            return Err(PortfolioError::InvalidParameter(
345                "l1 turnover costs must be non-negative",
346            ));
347        }
348        self.previous_weights = Some(previous_weights);
349        self.l1_turnover_costs = Some(costs);
350        Ok(self)
351    }
352
353    /// Sets the benchmark for a tracking-error objective (roadmap 2.6).
354    ///
355    /// The risk term becomes `risk_aversion / 2 * (w - b)' Σ (w - b)`:
356    /// active risk against the benchmark instead of absolute risk. It is the
357    /// same QP underneath — expanding the square only shifts the linear
358    /// objective by `-risk_aversion * Σ b` — so no solver machinery changes
359    /// and rolling sequences keep their cached factorizations when the
360    /// benchmark moves.
361    ///
362    /// The constant `risk_aversion / 2 * b' Σ b` is dropped from reported
363    /// objective values, as is conventional for QP solvers; add it back if
364    /// an absolute tracking-error number is needed.
365    ///
366    /// The benchmark does not need to satisfy the portfolio's constraints.
367    ///
368    /// # Errors
369    ///
370    /// Returns an error for wrong-length or non-finite benchmark weights.
371    pub fn with_tracking_benchmark(
372        mut self,
373        benchmark_weights: Vec<f64>,
374    ) -> Result<Self, PortfolioError> {
375        validate_vector(
376            "benchmark_weights",
377            &benchmark_weights,
378            self.dimension(),
379            FinitePolicy::Finite,
380        )?;
381        self.benchmark_weights = Some(benchmark_weights);
382        Ok(self)
383    }
384
385    /// Adds industry-neutrality equalities derived from the tracking
386    /// benchmark (roadmap 3.1).
387    ///
388    /// `industries[i]` is the zero-based industry id of asset `i`; industry
389    /// ids run from `0` to `max(industries)`. One equality row is appended
390    /// per industry, pinning the portfolio's industry weight to the
391    /// benchmark's:
392    ///
393    /// ```text
394    /// sum_{i in industry g} w_i = sum_{i in industry g} b_i
395    /// ```
396    ///
397    /// which is exactly "zero net active industry exposure". Requires
398    /// [`PortfolioProblem::with_tracking_benchmark`] to have been called
399    /// first; for explicit targets (or without a benchmark) use
400    /// [`PortfolioProblem::with_group_targets`].
401    ///
402    /// The rows are appended after any existing user equality rows, one per
403    /// industry in id order, and count as user equality rows from then on:
404    /// rolling sequences move the targets through
405    /// [`RebalanceStep::equality_rhs`](crate::RebalanceStep) like any other
406    /// equality target. Call
407    /// [`PortfolioProblem::with_equalities`] *before* this method — it
408    /// replaces the user equality block.
409    ///
410    /// # Errors
411    ///
412    /// Returns an error when no tracking benchmark is set, `industries` has
413    /// the wrong length, or an industry has no member assets.
414    pub fn with_industry_neutrality(self, industries: &[usize]) -> Result<Self, PortfolioError> {
415        let Some(benchmark_weights) = self.benchmark_weights.clone() else {
416            return Err(PortfolioError::Template(
417                "industry neutrality derives its targets from the tracking benchmark; \
418                 call with_tracking_benchmark first or supply explicit targets with \
419                 with_group_targets"
420                    .to_owned(),
421            ));
422        };
423        validate_group_ids("industries", industries, self.dimension(), None)?;
424        let group_count = industries.iter().copied().max().map_or(0, |max| max + 1);
425        let mut targets = vec![0.0; group_count];
426        for (asset, group) in industries.iter().enumerate() {
427            targets[*group] += benchmark_weights[asset];
428        }
429        self.append_group_equalities("industries", industries, &targets)
430    }
431
432    /// Adds group-weight equalities: one row per group pinning the summed
433    /// member weights to an explicit target (roadmap 3.1).
434    ///
435    /// `groups[i]` is the zero-based group id of asset `i` and must be
436    /// smaller than `targets.len()`; group `g` contributes the row
437    ///
438    /// ```text
439    /// sum_{i in group g} w_i = targets[g]
440    /// ```
441    ///
442    /// This is the explicit-target form of
443    /// [`PortfolioProblem::with_industry_neutrality`] and also covers
444    /// sector/country sleeves or a zero-net-exposure book
445    /// (`targets = [0.0, ...]`).
446    ///
447    /// The rows are appended after any existing user equality rows, one per
448    /// group in id order, and count as user equality rows from then on:
449    /// rolling sequences move the targets through
450    /// [`RebalanceStep::equality_rhs`](crate::RebalanceStep). Call
451    /// [`PortfolioProblem::with_equalities`] *before* this method — it
452    /// replaces the user equality block.
453    ///
454    /// # Errors
455    ///
456    /// Returns an error when `groups` has the wrong length or an id out of
457    /// range, a target is non-finite, or a group has no member assets.
458    pub fn with_group_targets(
459        self,
460        groups: &[usize],
461        targets: &[f64],
462    ) -> Result<Self, PortfolioError> {
463        validate_group_ids("groups", groups, self.dimension(), Some(targets.len()))?;
464        if targets.iter().any(|value| !value.is_finite()) {
465            return Err(ProblemError::NonFinite("group targets").into());
466        }
467        self.append_group_equalities("groups", groups, targets)
468    }
469
470    /// Adds style-exposure bands `lower[s] <= exposures[s] · w <= upper[s]`
471    /// as linear constraint rows (roadmap 3.1).
472    ///
473    /// `exposures` is styles-by-assets: row `s` holds the per-asset loadings
474    /// of style `s` (momentum, value, size, ...). Use
475    /// `f64::NEG_INFINITY` / `f64::INFINITY` for one-sided bands. A band
476    /// with `lower[s] == upper[s]` becomes a single equality row (style
477    /// neutrality); other bands append their finite sides as inequality
478    /// rows.
479    ///
480    /// Appended row order, per style in index order: the equality row (when
481    /// the band is exact), otherwise the upper row
482    /// `exposures[s] · w <= upper[s]` followed by the lower row
483    /// `-exposures[s] · w <= -lower[s]`, skipping infinite sides. The rows
484    /// count as user constraint rows from then on: rolling sequences move
485    /// the bands through
486    /// [`RebalanceStep::equality_rhs`](crate::RebalanceStep) /
487    /// [`RebalanceStep::inequality_rhs`](crate::RebalanceStep) (mind the
488    /// sign convention on lower rows). Call
489    /// [`PortfolioProblem::with_equalities`] /
490    /// [`PortfolioProblem::with_inequalities`] *before* this method — they
491    /// replace their constraint blocks.
492    ///
493    /// # Errors
494    ///
495    /// Returns an error for dimension mismatches, non-finite loadings, NaN
496    /// or crossing bounds, or a style with neither side finite.
497    pub fn with_style_bounds(
498        mut self,
499        exposures: &Matrix,
500        lower: &[f64],
501        upper: &[f64],
502    ) -> Result<Self, PortfolioError> {
503        let dimension = self.dimension();
504        let styles = exposures.rows();
505        if exposures.cols() != dimension {
506            return Err(ProblemError::Dimension {
507                field: "style exposures",
508                expected: dimension,
509                actual: exposures.cols(),
510            }
511            .into());
512        }
513        if exposures.as_slice().iter().any(|value| !value.is_finite()) {
514            return Err(ProblemError::NonFinite("style exposures").into());
515        }
516        validate_vector("style lower", lower, styles, FinitePolicy::AllowInfinity)?;
517        validate_vector("style upper", upper, styles, FinitePolicy::AllowInfinity)?;
518
519        let mut equality_rows = Vec::new();
520        let mut equality_rhs = Vec::new();
521        let mut inequality_rows = Vec::new();
522        let mut inequality_rhs = Vec::new();
523        for style in 0..styles {
524            let (low, high) = (lower[style], upper[style]);
525            if low > high {
526                return Err(PortfolioError::Template(format!(
527                    "style {style} bounds cross: lower {low} exceeds upper {high}"
528                )));
529            }
530            // Exact equality is deliberate here: a band collapses to an
531            // equality row only when the caller passed identical bounds.
532            if low >= high {
533                equality_rows.push(exposures.row(style).to_vec());
534                equality_rhs.push(high);
535                continue;
536            }
537            if !low.is_finite() && !high.is_finite() {
538                return Err(PortfolioError::Template(format!(
539                    "style {style} has neither a finite lower nor a finite upper bound; \
540                     drop the row instead of leaving it unconstrained"
541                )));
542            }
543            if high.is_finite() {
544                inequality_rows.push(exposures.row(style).to_vec());
545                inequality_rhs.push(high);
546            }
547            if low.is_finite() {
548                inequality_rows.push(exposures.row(style).iter().map(|value| -value).collect());
549                inequality_rhs.push(-low);
550            }
551        }
552        if !equality_rows.is_empty() {
553            self.equalities =
554                append_constraint_rows(&self.equalities, equality_rows, equality_rhs)?;
555        }
556        if !inequality_rows.is_empty() {
557            self.inequalities =
558                append_constraint_rows(&self.inequalities, inequality_rows, inequality_rhs)?;
559        }
560        Ok(self)
561    }
562
563    /// Caps every asset's absolute weight: `|w_i| <= max_weight`
564    /// (roadmap 3.1).
565    ///
566    /// Concentration limits map onto the existing box constraints — upper
567    /// bounds are tightened to `min(upper_i, max_weight)` and lower bounds
568    /// raised to `max(lower_i, -max_weight)` — so no constraint rows are
569    /// added and the reduced factorization keeps its dimension. Long-only
570    /// problems (default bounds `[0, 1]`) end up with `w_i <= max_weight`.
571    ///
572    /// Apply after [`PortfolioProblem::with_bounds`]; the tightening keeps
573    /// whichever side is already stricter.
574    ///
575    /// # Errors
576    ///
577    /// Returns an error unless `max_weight` is finite and positive, or when
578    /// the cap contradicts an existing bound (for example a forced minimum
579    /// position above the cap).
580    pub fn with_concentration_limit(self, max_weight: f64) -> Result<Self, PortfolioError> {
581        if !max_weight.is_finite() || max_weight <= 0.0 {
582            return Err(PortfolioError::InvalidParameter(
583                "max_weight must be finite and positive",
584            ));
585        }
586        let lower: Vec<f64> = self
587            .lower_bounds
588            .iter()
589            .map(|value| value.max(-max_weight))
590            .collect();
591        let upper: Vec<f64> = self
592            .upper_bounds
593            .iter()
594            .map(|value| value.min(max_weight))
595            .collect();
596        self.with_bounds(lower, upper)
597    }
598
599    /// Caps every asset's short position: `w_i >= -max_short`
600    /// (roadmap 3.1).
601    ///
602    /// Short limits map onto the existing box constraints — lower bounds are
603    /// raised to `max(lower_i, -max_short)` — so no constraint rows are
604    /// added. `max_short = 0` forces a long-only portfolio. This is a
605    /// per-asset limit; a total short-budget cap
606    /// (`sum_i max(-w_i, 0) <= S`) needs a long/short variable split that
607    /// the QP form deliberately does not model.
608    ///
609    /// Apply after [`PortfolioProblem::with_bounds`]; the tightening keeps
610    /// whichever lower bound is already stricter.
611    ///
612    /// # Errors
613    ///
614    /// Returns an error unless `max_short` is finite and non-negative, or
615    /// when the limit contradicts an existing bound (an upper bound forcing
616    /// a deeper short).
617    pub fn with_short_limit(self, max_short: f64) -> Result<Self, PortfolioError> {
618        if !max_short.is_finite() || max_short < 0.0 {
619            return Err(PortfolioError::InvalidParameter(
620                "max_short must be finite and non-negative",
621            ));
622        }
623        let lower: Vec<f64> = self
624            .lower_bounds
625            .iter()
626            .map(|value| value.max(-max_short))
627            .collect();
628        let upper = self.upper_bounds.clone();
629        self.with_bounds(lower, upper)
630    }
631
632    /// Appends one equality row per group; shared by the industry and
633    /// explicit-target templates. `targets.len()` is the group count and
634    /// every group must have at least one member asset.
635    fn append_group_equalities(
636        mut self,
637        field: &'static str,
638        groups: &[usize],
639        targets: &[f64],
640    ) -> Result<Self, PortfolioError> {
641        let dimension = self.dimension();
642        let mut member_counts = vec![0_usize; targets.len()];
643        let mut rows = vec![vec![0.0; dimension]; targets.len()];
644        for (asset, group) in groups.iter().enumerate() {
645            rows[*group][asset] = 1.0;
646            member_counts[*group] += 1;
647        }
648        if let Some(empty) = member_counts.iter().position(|count| *count == 0) {
649            return Err(PortfolioError::Template(format!(
650                "{field} group {empty} has no member assets; its row would read 0 = target"
651            )));
652        }
653        self.equalities = append_constraint_rows(&self.equalities, rows, targets.to_vec())?;
654        Ok(self)
655    }
656
657    /// Builds the standard-form QP consumed by the numerical kernel.
658    ///
659    /// # Errors
660    ///
661    /// Returns an error when the budget is already impossible under the box
662    /// constraints or if final QP validation fails.
663    pub fn to_qp(&self) -> Result<QpProblem, PortfolioError> {
664        if let Some(budget) = self.budget {
665            let minimum: f64 = self.lower_bounds.iter().sum();
666            let maximum: f64 = self.upper_bounds.iter().sum();
667            if budget < minimum || budget > maximum {
668                return Err(PortfolioError::BudgetOutsideBounds {
669                    budget,
670                    minimum,
671                    maximum,
672                });
673            }
674        }
675
676        let omega = match &self.covariance.omega {
677            FactorCovariance::Diagonal(values) => FactorCovariance::Diagonal(
678                values
679                    .iter()
680                    .map(|value| self.risk_aversion * value)
681                    .collect(),
682            ),
683            FactorCovariance::Dense(matrix) => {
684                let mut scaled = matrix.clone();
685                for value in scaled.as_mut_slice() {
686                    *value *= self.risk_aversion;
687                }
688                FactorCovariance::Dense(scaled)
689            }
690        };
691        let diagonal: Vec<f64> = self
692            .covariance
693            .diagonal
694            .iter()
695            .map(|value| self.risk_aversion * value + self.turnover_penalty)
696            .collect();
697        let quadratic = FactorQuad::new(self.covariance.factors.clone(), omega, diagonal)?;
698        let mut linear: Vec<f64> = self.expected_returns.iter().map(|value| -value).collect();
699        if let Some(previous_weights) = &self.previous_weights {
700            for (value, previous) in linear.iter_mut().zip(previous_weights) {
701                *value -= self.turnover_penalty * previous;
702            }
703        }
704        if let Some(benchmark_weights) = &self.benchmark_weights {
705            // (w-b)' Σ (w-b) expanded: the cross term shifts the linear cost
706            // by -risk_aversion * Σ b. Uses the raw covariance — the turnover
707            // penalty tracks the previous portfolio, not the benchmark.
708            let covariance_times_benchmark = self.covariance.apply(benchmark_weights);
709            for (value, product) in linear.iter_mut().zip(&covariance_times_benchmark) {
710                *value -= self.risk_aversion * product;
711            }
712        }
713
714        let l1 = match (&self.l1_turnover_costs, &self.previous_weights) {
715            (Some(costs), Some(previous_weights)) => Some(L1Term {
716                costs: costs.clone(),
717                anchor: previous_weights.clone(),
718            }),
719            _ => None,
720        };
721
722        let equalities = self.combined_equalities()?;
723        let problem = QpProblem {
724            quadratic,
725            linear,
726            l1,
727            equalities,
728            inequalities: self.inequalities.clone(),
729            lower_bounds: self.lower_bounds.clone(),
730            upper_bounds: self.upper_bounds.clone(),
731        };
732        problem.validate()?;
733        Ok(problem)
734    }
735
736    /// Solves with default settings.
737    ///
738    /// # Errors
739    ///
740    /// Returns setup and validation errors. A completed solve reports
741    /// convergence through [`crate::SolveStatus`] on the returned solution.
742    pub fn solve(&self, warm_start: Option<&WarmStart>) -> Result<Solution, PortfolioError> {
743        self.solve_with(&Solver::default(), warm_start)
744    }
745
746    /// Builds a rolling [`PortfolioSequence`] with default settings
747    /// (roadmap 2.5).
748    ///
749    /// The sequence reuses the equilibration and the reduced factorizations
750    /// across dates and chains warm starts automatically; per-date data
751    /// changes are described by [`crate::RebalanceStep`].
752    ///
753    /// # Errors
754    ///
755    /// Returns setup and validation errors for the base problem.
756    pub fn sequence(&self) -> Result<PortfolioSequence, PortfolioError> {
757        self.sequence_with(&Solver::default())
758    }
759
760    /// Builds a rolling [`PortfolioSequence`] iterating with an explicitly
761    /// configured solver.
762    ///
763    /// # Errors
764    ///
765    /// Returns setup and validation errors for the base problem.
766    pub fn sequence_with(&self, solver: &Solver) -> Result<PortfolioSequence, PortfolioError> {
767        PortfolioSequence::new(self, solver)
768    }
769
770    /// Solves with an explicitly configured solver.
771    ///
772    /// # Errors
773    ///
774    /// Returns setup and validation errors. A completed solve reports
775    /// convergence through [`crate::SolveStatus`] on the returned solution.
776    pub fn solve_with(
777        &self,
778        solver: &Solver,
779        warm_start: Option<&WarmStart>,
780    ) -> Result<Solution, PortfolioError> {
781        let problem = self.to_qp()?;
782        let mut solution = solver.solve(&problem, warm_start)?;
783        append_certificate_hints(
784            &mut solution,
785            &PortfolioSemantics {
786                budget: self.budget,
787                user_equality_count: self.equalities.len(),
788            },
789        );
790        Ok(solution)
791    }
792
793    pub(crate) fn expected_returns(&self) -> &[f64] {
794        &self.expected_returns
795    }
796
797    pub(crate) fn previous_weights(&self) -> Option<&[f64]> {
798        self.previous_weights.as_deref()
799    }
800
801    pub(crate) const fn turnover_penalty(&self) -> f64 {
802        self.turnover_penalty
803    }
804
805    pub(crate) fn has_l1_turnover(&self) -> bool {
806        self.l1_turnover_costs.is_some()
807    }
808
809    pub(crate) fn benchmark_weights(&self) -> Option<&[f64]> {
810        self.benchmark_weights.as_deref()
811    }
812
813    pub(crate) const fn covariance(&self) -> &FactorQuad {
814        &self.covariance
815    }
816
817    pub(crate) const fn risk_aversion(&self) -> f64 {
818        self.risk_aversion
819    }
820
821    pub(crate) const fn budget(&self) -> Option<f64> {
822        self.budget
823    }
824
825    pub(crate) fn user_equality_rhs(&self) -> &[f64] {
826        &self.equalities.rhs
827    }
828
829    pub(crate) fn lower_bounds(&self) -> &[f64] {
830        &self.lower_bounds
831    }
832
833    pub(crate) fn upper_bounds(&self) -> &[f64] {
834        &self.upper_bounds
835    }
836
837    fn combined_equalities(&self) -> Result<LinearConstraints, PortfolioError> {
838        let budget_rows = usize::from(self.budget.is_some());
839        let rows = budget_rows + self.equalities.len();
840        let dimension = self.dimension();
841        let mut matrix = Matrix::zeros(rows, dimension);
842        let mut rhs = Vec::with_capacity(rows);
843        if let Some(budget) = self.budget {
844            for column in 0..dimension {
845                matrix[(0, column)] = 1.0;
846            }
847            rhs.push(budget);
848        }
849        for row in 0..self.equalities.len() {
850            for column in 0..dimension {
851                matrix[(budget_rows + row, column)] = self.equalities.matrix[(row, column)];
852            }
853            rhs.push(self.equalities.rhs[row]);
854        }
855        Ok(LinearConstraints::new(matrix, rhs)?)
856    }
857}
858
859/// Solves a factor mean-variance portfolio with optional custom settings.
860///
861/// # Errors
862///
863/// Returns validation or solver setup errors. Inspect the returned
864/// [`crate::SolveStatus`] to determine whether iteration converged.
865pub fn solve_mean_variance_factor(
866    problem: &PortfolioProblem,
867    settings: Option<SolverSettings>,
868    warm_start: Option<&WarmStart>,
869) -> Result<Solution, PortfolioError> {
870    let solver = Solver::new(settings.unwrap_or_default());
871    problem.solve_with(&solver, warm_start)
872}
873
874/// Errors from high-level portfolio construction and solution.
875#[derive(Debug, Error)]
876pub enum PortfolioError {
877    /// Invalid covariance, objective, constraint, or box data.
878    #[error(transparent)]
879    Problem(#[from] ProblemError),
880    /// A matrix could not be constructed.
881    #[error(transparent)]
882    Matrix(#[from] MatrixError),
883    /// Numerical solver setup failed.
884    #[error(transparent)]
885    Solver(#[from] SolverError),
886    /// A high-level scalar parameter is invalid.
887    #[error("invalid portfolio parameter: {0}")]
888    InvalidParameter(&'static str),
889    /// A constraint template's group, style, or bound data is inconsistent.
890    #[error("invalid constraint template: {0}")]
891    Template(String),
892    /// The budget cannot be met under the supplied box constraints.
893    #[error(
894        "budget {budget} is impossible under the box constraints: reachable sum is [{minimum}, {maximum}]"
895    )]
896    BudgetOutsideBounds {
897        /// Requested budget.
898        budget: f64,
899        /// Sum of lower bounds.
900        minimum: f64,
901        /// Sum of upper bounds.
902        maximum: f64,
903    },
904}
905
906/// How the portfolio layer maps rows of the standard-form QP back to the
907/// user's vocabulary when explaining an infeasibility certificate.
908pub(crate) struct PortfolioSemantics {
909    pub budget: Option<f64>,
910    pub user_equality_count: usize,
911}
912
913/// Weights below this share of the (unit-norm) certificate are treated as
914/// non-participating when naming constraints in hints.
915const PARTICIPATION_THRESHOLD: f64 = 0.05;
916
917/// Prepends a portfolio-vocabulary explanation of an infeasibility
918/// certificate to the solution's hints (roadmap 2.2).
919///
920/// The certificate itself stays on [`Solution::certificate`] untouched; this
921/// only translates "equality row 0" into "the budget" and groups bound
922/// participants, so users see which portfolio constraints conflict.
923pub(crate) fn append_certificate_hints(solution: &mut Solution, semantics: &PortfolioSemantics) {
924    let hint = match &solution.certificate {
925        Some(Certificate::Primal(certificate)) => explain_primal(certificate, semantics),
926        Some(Certificate::Dual(certificate)) => Some(explain_dual(certificate)),
927        None => None,
928    };
929    if let (Some(hint), Some(diagnostics)) = (hint, solution.diagnostics.as_mut()) {
930        diagnostics.hints.insert(0, hint);
931    }
932}
933
934fn explain_primal(
935    certificate: &PrimalCertificate,
936    semantics: &PortfolioSemantics,
937) -> Option<String> {
938    let budget_rows = usize::from(semantics.budget.is_some());
939    let mut parts = Vec::new();
940
941    if budget_rows == 1
942        && certificate
943            .equality_dual
944            .first()
945            .is_some_and(|weight| weight.abs() >= PARTICIPATION_THRESHOLD)
946    {
947        let budget = semantics.budget.unwrap_or_default();
948        parts.push(format!("the budget (sum of weights = {budget})"));
949    }
950    let equality_rows: Vec<usize> = (0..semantics.user_equality_count)
951        .filter(|row| certificate.equality_dual[budget_rows + row].abs() >= PARTICIPATION_THRESHOLD)
952        .collect();
953    if !equality_rows.is_empty() {
954        parts.push(format!(
955            "equality row(s) {}",
956            enumerate_indices(&equality_rows)
957        ));
958    }
959    let inequality_rows: Vec<usize> = certificate
960        .inequality_dual
961        .iter()
962        .enumerate()
963        .filter(|(_, weight)| **weight >= PARTICIPATION_THRESHOLD)
964        .map(|(row, _)| row)
965        .collect();
966    if !inequality_rows.is_empty() {
967        parts.push(format!(
968            "inequality cap(s) {}",
969            enumerate_indices(&inequality_rows)
970        ));
971    }
972    let upper_assets: Vec<usize> = certificate
973        .bound_dual
974        .iter()
975        .enumerate()
976        .filter(|(_, weight)| **weight >= PARTICIPATION_THRESHOLD)
977        .map(|(asset, _)| asset)
978        .collect();
979    if !upper_assets.is_empty() {
980        parts.push(format!(
981            "upper bounds on asset(s) {}",
982            enumerate_indices(&upper_assets)
983        ));
984    }
985    let lower_assets: Vec<usize> = certificate
986        .bound_dual
987        .iter()
988        .enumerate()
989        .filter(|(_, weight)| **weight <= -PARTICIPATION_THRESHOLD)
990        .map(|(asset, _)| asset)
991        .collect();
992    if !lower_assets.is_empty() {
993        parts.push(format!(
994            "lower bounds on asset(s) {}",
995            enumerate_indices(&lower_assets)
996        ));
997    }
998
999    if parts.is_empty() {
1000        return None;
1001    }
1002    Some(format!(
1003        "no portfolio satisfies these constraints simultaneously: {}; relax at \
1004         least one of them (Farkas weights are on Solution::certificate)",
1005        parts.join(", ")
1006    ))
1007}
1008
1009fn explain_dual(certificate: &DualCertificate) -> String {
1010    let mut assets: Vec<(usize, f64)> = certificate
1011        .direction
1012        .iter()
1013        .enumerate()
1014        .filter(|(_, value)| value.abs() >= 2.0 * PARTICIPATION_THRESHOLD)
1015        .map(|(asset, value)| (asset, *value))
1016        .collect();
1017    assets.sort_by(|left, right| right.1.abs().total_cmp(&left.1.abs()));
1018    let indices: Vec<usize> = assets.iter().map(|(asset, _)| *asset).collect();
1019    format!(
1020        "the objective is unbounded: expected returns reward a direction the \
1021         risk model prices at (near) zero risk and no bound or constraint caps \
1022         it (largest components at asset(s) {}); check factor exposures and \
1023         specific variance for missing risk, or add bounds \
1024         (the direction is on Solution::certificate)",
1025        enumerate_indices(&indices)
1026    )
1027}
1028
1029/// `"0, 3, 7, ... (12 total)"` — at most five indices spelled out.
1030fn enumerate_indices(indices: &[usize]) -> String {
1031    const LIMIT: usize = 5;
1032    let shown: Vec<String> = indices
1033        .iter()
1034        .take(LIMIT)
1035        .map(ToString::to_string)
1036        .collect();
1037    if indices.len() > LIMIT {
1038        format!("{}, ... ({} total)", shown.join(", "), indices.len())
1039    } else {
1040        shown.join(", ")
1041    }
1042}
1043
1044#[derive(Clone, Copy)]
1045pub(crate) enum FinitePolicy {
1046    Finite,
1047    AllowInfinity,
1048}
1049
1050pub(crate) fn validate_vector(
1051    field: &'static str,
1052    values: &[f64],
1053    expected: usize,
1054    finite_policy: FinitePolicy,
1055) -> Result<(), PortfolioError> {
1056    if values.len() != expected {
1057        return Err(ProblemError::Dimension {
1058            field,
1059            expected,
1060            actual: values.len(),
1061        }
1062        .into());
1063    }
1064    let invalid = match finite_policy {
1065        FinitePolicy::Finite => values.iter().any(|value| !value.is_finite()),
1066        FinitePolicy::AllowInfinity => values.iter().any(|value| value.is_nan()),
1067    };
1068    if invalid {
1069        return Err(ProblemError::NonFinite(field).into());
1070    }
1071    Ok(())
1072}
1073
1074/// Checks per-asset group ids: right length and, when the group count is
1075/// fixed by an explicit target vector, ids within range.
1076fn validate_group_ids(
1077    field: &'static str,
1078    groups: &[usize],
1079    dimension: usize,
1080    group_count: Option<usize>,
1081) -> Result<(), PortfolioError> {
1082    if groups.len() != dimension {
1083        return Err(ProblemError::Dimension {
1084            field,
1085            expected: dimension,
1086            actual: groups.len(),
1087        }
1088        .into());
1089    }
1090    if let Some(count) = group_count {
1091        if let Some(out_of_range) = groups.iter().find(|group| **group >= count) {
1092            return Err(PortfolioError::Template(format!(
1093                "{field} contains id {out_of_range} but only {count} target(s) were supplied"
1094            )));
1095        }
1096    }
1097    Ok(())
1098}
1099
1100/// Returns `existing` with `rows` appended (template builders never replace
1101/// user-supplied constraint rows).
1102fn append_constraint_rows(
1103    existing: &LinearConstraints,
1104    rows: Vec<Vec<f64>>,
1105    rhs: Vec<f64>,
1106) -> Result<LinearConstraints, PortfolioError> {
1107    let dimension = existing.matrix.cols();
1108    let total_rows = existing.len() + rows.len();
1109    let mut data = Vec::with_capacity(total_rows * dimension);
1110    data.extend_from_slice(existing.matrix.as_slice());
1111    for row in rows {
1112        debug_assert_eq!(row.len(), dimension);
1113        data.extend(row);
1114    }
1115    let mut combined_rhs = Vec::with_capacity(total_rows);
1116    combined_rhs.extend_from_slice(&existing.rhs);
1117    combined_rhs.extend(rhs);
1118    Ok(LinearConstraints::new(
1119        Matrix::new(total_rows, dimension, data)?,
1120        combined_rhs,
1121    )?)
1122}
1123
1124fn validated_constraints(
1125    field: &'static str,
1126    matrix: Matrix,
1127    rhs: Vec<f64>,
1128    dimension: usize,
1129) -> Result<LinearConstraints, PortfolioError> {
1130    if matrix.cols() != dimension {
1131        return Err(ProblemError::Dimension {
1132            field,
1133            expected: dimension,
1134            actual: matrix.cols(),
1135        }
1136        .into());
1137    }
1138    if matrix.as_slice().iter().any(|value| !value.is_finite())
1139        || rhs.iter().any(|value| !value.is_finite())
1140    {
1141        return Err(ProblemError::NonFinite(field).into());
1142    }
1143    Ok(LinearConstraints::new(matrix, rhs)?)
1144}