Skip to main content

ledge_core/
generator.rs

1//! Deterministic synthetic factor-model portfolio instances.
2
3use 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/// Configuration for a reproducible synthetic portfolio QP.
13#[derive(Clone, Debug, PartialEq)]
14pub struct SyntheticConfig {
15    /// Number of assets.
16    pub assets: usize,
17    /// Number of covariance factors.
18    pub factors: usize,
19    /// Number of additional upper-inequality rows.
20    pub inequalities: usize,
21    /// Deterministic random seed.
22    pub seed: u64,
23    /// Portfolio budget imposed as an equality.
24    pub budget: f64,
25    /// Per-asset upper bound. Set to infinity for no upper bound.
26    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/// Generated problem plus a known feasible reference point.
43#[derive(Clone, Debug, PartialEq)]
44pub struct GeneratedInstance {
45    /// Reproducible name containing dimensions and seed.
46    pub name: String,
47    /// Generated convex QP.
48    pub problem: QpProblem,
49    /// Uniform feasible portfolio used to place constraints.
50    pub feasible_reference: Vec<f64>,
51    /// Configuration used to produce this instance.
52    pub config: SyntheticConfig,
53}
54
55/// Synthetic generator errors.
56#[derive(Debug, Error)]
57pub enum GeneratorError {
58    /// Invalid generator setting.
59    #[error("invalid synthetic configuration: {0}")]
60    InvalidConfig(&'static str),
61    /// Matrix construction failed.
62    #[error(transparent)]
63    Matrix(#[from] MatrixError),
64    /// Generated problem construction failed.
65    #[error(transparent)]
66    Problem(#[from] ProblemError),
67}
68
69/// Generates a deterministic, feasible, long-only factor-model portfolio QP.
70///
71/// The instance has one budget equality, configurable random exposure caps,
72/// diagonal factor covariance, and positive idiosyncratic risk.
73///
74/// # Errors
75///
76/// Returns [`GeneratorError`] when dimensions or bounds cannot contain the
77/// uniform reference portfolio.
78pub 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    // The linear term is -mu: minimization therefore seeks positive expected
112    // returns while risk and constraints regularize the portfolio.
113    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
151/// Tiny deterministic PRNG to avoid making benchmark reproducibility depend on
152/// a third-party distribution implementation.
153struct 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        // 53 random bits mapped to the open interval (0, 1).
176        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}