1use 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#[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#[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 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 #[must_use]
174 pub fn dimension(&self) -> usize {
175 self.covariance.dimension()
176 }
177
178 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn solve(&self, warm_start: Option<&WarmStart>) -> Result<Solution, PortfolioError> {
743 self.solve_with(&Solver::default(), warm_start)
744 }
745
746 pub fn sequence(&self) -> Result<PortfolioSequence, PortfolioError> {
757 self.sequence_with(&Solver::default())
758 }
759
760 pub fn sequence_with(&self, solver: &Solver) -> Result<PortfolioSequence, PortfolioError> {
767 PortfolioSequence::new(self, solver)
768 }
769
770 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
859pub 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#[derive(Debug, Error)]
876pub enum PortfolioError {
877 #[error(transparent)]
879 Problem(#[from] ProblemError),
880 #[error(transparent)]
882 Matrix(#[from] MatrixError),
883 #[error(transparent)]
885 Solver(#[from] SolverError),
886 #[error("invalid portfolio parameter: {0}")]
888 InvalidParameter(&'static str),
889 #[error("invalid constraint template: {0}")]
891 Template(String),
892 #[error(
894 "budget {budget} is impossible under the box constraints: reachable sum is [{minimum}, {maximum}]"
895 )]
896 BudgetOutsideBounds {
897 budget: f64,
899 minimum: f64,
901 maximum: f64,
903 },
904}
905
906pub(crate) struct PortfolioSemantics {
909 pub budget: Option<f64>,
910 pub user_equality_count: usize,
911}
912
913const PARTICIPATION_THRESHOLD: f64 = 0.05;
916
917pub(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
1029fn 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
1074fn 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
1100fn 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}