When someone shows me a portfolio labeled “diversified,” I ask what happens if two holdings are effectively the same bet. Four equal capital weights can hide two tightly correlated pairs. Hierarchical Risk Parity (HRP) starts with that relationship: it groups assets by a correlation-derived distance, orders them along a tree, then allocates capital down its branches. The final weights are an output of the whole procedure—not a promise that each stock contributes equal risk.
This article implements one reproducible version of López de Prado's original HRP recipe: correlation profiles, Euclidean profile distance, deterministic single linkage, quasi-diagonal ordering, and recursive bisection using inverse-variance branch portfolios. The paper's title makes an out-of-sample performance claim; our four-asset teaching example does not test or repeat it. Modern libraries expose other linkage and risk choices. Those are variants, not interchangeable defaults for this exact example (skfolio's HRP API).
What question does HRP answer?
Given a covariance matrix for assets at the same observation horizon and currency basis, HRP returns fully invested, long-only capital weights. It does not estimate expected returns, choose securities, validate historic prices, solve a mean–variance objective, or guarantee future diversification. Its useful question is narrower: if I trust this covariance and this tree-building rule, how is capital distributed across the discovered branches?
That matters when covariance inversion is unstable or when a flat list of asset weights hides clusters. But avoiding an inverse is not the same as eliminating estimation risk: a different sample window can change correlations, merge order, and weights. Palomar's hierarchical-portfolio chapter treats these allocations as heuristics and distinguishes HRP from related methods.
First transformation: covariance to profile distance
For covariance , compute correlation . The pair-profile matrix is
The clustering input in this frozen recipe is not simply . Each asset has a full column of relationships to every asset. We compare those columns:
This distinction is easy to miss. Two assets can have a particular pairwise correlation while showing different relationships to the rest of the universe. Using only changes the tree-building method; it is not the full profile-distance calculation used here. The factor-of-two convention is less important than making the second distance transformation explicit and testing it.
Single linkage joins clusters using the smallest cross-cluster leaf distance. A deterministic tie rule matters: equal distances should not change weights merely because a runtime traversed a map differently. We traverse the merge tree into a quasi-diagonal leaf order, placing nearby cluster members together for allocation.
Open the tree diagram at full size. It depicts the exact synthetic fixture below, not a dendrogram estimated from a live feed.
Second transformation: branch variance to capital
At each split, calculate an inverse-variance portfolio within each of the left and right branches. If those branch portfolios have variances and , allocate capital by
The lower-variance branch receives more capital. Multiply allocations down the tree until every leaf has a weight. This is not equal-risk contribution, which tries to equalize asset-level component risk, or risk budgeting, which takes desired risk shares as input. A branch split uses branch variance; final asset risk shares can differ substantially.
Read a complete four-asset example
This synthetic covariance has an A/B pair and a C/D pair. Its diagonal entries are variances on one common return horizon:
With the frozen distance, linkage, and tie rules, the merges are (A,B), (C,D), then ((A,B),(C,D)); leaf order is A,B,C,D. Recursive bisection gives:
| Asset | Capital weight | Share of measured volatility risk |
|---|---|---|
| A | 10.6881% | 12.6764% |
| B | 10.6881% | 11.7522% |
| C | 54.4319% | 48.6501% |
| D | 24.1920% | 26.9214% |
Full-precision weights are ; portfolio volatility is . The risk-share column totals 100% up to rounding, but it is plainly not 25% each. Equal capital, equal asset risk, and HRP branch allocation are three different propositions. The canonical README and source fixtures retain full precision so the table can be checked rather than trusted by eye.
The price and FX trap comes before HRP
I have encountered the input problems that make a tidy allocation untrustworthy: a price invalid on its historical split basis; an old holding with a suspect quantity and mark; displayed weights shifting when units change; and Egypt, Japan, and US positions expressed in local currencies while the portfolio is reported in USD. I cannot attach a date or confirmed real trade to the particular 1010.SR illustration, so I do not present it as a verified market incident.
For a USD portfolio, a holding's current value is quantity × current local mark × contemporaneous local-to-USD FX. Purchase-price fields are neither valuation-day marks nor necessarily adjusted to the same share count. Covariance of local-currency returns is not automatically covariance of USD returns. Cash dividends require a clearly chosen price-return versus total-return basis: a price-only drop after a payment need not equal the investor's total return. Before HRP, align identity, split basis, dividends, timestamps, FX convention, and missingness. A suspended or stale quote must not become a zero return; the existing Stale-Quote Detector explains that upstream gate. The referenced 4070.SR suspension/resumption is an availability lesson, not the source of the synthetic covariance.
The implementation deliberately accepts a covariance matrix rather than pretending to reconstruct those events. It rejects malformed shape, nonfinite values, asymmetry, invalid diagonal, and materially non-PSD input rather than repairing evidence silently. A singular positive-semidefinite matrix can be accepted if the required branch and portfolio variances remain positive. These boundaries are summarized in the canonical README and specified fully in the source-package definition contract.
Use the computed lab as a question, not a picture
Open the interactive HRP playground and change the A/B correlation. Inspect both merge distances and weights: did allocation change because the tree changed, branch variance changed, or both? Then try the indefinite matrix. The previous result must clear and the input must be rejected; a tool that quietly displays the last valid portfolio after a failed edit teaches the wrong lesson. The stage controls explain distance, merge, allocation, and risk inspection. Their labels are not prerecorded calculations.
The Python and TypeScript implementations, shared fixtures, and direct parity tests live beside this article. From the topic directory, run:
python -m unittest discover -s tests/python -p 'test_*.py'
For TypeScript, use the installed compiler and exact paths documented in README. Serve visuals/animated over HTTP for the browser lab; direct file:// loading cannot resolve its ES module reliably. Those tests establish parity and the stated input contract, not historical strategy performance.
Decision boundary
HRP is a useful allocation baseline when covariance is credible and you want cluster structure visible. It cannot tell you that the covariance is historically valid, that a split or dividend was processed correctly, that liquidity or costs permit trades, or that future returns will outperform another method. I would keep the tree, distance convention, currency/return basis, asset risk shares, and rejection cases with any portfolio report. A later reviewer needs those facts to reproduce the decision.
The reference ledger separates original method evidence, implementation variants, and the unverified historical boundary. Compare this tree-driven result with Hierarchical Equal Risk Contribution only after its distinct cluster-risk rule is defined and tested; the shared word “hierarchical” is not proof the methods agree.
Rendered from the canonical Mermaid sources linked by this article.
HRP flow
The dashed route is evidence validation, not an arithmetic repair.
ReferencesPrimary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
- Accessed: 2026-09-14 through 2026-09-15, Africa/Cairo. Direct SSRN was unavailable; the author-paper mirror was read.
- Supports: R01 supports the original two-stage profile-distance, single-linkage, and recursive-bisection recipe. R03 documents library variants; R04 is a secondary cross-check.
- Limitations: Google rankings are discovery metadata, not evidence. No source authenticates the synthetic covariance as a historical portfolio or proves that this package outperforms another strategy.
| ID | Source | Read extent (2026-09-14) | Use and limitation |
|---|---|---|---|
| R01 | López de Prado, Building Diversified Portfolios that Outperform Out of Sample | Author-paper mirror opened as a 31-page PDF with 958 extracted lines; read pages/sections P0–P7 covering instability/concentration motivation, correlation distance, clustering, quasi-diagonalization, and recursive bisection. | Primary-content mirror for the original recipe; empirical claims remain paper-reported and are not package performance claims. |
| R02 | SSRN original record | Direct host returned security verification/403; no body read and no bypass attempted. | Unavailable host; mirror above is the inspected paper content. |
| R03 | skfolio HierarchicalRiskParity API | Official docs page, 733 extracted lines; read class description/implementation notes lines 364–429 and parameters through 480. | Primary library documentation for variant boundaries; defaults may be Ward or other measures and are not silently labeled original HRP. |
| R04 | Palomar, Portfolio Optimization §12.3 | 184 extracted lines; read HRP distance/linkage/split/intra/inter choices lines 63–101 and HERC contrast/limitations 114–163. | Secondary mathematical cross-check; its heuristic/suboptimal warning is retained. |
| R05 | Quant Memo HRP explanation | 123 extracted lines; read steps and limitations lines 27–66. | Secondary explainer; its performance language is excluded. |
| R06 | vdp96 HRP study repository | 230 extracted lines; read files and README workflow/assumptions lines 141–196. | Secondary implementation study; its fill-zero choice is a caution, not this package policy. |
| R07 | CBS HRP thesis record | 42 extracted lines; read metadata and abstract lines 9–22. | Secondary thesis record; no ranking/superiority claim reused. |
Google search was performed in connected Edge on 2026-09-14, Africa/Cairo, query hierarchical risk parity portfolio Lopez de Prado, with hl=en, gl=eg, pws=0, udm=14, num=10. Ten organic slots and per-result access/reader-value notes are retained in the research ledger. Google ranking is discovery metadata, not evidence of algorithm correctness.
The search/page-access record retains the first ten observed Google results and reader-value gaps. The claim ledger maps formula, limitation, and historical-boundary claims to inspected sources.
Full dependency-light reference implementations in both supported languages.
/**
* Pure D14-F02-A04 hierarchical risk-parity allocator.
*
* Frozen profile: correlation distance, deterministic single linkage,
* quasi-diagonal leaf order, and inverse-variance recursive bisection.
* This supplied-covariance function is a heuristic recipe, not an optimizer.
*/
export const WEIGHT_SUM_ABSOLUTE_TOLERANCE = 1e-12;
export const PSD_TOLERANCE = 1e-12;
export const PAIR_DISTANCE_TOLERANCE = 1e-12;
export type HierarchicalRiskParityErrorCode =
| "invalid-input"
| "invalid-covariance"
| "numerical-range-invalid";
export interface HierarchicalRiskParityResult {
assetIds: string[];
weights: number[];
componentRiskContributions: number[];
componentRiskShares: number[];
portfolioVariance: number;
portfolioVolatility: number;
sumWeights: number;
quasiDiagonalOrder: number[];
correlationMatrix: number[][];
distanceMatrix: number[][];
merges: Array<{ left: number[]; right: number[]; distance: number }>;
method: "hierarchical-risk-parity";
linkage: "single";
distance: "euclidean-distance-between-sqrt((1-correlation)/2)-profiles";
intraClusterAllocation: "inverse-variance";
splitRule: "quasi-diagonal-order-midpoint-recursive-bisection";
covarianceScale: number;
status: "ok";
}
export class HierarchicalRiskParityValidationError extends Error {
readonly code: HierarchicalRiskParityErrorCode;
constructor(code: HierarchicalRiskParityErrorCode, message: string) {
super(message);
this.name = "HierarchicalRiskParityValidationError";
this.code = code;
}
}
function invalid(code: HierarchicalRiskParityErrorCode, message: string): never {
throw new HierarchicalRiskParityValidationError(code, message);
}
function neumaierSum(values: readonly number[]): number {
let total = 0;
let compensation = 0;
for (const value of values) {
const candidate = total + value;
if (Math.abs(total) >= Math.abs(value)) compensation += (total - candidate) + value;
else compensation += (value - candidate) + total;
total = candidate;
}
return total + compensation;
}
function dot(row: readonly number[], vector: readonly number[]): number {
const products: number[] = [];
for (let index = 0; index < vector.length; index += 1) products.push(row[index] * vector[index]);
return neumaierSum(products);
}
function validateIds(assetIds: readonly unknown[]): string[] {
if (!Array.isArray(assetIds)) invalid("invalid-input", "assetIds must be an ordered array.");
if (assetIds.length === 0) invalid("invalid-input", "the asset universe must not be empty.");
const ids: string[] = [];
for (let index = 0; index < assetIds.length; index += 1) {
const value = assetIds[index];
if (typeof value !== "string" || value.trim() === "") invalid("invalid-input", `assetIds[${index}] must be a non-empty string.`);
ids.push(value);
}
if (new Set(ids).size !== ids.length) invalid("invalid-input", "assetIds must be unique.");
return ids;
}
function minimumSymmetricEigenvalue(matrix: readonly number[][]): number {
const n = matrix.length;
if (n === 1) return matrix[0][0];
const work = matrix.map((row) => [...row]);
for (let sweep = 0; sweep < Math.max(8, 50 * n * n); sweep += 1) {
let p = 0; let q = 0; let largest = 0;
for (let i = 0; i < n; i += 1) {
for (let j = i + 1; j < n; j += 1) {
const value = Math.abs(work[i][j]);
if (value > largest) { largest = value; p = i; q = j; }
}
}
if (largest <= 1e-14) break;
const app = work[p][p]; const aqq = work[q][q]; const apq = work[p][q];
if (apq === 0) continue;
const tau = (aqq - app) / (2 * apq);
const t = (tau >= 0 ? 1 : -1) / (Math.abs(tau) + Math.sqrt(1 + tau * tau));
const c = 1 / Math.sqrt(1 + t * t); const s = t * c;
for (let k = 0; k < n; k += 1) {
if (k === p || k === q) continue;
const akp = work[k][p]; const akq = work[k][q];
work[k][p] = c * akp - s * akq; work[p][k] = work[k][p];
work[k][q] = s * akp + c * akq; work[q][k] = work[k][q];
}
work[p][p] = c * c * app - 2 * s * c * apq + s * s * aqq;
work[q][q] = s * s * app + 2 * s * c * apq + c * c * aqq;
work[p][q] = 0; work[q][p] = 0;
}
let minimum = work[0][0];
for (let i = 1; i < n; i += 1) if (work[i][i] < minimum) minimum = work[i][i];
return minimum;
}
function validateCovariance(
covariance: readonly unknown[],
n: number,
): { matrix: number[][]; scale: number; normalized: number[][] } {
if (!Array.isArray(covariance)) invalid("invalid-input", "covariance must be a square numeric matrix.");
if (covariance.length !== n) invalid("invalid-covariance", "covariance must have one row per asset.");
const matrix: number[][] = [];
for (let i = 0; i < covariance.length; i += 1) {
const rawRow = covariance[i];
if (!Array.isArray(rawRow)) invalid("invalid-covariance", `covariance[${i}] must be a numeric row.`);
if (rawRow.length !== n) invalid("invalid-covariance", "covariance must be square.");
const row: number[] = [];
for (let j = 0; j < rawRow.length; j += 1) {
const value = rawRow[j];
if (typeof value !== "number") invalid("invalid-covariance", `covariance[${i}][${j}] must be numeric data.`);
if (!Number.isFinite(value)) invalid("numerical-range-invalid", "covariance must contain only finite values.");
row.push(value);
}
matrix.push(row);
}
for (let i = 0; i < n; i += 1) {
for (let j = i + 1; j < n; j += 1) {
if (matrix[i][j] !== matrix[j][i]) invalid("invalid-covariance", "covariance must be exactly symmetric; no repair is performed.");
}
if (!(matrix[i][i] > 0)) invalid("invalid-covariance", "covariance diagonal must be strictly positive.");
}
let scale = 0;
for (const row of matrix) for (const value of row) if (Math.abs(value) > scale) scale = Math.abs(value);
if (!Number.isFinite(scale) || scale <= 0) invalid("numerical-range-invalid", "covariance scale is zero or non-finite.");
const normalized = matrix.map((row) => row.map((value) => value / scale));
const minimum = minimumSymmetricEigenvalue(normalized);
if (!Number.isFinite(minimum)) invalid("numerical-range-invalid", "PSD diagnostic is outside the finite range.");
if (minimum < -PSD_TOLERANCE) invalid("invalid-covariance", "covariance must be positive semidefinite.");
return { matrix, scale, normalized };
}
function correlationDistance(normalized: readonly number[][]): { correlation: number[][]; distance: number[][] } {
const n = normalized.length;
const correlation = Array.from({ length: n }, () => Array<number>(n).fill(0));
const correlationDistance = Array.from({ length: n }, () => Array<number>(n).fill(0));
for (let i = 0; i < n; i += 1) {
correlation[i][i] = 1;
for (let j = i + 1; j < n; j += 1) {
// Take square roots before multiplying: the product of two tiny
// diagonals can underflow even when their square-root product is valid.
const denominator = Math.sqrt(normalized[i][i]) * Math.sqrt(normalized[j][j]);
if (!Number.isFinite(denominator) || denominator <= 0) {
invalid("numerical-range-invalid", "correlation denominator is outside the finite positive range.");
}
const rho = normalized[i][j] / denominator;
if (!Number.isFinite(rho) || rho < -1 - PAIR_DISTANCE_TOLERANCE || rho > 1 + PAIR_DISTANCE_TOLERANCE) {
invalid("invalid-covariance", "correlation is outside [-1, 1].");
}
const safeRho = Math.min(1, Math.max(-1, rho));
const value = Math.sqrt((1 - safeRho) / 2);
correlation[i][j] = safeRho; correlation[j][i] = safeRho;
correlationDistance[i][j] = value; correlationDistance[j][i] = value;
}
}
const distance = Array.from({ length: n }, () => Array<number>(n).fill(0));
for (let i = 0; i < n; i += 1) {
for (let j = i + 1; j < n; j += 1) {
const value = Math.sqrt(neumaierSum(Array.from({ length: n }, (_, k) => (correlationDistance[k][i] - correlationDistance[k][j]) ** 2)));
if (!Number.isFinite(value)) invalid("numerical-range-invalid", "profile distance is not finite.");
distance[i][j] = value; distance[j][i] = value;
}
}
return { correlation, distance };
}
type Node = { leaf: number } | { merge: [Node, Node] };
type Cluster = number[];
function compareClusters(left: readonly number[], right: readonly number[]): number {
const length = Math.min(left.length, right.length);
for (let index = 0; index < length; index += 1) {
if (left[index] !== right[index]) return left[index] - right[index];
}
return left.length - right.length;
}
function comparePairs(
left: readonly number[],
right: readonly number[],
otherLeft: readonly number[],
otherRight: readonly number[],
): number {
const leftComparison = compareClusters(left, otherLeft);
return leftComparison !== 0 ? leftComparison : compareClusters(right, otherRight);
}
function singleLinkage(distance: readonly number[][]): { order: number[]; merges: Array<{ left: number[]; right: number[]; distance: number }> } {
let clusters: Cluster[] = distance.map((_, index) => [index]);
let nodes: Node[] = distance.map((_, index) => ({ leaf: index }));
const merges: Array<{ left: number[]; right: number[]; distance: number }> = [];
while (clusters.length > 1) {
let bestDistance = Number.POSITIVE_INFINITY;
let bestLeft = 0; let bestRight = 1;
for (let leftIndex = 0; leftIndex < clusters.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < clusters.length; rightIndex += 1) {
const left = clusters[leftIndex]; const right = clusters[rightIndex];
let linkageDistance = Number.POSITIVE_INFINITY;
for (const i of left) for (const j of right) if (distance[i][j] < linkageDistance) linkageDistance = distance[i][j];
const currentLeft = clusters[bestLeft]; const currentRight = clusters[bestRight];
const better = linkageDistance < bestDistance
|| (linkageDistance === bestDistance
&& comparePairs(left, right, currentLeft, currentRight) < 0);
if (better) { bestDistance = linkageDistance; bestLeft = leftIndex; bestRight = rightIndex; }
}
}
let leftCluster = clusters[bestLeft]; let rightCluster = clusters[bestRight];
if (compareClusters(leftCluster, rightCluster) > 0) {
const swap = leftCluster; leftCluster = rightCluster; rightCluster = swap;
const swapIndex = bestLeft; bestLeft = bestRight; bestRight = swapIndex;
}
const merged = [...leftCluster, ...rightCluster].sort((a, b) => a - b);
merges.push({ left: [...leftCluster], right: [...rightCluster], distance: bestDistance });
const newClusters: Cluster[] = []; const newNodes: Node[] = [];
for (let index = 0; index < clusters.length; index += 1) {
if (index !== bestLeft && index !== bestRight) { newClusters.push(clusters[index]); newNodes.push(nodes[index]); }
}
newClusters.push(merged); newNodes.push({ merge: [nodes[bestLeft], nodes[bestRight]] });
const pairs = newClusters.map((cluster, index) => ({ cluster, node: newNodes[index] }));
pairs.sort((a, b) => compareClusters(a.cluster, b.cluster));
clusters = pairs.map((pair) => pair.cluster); nodes = pairs.map((pair) => pair.node);
}
const root = nodes[0];
function leaves(node: Node): number[] {
return "leaf" in node ? [node.leaf] : [...leaves(node.merge[0]), ...leaves(node.merge[1])];
}
return { order: leaves(root), merges };
}
function inverseVarianceWeights(cluster: readonly number[], normalized: readonly number[][]): number[] {
let minimum = Number.POSITIVE_INFINITY;
for (const index of cluster) if (normalized[index][index] < minimum) minimum = normalized[index][index];
const scores = cluster.map((index) => minimum / normalized[index][index]);
const total = neumaierSum(scores);
if (!Number.isFinite(total) || total <= 0 || scores.some((value) => !Number.isFinite(value) || value <= 0)) invalid("numerical-range-invalid", "inverse-variance branch weights are not representable.");
return scores.map((score) => score / total);
}
function clusterVariance(cluster: readonly number[], normalized: readonly number[][]): number {
const local = inverseVarianceWeights(cluster, normalized);
const product = cluster.map((i) => neumaierSum(cluster.map((j, index) => normalized[i][j] * local[index])));
const q = neumaierSum(product.map((value, index) => local[index] * value));
if (!Number.isFinite(q) || q <= 0) invalid("numerical-range-invalid", "a positive branch risk was not representable.");
return q;
}
function recursiveBisection(order: readonly number[], normalized: readonly number[][]): number[] {
const weights = Array<number>(normalized.length).fill(0);
function allocate(cluster: readonly number[], capital: number): void {
if (cluster.length === 1) { weights[cluster[0]] = capital; return; }
const midpoint = Math.floor(cluster.length / 2);
const left = cluster.slice(0, midpoint); const right = cluster.slice(midpoint);
const leftVariance = clusterVariance(left, normalized); const rightVariance = clusterVariance(right, normalized);
const total = leftVariance + rightVariance;
if (!Number.isFinite(total) || total <= 0) invalid("numerical-range-invalid", "branch-risk split is not representable.");
const leftCapital = capital * rightVariance / total; const rightCapital = capital * leftVariance / total;
if (!(Number.isFinite(leftCapital) && Number.isFinite(rightCapital) && leftCapital > 0 && rightCapital > 0)) invalid("numerical-range-invalid", "recursive-bisection weights are not positive and finite.");
allocate(left, leftCapital); allocate(right, rightCapital);
}
allocate(order, 1);
return weights;
}
export function hierarchicalRiskParityWeights(
assetIds: readonly unknown[],
covariance: readonly unknown[],
): HierarchicalRiskParityResult {
const ids = validateIds(assetIds);
const validated = validateCovariance(covariance, ids.length);
const { correlation, distance } = correlationDistance(validated.normalized);
const hierarchy = singleLinkage(distance);
const weights = recursiveBisection(hierarchy.order, validated.normalized);
const sumWeights = neumaierSum(weights);
if (!Number.isFinite(sumWeights) || Math.abs(sumWeights - 1) > WEIGHT_SUM_ABSOLUTE_TOLERANCE) invalid("numerical-range-invalid", "HRP weights do not satisfy the sum tolerance.");
const product = validated.normalized.map((row) => dot(row, weights));
const normalizedVariance = dot(weights, product);
if (!Number.isFinite(normalizedVariance) || normalizedVariance <= 0) invalid("numerical-range-invalid", "HRP portfolio variance is not positive and finite.");
const scaleRoot = Math.sqrt(validated.scale);
const variance = normalizedVariance * validated.scale;
const volatility = Math.sqrt(normalizedVariance) * scaleRoot;
if (![scaleRoot, variance, volatility].every(Number.isFinite) || variance <= 0 || volatility <= 0) invalid("numerical-range-invalid", "restoring HRP risk scale lost positive risk.");
const normalizedVolatility = Math.sqrt(normalizedVariance);
// Divide before multiplying by a tiny weight so a finite contribution is
// not lost to an avoidable product underflow.
const componentRiskContributions = weights.map((weight, index) => weight * (product[index] / normalizedVolatility) * scaleRoot);
if (componentRiskContributions.some((value) => !Number.isFinite(value))) invalid("numerical-range-invalid", "HRP component risk contribution is non-finite.");
const componentRiskShares = weights.map((weight, index) => weight * (product[index] / normalizedVariance));
return {
assetIds: ids,
weights,
componentRiskContributions,
componentRiskShares,
portfolioVariance: variance,
portfolioVolatility: volatility,
sumWeights,
quasiDiagonalOrder: hierarchy.order,
correlationMatrix: correlation,
distanceMatrix: distance,
merges: hierarchy.merges,
method: "hierarchical-risk-parity",
linkage: "single",
distance: "euclidean-distance-between-sqrt((1-correlation)/2)-profiles",
intraClusterAllocation: "inverse-variance",
splitRule: "quasi-diagonal-order-midpoint-recursive-bisection",
covarianceScale: validated.scale,
status: "ok",
};
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.
