Skip to main content

batch/
batch.rs

1//! Multi-account batch rebalance throughput driver (roadmap 3.2).
2//!
3//! One factor model, many accounts, many trading dates — the persona-C
4//! workload from `docs/PLAN.md`. Every account shares the risk model but
5//! carries its own expected-return tilt and its own turnover anchor (10 bps
6//! proportional costs plus an L2 penalty), and rolls through the dates with
7//! backtest anchor chaining: each date trades from the weights the previous
8//! `Solved` date left behind.
9//!
10//! Build with `--features rayon` to distribute accounts over a thread pool
11//! (`RAYON_NUM_THREADS` controls the width); without the feature the same
12//! code runs serially. Defaults reproduce the published
13//! "1 model x 500 accounts x 250 dates" number:
14//!
15//! ```text
16//! cargo run -p ledge --release --features rayon --example batch
17//! cargo run -p ledge --release --example batch -- --accounts 20 --dates 10
18//! ```
19
20use 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    /// Optional per-account CSV path for published artifacts.
33    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
48/// Deterministic pseudo-returns: a per-account tilt plus a per-date drift,
49/// so every account solves a genuinely different sequence.
50fn 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    // One shared model: exposures, factor covariance, and specific variance
61    // are identical across accounts.
62    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        // Mirrors rayon's default: RAYON_NUM_THREADS, else available CPUs.
107        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}