use ledge_core::{
check_kkt, generate_synthetic, FactorCovariance, FactorQuad, KktResiduals, LinearConstraints,
Matrix, QpProblem, RebalanceStep, SolveStatus, Solver, SolverSettings, SyntheticConfig,
};
fn worst(residuals: &KktResiduals) -> f64 {
residuals
.primal
.max(residuals.dual)
.max(residuals.complementarity)
}
fn no_polish() -> SolverSettings {
SolverSettings {
polish: false,
..SolverSettings::default()
}
}
#[test]
fn polish_reaches_high_accuracy_on_synthetic_portfolios() {
for (assets, factors, inequalities) in [(60, 6, 3), (150, 10, 5), (400, 20, 8)] {
let instance = generate_synthetic(SyntheticConfig {
assets,
factors,
inequalities,
seed: 7,
..SyntheticConfig::default()
})
.unwrap();
let solution = Solver::default().solve(&instance.problem, None).unwrap();
assert_eq!(solution.status, SolveStatus::Solved);
assert!(
solution.polished,
"expected polish to improve n={assets}, residuals {:?}",
solution.residuals
);
let audited = check_kkt(&instance.problem, &solution.x, &solution.dual).unwrap();
assert!(
worst(&audited) <= 1.0e-9,
"n={assets}: polished residuals should reach 1e-9, got {audited:?}"
);
}
}
#[test]
fn polish_never_regresses_the_worst_residual() {
for seed in [1, 2, 3, 4, 5, 6, 7, 8] {
let instance = generate_synthetic(SyntheticConfig {
assets: 80,
factors: 8,
inequalities: 4,
seed,
..SyntheticConfig::default()
})
.unwrap();
let polished = Solver::default().solve(&instance.problem, None).unwrap();
let raw = Solver::new(no_polish())
.solve(&instance.problem, None)
.unwrap();
assert_eq!(polished.status, SolveStatus::Solved);
assert_eq!(raw.status, SolveStatus::Solved);
assert_eq!(polished.iterations, raw.iterations);
assert!(
worst(&polished.residuals) <= worst(&raw.residuals),
"seed {seed}: polish must never report worse residuals \
({:?} vs {:?})",
polished.residuals,
raw.residuals
);
for (with, without) in polished.x.iter().zip(&raw.x) {
assert!(
(with - without).abs() <= 1.0e-4,
"seed {seed}: polish moved the solution more than refinement should"
);
}
}
}
#[test]
fn polish_recovers_the_exact_constrained_optimum() {
let problem = QpProblem {
quadratic: FactorQuad::new(
Matrix::new(1, 0, Vec::new()).unwrap(),
FactorCovariance::Diagonal(Vec::new()),
vec![1.0],
)
.unwrap(),
linear: vec![-2.0],
l1: None,
equalities: LinearConstraints::empty(1),
inequalities: LinearConstraints::new(Matrix::new(1, 1, vec![1.0]).unwrap(), vec![1.0])
.unwrap(),
lower_bounds: vec![f64::NEG_INFINITY],
upper_bounds: vec![f64::INFINITY],
};
let solution = Solver::default().solve(&problem, None).unwrap();
assert_eq!(solution.status, SolveStatus::Solved);
assert!(solution.polished);
assert!((solution.x[0] - 1.0).abs() <= 1.0e-12);
assert!((solution.dual.inequalities[0] - 1.0).abs() <= 1.0e-12);
assert!(worst(&solution.residuals) <= 1.0e-12);
}
#[test]
fn polish_handles_active_bounds_without_growing_the_reduced_system() {
let n = 30;
let linear: Vec<f64> = (0..n).map(|index| -0.05 + 0.01 * index as f64).collect();
let problem = QpProblem {
quadratic: FactorQuad::new(
Matrix::new(n, 0, Vec::new()).unwrap(),
FactorCovariance::Diagonal(Vec::new()),
vec![1.0; n],
)
.unwrap(),
linear,
l1: None,
equalities: LinearConstraints::new(Matrix::new(1, n, vec![1.0; n]).unwrap(), vec![1.0])
.unwrap(),
inequalities: LinearConstraints::empty(n),
lower_bounds: vec![0.0; n],
upper_bounds: vec![0.1; n],
};
let solution = Solver::default().solve(&problem, None).unwrap();
assert_eq!(solution.status, SolveStatus::Solved);
assert!(solution.polished);
assert!(worst(&solution.residuals) <= 1.0e-10);
let capped = solution
.x
.iter()
.filter(|&&w| (w - 0.1).abs() < 1e-9)
.count();
let floored = solution.x.iter().filter(|&&w| w.abs() < 1e-9).count();
let interior = solution
.x
.iter()
.filter(|&&w| w > 1e-6 && w < 0.1 - 1e-6)
.count();
assert!(capped >= 5, "expected the cheap assets pinned at the cap");
assert!(floored >= 5, "expected the expensive assets pinned at zero");
assert!(interior >= 1, "expected at least one interior asset");
}
#[test]
fn fully_degenerate_active_sets_fall_back_to_the_admm_iterate() {
let n = 30;
let linear: Vec<f64> = (0..n).map(|index| -0.05 + 0.01 * index as f64).collect();
let problem = QpProblem {
quadratic: FactorQuad::new(
Matrix::new(n, 0, Vec::new()).unwrap(),
FactorCovariance::Diagonal(Vec::new()),
vec![0.01; n],
)
.unwrap(),
linear,
l1: None,
equalities: LinearConstraints::new(Matrix::new(1, n, vec![1.0; n]).unwrap(), vec![1.0])
.unwrap(),
inequalities: LinearConstraints::empty(n),
lower_bounds: vec![0.0; n],
upper_bounds: vec![0.1; n],
};
let solution = Solver::default().solve(&problem, None).unwrap();
assert_eq!(solution.status, SolveStatus::Solved);
let audited = check_kkt(&problem, &solution.x, &solution.dual).unwrap();
assert!(worst(&audited) <= 1.0e-4, "iterate must stay tolerable");
}
#[test]
fn polish_can_be_disabled() {
let instance = generate_synthetic(SyntheticConfig::default()).unwrap();
let solution = Solver::new(no_polish())
.solve(&instance.problem, None)
.unwrap();
assert_eq!(solution.status, SolveStatus::Solved);
assert!(!solution.polished);
}
#[test]
fn infeasible_problems_keep_their_certificate_and_are_never_polished() {
let n = 4;
let problem = QpProblem {
quadratic: FactorQuad::new(
Matrix::new(n, 0, Vec::new()).unwrap(),
FactorCovariance::Diagonal(Vec::new()),
vec![1.0; n],
)
.unwrap(),
linear: vec![0.0; n],
l1: None,
equalities: LinearConstraints::new(Matrix::new(1, n, vec![1.0; n]).unwrap(), vec![1.0])
.unwrap(),
inequalities: LinearConstraints::empty(n),
lower_bounds: vec![0.0; n],
upper_bounds: vec![0.2; n],
};
let solution = Solver::default().solve(&problem, None).unwrap();
assert_eq!(solution.status, SolveStatus::PrimalInfeasible);
assert!(!solution.polished);
assert!(solution.certificate.is_some());
}
#[test]
fn rolling_sequence_solves_are_polished_every_date() {
let instance = generate_synthetic(SyntheticConfig {
assets: 50,
factors: 5,
inequalities: 3,
..SyntheticConfig::default()
})
.unwrap();
let problem = ledge_core::PortfolioProblem::new(
instance.problem.quadratic.factors.clone(),
instance.problem.quadratic.omega.clone(),
instance.problem.quadratic.diagonal.clone(),
instance.problem.linear.iter().map(|value| -value).collect(),
)
.unwrap()
.with_bounds(
instance.problem.lower_bounds.clone(),
instance.problem.upper_bounds.clone(),
)
.unwrap()
.with_inequalities(
instance.problem.inequalities.matrix.clone(),
instance.problem.inequalities.rhs.clone(),
)
.unwrap();
let mut sequence = problem.sequence().unwrap();
let mut returns: Vec<f64> = instance.problem.linear.iter().map(|v| -v).collect();
for date in 0..4 {
let step = if date == 0 {
RebalanceStep::default()
} else {
for (index, value) in returns.iter_mut().enumerate() {
*value += 1.0e-4 * ((date * 7 + index) % 5) as f64;
}
RebalanceStep {
expected_returns: Some(returns.clone()),
..RebalanceStep::default()
}
};
let solution = sequence.solve_next(&step).unwrap();
assert_eq!(solution.status, SolveStatus::Solved);
assert!(solution.polished, "date {date} should polish");
assert!(
worst(&solution.residuals) <= 1.0e-9,
"date {date}: rolling polished residuals should reach 1e-9, got {:?}",
solution.residuals
);
}
}
#[test]
fn rejects_non_positive_polish_regularization() {
let instance = generate_synthetic(SyntheticConfig::default()).unwrap();
let settings = SolverSettings {
polish_regularization: 0.0,
..SolverSettings::default()
};
let error = Solver::new(settings)
.solve(&instance.problem, None)
.unwrap_err();
assert!(error
.to_string()
.contains("polish_regularization must be finite and positive"));
}