1use std::{env, error::Error, fs, time::Instant};
21
22use ledge::{
23 solve_batch, BatchAccount, FactorCovariance, Matrix, PortfolioProblem, RebalanceStep,
24 SolveStatus,
25};
26
27struct Config {
28 accounts: usize,
29 dates: usize,
30 assets: usize,
31 factors: usize,
32 out: Option<String>,
34}
35
36impl Default for Config {
37 fn default() -> Self {
38 Self {
39 accounts: 500,
40 dates: 250,
41 assets: 200,
42 factors: 15,
43 out: None,
44 }
45 }
46}
47
48fn expected_returns(config: &Config, account: usize, date: usize) -> Vec<f64> {
51 (0..config.assets)
52 .map(|asset| {
53 0.05 + 0.03 * (asset as f64 * 0.7 + account as f64 * 0.31).cos()
54 + 0.01 * ((asset + 5 * date) as f64 * 0.17).sin()
55 })
56 .collect()
57}
58
59fn build_accounts(config: &Config) -> Result<Vec<BatchAccount>, Box<dyn Error>> {
60 let exposures: Vec<f64> = (0..config.assets * config.factors)
63 .map(|index| 0.3 * ((index + 1) as f64 * 12.9898).sin())
64 .collect();
65 let factors = Matrix::new(config.assets, config.factors, exposures)?;
66 let omega = FactorCovariance::Diagonal(
67 (0..config.factors)
68 .map(|index| 0.05 + 0.01 * index as f64)
69 .collect(),
70 );
71 let specific: Vec<f64> = (0..config.assets)
72 .map(|index| 0.08 + 0.04 * ((index * 7 % 13) as f64) / 13.0)
73 .collect();
74 let max_weight = (10.0 / config.assets as f64).min(1.0);
75 let uniform = vec![1.0 / config.assets as f64; config.assets];
76
77 (0..config.accounts)
78 .map(|account| {
79 let problem = PortfolioProblem::new(
80 factors.clone(),
81 omega.clone(),
82 specific.clone(),
83 expected_returns(config, account, 0),
84 )?
85 .with_risk_aversion(6.0)?
86 .with_bounds(vec![0.0; config.assets], vec![max_weight; config.assets])?
87 .with_quadratic_turnover(uniform.clone(), 0.5)?
88 .with_l1_turnover(uniform.clone(), vec![0.001; config.assets])?;
89 let steps = (0..config.dates)
90 .map(|date| RebalanceStep {
91 expected_returns: (date > 0).then(|| expected_returns(config, account, date)),
92 ..RebalanceStep::default()
93 })
94 .collect();
95 Ok(BatchAccount {
96 problem,
97 steps,
98 chain_previous_weights: true,
99 })
100 })
101 .collect()
102}
103
104fn thread_count() -> usize {
105 if cfg!(feature = "rayon") {
106 env::var("RAYON_NUM_THREADS")
108 .ok()
109 .and_then(|value| value.parse::<usize>().ok())
110 .filter(|&threads| threads > 0)
111 .unwrap_or_else(|| {
112 std::thread::available_parallelism().map_or(1, std::num::NonZero::get)
113 })
114 } else {
115 1
116 }
117}
118
119fn main() -> Result<(), Box<dyn Error>> {
120 let mut config = Config::default();
121 let arguments: Vec<String> = env::args().skip(1).collect();
122 let mut index = 0;
123 while index < arguments.len() {
124 let flag = &arguments[index];
125 let value = arguments
126 .get(index + 1)
127 .ok_or_else(|| format!("missing value after {flag}"))?;
128 match flag.as_str() {
129 "--accounts" => config.accounts = value.parse()?,
130 "--dates" => config.dates = value.parse()?,
131 "--n" => config.assets = value.parse()?,
132 "--k" => config.factors = value.parse()?,
133 "--out" => config.out = Some(value.clone()),
134 _ => return Err(format!("unknown argument: {flag}").into()),
135 }
136 index += 2;
137 }
138
139 let build_started = Instant::now();
140 let accounts = build_accounts(&config)?;
141 let build_seconds = build_started.elapsed().as_secs_f64();
142
143 let solve_started = Instant::now();
144 let results = solve_batch(&accounts, None);
145 let wall_seconds = solve_started.elapsed().as_secs_f64();
146
147 let mut solved = 0_usize;
148 let mut unconverged = 0_usize;
149 let mut total_iterations = 0_usize;
150 let mut solver_seconds = 0.0_f64;
151 let mut rows = Vec::with_capacity(config.accounts);
152 for (account, result) in results.iter().enumerate() {
153 let solutions = result
154 .as_ref()
155 .map_err(|error| format!("account {account}: {error}"))?;
156 let mut account_iterations = 0_usize;
157 let mut account_solved = 0_usize;
158 let mut account_seconds = 0.0_f64;
159 for solution in solutions {
160 match solution.status {
161 SolveStatus::Solved => account_solved += 1,
162 _ => unconverged += 1,
163 }
164 account_iterations += solution.iterations;
165 account_seconds += solution.solve_time.as_secs_f64();
166 }
167 solved += account_solved;
168 total_iterations += account_iterations;
169 solver_seconds += account_seconds;
170 rows.push(format!(
171 "{account},{},{account_solved},{account_iterations},{:.3}",
172 solutions.len(),
173 1.0e3 * account_seconds
174 ));
175 }
176
177 let total_solves = config.accounts * config.dates;
178 println!(
179 "batch: {} accounts x {} dates, n={} assets, k={} factors, threads={} ({})",
180 config.accounts,
181 config.dates,
182 config.assets,
183 config.factors,
184 thread_count(),
185 if cfg!(feature = "rayon") {
186 "rayon"
187 } else {
188 "serial"
189 },
190 );
191 println!("account setup: {build_seconds:.2} s (problems + steps, single-threaded)");
192 println!(
193 "solve wall time: {wall_seconds:.2} s for {total_solves} account-dates \
194 => {:.0} solves/s",
195 total_solves as f64 / wall_seconds
196 );
197 println!(
198 "statuses: {solved} solved, {unconverged} other; iterations: {total_iterations} total, \
199 {:.1} mean/solve",
200 total_iterations as f64 / total_solves as f64
201 );
202 println!(
203 "solver time (iteration-only, summed across threads): {solver_seconds:.2} s, \
204 {:.3} ms mean/solve",
205 1.0e3 * solver_seconds / total_solves as f64
206 );
207
208 if let Some(path) = &config.out {
209 let mut csv = String::from("account,dates,solved,iterations,solve_time_ms\n");
210 for row in &rows {
211 csv.push_str(row);
212 csv.push('\n');
213 }
214 fs::write(path, csv)?;
215 println!("per-account samples written to {path}");
216 }
217
218 if unconverged > 0 {
219 return Err(format!("{unconverged} account-dates did not reach Solved").into());
220 }
221 Ok(())
222}