Combinatorial
PortfolioOptimisers.CombinatorialCrossValidation — Type
struct CombinatorialCrossValidation{__T_n_folds, __T_n_test_folds, __T_purged_size, __T_embargo_size, __T_wd, __T_fa, __T_store_weight_path, __T_strict} <: NonSequentialCrossValidationEstimatorImplements combinatorial non-sequential cross-validation with purging and embargoing, allowing for all possible combinations of test folds.
The observations are cut into n_folds consecutive folds, and every combination of n_test_folds of them is one split's test set. The remaining folds form that split's training set, less the purged and embargoed rows on each side of every test block. Each fold is a test fold in the same number of splits, so the test blocks recombine into n_test_paths full backtest paths, each covering every observation exactly once.
Mathematical definition
\[\begin{align} S(n,\, k) &= \binom{n}{k} = \frac{n!}{k!\,(n-k)!}\,, \\ \varphi(n,\, k) &= \frac{k}{n} \binom{n}{k} = \frac{(n-1)!}{(k-1)!\,(n-k)!}\,, \\ \bar{T}(n,\, k) &= \frac{T}{n} (n - k)\,. \end{align}\]
Where:
- $S(n,\, k)$: Number of splits, one per combination of test folds.
- $\varphi(n,\, k)$: Number of recombined test paths.
- $\bar{T}(n,\, k)$: Average number of training observations per split, before purging and embargoing.
- $n$: Number of folds,
n_folds. - $k$: Number of test folds per split,
n_test_folds. - $T$: Number of observations.
Fields
n_folds: Number of folds.
n_test_folds: Number of folds held out for testing in each combination. The remainingn_folds - n_test_foldsfolds train.
purged_size: Number of observations to purge between train and test sets.
embargo_size: Number of observations to embargo after the test set.
wd: Weight drift the fold's return series is read under, ornothingto read it at the target weights of the fold.
fa: Fee amortisation algorithm the fold's realised series charges the two fixed fee terms on, ornothingto inherit the clock the fee itself states. It overridesFees.fafor that series alone, and it reaches the fit not at all.
store_weight_path: Iftrue, the fold stores the weight path it computed; iffalse, a reader rebuilds it on demand.
strict: Iftrue, a Held Gap raises anArgumentError; iffalse, it warns and the pair contributes zero. A Held Gap is an (observation, asset) pair at which the fold's weight is non-zero and the asset's return is missing, which is what a delisting inside a test window makes.
Constructors
CombinatorialCrossValidation(; n_folds::Integer = 10, n_test_folds::Integer = 2, purged_size::Integer = 0, embargo_size::Integer = 0, wd::Option{<:AbstractWeightDrift} = nothing, fa::Option{<:AbstractFeeAmortisation} = nothing, store_weight_path::Bool = false, strict::Bool = false, max_comb::Integer = 100_000,) -> CombinatorialCrossValidationKeyword arguments correspond to the struct's fields.
Weight drift
wd is the Weight Drift of the scheme, and nothing is the library's original behaviour: a fold's return series is X * w net of fees, read at the target weights of that fold. A SelfFinancingDrift reads the series as the wealth ratio of the drifted holdings instead, and the fold carries a HeldWeightsResult. store_weight_path makes the fold store the weight path it computed, which a reader otherwise rebuilds on demand. strict decides what a Held Gap does: an asset that delists inside a test window carries a non-zero weight and a missing return, and the fold zeroes that pair and warns, or refuses with an ArgumentError under strict.
A combinatorial enumeration is not a timeline, so this scheme carries no Previous-Weights Source. Its splits recombine into several paths, and a split's folds are independent of the others.
Fee clock
fa is the clock the fold's realised series charges the two fixed fee terms on, and it overrides the fa of the fee itself. nothing inherits that fee's clock, which is the library's original behaviour. A FirstObservationFees charges the two terms on the first observation of the fold, and an AmortisedFees spreads them over the fold. The field reaches the fit not at all, so the optimiser keeps pricing the fee the way its own objective must.
The default holds out 2 of 10 folds for testing. This gives binomial(10, 2) = 45 splits, 9 recombined test paths, and an average training set of 80% of the observations. Because binomial(n, k) == binomial(n, n - k), a transposed pair such as n_test_folds = 8 produces the same number of splits on a training set of only 20% of the observations. Choose n_test_folds well below div(n_folds, 2), or let optimal_number_folds choose the pair.
Validation
n_foldsmust be non-empty, greater than zero, and finite.n_test_foldsmust be non-empty, greater than zero, and finite.purged_sizeandembargo_sizemust be non-empty and finite.binomial(n_folds, n_test_folds) <= max_comb.max_combis a constructor argument, not a field, so it bounds the split count at construction and is not carried on the estimator.Base.splitadditionally checkspurged_size + embargo_size < div(T, n_folds), because a gap as wide as the smallest fold would empty a training fold.
Examples
julia> CombinatorialCrossValidation(; n_folds = 10, n_test_folds = 2, purged_size = 2, embargo_size = 1)CombinatorialCrossValidation n_folds ┼ Int64: 10 n_test_folds ┼ Int64: 2 purged_size ┼ Int64: 2 embargo_size ┼ Int64: 1 wd ┼ nothing fa ┼ nothing store_weight_path ┼ Bool: false strict ┴ Bool: falseRelated
cross_val_predictsearch_cross_validationNonSequentialCrossValidationEstimatorCombinatorialCrossValidationResultn_splitsn_test_pathsaverage_train_sizeoptimal_number_folds
References
PortfolioOptimisers.CombinatorialCrossValidationResult — Type
struct CombinatorialCrossValidationResult{__T_train_idx, __T_test_idx, __T_path_ids} <: NonSequentialCrossValidationResultResult type produced by CombinatorialCrossValidation after splitting data into combinatorial training and testing folds.
Stores the train index vectors, the nested test index vectors, and a matrix of path identifiers.
Every field is indexed by split. train_idx[j] holds the training rows of split j, purged and embargoed. test_idx[j] holds that split's n_test_folds test blocks, sorted by first observation. Column j of path_ids names the path each of those blocks belongs to, in the same order, which is what recombines the blocks of all splits into full backtest paths.
Fields
train_idx: Training set indices.
test_idx: Test set indices.
path_ids: Path identifiers for cross-validation splits.
Constructors
CombinatorialCrossValidationResult(; train_idx::VecVecInt, test_idx::VecVecVecInt, path_ids::AbstractMatrix{<:Integer}) -> CombinatorialCrossValidationResultKeywords correspond to the struct's fields.
Validation
!isempty(train_idx).!isempty(test_idx).!isempty(path_ids).length(train_idx) == length(test_idx) == size(path_ids, 2).
Related
Base.split — Method
Base.split(ccv::CombinatorialCrossValidation, rd::Prices_RR) -> CombinatorialCrossValidationResultSplit the data rd into all possible combinations of training and test folds using combinatorial cross-validation with optional purging and embargoing.
Arguments
ccv::CombinatorialCrossValidation: Combinatorial cross-validation estimator.rd: Returns-level or price-level data to split (Prices_RR).
Validation
purged_size + embargo_size < div(T, n_folds), whereTis the number of observations. A gap as wide as the smallest fold would empty a training fold.
Returns
CombinatorialCrossValidationResult: Result containing train indices, nested test index vectors (one per path), and a matrix of path IDs mapping folds to paths.
Details
- The folds are consecutive and cover every observation exactly once.
mod(T, n_folds)extra rows go to the last fold, so the fold sizes differ by at mostn_folds - 1rows. - Every training row within
purged_sizerows before a test block, or withinpurged_size + embargo_sizerows after one, is dropped from that split's training set. - Column
jofpath_idsnames the path of each test block of splitj, in the same order astest_idx[j], which is sorted by first observation. Collecting the blocks of one path over all splits therefore reproduces the whole timeline in order.
Related
PortfolioOptimisers.optimal_number_folds — Function
optimal_number_folds(T::Integer, target_train_size::Integer,
target_n_test_paths::Integer; train_size_w::Number = 1,
n_test_paths_w::Number = 1, maxval::Number = 1e5) -> Tuple{Int, Int}Find the optimal (n_folds, n_test_folds) pair for combinatorial cross-validation by minimising a weighted cost that balances the average training size against the number of test paths.
Mathematical definition
The cost function for a candidate (n_folds, n_test_folds) pair is:
\[\begin{align} \text{cost} &= w_{\text{ntp}} \frac{|P(n,k) - P^*|}{P^*} + w_{\text{tr}} \frac{|\bar{T}(n,k) - T^*|}{T^*}\,. \end{align}\]
Where:
- $\text{cost}$: Weighted cost for the candidate fold configuration.
- $w_{\text{ntp}}$: Weight on the test-paths component.
- $w_{\text{tr}}$: Weight on the training-size component.
- $P(n,k)$: Number of test paths for $n$ folds and $k$ test folds.
- $\bar{T}(n,k)$: Average training size for $n$ folds and $k$ test folds.
- $P^*$: Target number of test paths (
target_n_test_paths). - $T^*$: Target training size (
target_train_size).
Arguments
T: Total number of observations in the dataset.target_train_size: Desired average number of observations in each training set.target_n_test_paths: Desired number of recombined test paths.train_size_w: Weight applied to the training-size component of the cost (default1).n_test_paths_w: Weight applied to the test-paths component of the cost (default1).maxval: Pruning threshold (default1e5). Within onen_folds, letmbe the firstn_test_foldswhose cost exceedsmaxval. The search then skips everyn_test_foldsin(m, n_folds - m)and resumes at the mirror valuen_folds - m, becausebinomial(n, k) == binomial(n, n - k)makes the two ends of the range the cheap ones and the middle the expensive one.
Returns
Tuple{Int, Int}: The optimal(n_folds, n_test_folds)pair minimising the weighted cost. Returns(0, 0)when no valid configuration is found.
Details
- The search walks
n_foldsfrom3toT + 1andn_test_foldsfrom2ton_folds. - A candidate whose split count overflows
Intis treated as infinitely expensive rather than raising. Such a configuration is unusable:CombinatorialCrossValidationrefuses it throughmax_comblong before the count reaches that size. Raisingmaxvalwidens the search into that region, so the guard is what keeps a largemaxvalfrom failing.
Related
References
- [5]
- D. Cajas. Advanced Portfolio Optimization: A Cutting-edge Quantitative Approach (Springer Nature Switzerland, 2025).
- [118]
- M. López de Prado. Advances in Financial Machine Learning (John Wiley & Sons, Hoboken, NJ, 2018).