The source files can be found in examples/.

Feature matrices as a distance source

Every clustering optimiser met so far builds its hierarchy from the returns: a covariance estimate becomes a correlation, a correlation becomes a distance, and the distance becomes a dendrogram. That route can only ever see structure the price history contains.

A FeatureDistance replaces the returns with an assets × features matrix Z. Feature k is any per-asset quantity you can name — a sector membership, a factor loading, a position in the asset network, a trailing characteristic — and two assets are close when their feature rows point the same way. The output is an ordinary distance matrix, so every consumer that takes a distance estimator takes this one: ClustersEstimator, NetworkEstimator, the clustering optimisers, and the phylogeny and centrality constraint families.

The point of the exercise is exogenous structure. A classification, a mandate, a supply chain or a factor model brings in relationships the returns do not contain; feeding the returns graph back in as features is a different and more subtle tool, covered in §4.

When to reach for this

Reach for a FeatureDistance when you can name the structure you want the allocation to respect and it is not in the price history — a sector or country taxonomy, a regulatory bucketing, an ESG classification, a factor exposure profile. Reach for it too when you want the hierarchy to stop churning between rebalances: an exogenous classification does not move when the covariance does, which is worth a large turnover reduction (§9). Stay with the ordinary correlation distance when the structure you care about is co-movement.

using PortfolioOptimisers, CSV, TimeSeries, DataFrames, PrettyTables, Clarabel, StatsPlots,      GraphRecipes, Statistics, LinearAlgebra, Clusteringresfmt = (v, i, j) -> begin    return if j == 1        v    else        isa(v, AbstractFloat) ? "$(round(v*100, digits=3)) %" : v    endend;

1. ReturnsResult data and a classification

The same twenty-name S&P 500 slice as the other optimiser examples, plus an illustrative two-level classification. The two levels are nested: every industry belongs to exactly one sector, so agreeing on an industry implies agreeing on a sector.

X = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500.csv.gz")); timestamp = :Date)[(end - 252):end]rd = prices_to_returns(X)pr = prior(EmpiricalPrior(), rd)slv = Solver(; name = :clarabel, solver = Clarabel.Optimizer,             settings = Dict("verbose" => false),             check_sol = (; allow_local = true, allow_almost = true))sector = Dict("AAPL" => "Technology", "AMD" => "Technology", "MSFT" => "Technology",              "BAC" => "Financials", "JPM" => "Financials", "CVX" => "Energy",              "XOM" => "Energy", "RRC" => "Energy", "GE" => "Industrials",              "BBY" => "ConsumerDiscretionary", "HD" => "ConsumerDiscretionary",              "KO" => "ConsumerStaples", "PEP" => "ConsumerStaples",              "PG" => "ConsumerStaples", "WMT" => "ConsumerStaples", "JNJ" => "HealthCare",              "LLY" => "HealthCare", "MRK" => "HealthCare", "PFE" => "HealthCare",              "UNH" => "HealthCare")industry = Dict("AAPL" => "ConsumerHardware", "AMD" => "Semiconductors",                "MSFT" => "Software", "BAC" => "Banks", "JPM" => "Banks",                "CVX" => "IntegratedOil", "XOM" => "IntegratedOil",                "RRC" => "ExplorationProduction", "GE" => "Conglomerates",                "BBY" => "SpecialtyRetail", "HD" => "SpecialtyRetail", "KO" => "Beverages",                "PEP" => "Beverages", "PG" => "HouseholdProducts", "WMT" => "MassMerchants",                "JNJ" => "Pharmaceuticals", "LLY" => "Pharmaceuticals",                "MRK" => "Pharmaceuticals", "PFE" => "Pharmaceuticals",                "UNH" => "ManagedCare")
Dict{String, String} with 20 entries:
  "MSFT" => "Software"
  "JPM"  => "Banks"
  "MRK"  => "Pharmaceuticals"
  "UNH"  => "ManagedCare"
  "PFE"  => "Pharmaceuticals"
  "BBY"  => "SpecialtyRetail"
  "BAC"  => "Banks"
  "PG"   => "HouseholdProducts"
  "JNJ"  => "Pharmaceuticals"
  "XOM"  => "IntegratedOil"
  "LLY"  => "Pharmaceuticals"
  "AMD"  => "Semiconductors"
  "CVX"  => "IntegratedOil"
  "WMT"  => "MassMerchants"
  "PEP"  => "Beverages"
  "GE"   => "Conglomerates"
  "AAPL" => "ConsumerHardware"
  "HD"   => "SpecialtyRetail"
  "KO"   => "Beverages"
  "RRC"  => "ExplorationProduction"

The classification travels on a UniverseSets. Every key an asset view has to follow must carry the xkey prefix — "nx_sector", not "sector" — because that prefix is what port_opt_view slices alongside the asset names.

sets = UniverseSets(; xkey = "nx",                    dict = Dict("nx" => rd.nx, "nx_sector" => [sector[a] for a in rd.nx],                                "nx_industry" => [industry[a] for a in rd.nx]))taxonomy = ["nx_sector", "nx_industry"]pretty_table(DataFrame("Asset" => rd.nx, "Sector" => [sector[a] for a in rd.nx],                       "Industry" => [industry[a] for a in rd.nx]);             title = "The classification the feature matrix will encode")
    The classification the feature matrix will encode
┌────────┬───────────────────────┬───────────────────────┐
│  Asset                 Sector               Industry │
│ String                 String                 String │
├────────┼───────────────────────┼───────────────────────┤
│   AAPL │            Technology │      ConsumerHardware │
│    AMD │            Technology │        Semiconductors │
│    BAC │            Financials │                 Banks │
│    BBY │ ConsumerDiscretionary │       SpecialtyRetail │
│    CVX │                Energy │         IntegratedOil │
│     GE │           Industrials │         Conglomerates │
│     HD │ ConsumerDiscretionary │       SpecialtyRetail │
│    JNJ │            HealthCare │       Pharmaceuticals │
│    JPM │            Financials │                 Banks │
│     KO │       ConsumerStaples │             Beverages │
│    LLY │            HealthCare │       Pharmaceuticals │
│    MRK │            HealthCare │       Pharmaceuticals │
│   MSFT │            Technology │              Software │
│    PEP │       ConsumerStaples │             Beverages │
│    PFE │            HealthCare │       Pharmaceuticals │
│     PG │       ConsumerStaples │     HouseholdProducts │
│    RRC │                Energy │ ExplorationProduction │
│    UNH │            HealthCare │           ManagedCare │
│    WMT │       ConsumerStaples │         MassMerchants │
│    XOM │                Energy │         IntegratedOil │
└────────┴───────────────────────┴───────────────────────┘

2. From a classification to a distance

asset_sets_features stacks one indicator block per key into an assets × features matrix: Z[i, k] == 1 when asset i belongs to group k. asset_sets_feature_names gives the matching column names — take them from there rather than rebuilding the column order by hand, because the block order is the order of taxonomy and the group order inside a block is not something to guess at.

Z = asset_sets_features(taxonomy, sets)nz = asset_sets_feature_names(taxonomy, sets)pretty_table(DataFrame(["Asset" => rd.nx;                        [nz[k] => Z[:, k] for k in 1:6]...]);             title = "The first six feature columns (of $(length(nz)))")
                                                           The first six feature columns (of 20)
