use crate::{
portfolio::{
append_certificate_hints, validate_vector, FinitePolicy, PortfolioError, PortfolioProblem,
PortfolioSemantics,
},
problem::{FactorQuad, ProblemError},
solver::{Solution, SolveStatus, Solver, SolverSettings, WarmStart},
workspace::Workspace,
};
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RebalanceStep {
pub expected_returns: Option<Vec<f64>>,
pub previous_weights: Option<Vec<f64>>,
pub benchmark_weights: Option<Vec<f64>>,
pub budget: Option<f64>,
pub equality_rhs: Option<Vec<f64>>,
pub inequality_rhs: Option<Vec<f64>>,
}
impl RebalanceStep {
const fn is_empty(&self) -> bool {
self.expected_returns.is_none()
&& self.previous_weights.is_none()
&& self.benchmark_weights.is_none()
&& self.budget.is_none()
&& self.equality_rhs.is_none()
&& self.inequality_rhs.is_none()
}
}
pub struct PortfolioSequence {
workspace: Workspace,
expected_returns: Vec<f64>,
previous_weights: Option<Vec<f64>>,
turnover_penalty: f64,
has_l1_turnover: bool,
covariance: FactorQuad,
risk_aversion: f64,
benchmark_weights: Option<Vec<f64>>,
budget: Option<f64>,
user_equality_rhs: Vec<f64>,
minimum_budget: f64,
maximum_budget: f64,
warm_start: Option<WarmStart>,
}
impl PortfolioSequence {
pub(crate) fn new(problem: &PortfolioProblem, solver: &Solver) -> Result<Self, PortfolioError> {
let qp = problem.to_qp()?;
let workspace = solver.workspace(&qp)?;
Ok(Self {
workspace,
expected_returns: problem.expected_returns().to_vec(),
previous_weights: problem.previous_weights().map(<[f64]>::to_vec),
turnover_penalty: problem.turnover_penalty(),
has_l1_turnover: problem.has_l1_turnover(),
covariance: problem.covariance().clone(),
risk_aversion: problem.risk_aversion(),
benchmark_weights: problem.benchmark_weights().map(<[f64]>::to_vec),
budget: problem.budget(),
user_equality_rhs: problem.user_equality_rhs().to_vec(),
minimum_budget: problem.lower_bounds().iter().sum(),
maximum_budget: problem.upper_bounds().iter().sum(),
warm_start: None,
})
}
#[must_use]
pub fn dimension(&self) -> usize {
self.expected_returns.len()
}
#[must_use]
pub const fn settings(&self) -> &SolverSettings {
self.workspace.settings()
}
#[must_use]
pub const fn factorizations(&self) -> usize {
self.workspace.factorizations()
}
pub fn solve_next(&mut self, step: &RebalanceStep) -> Result<Solution, PortfolioError> {
self.apply(step)?;
let mut solution = self.workspace.solve(self.warm_start.as_ref())?;
append_certificate_hints(
&mut solution,
&PortfolioSemantics {
budget: self.budget,
user_equality_count: self.user_equality_rhs.len(),
},
);
self.warm_start = matches!(
solution.status,
SolveStatus::Solved | SolveStatus::MaxIterations
)
.then(|| solution.warm_start());
Ok(solution)
}
fn apply(&mut self, step: &RebalanceStep) -> Result<(), PortfolioError> {
if step.is_empty() {
return Ok(());
}
let dimension = self.dimension();
if let Some(expected_returns) = &step.expected_returns {
validate_vector(
"expected_returns",
expected_returns,
dimension,
FinitePolicy::Finite,
)?;
}
if let Some(previous_weights) = &step.previous_weights {
if self.previous_weights.is_none() {
return Err(PortfolioError::InvalidParameter(
"previous_weights updates require a base problem built with \
with_quadratic_turnover and/or with_l1_turnover; the L2 penalty \
and the L1 costs are part of the problem structure and stay \
fixed for the whole sequence",
));
}
validate_vector(
"previous_weights",
previous_weights,
dimension,
FinitePolicy::Finite,
)?;
}
if let Some(benchmark_weights) = &step.benchmark_weights {
if self.benchmark_weights.is_none() {
return Err(PortfolioError::InvalidParameter(
"benchmark_weights updates require a base problem built with \
with_tracking_benchmark; switching between absolute-risk and \
tracking objectives changes what the sequence is solving",
));
}
validate_vector(
"benchmark_weights",
benchmark_weights,
dimension,
FinitePolicy::Finite,
)?;
}
if let Some(budget) = step.budget {
if self.budget.is_none() {
return Err(PortfolioError::InvalidParameter(
"budget updates require a base problem with a budget constraint; \
adding or removing the budget row changes the factored system",
));
}
if !budget.is_finite() {
return Err(PortfolioError::InvalidParameter(
"budget must be finite when provided",
));
}
if budget < self.minimum_budget || budget > self.maximum_budget {
return Err(PortfolioError::BudgetOutsideBounds {
budget,
minimum: self.minimum_budget,
maximum: self.maximum_budget,
});
}
}
if let Some(equality_rhs) = &step.equality_rhs {
validate_vector(
"equality_rhs",
equality_rhs,
self.user_equality_rhs.len(),
FinitePolicy::Finite,
)?;
}
if let Some(inequality_rhs) = &step.inequality_rhs {
validate_vector(
"inequality_rhs",
inequality_rhs,
self.workspace.problem().inequalities.len(),
FinitePolicy::Finite,
)?;
}
let linear = if step.expected_returns.is_some()
|| step.previous_weights.is_some()
|| step.benchmark_weights.is_some()
{
let expected_returns = step
.expected_returns
.as_deref()
.unwrap_or(&self.expected_returns);
let mut linear: Vec<f64> = expected_returns.iter().map(|value| -value).collect();
if let Some(previous_weights) = step
.previous_weights
.as_deref()
.or(self.previous_weights.as_deref())
{
for (value, previous) in linear.iter_mut().zip(previous_weights) {
*value -= self.turnover_penalty * previous;
}
}
if let Some(benchmark_weights) = step
.benchmark_weights
.as_deref()
.or(self.benchmark_weights.as_deref())
{
let covariance_times_benchmark = self.covariance.apply(benchmark_weights);
for (value, product) in linear.iter_mut().zip(&covariance_times_benchmark) {
*value -= self.risk_aversion * product;
}
}
if linear.iter().any(|value| !value.is_finite()) {
return Err(ProblemError::NonFinite("linear").into());
}
Some(linear)
} else {
None
};
let combined_equality_rhs = if step.budget.is_some() || step.equality_rhs.is_some() {
let mut combined = Vec::with_capacity(
usize::from(self.budget.is_some()) + self.user_equality_rhs.len(),
);
if let Some(budget) = step.budget.or(self.budget) {
combined.push(budget);
}
combined.extend_from_slice(
step.equality_rhs
.as_deref()
.unwrap_or(&self.user_equality_rhs),
);
Some(combined)
} else {
None
};
if let Some(linear) = &linear {
self.workspace.update_linear(linear)?;
}
if let (Some(previous_weights), true) = (&step.previous_weights, self.has_l1_turnover) {
self.workspace.update_l1_anchor(previous_weights)?;
}
if let Some(combined) = &combined_equality_rhs {
self.workspace.update_equality_rhs(combined)?;
}
if let Some(inequality_rhs) = &step.inequality_rhs {
self.workspace.update_inequality_rhs(inequality_rhs)?;
}
if let Some(expected_returns) = &step.expected_returns {
self.expected_returns.clone_from(expected_returns);
}
if let Some(previous_weights) = &step.previous_weights {
self.previous_weights = Some(previous_weights.clone());
}
if let Some(benchmark_weights) = &step.benchmark_weights {
self.benchmark_weights = Some(benchmark_weights.clone());
}
if let Some(budget) = step.budget {
self.budget = Some(budget);
}
if let Some(equality_rhs) = &step.equality_rhs {
self.user_equality_rhs.clone_from(equality_rhs);
}
Ok(())
}
}
pub fn solve_sequence(
problem: &PortfolioProblem,
settings: Option<SolverSettings>,
steps: &[RebalanceStep],
) -> Result<Vec<Solution>, PortfolioError> {
let solver = Solver::new(settings.unwrap_or_default());
let mut sequence = problem.sequence_with(&solver)?;
steps.iter().map(|step| sequence.solve_next(step)).collect()
}