pub struct PortfolioProblem { /* private fields */ }Expand description
A mean-variance portfolio problem backed by a factor covariance.
The objective is
risk_aversion / 2 * (w - b)' Σ (w - b) - expected_returns' w
+ turnover_penalty / 2 * ||w - previous_weights||²
+ l1_turnover_costs' |w - previous_weights|where the benchmark b defaults to zero (absolute risk) and is set by
PortfolioProblem::with_tracking_benchmark for tracking-error
problems. The benchmark only shifts the linear term — the QP structure
is unchanged — and the constant risk_aversion / 2 * b' Σ b is dropped
from reported objective values, as is conventional for QP solvers.
The quadratic turnover term is an L2 approximation useful when a smooth
preference for stable weights is acceptable
(PortfolioProblem::with_quadratic_turnover). The L1 term models
proportional transaction costs exactly
(PortfolioProblem::with_l1_turnover); it is handled by a dedicated
proximal block inside the solver, so it never grows the reduced system.
Both may be combined; they share one previous portfolio.
Implementations§
Source§impl PortfolioProblem
impl PortfolioProblem
Sourcepub fn new(
factors: Matrix,
omega: FactorCovariance,
specific_variance: Vec<f64>,
expected_returns: Vec<f64>,
) -> Result<PortfolioProblem, PortfolioError>
pub fn new( factors: Matrix, omega: FactorCovariance, specific_variance: Vec<f64>, expected_returns: Vec<f64>, ) -> Result<PortfolioProblem, PortfolioError>
Creates a long-only, fully invested mean-variance problem.
Defaults are risk aversion 1, budget 1, and bounds [0, 1].
§Errors
Returns an error when covariance data or expected-return dimensions are invalid.
Examples found in repository?
7fn portfolio(expected_returns: Vec<f64>) -> Result<PortfolioProblem, Box<dyn Error>> {
8 let factors = Matrix::new(
9 6,
10 2,
11 vec![
12 0.8, 0.1, 0.7, -0.2, 0.2, 0.9, -0.1, 0.8, -0.5, 0.3, -0.6, -0.4,
13 ],
14 )?;
15 Ok(PortfolioProblem::new(
16 factors,
17 FactorCovariance::Diagonal(vec![0.08, 0.05]),
18 vec![0.12, 0.10, 0.09, 0.11, 0.08, 0.10],
19 expected_returns,
20 )?
21 .with_risk_aversion(6.0)?
22 .with_bounds(vec![0.0; 6], vec![0.35; 6])?)
23}More examples
59fn build_accounts(config: &Config) -> Result<Vec<BatchAccount>, Box<dyn Error>> {
60 // One shared model: exposures, factor covariance, and specific variance
61 // are identical across accounts.
62 let exposures: Vec<f64> = (0..config.assets * config.factors)
63 .map(|index| 0.3 * ((index + 1) as f64 * 12.9898).sin())
64 .collect();
65 let factors = Matrix::new(config.assets, config.factors, exposures)?;
66 let omega = FactorCovariance::Diagonal(
67 (0..config.factors)
68 .map(|index| 0.05 + 0.01 * index as f64)
69 .collect(),
70 );
71 let specific: Vec<f64> = (0..config.assets)
72 .map(|index| 0.08 + 0.04 * ((index * 7 % 13) as f64) / 13.0)
73 .collect();
74 let max_weight = (10.0 / config.assets as f64).min(1.0);
75 let uniform = vec![1.0 / config.assets as f64; config.assets];
76
77 (0..config.accounts)
78 .map(|account| {
79 let problem = PortfolioProblem::new(
80 factors.clone(),
81 omega.clone(),
82 specific.clone(),
83 expected_returns(config, account, 0),
84 )?
85 .with_risk_aversion(6.0)?
86 .with_bounds(vec![0.0; config.assets], vec![max_weight; config.assets])?
87 .with_quadratic_turnover(uniform.clone(), 0.5)?
88 .with_l1_turnover(uniform.clone(), vec![0.001; config.assets])?;
89 let steps = (0..config.dates)
90 .map(|date| RebalanceStep {
91 expected_returns: (date > 0).then(|| expected_returns(config, account, date)),
92 ..RebalanceStep::default()
93 })
94 .collect();
95 Ok(BatchAccount {
96 problem,
97 steps,
98 chain_previous_weights: true,
99 })
100 })
101 .collect()
102}22fn main() -> Result<(), Box<dyn Error>> {
23 let exposures: Vec<f64> = (0..ASSETS * FACTORS)
24 .map(|index| 0.3 * (index as f64 * 12.9898).sin())
25 .collect();
26 let problem = PortfolioProblem::new(
27 Matrix::new(ASSETS, FACTORS, exposures)?,
28 FactorCovariance::Diagonal(vec![0.06; FACTORS]),
29 vec![0.1; ASSETS],
30 expected_returns(0),
31 )?
32 .with_risk_aversion(6.0)?
33 .with_bounds(vec![0.0; ASSETS], vec![0.2; ASSETS])?
34 .with_quadratic_turnover(vec![1.0 / ASSETS as f64; ASSETS], 0.5)?;
35
36 let mut sequence = problem.sequence()?;
37 let mut previous_weights: Option<Vec<f64>> = None;
38
39 println!("date | status | iterations | new factorizations | one-way turnover");
40 println!("---|---|---|---|---");
41 for date in 0..DATES {
42 let factorizations_before = sequence.factorizations();
43 let step = RebalanceStep {
44 expected_returns: (date > 0).then(|| expected_returns(date)),
45 previous_weights: previous_weights.clone(),
46 ..RebalanceStep::default()
47 };
48 let solution = sequence.solve_next(&step)?;
49 if solution.status != SolveStatus::Solved {
50 return Err(format!(
51 "date {date} stopped with status '{}' after {} iterations",
52 solution.status, solution.iterations
53 )
54 .into());
55 }
56
57 let turnover = previous_weights.as_ref().map_or(0.0, |previous| {
58 solution
59 .x
60 .iter()
61 .zip(previous)
62 .map(|(new, old)| (new - old).abs())
63 .sum::<f64>()
64 / 2.0
65 });
66 println!(
67 "{date} | {} | {} | {} | {turnover:.6}",
68 solution.status,
69 solution.iterations,
70 sequence.factorizations() - factorizations_before,
71 );
72 previous_weights = Some(solution.x);
73 }
74 println!(
75 "total reduced factorizations across {DATES} dates: {}",
76 sequence.factorizations()
77 );
78 Ok(())
79}Sourcepub fn with_risk_aversion(
self,
risk_aversion: f64,
) -> Result<PortfolioProblem, PortfolioError>
pub fn with_risk_aversion( self, risk_aversion: f64, ) -> Result<PortfolioProblem, PortfolioError>
Sets the positive multiplier on portfolio variance.
§Errors
Returns an error unless risk_aversion is finite and positive.
Examples found in repository?
7fn portfolio(expected_returns: Vec<f64>) -> Result<PortfolioProblem, Box<dyn Error>> {
8 let factors = Matrix::new(
9 6,
10 2,
11 vec![
12 0.8, 0.1, 0.7, -0.2, 0.2, 0.9, -0.1, 0.8, -0.5, 0.3, -0.6, -0.4,
13 ],
14 )?;
15 Ok(PortfolioProblem::new(
16 factors,
17 FactorCovariance::Diagonal(vec![0.08, 0.05]),
18 vec![0.12, 0.10, 0.09, 0.11, 0.08, 0.10],
19 expected_returns,
20 )?
21 .with_risk_aversion(6.0)?
22 .with_bounds(vec![0.0; 6], vec![0.35; 6])?)
23}More examples
59fn build_accounts(config: &Config) -> Result<Vec<BatchAccount>, Box<dyn Error>> {
60 // One shared model: exposures, factor covariance, and specific variance
61 // are identical across accounts.
62 let exposures: Vec<f64> = (0..config.assets * config.factors)
63 .map(|index| 0.3 * ((index + 1) as f64 * 12.9898).sin())
64 .collect();
65 let factors = Matrix::new(config.assets, config.factors, exposures)?;
66 let omega = FactorCovariance::Diagonal(
67 (0..config.factors)
68 .map(|index| 0.05 + 0.01 * index as f64)
69 .collect(),
70 );
71 let specific: Vec<f64> = (0..config.assets)
72 .map(|index| 0.08 + 0.04 * ((index * 7 % 13) as f64) / 13.0)
73 .collect();
74 let max_weight = (10.0 / config.assets as f64).min(1.0);
75 let uniform = vec![1.0 / config.assets as f64; config.assets];
76
77 (0..config.accounts)
78 .map(|account| {
79 let problem = PortfolioProblem::new(
80 factors.clone(),
81 omega.clone(),
82 specific.clone(),
83 expected_returns(config, account, 0),
84 )?
85 .with_risk_aversion(6.0)?
86 .with_bounds(vec![0.0; config.assets], vec![max_weight; config.assets])?
87 .with_quadratic_turnover(uniform.clone(), 0.5)?
88 .with_l1_turnover(uniform.clone(), vec![0.001; config.assets])?;
89 let steps = (0..config.dates)
90 .map(|date| RebalanceStep {
91 expected_returns: (date > 0).then(|| expected_returns(config, account, date)),
92 ..RebalanceStep::default()
93 })
94 .collect();
95 Ok(BatchAccount {
96 problem,
97 steps,
98 chain_previous_weights: true,
99 })
100 })
101 .collect()
102}22fn main() -> Result<(), Box<dyn Error>> {
23 let exposures: Vec<f64> = (0..ASSETS * FACTORS)
24 .map(|index| 0.3 * (index as f64 * 12.9898).sin())
25 .collect();
26 let problem = PortfolioProblem::new(
27 Matrix::new(ASSETS, FACTORS, exposures)?,
28 FactorCovariance::Diagonal(vec![0.06; FACTORS]),
29 vec![0.1; ASSETS],
30 expected_returns(0),
31 )?
32 .with_risk_aversion(6.0)?
33 .with_bounds(vec![0.0; ASSETS], vec![0.2; ASSETS])?
34 .with_quadratic_turnover(vec![1.0 / ASSETS as f64; ASSETS], 0.5)?;
35
36 let mut sequence = problem.sequence()?;
37 let mut previous_weights: Option<Vec<f64>> = None;
38
39 println!("date | status | iterations | new factorizations | one-way turnover");
40 println!("---|---|---|---|---");
41 for date in 0..DATES {
42 let factorizations_before = sequence.factorizations();
43 let step = RebalanceStep {
44 expected_returns: (date > 0).then(|| expected_returns(date)),
45 previous_weights: previous_weights.clone(),
46 ..RebalanceStep::default()
47 };
48 let solution = sequence.solve_next(&step)?;
49 if solution.status != SolveStatus::Solved {
50 return Err(format!(
51 "date {date} stopped with status '{}' after {} iterations",
52 solution.status, solution.iterations
53 )
54 .into());
55 }
56
57 let turnover = previous_weights.as_ref().map_or(0.0, |previous| {
58 solution
59 .x
60 .iter()
61 .zip(previous)
62 .map(|(new, old)| (new - old).abs())
63 .sum::<f64>()
64 / 2.0
65 });
66 println!(
67 "{date} | {} | {} | {} | {turnover:.6}",
68 solution.status,
69 solution.iterations,
70 sequence.factorizations() - factorizations_before,
71 );
72 previous_weights = Some(solution.x);
73 }
74 println!(
75 "total reduced factorizations across {DATES} dates: {}",
76 sequence.factorizations()
77 );
78 Ok(())
79}Sourcepub fn with_budget(
self,
budget: Option<f64>,
) -> Result<PortfolioProblem, PortfolioError>
pub fn with_budget( self, budget: Option<f64>, ) -> Result<PortfolioProblem, PortfolioError>
Sets the budget equality, or removes it with None.
§Errors
Returns an error when a supplied budget is not finite.
Sourcepub fn with_bounds(
self,
lower_bounds: Vec<f64>,
upper_bounds: Vec<f64>,
) -> Result<PortfolioProblem, PortfolioError>
pub fn with_bounds( self, lower_bounds: Vec<f64>, upper_bounds: Vec<f64>, ) -> Result<PortfolioProblem, PortfolioError>
Replaces the per-asset box constraints.
§Errors
Returns an error for wrong dimensions, NaNs, or lower bounds above upper bounds.
Examples found in repository?
7fn portfolio(expected_returns: Vec<f64>) -> Result<PortfolioProblem, Box<dyn Error>> {
8 let factors = Matrix::new(
9 6,
10 2,
11 vec![
12 0.8, 0.1, 0.7, -0.2, 0.2, 0.9, -0.1, 0.8, -0.5, 0.3, -0.6, -0.4,
13 ],
14 )?;
15 Ok(PortfolioProblem::new(
16 factors,
17 FactorCovariance::Diagonal(vec![0.08, 0.05]),
18 vec![0.12, 0.10, 0.09, 0.11, 0.08, 0.10],
19 expected_returns,
20 )?
21 .with_risk_aversion(6.0)?
22 .with_bounds(vec![0.0; 6], vec![0.35; 6])?)
23}More examples
59fn build_accounts(config: &Config) -> Result<Vec<BatchAccount>, Box<dyn Error>> {
60 // One shared model: exposures, factor covariance, and specific variance
61 // are identical across accounts.
62 let exposures: Vec<f64> = (0..config.assets * config.factors)
63 .map(|index| 0.3 * ((index + 1) as f64 * 12.9898).sin())
64 .collect();
65 let factors = Matrix::new(config.assets, config.factors, exposures)?;
66 let omega = FactorCovariance::Diagonal(
67 (0..config.factors)
68 .map(|index| 0.05 + 0.01 * index as f64)
69 .collect(),
70 );
71 let specific: Vec<f64> = (0..config.assets)
72 .map(|index| 0.08 + 0.04 * ((index * 7 % 13) as f64) / 13.0)
73 .collect();
74 let max_weight = (10.0 / config.assets as f64).min(1.0);
75 let uniform = vec![1.0 / config.assets as f64; config.assets];
76
77 (0..config.accounts)
78 .map(|account| {
79 let problem = PortfolioProblem::new(
80 factors.clone(),
81 omega.clone(),
82 specific.clone(),
83 expected_returns(config, account, 0),
84 )?
85 .with_risk_aversion(6.0)?
86 .with_bounds(vec![0.0; config.assets], vec![max_weight; config.assets])?
87 .with_quadratic_turnover(uniform.clone(), 0.5)?
88 .with_l1_turnover(uniform.clone(), vec![0.001; config.assets])?;
89 let steps = (0..config.dates)
90 .map(|date| RebalanceStep {
91 expected_returns: (date > 0).then(|| expected_returns(config, account, date)),
92 ..RebalanceStep::default()
93 })
94 .collect();
95 Ok(BatchAccount {
96 problem,
97 steps,
98 chain_previous_weights: true,
99 })
100 })
101 .collect()
102}22fn main() -> Result<(), Box<dyn Error>> {
23 let exposures: Vec<f64> = (0..ASSETS * FACTORS)
24 .map(|index| 0.3 * (index as f64 * 12.9898).sin())
25 .collect();
26 let problem = PortfolioProblem::new(
27 Matrix::new(ASSETS, FACTORS, exposures)?,
28 FactorCovariance::Diagonal(vec![0.06; FACTORS]),
29 vec![0.1; ASSETS],
30 expected_returns(0),
31 )?
32 .with_risk_aversion(6.0)?
33 .with_bounds(vec![0.0; ASSETS], vec![0.2; ASSETS])?
34 .with_quadratic_turnover(vec![1.0 / ASSETS as f64; ASSETS], 0.5)?;
35
36 let mut sequence = problem.sequence()?;
37 let mut previous_weights: Option<Vec<f64>> = None;
38
39 println!("date | status | iterations | new factorizations | one-way turnover");
40 println!("---|---|---|---|---");
41 for date in 0..DATES {
42 let factorizations_before = sequence.factorizations();
43 let step = RebalanceStep {
44 expected_returns: (date > 0).then(|| expected_returns(date)),
45 previous_weights: previous_weights.clone(),
46 ..RebalanceStep::default()
47 };
48 let solution = sequence.solve_next(&step)?;
49 if solution.status != SolveStatus::Solved {
50 return Err(format!(
51 "date {date} stopped with status '{}' after {} iterations",
52 solution.status, solution.iterations
53 )
54 .into());
55 }
56
57 let turnover = previous_weights.as_ref().map_or(0.0, |previous| {
58 solution
59 .x
60 .iter()
61 .zip(previous)
62 .map(|(new, old)| (new - old).abs())
63 .sum::<f64>()
64 / 2.0
65 });
66 println!(
67 "{date} | {} | {} | {} | {turnover:.6}",
68 solution.status,
69 solution.iterations,
70 sequence.factorizations() - factorizations_before,
71 );
72 previous_weights = Some(solution.x);
73 }
74 println!(
75 "total reduced factorizations across {DATES} dates: {}",
76 sequence.factorizations()
77 );
78 Ok(())
79}Sourcepub fn with_equalities(
self,
matrix: Matrix,
rhs: Vec<f64>,
) -> Result<PortfolioProblem, PortfolioError>
pub fn with_equalities( self, matrix: Matrix, rhs: Vec<f64>, ) -> Result<PortfolioProblem, PortfolioError>
Adds user-supplied equality constraints alongside the optional budget.
§Errors
Returns an error when dimensions or coefficients are invalid.
Sourcepub fn with_inequalities(
self,
matrix: Matrix,
rhs: Vec<f64>,
) -> Result<PortfolioProblem, PortfolioError>
pub fn with_inequalities( self, matrix: Matrix, rhs: Vec<f64>, ) -> Result<PortfolioProblem, PortfolioError>
Adds upper-form linear constraints matrix * weights <= rhs.
§Errors
Returns an error when dimensions or coefficients are invalid.
Sourcepub fn with_quadratic_turnover(
self,
previous_weights: Vec<f64>,
turnover_penalty: f64,
) -> Result<PortfolioProblem, PortfolioError>
pub fn with_quadratic_turnover( self, previous_weights: Vec<f64>, turnover_penalty: f64, ) -> Result<PortfolioProblem, PortfolioError>
Adds an L2 penalty around the previous portfolio.
This is not proportional transaction cost — use
PortfolioProblem::with_l1_turnover for that. The problem models a
single previous portfolio: calling this after with_l1_turnover
replaces the shared anchor.
§Errors
Returns an error for a wrong-length/non-finite previous portfolio or a negative/non-finite penalty.
Examples found in repository?
40fn main() -> Result<(), Box<dyn Error>> {
41 let first = portfolio(vec![0.09, 0.08, 0.07, 0.06, 0.05, 0.04])?.solve(None)?;
42 require_solved(&first)?;
43
44 let warm_start = first.warm_start();
45 let second = portfolio(vec![0.07, 0.08, 0.10, 0.05, 0.06, 0.04])?
46 .with_quadratic_turnover(first.x.clone(), 0.4)?
47 .solve(Some(&warm_start))?;
48 require_solved(&second)?;
49
50 let budget: f64 = second.x.iter().sum();
51 let one_way_turnover: f64 = first
52 .x
53 .iter()
54 .zip(&second.x)
55 .map(|(old, new)| (new - old).abs())
56 .sum::<f64>()
57 / 2.0;
58 println!("first weights: {:.6?}", first.x);
59 println!("second weights: {:.6?}", second.x);
60 println!("budget: {budget:.10}");
61 println!("one-way turnover: {one_way_turnover:.6}");
62 println!(
63 "status: {}, iterations: {}, primal residual: {:.3e}, dual residual: {:.3e}",
64 second.status, second.iterations, second.residuals.primal, second.residuals.dual
65 );
66 Ok(())
67}More examples
59fn build_accounts(config: &Config) -> Result<Vec<BatchAccount>, Box<dyn Error>> {
60 // One shared model: exposures, factor covariance, and specific variance
61 // are identical across accounts.
62 let exposures: Vec<f64> = (0..config.assets * config.factors)
63 .map(|index| 0.3 * ((index + 1) as f64 * 12.9898).sin())
64 .collect();
65 let factors = Matrix::new(config.assets, config.factors, exposures)?;
66 let omega = FactorCovariance::Diagonal(
67 (0..config.factors)
68 .map(|index| 0.05 + 0.01 * index as f64)
69 .collect(),
70 );
71 let specific: Vec<f64> = (0..config.assets)
72 .map(|index| 0.08 + 0.04 * ((index * 7 % 13) as f64) / 13.0)
73 .collect();
74 let max_weight = (10.0 / config.assets as f64).min(1.0);
75 let uniform = vec![1.0 / config.assets as f64; config.assets];
76
77 (0..config.accounts)
78 .map(|account| {
79 let problem = PortfolioProblem::new(
80 factors.clone(),
81 omega.clone(),
82 specific.clone(),
83 expected_returns(config, account, 0),
84 )?
85 .with_risk_aversion(6.0)?
86 .with_bounds(vec![0.0; config.assets], vec![max_weight; config.assets])?
87 .with_quadratic_turnover(uniform.clone(), 0.5)?
88 .with_l1_turnover(uniform.clone(), vec![0.001; config.assets])?;
89 let steps = (0..config.dates)
90 .map(|date| RebalanceStep {
91 expected_returns: (date > 0).then(|| expected_returns(config, account, date)),
92 ..RebalanceStep::default()
93 })
94 .collect();
95 Ok(BatchAccount {
96 problem,
97 steps,
98 chain_previous_weights: true,
99 })
100 })
101 .collect()
102}22fn main() -> Result<(), Box<dyn Error>> {
23 let exposures: Vec<f64> = (0..ASSETS * FACTORS)
24 .map(|index| 0.3 * (index as f64 * 12.9898).sin())
25 .collect();
26 let problem = PortfolioProblem::new(
27 Matrix::new(ASSETS, FACTORS, exposures)?,
28 FactorCovariance::Diagonal(vec![0.06; FACTORS]),
29 vec![0.1; ASSETS],
30 expected_returns(0),
31 )?
32 .with_risk_aversion(6.0)?
33 .with_bounds(vec![0.0; ASSETS], vec![0.2; ASSETS])?
34 .with_quadratic_turnover(vec![1.0 / ASSETS as f64; ASSETS], 0.5)?;
35
36 let mut sequence = problem.sequence()?;
37 let mut previous_weights: Option<Vec<f64>> = None;
38
39 println!("date | status | iterations | new factorizations | one-way turnover");
40 println!("---|---|---|---|---");
41 for date in 0..DATES {
42 let factorizations_before = sequence.factorizations();
43 let step = RebalanceStep {
44 expected_returns: (date > 0).then(|| expected_returns(date)),
45 previous_weights: previous_weights.clone(),
46 ..RebalanceStep::default()
47 };
48 let solution = sequence.solve_next(&step)?;
49 if solution.status != SolveStatus::Solved {
50 return Err(format!(
51 "date {date} stopped with status '{}' after {} iterations",
52 solution.status, solution.iterations
53 )
54 .into());
55 }
56
57 let turnover = previous_weights.as_ref().map_or(0.0, |previous| {
58 solution
59 .x
60 .iter()
61 .zip(previous)
62 .map(|(new, old)| (new - old).abs())
63 .sum::<f64>()
64 / 2.0
65 });
66 println!(
67 "{date} | {} | {} | {} | {turnover:.6}",
68 solution.status,
69 solution.iterations,
70 sequence.factorizations() - factorizations_before,
71 );
72 previous_weights = Some(solution.x);
73 }
74 println!(
75 "total reduced factorizations across {DATES} dates: {}",
76 sequence.factorizations()
77 );
78 Ok(())
79}Sourcepub fn with_l1_turnover(
self,
previous_weights: Vec<f64>,
costs: Vec<f64>,
) -> Result<PortfolioProblem, PortfolioError>
pub fn with_l1_turnover( self, previous_weights: Vec<f64>, costs: Vec<f64>, ) -> Result<PortfolioProblem, PortfolioError>
Adds exact proportional transaction costs
costs' |w - previous_weights| around the previous portfolio
(roadmap 2.1).
Unlike the L2 approximation, this is the real trading-cost model: a
per-asset charge proportional to the traded amount, with a genuine
no-trade region. The term is handled by a dedicated soft-threshold
proximal block, so the reduced factorization keeps its
factors + constraints dimension — an epigraph reformulation would
add 2n constraint rows instead.
May be combined with PortfolioProblem::with_quadratic_turnover;
the two terms share a single previous portfolio, and calling either
method replaces that shared anchor.
§Errors
Returns an error for wrong-length or non-finite previous weights, or costs that are not finite and non-negative.
Examples found in repository?
59fn build_accounts(config: &Config) -> Result<Vec<BatchAccount>, Box<dyn Error>> {
60 // One shared model: exposures, factor covariance, and specific variance
61 // are identical across accounts.
62 let exposures: Vec<f64> = (0..config.assets * config.factors)
63 .map(|index| 0.3 * ((index + 1) as f64 * 12.9898).sin())
64 .collect();
65 let factors = Matrix::new(config.assets, config.factors, exposures)?;
66 let omega = FactorCovariance::Diagonal(
67 (0..config.factors)
68 .map(|index| 0.05 + 0.01 * index as f64)
69 .collect(),
70 );
71 let specific: Vec<f64> = (0..config.assets)
72 .map(|index| 0.08 + 0.04 * ((index * 7 % 13) as f64) / 13.0)
73 .collect();
74 let max_weight = (10.0 / config.assets as f64).min(1.0);
75 let uniform = vec![1.0 / config.assets as f64; config.assets];
76
77 (0..config.accounts)
78 .map(|account| {
79 let problem = PortfolioProblem::new(
80 factors.clone(),
81 omega.clone(),
82 specific.clone(),
83 expected_returns(config, account, 0),
84 )?
85 .with_risk_aversion(6.0)?
86 .with_bounds(vec![0.0; config.assets], vec![max_weight; config.assets])?
87 .with_quadratic_turnover(uniform.clone(), 0.5)?
88 .with_l1_turnover(uniform.clone(), vec![0.001; config.assets])?;
89 let steps = (0..config.dates)
90 .map(|date| RebalanceStep {
91 expected_returns: (date > 0).then(|| expected_returns(config, account, date)),
92 ..RebalanceStep::default()
93 })
94 .collect();
95 Ok(BatchAccount {
96 problem,
97 steps,
98 chain_previous_weights: true,
99 })
100 })
101 .collect()
102}Sourcepub fn with_tracking_benchmark(
self,
benchmark_weights: Vec<f64>,
) -> Result<PortfolioProblem, PortfolioError>
pub fn with_tracking_benchmark( self, benchmark_weights: Vec<f64>, ) -> Result<PortfolioProblem, PortfolioError>
Sets the benchmark for a tracking-error objective (roadmap 2.6).
The risk term becomes risk_aversion / 2 * (w - b)' Σ (w - b):
active risk against the benchmark instead of absolute risk. It is the
same QP underneath — expanding the square only shifts the linear
objective by -risk_aversion * Σ b — so no solver machinery changes
and rolling sequences keep their cached factorizations when the
benchmark moves.
The constant risk_aversion / 2 * b' Σ b is dropped from reported
objective values, as is conventional for QP solvers; add it back if
an absolute tracking-error number is needed.
The benchmark does not need to satisfy the portfolio’s constraints.
§Errors
Returns an error for wrong-length or non-finite benchmark weights.
Sourcepub fn with_industry_neutrality(
self,
industries: &[usize],
) -> Result<PortfolioProblem, PortfolioError>
pub fn with_industry_neutrality( self, industries: &[usize], ) -> Result<PortfolioProblem, PortfolioError>
Adds industry-neutrality equalities derived from the tracking benchmark (roadmap 3.1).
industries[i] is the zero-based industry id of asset i; industry
ids run from 0 to max(industries). One equality row is appended
per industry, pinning the portfolio’s industry weight to the
benchmark’s:
sum_{i in industry g} w_i = sum_{i in industry g} b_iwhich is exactly “zero net active industry exposure”. Requires
PortfolioProblem::with_tracking_benchmark to have been called
first; for explicit targets (or without a benchmark) use
PortfolioProblem::with_group_targets.
The rows are appended after any existing user equality rows, one per
industry in id order, and count as user equality rows from then on:
rolling sequences move the targets through
RebalanceStep::equality_rhs like any other
equality target. Call
PortfolioProblem::with_equalities before this method — it
replaces the user equality block.
§Errors
Returns an error when no tracking benchmark is set, industries has
the wrong length, or an industry has no member assets.
Sourcepub fn with_group_targets(
self,
groups: &[usize],
targets: &[f64],
) -> Result<PortfolioProblem, PortfolioError>
pub fn with_group_targets( self, groups: &[usize], targets: &[f64], ) -> Result<PortfolioProblem, PortfolioError>
Adds group-weight equalities: one row per group pinning the summed member weights to an explicit target (roadmap 3.1).
groups[i] is the zero-based group id of asset i and must be
smaller than targets.len(); group g contributes the row
sum_{i in group g} w_i = targets[g]This is the explicit-target form of
PortfolioProblem::with_industry_neutrality and also covers
sector/country sleeves or a zero-net-exposure book
(targets = [0.0, ...]).
The rows are appended after any existing user equality rows, one per
group in id order, and count as user equality rows from then on:
rolling sequences move the targets through
RebalanceStep::equality_rhs. Call
PortfolioProblem::with_equalities before this method — it
replaces the user equality block.
§Errors
Returns an error when groups has the wrong length or an id out of
range, a target is non-finite, or a group has no member assets.
Sourcepub fn with_style_bounds(
self,
exposures: &Matrix,
lower: &[f64],
upper: &[f64],
) -> Result<PortfolioProblem, PortfolioError>
pub fn with_style_bounds( self, exposures: &Matrix, lower: &[f64], upper: &[f64], ) -> Result<PortfolioProblem, PortfolioError>
Adds style-exposure bands lower[s] <= exposures[s] · w <= upper[s]
as linear constraint rows (roadmap 3.1).
exposures is styles-by-assets: row s holds the per-asset loadings
of style s (momentum, value, size, …). Use
f64::NEG_INFINITY / f64::INFINITY for one-sided bands. A band
with lower[s] == upper[s] becomes a single equality row (style
neutrality); other bands append their finite sides as inequality
rows.
Appended row order, per style in index order: the equality row (when
the band is exact), otherwise the upper row
exposures[s] · w <= upper[s] followed by the lower row
-exposures[s] · w <= -lower[s], skipping infinite sides. The rows
count as user constraint rows from then on: rolling sequences move
the bands through
RebalanceStep::equality_rhs /
RebalanceStep::inequality_rhs (mind the
sign convention on lower rows). Call
PortfolioProblem::with_equalities /
PortfolioProblem::with_inequalities before this method — they
replace their constraint blocks.
§Errors
Returns an error for dimension mismatches, non-finite loadings, NaN or crossing bounds, or a style with neither side finite.
Sourcepub fn with_concentration_limit(
self,
max_weight: f64,
) -> Result<PortfolioProblem, PortfolioError>
pub fn with_concentration_limit( self, max_weight: f64, ) -> Result<PortfolioProblem, PortfolioError>
Caps every asset’s absolute weight: |w_i| <= max_weight
(roadmap 3.1).
Concentration limits map onto the existing box constraints — upper
bounds are tightened to min(upper_i, max_weight) and lower bounds
raised to max(lower_i, -max_weight) — so no constraint rows are
added and the reduced factorization keeps its dimension. Long-only
problems (default bounds [0, 1]) end up with w_i <= max_weight.
Apply after PortfolioProblem::with_bounds; the tightening keeps
whichever side is already stricter.
§Errors
Returns an error unless max_weight is finite and positive, or when
the cap contradicts an existing bound (for example a forced minimum
position above the cap).
Sourcepub fn with_short_limit(
self,
max_short: f64,
) -> Result<PortfolioProblem, PortfolioError>
pub fn with_short_limit( self, max_short: f64, ) -> Result<PortfolioProblem, PortfolioError>
Caps every asset’s short position: w_i >= -max_short
(roadmap 3.1).
Short limits map onto the existing box constraints — lower bounds are
raised to max(lower_i, -max_short) — so no constraint rows are
added. max_short = 0 forces a long-only portfolio. This is a
per-asset limit; a total short-budget cap
(sum_i max(-w_i, 0) <= S) needs a long/short variable split that
the QP form deliberately does not model.
Apply after PortfolioProblem::with_bounds; the tightening keeps
whichever lower bound is already stricter.
§Errors
Returns an error unless max_short is finite and non-negative, or
when the limit contradicts an existing bound (an upper bound forcing
a deeper short).
Sourcepub fn to_qp(&self) -> Result<QpProblem, PortfolioError>
pub fn to_qp(&self) -> Result<QpProblem, PortfolioError>
Builds the standard-form QP consumed by the numerical kernel.
§Errors
Returns an error when the budget is already impossible under the box constraints or if final QP validation fails.
Sourcepub fn solve(
&self,
warm_start: Option<&WarmStart>,
) -> Result<Solution, PortfolioError>
pub fn solve( &self, warm_start: Option<&WarmStart>, ) -> Result<Solution, PortfolioError>
Solves with default settings.
§Errors
Returns setup and validation errors. A completed solve reports
convergence through crate::SolveStatus on the returned solution.
Examples found in repository?
40fn main() -> Result<(), Box<dyn Error>> {
41 let first = portfolio(vec![0.09, 0.08, 0.07, 0.06, 0.05, 0.04])?.solve(None)?;
42 require_solved(&first)?;
43
44 let warm_start = first.warm_start();
45 let second = portfolio(vec![0.07, 0.08, 0.10, 0.05, 0.06, 0.04])?
46 .with_quadratic_turnover(first.x.clone(), 0.4)?
47 .solve(Some(&warm_start))?;
48 require_solved(&second)?;
49
50 let budget: f64 = second.x.iter().sum();
51 let one_way_turnover: f64 = first
52 .x
53 .iter()
54 .zip(&second.x)
55 .map(|(old, new)| (new - old).abs())
56 .sum::<f64>()
57 / 2.0;
58 println!("first weights: {:.6?}", first.x);
59 println!("second weights: {:.6?}", second.x);
60 println!("budget: {budget:.10}");
61 println!("one-way turnover: {one_way_turnover:.6}");
62 println!(
63 "status: {}, iterations: {}, primal residual: {:.3e}, dual residual: {:.3e}",
64 second.status, second.iterations, second.residuals.primal, second.residuals.dual
65 );
66 Ok(())
67}Sourcepub fn sequence(&self) -> Result<PortfolioSequence, PortfolioError>
pub fn sequence(&self) -> Result<PortfolioSequence, PortfolioError>
Builds a rolling PortfolioSequence with default settings
(roadmap 2.5).
The sequence reuses the equilibration and the reduced factorizations
across dates and chains warm starts automatically; per-date data
changes are described by crate::RebalanceStep.
§Errors
Returns setup and validation errors for the base problem.
Examples found in repository?
22fn main() -> Result<(), Box<dyn Error>> {
23 let exposures: Vec<f64> = (0..ASSETS * FACTORS)
24 .map(|index| 0.3 * (index as f64 * 12.9898).sin())
25 .collect();
26 let problem = PortfolioProblem::new(
27 Matrix::new(ASSETS, FACTORS, exposures)?,
28 FactorCovariance::Diagonal(vec![0.06; FACTORS]),
29 vec![0.1; ASSETS],
30 expected_returns(0),
31 )?
32 .with_risk_aversion(6.0)?
33 .with_bounds(vec![0.0; ASSETS], vec![0.2; ASSETS])?
34 .with_quadratic_turnover(vec![1.0 / ASSETS as f64; ASSETS], 0.5)?;
35
36 let mut sequence = problem.sequence()?;
37 let mut previous_weights: Option<Vec<f64>> = None;
38
39 println!("date | status | iterations | new factorizations | one-way turnover");
40 println!("---|---|---|---|---");
41 for date in 0..DATES {
42 let factorizations_before = sequence.factorizations();
43 let step = RebalanceStep {
44 expected_returns: (date > 0).then(|| expected_returns(date)),
45 previous_weights: previous_weights.clone(),
46 ..RebalanceStep::default()
47 };
48 let solution = sequence.solve_next(&step)?;
49 if solution.status != SolveStatus::Solved {
50 return Err(format!(
51 "date {date} stopped with status '{}' after {} iterations",
52 solution.status, solution.iterations
53 )
54 .into());
55 }
56
57 let turnover = previous_weights.as_ref().map_or(0.0, |previous| {
58 solution
59 .x
60 .iter()
61 .zip(previous)
62 .map(|(new, old)| (new - old).abs())
63 .sum::<f64>()
64 / 2.0
65 });
66 println!(
67 "{date} | {} | {} | {} | {turnover:.6}",
68 solution.status,
69 solution.iterations,
70 sequence.factorizations() - factorizations_before,
71 );
72 previous_weights = Some(solution.x);
73 }
74 println!(
75 "total reduced factorizations across {DATES} dates: {}",
76 sequence.factorizations()
77 );
78 Ok(())
79}Sourcepub fn sequence_with(
&self,
solver: &Solver,
) -> Result<PortfolioSequence, PortfolioError>
pub fn sequence_with( &self, solver: &Solver, ) -> Result<PortfolioSequence, PortfolioError>
Builds a rolling PortfolioSequence iterating with an explicitly
configured solver.
§Errors
Returns setup and validation errors for the base problem.
Sourcepub fn solve_with(
&self,
solver: &Solver,
warm_start: Option<&WarmStart>,
) -> Result<Solution, PortfolioError>
pub fn solve_with( &self, solver: &Solver, warm_start: Option<&WarmStart>, ) -> Result<Solution, PortfolioError>
Solves with an explicitly configured solver.
§Errors
Returns setup and validation errors. A completed solve reports
convergence through crate::SolveStatus on the returned solution.
Trait Implementations§
Source§impl Clone for PortfolioProblem
impl Clone for PortfolioProblem
Source§fn clone(&self) -> PortfolioProblem
fn clone(&self) -> PortfolioProblem
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read more