┌────────┬──────────────────────┬──────────────────────┬─────────────────────────────────┬──────────────────┬───────────────────────┬──────────────────────┐
│  Asset  nx_sector=Technology  nx_sector=Financials  nx_sector=ConsumerDiscretionary  nx_sector=Energy  nx_sector=Industrials  nx_sector=HealthCare │
│ String               Float64               Float64                          Float64           Float64                Float64               Float64 │
├────────┼──────────────────────┼──────────────────────┼─────────────────────────────────┼──────────────────┼───────────────────────┼──────────────────────┤
│   AAPL │                  1.0 │                  0.0 │                             0.0 │              0.0 │                   0.0 │                  0.0 │
│    AMD │                  1.0 │                  0.0 │                             0.0 │              0.0 │                   0.0 │                  0.0 │
│    BAC │                  0.0 │                  1.0 │                             0.0 │              0.0 │                   0.0 │                  0.0 │
│    BBY │                  0.0 │                  0.0 │                             1.0 │              0.0 │                   0.0 │                  0.0 │
│    CVX │                  0.0 │                  0.0 │                             0.0 │              1.0 │                   0.0 │                  0.0 │
│     GE │                  0.0 │                  0.0 │                             0.0 │              0.0 │                   1.0 │                  0.0 │
│     HD │                  0.0 │                  0.0 │                             1.0 │              0.0 │                   0.0 │                  0.0 │
│    JNJ │                  0.0 │                  0.0 │                             0.0 │              0.0 │                   0.0 │                  1.0 │
│    JPM │                  0.0 │                  1.0 │                             0.0 │              0.0 │                   0.0 │                  0.0 │
│     KO │                  0.0 │                  0.0 │                             0.0 │              0.0 │                   0.0 │                  0.0 │
│    LLY │                  0.0 │                  0.0 │                             0.0 │              0.0 │                   0.0 │                  1.0 │
│    MRK │                  0.0 │                  0.0 │                             0.0 │              0.0 │                   0.0 │                  1.0 │
│   MSFT │                  1.0 │                  0.0 │                             0.0 │              0.0 │                   0.0 │                  0.0 │
│    PEP │                  0.0 │                  0.0 │                             0.0 │              0.0 │                   0.0 │                  0.0 │
│    PFE │                  0.0 │                  0.0 │                             0.0 │              0.0 │                   0.0 │                  1.0 │
│     PG │                  0.0 │                  0.0 │                             0.0 │              0.0 │                   0.0 │                  0.0 │
│    RRC │                  0.0 │                  0.0 │                             0.0 │              1.0 │                   0.0 │                  0.0 │
│    UNH │                  0.0 │                  0.0 │                             0.0 │              0.0 │                   0.0 │                  1.0 │
│    WMT │                  0.0 │                  0.0 │                             0.0 │              0.0 │                   0.0 │                  0.0 │
│    XOM │                  0.0 │                  0.0 │                             0.0 │              1.0 │                   0.0 │                  0.0 │
└────────┴──────────────────────┴──────────────────────┴─────────────────────────────────┴──────────────────┴───────────────────────┴──────────────────────┘

2.1 Why this producer needs no standardisation

asset_sets_matrix builds its groups from the distinct values present, so every key is a partition: each asset lands in exactly one group per key and every row carries exactly L = length(taxonomy) ones. All rows therefore have norm sqrt(L), and the cosine between two assets is exactly

cos(i, j) = shared(i, j) / L

the count of classification levels they agree on, divided by the number of levels. That makes AngularDist — the default metric — take only L + 1 distinct values, and bounds the distance by 0.5 rather than 1.0 because the cosine can never go negative.

This is the one producer with that property. The others build columns on unrelated scales and have no such guarantee.

D = distance(FeatureDistance(), Z; dims = 1)levels = sort(unique(round.(D; digits = 6)))pretty_table(DataFrame("Quantity" => ["Feature count", "Levels agreed on (L)",                                      "Row norm (every row, = sqrt(L))", "Distinct distances",                                      "The distances themselves"],                       "Value" => [string(size(Z, 2)), string(length(taxonomy)),                                   string(round(norm(Z[1, :]); digits = 4)),                                   string(length(levels)), string(round.(levels; digits = 4))]);             title = "The feature distance this classification produces")
   The feature distance this classification produces
┌─────────────────────────────────┬────────────────────┐
│                        Quantity               Value │
│                          String              String │
├─────────────────────────────────┼────────────────────┤
│                   Feature count │                 20 │
│            Levels agreed on (L) │                  2 │
│ Row norm (every row, = sqrt(L)) │             1.4142 │
│              Distinct distances │                  3 │
│        The distances themselves │ [0.0, 0.3333, 0.5] │
└─────────────────────────────────┴────────────────────┘

Three distances, one per agreement level, up to floating-point noise in acos: 0.0 for two assets in the same industry, 1/3 for two in the same sector but different industries, and 0.5 for two sharing nothing.

2.2 The clustering it produces

The distance goes into an ordinary ClustersEstimator through its de slot. The feature matrix itself is data, not configuration, so it does not live on the estimator — it rides beside the returns and is selected by z_src (§7). Passing it explicitly is the bare form that every optimiser wraps.

onc = OptimalNumberClusters(; alg = 7)cle_cor = ClustersEstimator(; onc = onc)cle_fea = ClustersEstimator(; de = FeatureDistance(), onc = onc)clr_cor = clusterise(cle_cor, pr.X)clr_fea = clusterise(cle_fea, pr.X; Z = Z, z_src = :data)pretty_table(DataFrame("Asset" => rd.nx, "Sector" => [sector[a] for a in rd.nx],                       "Correlation cut" => cutree(clr_cor.res; k = 4),                       "Feature cut" => cutree(clr_fea.res; k = 4));             title = "Four-way cuts, correlation against classification")
        Four-way cuts, correlation against classification
┌────────┬───────────────────────┬─────────────────┬─────────────┐
│  Asset                 Sector  Correlation cut  Feature cut │
│ String                 String            Int64        Int64 │
├────────┼───────────────────────┼─────────────────┼─────────────┤
│   AAPL │            Technology │               1 │           1 │
│    AMD │            Technology │               1 │           1 │
│    BAC │            Financials │               1 │           1 │
│    BBY │ ConsumerDiscretionary │               1 │           1 │
│    CVX │                Energy │               2 │           2 │
│     GE │           Industrials │               1 │           1 │
│     HD │ ConsumerDiscretionary │               1 │           1 │
│    JNJ │            HealthCare │               3 │           3 │
│    JPM │            Financials │               1 │           1 │
│     KO │       ConsumerStaples │               4 │           4 │
│    LLY │            HealthCare │               3 │           3 │
│    MRK │            HealthCare │               3 │           3 │
│   MSFT │            Technology │               1 │           1 │
│    PEP │       ConsumerStaples │               4 │           4 │
│    PFE │            HealthCare │               3 │           3 │
│     PG │       ConsumerStaples │               4 │           4 │
│    RRC │                Energy │               2 │           2 │
│    UNH │            HealthCare │               3 │           3 │
│    WMT │       ConsumerStaples │               4 │           4 │
│    XOM │                Energy │               2 │           2 │
└────────┴───────────────────────┴─────────────────┴─────────────┘

On this universe the two agree exactly at four clusters and diverge as the cut goes finer. That is worth reading carefully, because it is the honest result rather than the flattering one: a sector classification and a one-year correlation see the same coarse structure here, and the feature route earns its keep in the fine structure, in the merge order, and — most of all — in what happens when the sample moves (§9).

agreement = DataFrame("k" => 2:10,                      "Adjusted Rand index" => [round(randindex(cutree(clr_cor.res; k = k),                                                                cutree(clr_fea.res; k = k))[1]; digits = 3)                                                for k in 2:10])pretty_table(agreement; title = "How far the two hierarchies agree, cut by cut")plot_dendrogram(clr_fea, rd.nx)
Example block output

Feeding both hierarchies to HierarchicalRiskParity shows the allocation moving. Note z_src = :data on the HierarchicalOptimiser and the feature matrix carried on a ReturnsResult — that pairing is §7's subject.

