Skip to main content

ledge_core/
batch.rs

1//! Multi-thread batch rebalancing over the account axis (roadmap 3.2).
2//!
3//! The persona-C workload is "one factor model, many accounts": every account
4//! shares the risk model but carries its own constraints, budget, and
5//! turnover anchor, and every account rolls through the same trading dates.
6//! Accounts are independent — no state is shared between them — so the batch
7//! axis parallelizes embarrassingly: [`solve_batch`] runs one
8//! [`PortfolioSequence`](crate::PortfolioSequence) per account, exactly as if
9//! the caller had looped over [`solve_sequence`](crate::solve_sequence).
10//!
11//! Threading is feature-gated: with the non-default `rayon` cargo feature the
12//! accounts are distributed over rayon's global thread pool
13//! (`RAYON_NUM_THREADS` or a caller-installed pool control the width);
14//! without it the same function runs the accounts serially. Results are
15//! identical either way — each account's iterate path never depends on the
16//! other accounts or on scheduling — so the feature changes wall-clock time,
17//! never answers.
18//!
19//! Failures stay per-account: one account with invalid step data reports its
20//! error while every other account still returns its solutions. Within an
21//! account the sequence semantics are unchanged (an invalid step aborts that
22//! account; an unconverged date does not).
23//!
24//! ```
25//! use ledge_core::{
26//!     solve_batch, BatchAccount, FactorCovariance, Matrix, PortfolioProblem, RebalanceStep,
27//! };
28//!
29//! let problem = PortfolioProblem::new(
30//!     Matrix::new(3, 1, vec![1.0, -0.5, 0.25])?,
31//!     FactorCovariance::Diagonal(vec![0.1]),
32//!     vec![0.2, 0.3, 0.25],
33//!     vec![0.08, 0.04, 0.06],
34//! )?;
35//! let accounts = vec![
36//!     BatchAccount {
37//!         problem: problem.clone(),
38//!         steps: vec![RebalanceStep::default()],
39//!         chain_previous_weights: false,
40//!     },
41//!     BatchAccount {
42//!         problem,
43//!         steps: vec![RebalanceStep {
44//!             budget: Some(0.9),
45//!             ..RebalanceStep::default()
46//!         }],
47//!         chain_previous_weights: false,
48//!     },
49//! ];
50//! let results = solve_batch(&accounts, None);
51//! assert_eq!(results.len(), 2);
52//! for account in &results {
53//!     assert_eq!(account.as_ref().unwrap().len(), 1);
54//! }
55//! # Ok::<(), ledge_core::PortfolioError>(())
56//! ```
57
58use crate::{
59    portfolio::{PortfolioError, PortfolioProblem},
60    sequence::RebalanceStep,
61    solver::{Solution, SolveStatus, Solver, SolverSettings},
62};
63
64/// One account in a batch: its own portfolio problem plus its per-date steps.
65///
66/// The problem carries the account's structure and first date's data; `steps`
67/// are applied in order exactly like
68/// [`solve_sequence`](crate::solve_sequence), so pass
69/// [`RebalanceStep::default()`] first to solve the base data as-is.
70#[derive(Clone, Debug)]
71pub struct BatchAccount {
72    /// The account's portfolio problem (structure plus first date's data).
73    pub problem: PortfolioProblem,
74    /// Per-date data changes, in order. Only factorization-preserving
75    /// updates are expressible; see [`RebalanceStep`].
76    pub steps: Vec<RebalanceStep>,
77    /// When `true`, every date after a `Solved` one anchors the turnover
78    /// terms at that date's solved weights — the standard backtest
79    /// convention ("previous weights" are what the account actually holds)
80    /// — unless the step provides `previous_weights` explicitly, which
81    /// wins. Dates that did not reach `Solved` leave the anchor where it
82    /// was, because the account did not trade. Requires the problem to have
83    /// been built with a turnover term.
84    pub chain_previous_weights: bool,
85}
86
87/// Per-account outcome of [`solve_batch`]: all step solutions in order, or
88/// the error that stopped that account.
89pub type AccountResult = Result<Vec<Solution>, PortfolioError>;
90
91/// Solves every account's rolling sequence, in parallel over the account
92/// axis when the `rayon` cargo feature is enabled (roadmap 3.2).
93///
94/// Each account gets its own workspace (equilibration and factorizations
95/// built once per account, warm starts chained across its dates), so the
96/// batch does the same numerical work as calling
97/// [`solve_sequence`](crate::solve_sequence) per account — results are
98/// bit-identical to that loop regardless of the feature or thread count.
99/// One entry is returned per account, in input order; a failed account never
100/// affects the others.
101#[must_use]
102pub fn solve_batch(
103    accounts: &[BatchAccount],
104    settings: Option<SolverSettings>,
105) -> Vec<AccountResult> {
106    let solver = Solver::new(settings.unwrap_or_default());
107    map_accounts(accounts, &solver)
108}
109
110#[cfg(feature = "rayon")]
111fn map_accounts(accounts: &[BatchAccount], solver: &Solver) -> Vec<AccountResult> {
112    use rayon::prelude::*;
113    accounts
114        .par_iter()
115        .map(|account| solve_account(account, solver))
116        .collect()
117}
118
119#[cfg(not(feature = "rayon"))]
120fn map_accounts(accounts: &[BatchAccount], solver: &Solver) -> Vec<AccountResult> {
121    accounts
122        .iter()
123        .map(|account| solve_account(account, solver))
124        .collect()
125}
126
127fn solve_account(account: &BatchAccount, solver: &Solver) -> AccountResult {
128    if account.chain_previous_weights && account.problem.previous_weights().is_none() {
129        return Err(PortfolioError::InvalidParameter(
130            "chain_previous_weights requires a problem built with \
131             with_quadratic_turnover and/or with_l1_turnover; without a \
132             turnover term there is no anchor to move",
133        ));
134    }
135    let mut sequence = account.problem.sequence_with(solver)?;
136    let mut solutions = Vec::with_capacity(account.steps.len());
137    let mut held_weights: Option<Vec<f64>> = None;
138    for step in &account.steps {
139        let chained;
140        let step = if account.chain_previous_weights
141            && step.previous_weights.is_none()
142            && held_weights.is_some()
143        {
144            chained = RebalanceStep {
145                previous_weights: held_weights.clone(),
146                ..step.clone()
147            };
148            &chained
149        } else {
150            step
151        };
152        let solution = sequence.solve_next(step)?;
153        if account.chain_previous_weights && solution.status == SolveStatus::Solved {
154            held_weights = Some(solution.x.clone());
155        }
156        solutions.push(solution);
157    }
158    Ok(solutions)
159}