use ledge_core::{
FactorCovariance, Matrix, PortfolioProblem, QpProblem, Solution, SolveStatus, Solver,
SolverSettings, WarmStart,
};
fn qp_fixture() -> QpProblem {
let assets = 8;
let factors = Matrix::new(
assets,
2,
(0..assets * 2)
.map(|index| 0.3 * ((1 + index) as f64 * 12.9898).sin())
.collect(),
)
.unwrap();
let mut lower = vec![-0.5; assets];
let mut upper = vec![0.5; assets];
lower[2] = f64::NEG_INFINITY;
upper[5] = f64::INFINITY;
QpProblem {
quadratic: ledge_core::FactorQuad::new(
factors,
FactorCovariance::Diagonal(vec![0.05, 0.08]),
vec![0.1; assets],
)
.unwrap(),
linear: (0..assets).map(|index| -0.01 * index as f64).collect(),
l1: Some(ledge_core::L1Term {
costs: vec![2.0e-3; assets],
anchor: vec![0.0; assets],
}),
equalities: ledge_core::LinearConstraints::new(
Matrix::new(1, assets, vec![1.0; assets]).unwrap(),
vec![1.0],
)
.unwrap(),
inequalities: ledge_core::LinearConstraints::new(
Matrix::new(1, assets, vec![1.0 / assets as f64; assets]).unwrap(),
vec![0.4],
)
.unwrap(),
lower_bounds: lower,
upper_bounds: upper,
}
}
fn portfolio_fixture() -> PortfolioProblem {
let assets = 12;
let factors = Matrix::new(
assets,
3,
(0..assets * 3)
.map(|index| 0.3 * ((1 + index) as f64 * 7.13).sin())
.collect(),
)
.unwrap();
let benchmark = vec![1.0 / assets as f64; assets];
PortfolioProblem::new(
factors,
FactorCovariance::Diagonal(vec![0.05, 0.06, 0.07]),
vec![0.1; assets],
(0..assets)
.map(|index| 0.05 + 0.001 * index as f64)
.collect(),
)
.unwrap()
.with_risk_aversion(4.0)
.unwrap()
.with_tracking_benchmark(benchmark.clone())
.unwrap()
.with_industry_neutrality(&(0..assets).map(|asset| asset % 3).collect::<Vec<_>>())
.unwrap()
.with_l1_turnover(benchmark, vec![1.0e-3; assets])
.unwrap()
.with_concentration_limit(0.3)
.unwrap()
}
#[allow(clippy::float_cmp)] fn assert_same_solve(left: &Solution, right: &Solution) {
assert_eq!(left.status, right.status);
assert_eq!(left.x, right.x, "iterates must replay bit-identically");
assert_eq!(left.dual.equalities, right.dual.equalities);
assert_eq!(left.dual.inequalities, right.dual.inequalities);
assert_eq!(left.dual.bounds, right.dual.bounds);
assert_eq!(left.dual.l1, right.dual.l1);
assert_eq!(left.objective, right.objective);
assert_eq!(left.iterations, right.iterations);
assert_eq!(left.polished, right.polished);
}
#[test]
fn qp_json_round_trip_preserves_infinite_bounds_and_replays_the_solve() {
let problem = qp_fixture();
let json = serde_json::to_string_pretty(&problem).unwrap();
let restored: QpProblem = serde_json::from_str(&json).unwrap();
assert_eq!(problem, restored);
assert!(restored.lower_bounds[2].is_infinite());
assert!(restored.upper_bounds[5].is_infinite());
let solver = Solver::default();
let original = solver.solve(&problem, None).unwrap();
let replayed = solver.solve(&restored, None).unwrap();
assert_eq!(original.status, SolveStatus::Solved);
assert_same_solve(&original, &replayed);
}
#[test]
fn solution_settings_and_warm_start_round_trip_through_json() {
let problem = qp_fixture();
let settings = SolverSettings {
max_iterations: 500,
polish: false,
..SolverSettings::default()
};
let solution = Solver::new(settings.clone()).solve(&problem, None).unwrap();
let settings_restored: SolverSettings =
serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap();
assert_eq!(settings, settings_restored);
let solution_restored: Solution =
serde_json::from_str(&serde_json::to_string(&solution).unwrap()).unwrap();
assert_eq!(solution, solution_restored);
let warm = solution.warm_start();
let warm_restored: WarmStart =
serde_json::from_str(&serde_json::to_string(&warm).unwrap()).unwrap();
assert_eq!(warm, warm_restored);
}
#[test]
fn infeasible_solves_round_trip_their_certificates() {
let mut problem = qp_fixture();
problem.l1 = None;
problem.lower_bounds = vec![0.0; 8];
problem.upper_bounds = vec![0.05; 8];
let solution = Solver::default().solve(&problem, None).unwrap();
assert_eq!(solution.status, SolveStatus::PrimalInfeasible);
assert!(solution.certificate.is_some());
let restored: Solution =
serde_json::from_str(&serde_json::to_string(&solution).unwrap()).unwrap();
assert_eq!(solution.certificate, restored.certificate);
assert_eq!(solution.status, restored.status);
}
#[test]
fn portfolio_json_round_trip_rebuilds_the_same_qp() {
let problem = portfolio_fixture();
let json = serde_json::to_string(&problem).unwrap();
let restored: PortfolioProblem = serde_json::from_str(&json).unwrap();
assert_eq!(problem, restored);
assert_eq!(problem.to_qp().unwrap(), restored.to_qp().unwrap());
}
#[test]
fn binary_round_trip_via_postcard() {
let problem = qp_fixture();
let portfolio = portfolio_fixture();
let solution = Solver::default().solve(&problem, None).unwrap();
let restored: QpProblem =
postcard::from_bytes(&postcard::to_allocvec(&problem).unwrap()).unwrap();
assert_eq!(problem, restored);
let restored: PortfolioProblem =
postcard::from_bytes(&postcard::to_allocvec(&portfolio).unwrap()).unwrap();
assert_eq!(portfolio, restored);
let restored: Solution =
postcard::from_bytes(&postcard::to_allocvec(&solution).unwrap()).unwrap();
assert_eq!(solution, restored);
}
#[test]
fn corrupted_dumps_are_rejected_by_the_same_validation_as_construction() {
let bad_matrix = r#"{"rows": 2, "cols": 3, "data": [1.0, 2.0]}"#;
assert!(serde_json::from_str::<Matrix>(bad_matrix)
.unwrap_err()
.to_string()
.contains("requires 6 values"));
let json = serde_json::to_string(&portfolio_fixture()).unwrap();
let tampered = json.replace("\"risk_aversion\":4.0", "\"risk_aversion\":-4.0");
assert_ne!(json, tampered);
assert!(serde_json::from_str::<PortfolioProblem>(&tampered)
.unwrap_err()
.to_string()
.contains("risk_aversion"));
let tampered = json.replace("\"previous_weights\":[", "\"previous_weights_ignored\":[");
assert_ne!(json, tampered);
assert!(serde_json::from_str::<PortfolioProblem>(&tampered).is_err());
}