rdz = ReturnsResult(; nx = rd.nx, X = rd.X, ts = rd.ts, nz = nz, Z = Z)hrp_cor = optimise(HierarchicalRiskParity(;                                          opt = HierarchicalOptimiser(; pe = pr,                                                                      cle = cle_cor,                                                                      slv = slv),                                          r = Variance()), rd)hrp_fea = optimise(HierarchicalRiskParity(;                                          opt = HierarchicalOptimiser(; pe = pr,                                                                      cle = cle_fea,                                                                      slv = slv,                                                                      z_src = :data),                                          r = Variance()), rdz)pretty_table(DataFrame("Asset" => rd.nx, "HRP correlation" => hrp_cor.w,                       "HRP features" => hrp_fea.w, "Difference" => hrp_fea.w - hrp_cor.w);             formatters = [resfmt],             title = "Same risk measure, same prior, two hierarchies")plot_stacked_bar_composition([hrp_cor, hrp_fea], rd;                             xticks = (1:2, ["Correlation", "Features"]))
Example block output

3. The four producers

A feature matrix reaches the library four ways. Three of them are AbstractFeatureMatrixEstimator producers that run inside FeaturePrior and attach Z to the prior result; the fourth is a literal matrix you supply yourself. FeaturePrior delegates every moment to the estimator it wraps and adds nothing but Z, so wrapping never changes the numbers the optimisation is solved on.

3.1 A literal matrix

The simplest producer is no producer at all: hand FeaturePrior the matrix. Use this when the features come from somewhere the library cannot see — a vendor file, a database, your own model.

pr_literal = prior(FeaturePrior(; pe = EmpiricalPrior(), ze = Z), rd)
LowOrderPrior
      X ┼ 252×20 Matrix{Float64}
    o_X ┼ nothing
     mu ┼ 20-element Vector{Float64}
  sigma ┼ 20×20 Matrix{Float64}
   chol ┼ nothing
      w ┼ nothing
    ens ┼ nothing
    kld ┼ nothing
     ow ┼ nothing
     rr ┼ nothing
    fpr ┼ nothing
      Z ┴ 20×20 Matrix{Float64}

3.2 AssetSetsFeatures — the exogenous taxonomy

AssetSetsFeatures is §2's classification as a producer, reading its taxonomy from FeaturePrior's sets field. It is the only producer that does not derive Z from the returns, and therefore the only one that brings in structure the price history cannot contain.

pe_taxonomy = FeaturePrior(; pe = EmpiricalPrior(),                           ze = AssetSetsFeatures(; vals = taxonomy), sets = sets)pr_taxonomy = prior(pe_taxonomy, rd)println("The producer and the public function agree exactly: ", pr_taxonomy.Z == Z)
The producer and the public function agree exactly: true

Grading the levels

cos = shared / L weights every level equally, which is rarely what a reader wants — agreeing on an industry is a stronger statement than agreeing on a sector. The vals argument also takes an ordered edge-authoring program of Pairs over a declared feature axis, which writes the weights directly. The axis is declared under sets.zkey, node names are bare, entries apply in order, and the last write wins.

nodes = unique([[sector[a] for a in rd.nx]; [industry[a] for a in rd.nx]])sets_graded = UniverseSets(; xkey = "nx", zkey = "nz",                           dict = Dict{String, Any}("nx" => rd.nx, "nz" => nodes,                                                    "nx_sector" =>                                                        [sector[a] for a in rd.nx],                                                    "nx_industry" =>                                                        [industry[a] for a in rd.nx]))Z_graded = asset_sets_features(["nx_sector" => 2.0, "nx_industry" => 1.0], sets_graded)D_graded = distance(FeatureDistance(), Z_graded; dims = 1)pretty_table(DataFrame("Pair" => ["AAPL–AMD (same sector, different industry)",                                  "CVX–XOM (same sector, same industry)",                                  "AAPL–KO (nothing shared)"],                       "Equal levels" => [D[1, 2], D[5, 20], D[1, 10]],                       "Sector doubled" =>                           [D_graded[1, 2], D_graded[5, 20], D_graded[1, 10]]);             formatters = [(v, i, j) -> j == 1 ? v : round(v; digits = 4)],             title = "Weighting the coarse level up pulls same-sector pairs together")
        Weighting the coarse level up pulls same-sector pairs together
┌────────────────────────────────────────────┬──────────────┬────────────────┐
│                                       Pair  Equal levels  Sector doubled │
│                                     String       Float64         Float64 │
├────────────────────────────────────────────┼──────────────┼────────────────┤
│ AAPL–AMD (same sector, different industry) │       0.3333 │         0.2048 │
│       CVX–XOM (same sector, same industry) │          0.0 │            0.0 │
│                   AAPL–KO (nothing shared) │          0.5 │            0.5 │
└────────────────────────────────────────────┴──────────────┴────────────────┘

3.3 RegressionFeatures — factor loadings

RegressionFeatures reads the loadings a factor prior has already fitted, so an asset's feature row is its position in the factor coordinate system. This is endogenous — the loadings come from the same returns — but it is a genuinely different reading of them: two assets can load alike and still co-move weakly.

Loadings are signed, which matters for the metric choice in §5.

F = TimeArray(CSV.File(joinpath(@__DIR__, "..", "Factors.csv.gz")); timestamp = :Date)rdf = prices_to_returns(X, F)pr_loadings = prior(FeaturePrior(; pe = FactorPrior(), ze = RegressionFeatures()), rdf)pretty_table(DataFrame(["Asset" => rd.nx;                        [rdf.nf[k] => pr_loadings.Z[:, k] for k in eachindex(rdf.nf)]...]);             formatters = [(v, i, j) -> j == 1 ? v : round(v; digits = 4)],             title = "Factor loadings as a feature matrix")
            Factor loadings as a feature matrix
┌────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│  Asset     MTUM     QUAL     SIZE     USMV     VLUE │
│ String  Float64  Float64  Float64  Float64  Float64 │
├────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│   AAPL │     0.0 │  1.9094 │ -0.7676 │     0.0 │     0.0 │
│    AMD │   0.377 │  3.0672 │     0.0 │ -2.1767 │     0.0 │
│    BAC │  0.2536 │     0.0 │     0.0 │ -0.5916 │  1.2588 │
│    BBY │ -0.6824 │  1.2054 │     0.0 │     0.0 │  0.6803 │
│    CVX │  0.2959 │     0.0 │     0.0 │ -0.8437 │  1.0085 │
│     GE │     0.0 │     0.0 │     0.0 │     0.0 │  1.1353 │
│     HD │ -0.3604 │  0.8314 │     0.0 │  0.5918 │     0.0 │
│    JNJ │ -0.1454 │     0.0 │ -0.8823 │  1.3659 │  0.3754 │
│    JPM │     0.0 │ -0.6254 │  0.4967 │     0.0 │  1.1515 │
│     KO │ -0.2146 │  -0.487 │     0.0 │  1.3327 │  0.2825 │
│    LLY │  0.2717 │     0.0 │ -0.9266 │  1.6874 │     0.0 │
│    MRK │     0.0 │     0.0 │ -1.1839 │   1.356 │  0.5509 │
│   MSFT │     0.0 │  2.0455 │ -0.4585 │     0.0 │ -0.5295 │
│    PEP │ -0.1564 │     0.0 │ -0.6833 │  1.5089 │  0.2629 │
│    PFE │     0.0 │     0.0 │ -0.9477 │  1.3855 │   0.521 │
│     PG │ -0.2512 │     0.0 │ -0.7663 │   1.666 │  0.3126 │
│    RRC │     0.0 │     0.0 │     0.0 │     0.0 │  1.2234 │
│    UNH │  0.3807 │ -0.9579 │     0.0 │  1.7058 │     0.0 │
│    WMT │ -0.3598 │     0.0 │     0.0 │  1.1136 │     0.0 │
│    XOM │  0.4151 │ -0.9477 │     0.0 │     0.0 │  1.2728 │
└────────┴─────────┴─────────┴─────────┴─────────┴─────────┘

