1use std::f64::consts::TAU;
4
5use thiserror::Error;
6
7use crate::{
8 matrix::{dot, Matrix, MatrixError},
9 problem::{FactorCovariance, FactorQuad, LinearConstraints, ProblemError, QpProblem},
10};
11
12#[derive(Clone, Debug, PartialEq)]
14pub struct SyntheticConfig {
15 pub assets: usize,
17 pub factors: usize,
19 pub inequalities: usize,
21 pub seed: u64,
23 pub budget: f64,
25 pub max_weight: f64,
27}
28
29impl Default for SyntheticConfig {
30 fn default() -> Self {
31 Self {
32 assets: 100,
33 factors: 10,
34 inequalities: 4,
35 seed: 42,
36 budget: 1.0,
37 max_weight: 0.1,
38 }
39 }
40}
41
42#[derive(Clone, Debug, PartialEq)]
44pub struct GeneratedInstance {
45 pub name: String,
47 pub problem: QpProblem,
49 pub feasible_reference: Vec<f64>,
51 pub config: SyntheticConfig,
53}
54
55#[derive(Debug, Error)]
57pub enum GeneratorError {
58 #[error("invalid synthetic configuration: {0}")]
60 InvalidConfig(&'static str),
61 #[error(transparent)]
63 Matrix(#[from] MatrixError),
64 #[error(transparent)]
66 Problem(#[from] ProblemError),
67}
68
69pub fn generate_synthetic(config: SyntheticConfig) -> Result<GeneratedInstance, GeneratorError> {
79 if config.assets == 0 {
80 return Err(GeneratorError::InvalidConfig("assets must be positive"));
81 }
82 if config.factors == 0 || config.factors > config.assets {
83 return Err(GeneratorError::InvalidConfig(
84 "factors must be in 1..=assets",
85 ));
86 }
87 if !config.budget.is_finite() || config.budget <= 0.0 {
88 return Err(GeneratorError::InvalidConfig(
89 "budget must be finite and positive",
90 ));
91 }
92 let reference_weight = config.budget / config.assets as f64;
93 if config.max_weight.is_nan() || config.max_weight < reference_weight {
94 return Err(GeneratorError::InvalidConfig(
95 "max_weight must contain the uniform feasible portfolio",
96 ));
97 }
98
99 let n = config.assets;
100 let k = config.factors;
101 let mut random = SplitMix64::new(config.seed);
102 let factor_scale = 1.0 / (k as f64).sqrt();
103 let factor_data: Vec<f64> = (0..n * k)
104 .map(|_| 0.25 * factor_scale * random.standard_normal())
105 .collect();
106 let factors = Matrix::new(n, k, factor_data)?;
107 let omega: Vec<f64> = (0..k).map(|_| 0.05 + 0.15 * random.uniform()).collect();
108 let diagonal: Vec<f64> = (0..n).map(|_| 0.05 + 0.10 * random.uniform()).collect();
109 let quadratic = FactorQuad::new(factors, FactorCovariance::Diagonal(omega), diagonal)?;
110
111 let linear: Vec<f64> = (0..n)
114 .map(|_| -0.01 - 0.005 * random.standard_normal())
115 .collect();
116 let equalities = LinearConstraints::new(Matrix::new(1, n, vec![1.0; n])?, vec![config.budget])?;
117 let feasible_reference = vec![reference_weight; n];
118
119 let mut inequality_matrix = Matrix::zeros(config.inequalities, n);
120 let mut inequality_rhs = Vec::with_capacity(config.inequalities);
121 for row in 0..config.inequalities {
122 for col in 0..n {
123 inequality_matrix[(row, col)] = random.standard_normal();
124 }
125 let center = dot(inequality_matrix.row(row), &feasible_reference);
126 let row_norm = dot(inequality_matrix.row(row), inequality_matrix.row(row)).sqrt();
127 inequality_rhs.push(center + 0.10 * row_norm * reference_weight.sqrt());
128 }
129 let inequalities = LinearConstraints::new(inequality_matrix, inequality_rhs)?;
130 let lower_bounds = vec![0.0; n];
131 let upper_bounds = vec![config.max_weight; n];
132 let problem = QpProblem {
133 quadratic,
134 linear,
135 l1: None,
136 equalities,
137 inequalities,
138 lower_bounds,
139 upper_bounds,
140 };
141 problem.validate()?;
142
143 Ok(GeneratedInstance {
144 name: format!("factor-n{n}-k{k}-s{}", config.seed),
145 problem,
146 feasible_reference,
147 config,
148 })
149}
150
151struct SplitMix64 {
154 state: u64,
155 spare_normal: Option<f64>,
156}
157
158impl SplitMix64 {
159 const fn new(seed: u64) -> Self {
160 Self {
161 state: seed,
162 spare_normal: None,
163 }
164 }
165
166 fn next_u64(&mut self) -> u64 {
167 self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
168 let mut value = self.state;
169 value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
170 value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
171 value ^ (value >> 31)
172 }
173
174 fn uniform(&mut self) -> f64 {
175 let mantissa = self.next_u64() >> 11;
177 (mantissa as f64 + 0.5) / ((1_u64 << 53) as f64)
178 }
179
180 fn standard_normal(&mut self) -> f64 {
181 if let Some(spare) = self.spare_normal.take() {
182 return spare;
183 }
184 let radius = (-2.0 * self.uniform().ln()).sqrt();
185 let angle = TAU * self.uniform();
186 self.spare_normal = Some(radius * angle.sin());
187 radius * angle.cos()
188 }
189}