3.4 PhylogenyFeatures — the returns graph

PhylogenyFeatures turns the asset network into a square assets × assets matrix: feature k reads "how close is this asset to asset k". It is the most endogenous of the four — the graph is filtered out of the correlation — so it does not bring in outside structure. What it does bring is a graded reading of the network that phylogeny_matrix throws away: that routine accumulates a walk count and then clamps it to 0/1, destroying the step count, while this one keeps it.

Its source is always an estimator, never a precomputed result, so the graph is rebuilt on whatever universe the subproblem hands it.

pe_graph = FeaturePrior(; pe = EmpiricalPrior(),                        ze = PhylogenyFeatures(;                                               pl = NetworkEstimator(;                                                                     sep = HopCount(;                                                                                    n = 2)),                                               alg = Proximity(; decay = LinearDecay())))pr_graph = prior(pe_graph, rd)pretty_table(DataFrame(["Asset" => rd.nx;                        [rd.nx[k] => pr_graph.Z[:, k] for k in 1:6]...]);             formatters = [(v, i, j) -> j == 1 ? v : round(v; digits = 4)],             title = "The first six columns of the graph feature matrix")
          The first six columns of the graph feature matrix
┌────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐
│  Asset     AAPL      AMD      BAC      BBY      CVX       GE │
│ String  Float64  Float64  Float64  Float64  Float64  Float64 │
├────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤
│   AAPL │     3.0 │     2.0 │     0.0 │     0.0 │     0.0 │     1.0 │
│    AMD │     2.0 │     3.0 │     0.0 │     0.0 │     1.0 │     2.0 │
│    BAC │     0.0 │     0.0 │     3.0 │     0.0 │     0.0 │     1.0 │
│    BBY │     0.0 │     0.0 │     0.0 │     3.0 │     0.0 │     0.0 │
│    CVX │     0.0 │     1.0 │     0.0 │     0.0 │     3.0 │     2.0 │
│     GE │     1.0 │     2.0 │     1.0 │     0.0 │     2.0 │     3.0 │
│     HD │     1.0 │     0.0 │     0.0 │     2.0 │     0.0 │     0.0 │
│    JNJ │     1.0 │     0.0 │     0.0 │     0.0 │     0.0 │     0.0 │
│    JPM │     0.0 │     1.0 │     2.0 │     0.0 │     1.0 │     2.0 │
│     KO │     1.0 │     0.0 │     0.0 │     0.0 │     0.0 │     0.0 │
│    LLY │     0.0 │     0.0 │     0.0 │     0.0 │     0.0 │     0.0 │
│    MRK │     0.0 │     0.0 │     0.0 │     0.0 │     0.0 │     0.0 │
│   MSFT │     2.0 │     1.0 │     0.0 │     1.0 │     0.0 │     0.0 │
│    PEP │     2.0 │     1.0 │     0.0 │     0.0 │     0.0 │     0.0 │
│    PFE │     0.0 │     0.0 │     0.0 │     0.0 │     0.0 │     0.0 │
│     PG │     0.0 │     0.0 │     0.0 │     0.0 │     0.0 │     0.0 │
│    RRC │     0.0 │     0.0 │     0.0 │     0.0 │     1.0 │     0.0 │
│    UNH │     1.0 │     0.0 │     0.0 │     0.0 │     0.0 │     0.0 │
│    WMT │     1.0 │     0.0 │     0.0 │     0.0 │     0.0 │     0.0 │
│    XOM │     0.0 │     0.0 │     0.0 │     0.0 │     2.0 │     1.0 │
└────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘

Read the diagonal: 3 is the asset itself, 2 a direct neighbour, 1 a two-hop neighbour, 0 unreachable within the budget. §4 explains where those numbers come from — and why they are the one decay setting whose scale depends on the budget.

3.5 The four side by side

producers = DataFrame("Producer" =>                          ["Literal matrix", "AssetSetsFeatures", "RegressionFeatures",                           "PhylogenyFeatures"],                      "Feature axis" => ["whatever you supply", "taxonomy groups",                                         "factors / reduced dimensions", "the assets"],                      "Shape here" => [string(size(Z)), string(size(pr_taxonomy.Z)),                                       string(size(pr_loadings.Z)), string(size(pr_graph.Z))],                      "Exogenous" => ["depends on the source", "yes", "no", "no"],                      "Signed" => ["depends on the source", "no", "yes", "no"])pretty_table(producers; title = "The four routes a feature matrix takes")
                                      The four routes a feature matrix takes
┌────────────────────┬──────────────────────────────┬────────────┬───────────────────────┬───────────────────────┐
│           Producer                  Feature axis  Shape here              Exogenous                 Signed │
│             String                        String      String                 String                 String │
├────────────────────┼──────────────────────────────┼────────────┼───────────────────────┼───────────────────────┤
│     Literal matrix │          whatever you supply │   (20, 20) │ depends on the source │ depends on the source │
│  AssetSetsFeatures │              taxonomy groups │   (20, 20) │                   yes │                    no │
│ RegressionFeatures │ factors / reduced dimensions │    (20, 5) │                    no │                   yes │
│  PhylogenyFeatures │                   the assets │   (20, 20) │                    no │                    no │
└────────────────────┴──────────────────────────────┴────────────┴───────────────────────┴───────────────────────┘

4. Two knobs on the graph producer, and neither implies the other

PhylogenyFeatures is driven by two settings that live on two different objects:

  • sep on the NetworkEstimator decides which pairs are related — how far apart two assets may sit and still score above zero. HopCount counts edges with a budget of n of them; PathLength sums distances along the shortest path with a budget dmax in those units.
  • decay on Proximity decides how strongly a related pair scores as separation grows.

Setting one does not imply the other, and getting that wrong produces no error at all. The confusion is easy to fall into because the default pairing hides it: under LinearDecay with HopCount, the budget is the top of the scale, so changing sep appears to change the fall-off too. Under any other decay the two separate cleanly.

separations = ["HopCount(; n = 2)" => HopCount(; n = 2),               "PathLength(; dmax = 1.0)" => PathLength(; dmax = 1.0)]decays = ["LinearDecay()" => LinearDecay(), "ExponentialDecay()" => ExponentialDecay(),          "ReciprocalDecay()" => ReciprocalDecay(), "NoDecay()" => NoDecay()]function graph_features(sep, decay)    ze = PhylogenyFeatures(; pl = NetworkEstimator(; sep = sep),                           alg = Proximity(; decay = decay))    return prior(FeaturePrior(; pe = EmpiricalPrior(), ze = ze), rd).Zendsweep = DataFrame()for (sname, sep) in separations, (dname, decay) in decays    Zg = graph_features(sep, decay)    off = [Zg[i, j] for i in axes(Zg, 1) for j in axes(Zg, 2) if i != j]    Dg = distance(FeatureDistance(), Zg; dims = 1)    clg = clusterise(cle_fea, pr.X; Z = Zg, z_src = :data)    append!(sweep,            DataFrame("Separation" => sname, "Decay" => dname, "Self score" => Zg[1, 1],                      "Largest off-diagonal" => maximum(off),                      "Related pairs" => count(!iszero, off),                      "ARI vs correlation" =>                          randindex(cutree(clr_cor.res; k = 4), cutree(clg.res; k = 4))[1]))endpretty_table(sweep;             formatters = [(v, i, j) -> isa(v, AbstractFloat) ? round(v; digits = 4) : v],             title = "Four decays crossed against two separations")
                                       Four decays crossed against two separations
┌──────────────────────────┬────────────────────┬────────────┬──────────────────────┬───────────────┬────────────────────┐
│               Separation               Decay  Self score  Largest off-diagonal  Related pairs  ARI vs correlation │
│                   String              String     Float64               Float64          Int64             Float64 │
├──────────────────────────┼────────────────────┼────────────┼──────────────────────┼───────────────┼────────────────────┤
│        HopCount(; n = 2) │      LinearDecay() │        3.0 │                  2.0 │            96 │             0.4762 │
│        HopCount(; n = 2) │ ExponentialDecay() │        1.0 │               0.3679 │            96 │             0.4762 │
│        HopCount(; n = 2) │  ReciprocalDecay() │        1.0 │                  0.5 │            96 │             0.5053 │
│        HopCount(; n = 2) │          NoDecay() │        1.0 │                  1.0 │            96 │             0.2842 │
│ PathLength(; dmax = 1.0) │      LinearDecay() │        2.0 │               1.7747 │            94 │             0.4471 │
│ PathLength(; dmax = 1.0) │ ExponentialDecay() │        1.0 │               0.7983 │            94 │             0.5274 │
│ PathLength(; dmax = 1.0) │  ReciprocalDecay() │        1.0 │               0.8161 │            94 │             0.4471 │
│ PathLength(; dmax = 1.0) │          NoDecay() │        1.0 │                  1.0 │            94 │             0.2842 │
└──────────────────────────┴────────────────────┴────────────┴──────────────────────┴───────────────┴────────────────────┘

Read the table down the columns rather than across the rows:

  • Related pairs moves with the separation and never with the decay. Which pairs are related is sep's question alone.
  • Self score is 1.0 for every decay except LinearDecay, where it is the budget plus one — 3 under HopCount(; n = 2), 2 under PathLength(; dmax = 1.0). The other three pin f(0) = 1 and set the fall-off from their own parameter, independently of how far the budget looks. That is the whole of the coincidence: under the default pairing the budget doubles as the scale, and under every other it does not.
  • ARI vs correlation moves with both. The decay is not cosmetic — it changes the distance and the clusters that come out of it.
  • NoDecay is not "no truncation". The budget still cuts, so it gives 1 inside and 0 outside: a neighbourhood indicator, not a matrix of ones. It is also the row where the two separations agree exactly, because an indicator can only see the support.
plot(1:size(sweep, 1), sweep[!, "ARI vs correlation"]; marker = :circle, legend = false,     xticks = (1:size(sweep, 1),               [string(first(split(r.Separation, "(")), " / ", first(split(r.Decay, "(")))                for r in eachrow(sweep)]), xrotation = 45,     ylabel = "Adjusted Rand index against the correlation cut",     title = "Both knobs move the clustering")
Example block output
The same setting is a trap one step over

PathLength() with no dmax means the whole connected component. On this path that is a sensible choice — the budget only sets where the fall-off reaches zero, and the decay still grades everything inside it. On the constraint path the identical setting selects instead of shaping, so it declares every reachable pair related and forbids all pairwise co-movement, optimising successfully into a one-asset portfolio. See Phylogeny and centrality constraints §2.2 for that end of it. The bare default also makes the scale of Z depend on the sample — §8.2.

5. Metrics, and the similarity slot

FeatureDistance's metric field takes any Distances.SemiMetric, including one you define. The default is AngularDist, which is the arc-cosine of the cosine similarity scaled to [0, 1] and delegates to Distances' BLAS gemm path.

The choice is not free. Metrics differ in what they are defined on, and one of them fails silently on input outside its domain, which is why the library checks the domain rather than trusting it.

metrics = ["AngularDist()" => AngularDist(),           "Distances.CosineDist()" => PortfolioOptimisers.Distances.CosineDist(),           "Distances.Jaccard()" => PortfolioOptimisers.Distances.Jaccard()]metric_rows = DataFrame()for (mname, metric) in metrics    de = FeatureDistance(; metric = metric)    Dm = distance(de, Z; dims = 1)    append!(metric_rows,            DataFrame("Metric" => mname, "Default similarity" => strip(string(de.sim)),                      "Maximum distance" => round(maximum(Dm); digits = 4),                      "Distinct values" => length(unique(round.(Dm; digits = 6)))))endpretty_table(metric_rows; title = "Three metrics on the same classification matrix")
                    Three metrics on the same classification matrix
┌────────────────────────┬────────────────────────┬──────────────────┬─────────────────┐
│                 Metric      Default similarity  Maximum distance  Distinct values │
│                 String       SubString{String}           Float64            Int64 │
├────────────────────────┼────────────────────────┼──────────────────┼─────────────────┤
│          AngularDist() │    AngularSimilarity() │              0.5 │               3 │
│ Distances.CosineDist() │ ComplementSimilarity() │              1.0 │               3 │
│    Distances.Jaccard() │ ComplementSimilarity() │              1.0 │               3 │
└────────────────────────┴────────────────────────┴──────────────────┴─────────────────┘

On a partition matrix all three order the pairs identically — only the scale differs, and AngularDist stops at 0.5 because a non-negative matrix admits no negative cosine. They part company as soon as the matrix is signed:

signed_metric = try    distance(FeatureDistance(; metric = PortfolioOptimisers.Distances.Jaccard()),             pr_loadings.Z; dims = 1)    "no error"catch err    sprint(showerror, err)endprintln(signed_metric)
DomainError with all(x -> 0 <= x, Z) must hold. Got
all(x -> 0 <= x, Z) => false:

Distances.Jaccard is the Ruzicka form, defined only on non-negative reals, and on signed input it returns values up to 2 with no complaint at all — straight into a clustering routine. The domain check turns that silence into the error above; it covers Distances.BrayCurtis and Distances.ChiSqDist too. Signed feature matrices — factor loadings above all — want AngularDist or Distances.CosineDist.

The sim slot

Clustering consumers call cor_and_dist, not distance, so a feature distance owes a similarity matrix as well. sim supplies it, defaulted from the metric by default_similarity: AngularDist gets AngularSimilarity, which recovers the cosine exactly as cos(πD), and everything else gets ComplementSimilarity's 1 - D. Set it explicitly to override.

clr_pair = cor_and_dist(FeatureDistance(), nothing, pr.X; Z = Z, z_src = :data)println("S and D share provenance: ", size(clr_pair[1]) == size(clr_pair[2]))
S and D share provenance: true

6. Both shapes: static and time-varying

A feature matrix is either static (assets × features) or time-varying (observations × assets × features, observations leading). The shape is read from ndims, with no wrapper type. A time-varying matrix has to become one distance matrix somehow, and the alg field says how — an open family with four members.

Here the features are two trailing characteristics that genuinely move: annualised realised volatility over twenty-one days, and cumulative return over sixty-three.

T, N = size(rd.X)Ztv = zeros(T, N, 2)for t in 1:T    lo_vol = max(1, t - 20)    lo_mom = max(1, t - 62)    for i in 1:N        Ztv[t, i, 1] = std(view(rd.X, lo_vol:t, i)) * sqrt(252)        Ztv[t, i, 2] = sum(view(rd.X, lo_mom:t, i))    endendZtv[1, :, 1] .= Ztv[2, :, 1]collapses = ["LastObservation()" => LastObservation(),             "AggregateFeatures()" => AggregateFeatures(),             "AggregateDistances()" => AggregateDistances(),             "StackObservations()" => StackObservations()]collapse_rows = DataFrame()for (cname, alg) in collapses    Dc = distance(FeatureDistance(; alg = alg), Ztv; dims = 1)    D1 = distance(FeatureDistance(; alg = alg), reshape(Ztv[end, :, :], 1, N, 2); dims = 1)    append!(collapse_rows,            DataFrame("Collapse" => cname, "Mean distance" => round(mean(Dc); digits = 4),                      "Maximum distance" => round(maximum(Dc); digits = 4),                      "Mean at T = 1" => round(mean(D1); digits = 6)))endpretty_table(collapse_rows; title = "Four collapse rules on one $(T)×$(N)×2 feature matrix")
            Four collapse rules on one 252×20×2 feature matrix
┌──────────────────────┬───────────────┬──────────────────┬───────────────┐
│             Collapse  Mean distance  Maximum distance  Mean at T = 1 │
│               String        Float64           Float64        Float64 │
├──────────────────────┼───────────────┼──────────────────┼───────────────┤
│    LastObservation() │        0.1243 │           0.4684 │       0.12428 │
│  AggregateFeatures() │        0.0739 │           0.2265 │       0.12428 │
│ AggregateDistances() │        0.1163 │           0.2197 │       0.12428 │
│  StackObservations() │        0.1613 │           0.2731 │       0.12428 │
└──────────────────────┴───────────────┴──────────────────┴───────────────┘

The four rules give four different answers, and they are different in kind:

  • LastObservation (the default) takes the most recent slice and ignores the rest.
  • AggregateFeatures averages the features first, then measures once.
  • AggregateDistances measures each period, then averages the distance matrices. It refuses MedianCollapse at construction, because a convex combination of metrics is a metric while a median of distance matrices is not.
  • StackObservations concatenates every period into one long coordinate vector. It is the most scale-exposed of the four, since a period with large magnitudes dominates.

The last column is the degeneracy that shows they are the same idea: at one observation all four agree exactly. A static matrix never reads alg at all — it is inert there, not an error, because z_src legitimately switches between static and time-varying sources.

Both aggregating rules take observation weights on a w field, so an exponential decay or an entropy-pooling posterior reaches the collapse.

6.1 A producer can emit this shape, and it tracks a fold for free

The matrix above was built by hand and carried on the returns. The derived carrier takes the same shape, and a producer is the way to put it there. Nothing in the library ships one — every shipped producer returns assets × features — but the interface has no shape constraint, and a producer is handed the subproblem's own returns, so a time-varying Z comes out with exactly the rows the fit was given.

That is worth stating plainly because it is the answer to the failure in §8.1: a producer never has to be resliced, because it recomputes. A rolling-dispersion producer is about ten lines —

struct TrailingDispersionFeatures <: PortfolioOptimisers.AbstractFeatureMatrixEstimator    windows::Vector{Int}endfunction PortfolioOptimisers.feature_matrix(ze::TrailingDispersionFeatures,                                            ::PortfolioOptimisers.AbstractPriorResult,                                            X::PortfolioOptimisers.MatNum, args...; kwargs...)    T, N = size(X)    Z = zeros(T, N, length(ze.windows))    for (k, w) in pairs(ze.windows), t in 1:T, i in 1:N        Z[t, i, k] = std(view(X, max(1, t - w + 1):t, i))    end    Z[1, :, :] .= Z[2, :, :]    return Zend

— and fitting it on the full sample gives (252, 20, 2) while fitting it on the first hundred rows gives (100, 20, 2), with no clock, no indices and no plumbing. Cross-validation cannot take precomputed quantities in any case, so anything a fold reads has to be computed per fold, and a producer is what "computed per fold" means here.

plot([distance(FeatureDistance(; alg = alg), Ztv; dims = 1)[1, :] for (_, alg) in collapses];     label = reshape([c for (c, _) in collapses], 1, :), marker = :circle,     xticks = (1:N, rd.nx), xrotation = 90, ylabel = "Distance from AAPL",     title = "The collapse rule is a modelling choice, not a detail")
Example block output

7. Two carriers, one selector

A feature matrix reaches distance from one of two places, and z_src on the optimiser picks which:

  • z_src = :data reads ReturnsResult's Z — the matrix you supplied. This is the default, because an explicitly supplied matrix outranks a derived one. It is the opposite default to x_src, deliberately.
  • z_src = :prior reads the prior result's Z — the matrix a producer derived.

Provenance is strict: a producer only ever populates the prior carrier, and prior(pe, rd) always drops rd.Z. The two carriers therefore never hold two copies of one matrix, and z_src never picks between two spellings of the same thing.

7.1 Outside a fold the two routes agree

Given the same classification, the two routes are indistinguishable.

hrp_data = optimise(HierarchicalRiskParity(;                                           opt = HierarchicalOptimiser(; pe = pr,                                                                       cle = cle_fea,                                                                       slv = slv,                                                                       z_src = :data),                                           r = Variance()), rdz)hrp_prior = optimise(HierarchicalRiskParity(;                                            opt = HierarchicalOptimiser(; pe = pe_taxonomy,                                                                        cle = cle_fea,                                                                        slv = slv,                                                                        z_src = :prior),                                            r = Variance()), rd)println("Largest weight difference between the two carriers: ",        round(maximum(abs, hrp_data.w - hrp_prior.w); digits = 12))
Largest weight difference between the two carriers: 0.0

7.2 Inside a fold they are two different semantics

The equality above is a property of the whole universe, not of the two selectors. Under a meta-optimiser or a cross-validation fold:

  • :data slices. The carried matrix is subselected to the subproblem's assets. Its columns still describe the classification of the full universe.
  • :prior refits. The producer runs again inside the subproblem's own prior call and recomputes Z from the subproblem's returns and its sliced UniverseSets.

For an exogenous taxonomy the two coincide, because slicing a partition and re-deriving it give the same thing. For a returns-derived producer they do not: PhylogenyFeatures rebuilds the graph on the cluster it is handed, so its features describe that cluster's topology, while a carried copy of the full-universe graph describes the universe's.

7.3 The one shape where slicing and measuring do not commute

The rule that decides this is features_are_assets, and it compares names, not axis lengths: when nz == nx the feature axis is the asset axis, so an asset view slices it too. That makes a square, asset-keyed matrix the one shape where measuring a subproblem is not the same as reading a subproblem out of the universe's distance matrix.

Z_square = phylogeny_features(Proximity(; decay = LinearDecay()),                              NetworkEstimator(; sep = HopCount(; n = 2)), pr.X)rd_square = ReturnsResult(; nx = rd.nx, X = rd.X, ts = rd.ts, nz = rd.nx, Z = Z_square)subset = [1, 2, 3, 5, 8, 10, 13, 17]view_square = PortfolioOptimisers.port_opt_view(rd_square, subset)view_rect = PortfolioOptimisers.port_opt_view(rdz, subset)commute = DataFrame("Feature axis" =>                        ["The assets (square)", "Taxonomy groups (rectangular)"],                    "Shape of the view" =>                        [string(size(view_square.Z)), string(size(view_rect.Z))],                    "Largest disagreement" => [maximum(abs,                                                       distance(FeatureDistance(), Z_square; dims = 1)[subset,                                                                                                       subset] -                                                       distance(FeatureDistance(), view_square.Z; dims = 1)),                                               maximum(abs,                                                       distance(FeatureDistance(), Z; dims = 1)[subset, subset] -                                                       distance(FeatureDistance(), view_rect.Z; dims = 1))])pretty_table(commute;             formatters = [(v, i, j) -> isa(v, AbstractFloat) ? round(v; digits = 4) : v],             title = "Measuring the subproblem against measuring the universe")
          Measuring the subproblem against measuring the universe
┌───────────────────────────────┬───────────────────┬──────────────────────┐
│                  Feature axis  Shape of the view  Largest disagreement │
│                        String             String               Float64 │
├───────────────────────────────┼───────────────────┼──────────────────────┤
│           The assets (square) │            (8, 8) │               0.0768 │
│ Taxonomy groups (rectangular) │           (8, 20) │                  0.0 │
└───────────────────────────────┴───────────────────┴──────────────────────┘

The rectangular case agrees to the last bit: its columns are the same twenty groups whichever assets you keep, so slicing rows and measuring commute. The square case does not, and the gap is not noise — it is the difference between "how close are these two assets within this cluster" and "how close are they in the whole universe". Neither reading is wrong; they are different questions, and the shape of Z is what chooses between them.

The prior carrier has no squareness vocabulary at all, and that is deliberate: a derived Z is never cut down its feature axis, because a producer refits on whatever universe it is given rather than slicing a description of a larger one.

7.4 A meta-optimiser's outer problem

A meta-optimiser's outer problem is defined over synthetic assets — sub-portfolios, clusters, predictions — which have no rows in any feature matrix. The outer problem is nevertheless feature-capable: the inner universe's Z is collapsed onto the synthetic assets by the inner weights, so an outer FeatureDistance measures the sub-portfolios' features rather than failing.

nco = NestedClustered(; pe = pr, cle = cle_fea, z_src = :data,                      opti = HierarchicalRiskParity(;                                                    opt = HierarchicalOptimiser(; pe = pr,                                                                                cle = cle_cor,                                                                                slv = slv),                                                    r = Variance()),                      opto = HierarchicalRiskParity(;                                                    opt = HierarchicalOptimiser(;                                                                                cle = cle_fea,                                                                                slv = slv,                                                                                z_src = :data),                                                    r = Variance()))res_nco = optimise(nco, rdz)println("Outer problem solved on collapsed features: ",        isa(res_nco.retcode, OptimisationSuccess))
Outer problem solved on collapsed features: true

A rectangular matrix keeps its own feature axis through that collapse — a cluster's row reads "this cluster's weighted-average membership of each group". A square, asset-keyed one is contracted on both axes at once, so it stays square on the synthetic universe.

8. What changes once cross-validation is switched on

Two behaviours are invisible until the sample starts moving, and both surface as surprises.

8.1 A time-varying literal cannot survive an observation fold

The observation counts always match when you build the matrix — the fold is what breaks them, and it breaks them on one carrier only.

A literal ze sits on the estimator, and the view layer hands an estimator asset indices and nothing else. So port_opt_view cuts the literal's asset axis and leaves its observation axis untouched, while the returns lose every row outside the fold. A Z carried on the ReturnsResult is data, and its view takes observations as well as assets, so it follows.

Ztv_wide = zeros(2 * T, N, 2)Ztv_wide[1:T, :, :] .= ZtvZtv_wide[(T + 1):end, :, :] .= Ztvview_estimator = PortfolioOptimisers.port_opt_view(FeaturePrior(; pe = EmpiricalPrior(),                                                                ze = Ztv_wide), [1, 2, 3])view_carried = PortfolioOptimisers.port_opt_view(ReturnsResult(; nx = rd.nx, X = rd.X,                                                               ts = rd.ts,                                                               nz = ["volatility",                                                                     "momentum"], Z = Ztv),                                                 1:100, [1, 2, 3])pretty_table(DataFrame("Carrier" => ["Estimator-held literal `ze`", "ReturnsResult `Z`"],                       "Before the view" => [string(size(Ztv_wide)), string(size(Ztv))],                       "After the view" =>                           [string(size(view_estimator.ze)), string(size(view_carried.Z))],                       "Observation axis" => ["untouched", "sliced"]);             title = "Only one of the two carriers can follow an observation fold")
             Only one of the two carriers can follow an observation fold
┌─────────────────────────────┬─────────────────┬────────────────┬──────────────────┐
│                     Carrier  Before the view  After the view  Observation axis │
│                      String           String          String            String │
├─────────────────────────────┼─────────────────┼────────────────┼──────────────────┤
│ Estimator-held literal `ze` │    (504, 20, 2) │    (504, 3, 2) │        untouched │
│           ReturnsResult `Z` │    (252, 20, 2) │    (100, 3, 2) │           sliced │
└─────────────────────────────┴─────────────────┴────────────────┴──────────────────┘

The consequence is that a fit whose observation count has changed cannot line up, and it says so rather than guessing:

fold_error = try    prior(FeaturePrior(; pe = EmpiricalPrior(), ze = Ztv), rd.X[1:100, :])    "no error"catch err    sprint(showerror, err)endprintln(fold_error)
DimensionMismatch: a literal time-varying feature matrix on `FeaturePrior.ze` cannot follow an observation fold. The view layer hands an estimator asset indices only, so its observation axis stays at its original length while the returns are cut to the fold, and nothing can recover which rows to keep. Got size(ze, 1) = 252 and 100 observations. Three ways forward, best first:
  1. Compute the features with a producer (an `AbstractFeatureMatrixEstimator` with a `feature_matrix` method). A producer is handed the fold's own `X`, so a time-varying `Z` tracks the fold with no extra plumbing — the derived carrier supports the shape, nothing shipped emits it yet.
  2. Carry the matrix on the `ReturnsResult` as `Z` and read it with `z_src = :data`. There it is data, so the fold slices its observation axis alongside `X`.
  3. Pass a static assets × features matrix, which has no observation axis to fall out of step.

The error is loud and names the two counts, which is the whole point — the alternative is a feature matrix silently describing the wrong periods. Construction succeeds, an asset view succeeds, and a fit on the full sample succeeds: only a changed observation count fails, which is why cross-validation is where this surfaces and nothing before it does.

The message names three ways forward, best first:

  1. Compute the features with a producer (§6.1). It is handed the fold's own returns, so a time-varying Z tracks the fold with nothing to reslice. This is the answer for most people who hit the error, and the only one that also works on the derived carrier.
  2. Carry the matrix on the ReturnsResult and read it with z_src = :data. There it is data, so the fold slices its observation axis alongside X — the second row of the table above.
  3. Pass a static assets × features matrix, which has no observation axis to fall out of step.

Note what the failure is not: it is not the derived carrier refusing a time-varying shape. It accepts one, and a producer emits one. The refusal is narrower — a matrix that was computed in advance cannot be recomputed for a subproblem, and cross-validation cannot take precomputed quantities in any case.

Slicing the observation axis with asset indices is what an automatically generated view would have done here, and it would have been finite, plausible and wrong. The view is hand-written to refuse that trade.

8.2 A bare PathLength() makes the scale of Z sample-dependent

PathLength with no dmax resolves its budget to the graph's observed diameter. Under LinearDecay the top of the scale is the budget plus one, so a diameter that moves between folds moves the whole matrix with it. Rolling a one-year window forward a quarter at a time over five years:

Xb = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500.csv.gz")); timestamp = :Date)[(end - 1260):end]rdb = prices_to_returns(Xb)windows = [(i, i + 251) for i in 1:63:(size(rdb.X, 1) - 251)]function window_row(lo, hi)    Xw = prior(EmpiricalPrior(), rdb.X[lo:hi, :]).X    seps = separation_matrix(PathLength(), NetworkEstimator(), Xw)    Zbare = phylogeny_features(Proximity(; decay = LinearDecay()),                               NetworkEstimator(; sep = PathLength()), Xw)    Zfixed = phylogeny_features(Proximity(; decay = LinearDecay()),                                NetworkEstimator(; sep = PathLength(; dmax = 1.5)), Xw)    return (; Window = "$(lo)$(hi)",            var"Observed diameter" = maximum(filter(isfinite, seps)),            var"Self score, bare" = Zbare[1, 1], var"Self score, dmax = 1.5" = Zfixed[1, 1])enddiameters = DataFrame([window_row(lo, hi) for (lo, hi) in windows])pretty_table(diameters;             formatters = [(v, i, j) -> isa(v, AbstractFloat) ? round(v; digits = 4) : v],             title = "The bare budget follows the sample; a stated one does not")plot(1:length(windows), diameters[!, "Self score, bare"]; marker = :circle,     label = "PathLength()", xlabel = "Rolling window", ylabel = "Top of the Z scale",     title = "A data-dependent budget moves the whole feature matrix")plot!(1:length(windows), diameters[!, "Self score, dmax = 1.5"]; marker = :square,      label = "PathLength(; dmax = 1.5)")
Example block output

The diameter roughly doubles across these windows and the scale follows it exactly. What makes that worse than it sounds is how it moves. The difference between a bare budget and a stated one is a constant added to every in-budget entry, not a factor multiplying them:

Xw1 = prior(EmpiricalPrior(), rdb.X[1:252, :]).XZ_bare = phylogeny_features(Proximity(; decay = LinearDecay()),                            NetworkEstimator(; sep = PathLength()), Xw1)Z_fixed = phylogeny_features(Proximity(; decay = LinearDecay()),                             NetworkEstimator(; sep = PathLength(; dmax = 3.0)), Xw1)shared = (Z_bare .!= 0) .&& (Z_fixed .!= 0)D_bare = distance(FeatureDistance(), Z_bare; dims = 1)D_scaled = distance(FeatureDistance(), 7.3 .* Z_bare; dims = 1)Z_shifted = copy(Z_bare)Z_shifted[Z_bare .!= 0] .+= 1.0D_shifted = distance(FeatureDistance(), Z_shifted; dims = 1)pretty_table(DataFrame("Quantity" => ["Distinct differences on the shared support",                                      "The difference itself",                                      "Distance change from rescaling Z by 7.3",                                      "Distance change from adding 1.0 on the support"],                       "Value" =>                           [string(length(unique(round.(Z_bare[shared] - Z_fixed[shared];                                                        digits = 8)))),                            string(round(first(unique(round.(Z_bare[shared] -                                                             Z_fixed[shared]; digits = 8)));                                         digits = 6)),                            string(round(maximum(abs, D_scaled - D_bare); digits = 12)),                            string(round(maximum(abs, D_shifted - D_bare); digits = 4))]);             title = "A shift is not a rescale, and only one of them is invisible")
 A shift is not a rescale, and only one of them is invisible
┌────────────────────────────────────────────────┬──────────┐
│                                       Quantity     Value │
│                                         String    String │
├────────────────────────────────────────────────┼──────────┤
│     Distinct differences on the shared support │        1 │
│                          The difference itself │ 0.761185 │
│        Distance change from rescaling Z by 7.3 │      0.0 │
│ Distance change from adding 1.0 on the support │   0.0603 │
└────────────────────────────────────────────────┴──────────┘

AngularDist is invariant to rescaling an asset's whole row — that is why the third row is zero to machine precision — but a shift changes the direction the row points, and the fourth row is the proof. So a moving diameter is not absorbed by the metric's invariance: it reshapes the distance fold by fold.

Two ways out, and both are one keyword:

  • State a numeric dmax, which pins the budget and the scale across every fold.
  • Use a decay that pins f(0) = 1ExponentialDecay, ReciprocalDecay or NoDecay — which never had the exposure in the first place.

9. A walk-forward backtest

The sharpest argument for an exogenous feature matrix is what it does to stability. A correlation hierarchy is refitted from scratch every fold and moves whenever the covariance does; a classification does not move at all. Walking forward over five years with one-year training windows and quarterly rebalances:

Z_bt = asset_sets_features(taxonomy,                           UniverseSets(; xkey = "nx",                                        dict = Dict("nx" => rdb.nx,                                                    "nx_sector" =>                                                        [sector[a] for a in rdb.nx],                                                    "nx_industry" =>                                                        [industry[a] for a in rdb.nx])))rdbz = ReturnsResult(; nx = rdb.nx, X = rdb.X, ts = rdb.ts, nz = nz, Z = Z_bt)walk = IndexWalkForward(252, 63)bt_cor = cross_val_predict(HierarchicalRiskParity(;                                                  opt = HierarchicalOptimiser(;                                                                              pe = EmpiricalPrior(),                                                                              cle = ClustersEstimator(),                                                                              slv = slv),                                                  r = Variance()), rdb, walk)bt_fea = cross_val_predict(HierarchicalRiskParity(;                                                  opt = HierarchicalOptimiser(;                                                                              pe = EmpiricalPrior(),                                                                              cle = ClustersEstimator(;                                                                                                      de = FeatureDistance()),                                                                              slv = slv,                                                                              z_src = :data),                                                  r = Variance()), rdbz, walk)function backtest_row(name, p)    r = p.mrd.X    turn = mean(abs,                reduce(vcat,                       [p.pred[i + 1].res.w - p.pred[i].res.w                        for i in 1:(length(p.pred) - 1)]))    return (; Hierarchy = name, var"Annual return" = mean(r) * 252,            var"Annual volatility" = std(r) * sqrt(252),            var"Sharpe ratio" = mean(r) / std(r) * sqrt(252),            var"Maximum drawdown" = expected_risk(MaximumDrawdown(), p),            var"Mean weight change" = turn)endpretty_table(DataFrame([backtest_row("Correlation", bt_cor),                        backtest_row("Classification", bt_fea)]);             formatters = [(v, i, j) -> isa(v, AbstractFloat) ? round(v; digits = 4) : v],             title = "Out-of-sample, $(length(bt_cor.pred)) quarterly rebalances")
                                   Out-of-sample, 16 quarterly rebalances
┌────────────────┬───────────────┬───────────────────┬──────────────┬──────────────────┬────────────────────┐
│      Hierarchy  Annual return  Annual volatility  Sharpe ratio  Maximum drawdown  Mean weight change │
│         String        Float64            Float64       Float64           Float64             Float64 │
├────────────────┼───────────────┼───────────────────┼──────────────┼──────────────────┼────────────────────┤
│    Correlation │         0.197 │            0.1956 │       1.0071 │           0.3085 │             0.0124 │
│ Classification │        0.1977 │            0.1956 │       1.0108 │           0.2915 │             0.0054 │
└────────────────┴───────────────┴───────────────────┴──────────────┴──────────────────┴────────────────────┘

Return and volatility are effectively identical, and the drawdown is a little better. The interesting column is the last one: the classification hierarchy changes its weights by less than half as much between rebalances. That is not a modelling trick — it follows directly from where the structure comes from. A sector does not move when a correlation does, so the dendrogram, the merge order and the recursive bisection all stay put, and the only thing left moving is the risk estimate inside each cluster.

That is the trade to weigh. An exogenous feature matrix gives up the ability to react to a structural break the returns can see, and buys a hierarchy that does not churn.

plot_portfolio_cumulative_returns(bt_fea)
Example block output

10. Summary

  • A feature matrix is assets × features data carried beside the returns, not configuration held on an estimator. Static and time-varying shapes are told apart by ndims.
  • FeatureDistance turns it into a distance, so every clustering, network and constraint consumer takes it unchanged.
  • Four producers supply it. Only AssetSetsFeatures is exogenous, which is the property the whole exercise exists for.
  • PhylogenyFeatures has two independent knobs on two different objects: sep chooses which pairs are related, decay how strongly. Neither implies the other, and the default pairing hides the difference.
  • z_src picks the carrier: :data slices under a fold, :prior refits. They agree on the whole universe and diverge on a subproblem.
  • Cross-validation surfaces two things nothing else does — a time-varying literal cannot survive an observation fold, and a bare PathLength() makes the scale of Z follow the sample.

This page was generated using Literate.jl.