The demo below has four assets. The equilibrium weights come from the market-cap proportions. Use the slider to express a view that Asset A will outperform Asset B by some margin, and choose how confident you are. The model blends this view with the prior and updates all four weights.
<!-- Black-Litterman demo: blend market-equilibrium weights with a single investor view. -->
<div class="bl-wrap">
<div class="hint-box">Four assets start at market-cap weights (equilibrium). Express a view that <b>Asset A</b> will out- or underperform <b>Asset B</b>, set your confidence, and watch Black-Litterman update all four weights.</div>
<div class="controls">
<label class="ctrl-label">
View: A vs B expected excess (%/yr)
<span id="q-val" class="val-badge">+2%</span>
</label>
<input type="range" id="q-slider" min="-8" max="8" step="0.5" value="2" aria-label="View: A vs B expected excess (%/yr)">
<label class="ctrl-label" style="margin-top:.7rem">
Confidence in view
<span id="conf-val" class="val-badge">50%</span>
</label>
<input type="range" id="conf-slider" min="5" max="99" step="1" value="50" aria-label="Confidence in view">
</div>
<div class="view-desc" id="view-desc"></div>
<div class="chart-section">
<div class="chart-title">Portfolio weights — equilibrium vs Black-Litterman</div>
<div id="bars" class="bars"></div>
<div class="legend">
<span class="leg-dot eq"></span> Equilibrium
<span class="leg-dot bl" style="margin-left:.8rem"></span> Black-Litterman
</div>
</div>
<div class="detail-row" id="detail-row"></div>
</div>
/* Black-Litterman demo styles */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; font-size: 14px; }
.bl-wrap { padding: .6rem .2rem; }
.hint-box { font-size: .87rem; color: #444; line-height: 1.5; margin-bottom: .8rem; }
/* control panel */
.controls { background: #f0f4f8; border-radius: 10px; padding: .7rem .9rem; margin-bottom: .6rem; }
.ctrl-label { display: flex; justify-content: space-between; align-items: center;
font-size: .82rem; font-weight: 600; color: #334; margin-bottom: .25rem; }
.val-badge { background: #1d3557; color: #fff; border-radius: 5px;
padding: .1rem .4rem; font-size: .8rem; font-weight: 700; }
input[type=range] { width: 100%; accent-color: #1d3557; cursor: pointer; }
/* view description line */
.view-desc { font-size: .82rem; color: #1d3557; font-weight: 600;
margin-bottom: .5rem; min-height: 1.3em; }
/* bar chart section */
.chart-section { margin-bottom: .5rem; }
.chart-title { font-size: .8rem; font-weight: 700; color: #555;
text-transform: uppercase; letter-spacing: .04em; margin-bottom: .35rem; }
.bars { display: flex; gap: .5rem; align-items: flex-end; height: 140px; }
.bar-group { flex: 1; display: flex; gap: 3px; align-items: flex-end; flex-direction: column; }
.bar-group-inner { width: 100%; display: flex; gap: 3px; align-items: flex-end; }
.bar { flex: 1; border-radius: 4px 4px 0 0; min-height: 3px; transition: height .25s ease; }
.bar.eq { background: #9fb2c8; }
.bar.bl { background: #1d3557; }
.bar-label { font-size: .75rem; font-weight: 700; color: #445; text-align: center;
margin-top: .15rem; }
.legend { font-size: .78rem; color: #445; margin-top: .3rem; display: flex; align-items: center; }
.leg-dot { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; }
.leg-dot.eq { background: #9fb2c8; }
.leg-dot.bl { background: #1d3557; }
/* per-asset detail cards */
.detail-row { display: flex; gap: .4rem; flex-wrap: wrap; }
.detail-card { flex: 1; min-width: 80px; background: #f0f4f8; border-radius: 8px;
padding: .4rem .5rem; text-align: center; }
.detail-card .dc-name { font-size: .72rem; font-weight: 700; color: #556; margin-bottom: .15rem; }
.detail-card .dc-eq { font-size: .75rem; color: #88a; }
.detail-card .dc-bl { font-size: .9rem; font-weight: 700; color: #1d3557; }
.detail-card .dc-delta { font-size: .72rem; font-weight: 600; }
.detail-card .dc-delta.pos { color: #0a7d33; }
.detail-card .dc-delta.neg { color: #c92f3c; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Setup: assets, market-cap weights, covariance matrix
// Asset labels (A=large-cap, B=mid-cap, C=small-cap, D=EM)
const ASSETS = ['A', 'B', 'C', 'D'];
// Market-cap weights: 40 / 30 / 20 / 10 percent
const MKT_CAP = [0.40, 0.30, 0.20, 0.10];
// Annual covariance matrix (4x4). Diagonal = variance, off-diagonal = covariances.
// Higher values = more volatile / more correlated.
const SIGMA = [
[0.040, 0.012, 0.008, 0.004],
[0.012, 0.030, 0.006, 0.003],
[0.008, 0.006, 0.020, 0.002],
[0.004, 0.003, 0.002, 0.015]
];
const DELTA = 2.5; // Risk-aversion coefficient (CAPM calibrated, typical range 2-4)
const TAU = 0.05; // Tau: uncertainty in the prior (small scalar, typically 1/T)
const N = 4;
// Matrix-vector multiply
function matVec(M, v) {
return M.map(row => row.reduce((s, mij, j) => s + mij * v[j], 0));
}
// Gauss-Jordan matrix inversion (works for any n; used for 4x4 Sigma and posterior precision)
// In-place Gauss-Jordan on a copy of M; returns the inverse.
function matInvSymm(M) {
const n = M.length;
const A = M.map(r => [...r]);
const I = Array.from({length: n}, (_, i) => Array.from({length: n}, (_, j) => (i === j ? 1 : 0)));
for (let col = 0; col < n; col++) {
let pivot = A[col][col];
for (let r = 0; r < n; r++) { A[col][r] /= pivot; I[col][r] /= pivot; }
for (let row = 0; row < n; row++) {
if (row === col) continue;
const f = A[row][col];
for (let c = 0; c < n; c++) { A[row][c] -= f * A[col][c]; I[row][c] -= f * I[col][c]; }
}
}
return I;
}
function matAdd(A, B) { return A.map((r, i) => r.map((v, j) => v + B[i][j])); }
function matScale(M, s) { return M.map(r => r.map(v => v * s)); }
function vecAdd(a, b) { return a.map((v, i) => v + b[i]); }
// Equilibrium excess returns Pi = delta * Sigma * w_mkt (CAPM reverse-optimization)
const PI = matVec(matScale(SIGMA, DELTA), MKT_CAP);
function computeBL(qVal, omegaVal) {
// Black-Litterman posterior: mu_BL = [(tauSigma)^{-1} + P^T Omega^{-1} P]^{-1} [(tauSigma)^{-1} Pi + P^T Omega^{-1} Q]
// Pick matrix P (1 view x 4 assets): A outperforms B means P = [1,-1,0,0]
// view: w_A - w_B = q (Asset A outperforms Asset B)
const P = [[1, -1, 0, 0]];
const Q = [qVal / 100];
const Omega = [[omegaVal * omegaVal]];
// Prior precision: (tau * Sigma)^{-1}
const tauSigmaInv = matInvSymm(matScale(SIGMA, TAU));
// View precision contribution to posterior
// P^T Omega^{-1} P (N x N)
const omegaInv00 = 1 / (Omega[0][0]);
const PtOiP = Array.from({length: N}, (_, i) =>
Array.from({length: N}, (_, j) => P[0][i] * omegaInv00 * P[0][j])
);
// Posterior precision = prior precision + view precision
const precPost = matAdd(tauSigmaInv, PtOiP);
const precPostInv = matInvSymm(precPost);
// Right-hand side of BL formula
// (tau Sigma)^{-1} PI + P^T Omega^{-1} Q
const rhs1 = matVec(tauSigmaInv, PI);
const pTOiQ = P[0].map(pij => pij * omegaInv00 * Q[0]);
const rhs = vecAdd(rhs1, pTOiQ);
// Posterior mean: mu_BL = precPostInv * rhs
const muBL = matVec(precPostInv, rhs);
return muBL;
}
function muToWeights(mu) {
// Mean-Variance Optimization: w proportional to Sigma^{-1} * mu_BL, then normalize to sum=1
// MVO with Sigma: w proportional to Sigma^{-1} mu (unconstrained long-only approx)
const sigmaInv = matInvSymm(SIGMA);
const raw = matVec(sigmaInv, mu);
const posRaw = raw.map(v => Math.max(v, 0));
const sum = posRaw.reduce((a, b) => a + b, 0);
return sum > 0 ? posRaw.map(v => v / sum) : MKT_CAP.slice();
}
// DOM references
const qSlider = document.getElementById('q-slider');
const confSlider = document.getElementById('conf-slider');
const qValEl = document.getElementById('q-val');
const confValEl = document.getElementById('conf-val');
const viewDesc = document.getElementById('view-desc');
const barsEl = document.getElementById('bars');
const detailEl = document.getElementById('detail-row');
function update() {
const q = parseFloat(qSlider.value);
const conf = parseInt(confSlider.value);
// Omega (view variance): scales with (1-confidence); higher confidence => lower uncertainty
// omega: view uncertainty; low confidence => high omega
const omega = 0.02 * (1 - conf / 100) + 0.001;
qValEl.textContent = (q >= 0 ? '+' : '') + q.toFixed(1) + '%';
confValEl.textContent = conf + '%';
if (q === 0) {
viewDesc.textContent = 'No active view — portfolio equals equilibrium.';
} else {
viewDesc.textContent = i18nFmt('View: Asset A will earn %q%% %dir% Asset B per year.', { q: Math.abs(q).toFixed(1), dir: q > 0 ? 'above' : 'below' });
}
const muBL = computeBL(q, omega);
const wBL = muToWeights(muBL);
const wEQ = MKT_CAP;
const maxW = Math.max(...wBL, ...wEQ, 0.01);
const CHART_H = 120;
barsEl.innerHTML = '';
detailEl.innerHTML = '';
ASSETS.forEach((name, i) => {
const hEQ = Math.round((wEQ[i] / maxW) * CHART_H);
const hBL = Math.round((wBL[i] / maxW) * CHART_H);
const grp = document.createElement('div');
grp.className = 'bar-group';
const inner = document.createElement('div');
inner.className = 'bar-group-inner';
const bEQ = document.createElement('div');
bEQ.className = 'bar eq';
bEQ.style.height = hEQ + 'px';
bEQ.title = i18nFmt('Equilibrium weight for %name%: %pct%%', { name, pct: (wEQ[i]*100).toFixed(1) });
const bBL = document.createElement('div');
bBL.className = 'bar bl';
bBL.style.height = hBL + 'px';
bBL.title = i18nFmt('BL weight for %name%: %pct%%', { name, pct: (wBL[i]*100).toFixed(1) });
inner.appendChild(bEQ);
inner.appendChild(bBL);
const lbl = document.createElement('div');
lbl.className = 'bar-label';
lbl.textContent = name;
grp.appendChild(inner);
grp.appendChild(lbl);
barsEl.appendChild(grp);
const delta = wBL[i] - wEQ[i];
const card = document.createElement('div');
card.className = 'detail-card';
card.innerHTML =
`<div class="dc-name">${name}</div>` +
`<div class="dc-eq">${i18nFmt('Eq: %pct%%', { pct: (wEQ[i]*100).toFixed(1) })}</div>` +
`<div class="dc-bl">${(wBL[i]*100).toFixed(1)}%</div>` +
`<div class="dc-delta ${delta >= 0 ? 'pos' : 'neg'}">${delta >= 0 ? '+' : ''}${(delta*100).toFixed(1)}%</div>`;
detailEl.appendChild(card);
});
}
qSlider.addEventListener('input', update);
confSlider.addEventListener('input', update);
update(); // Draw initial chart on load
<!-- Black-Litterman demo: blend market-equilibrium weights with a single investor view. -->
<div class="bl-wrap">
<div class="hint-box">Four assets start at market-cap weights (equilibrium). Express a view that <b>Asset A</b> will out- or underperform <b>Asset B</b>, set your confidence, and watch Black-Litterman update all four weights.</div>
<div class="controls">
<label class="ctrl-label">
View: A vs B expected excess (%/yr)
<span id="q-val" class="val-badge">+2%</span>
</label>
<input type="range" id="q-slider" min="-8" max="8" step="0.5" value="2" aria-label="View: A vs B expected excess (%/yr)">
<label class="ctrl-label" style="margin-top:.7rem">
Confidence in view
<span id="conf-val" class="val-badge">50%</span>
</label>
<input type="range" id="conf-slider" min="5" max="99" step="1" value="50" aria-label="Confidence in view">
</div>
<div class="view-desc" id="view-desc"></div>
<div class="chart-section">
<div class="chart-title">Portfolio weights — equilibrium vs Black-Litterman</div>
<div id="bars" class="bars"></div>
<div class="legend">
<span class="leg-dot eq"></span> Equilibrium
<span class="leg-dot bl" style="margin-left:.8rem"></span> Black-Litterman
</div>
</div>
<div class="detail-row" id="detail-row"></div>
</div>
/* Black-Litterman demo styles */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; font-size: 14px; }
.bl-wrap { padding: .6rem .2rem; }
.hint-box { font-size: .87rem; color: #444; line-height: 1.5; margin-bottom: .8rem; }
/* control panel */
.controls { background: #f0f4f8; border-radius: 10px; padding: .7rem .9rem; margin-bottom: .6rem; }
.ctrl-label { display: flex; justify-content: space-between; align-items: center;
font-size: .82rem; font-weight: 600; color: #334; margin-bottom: .25rem; }
.val-badge { background: #1d3557; color: #fff; border-radius: 5px;
padding: .1rem .4rem; font-size: .8rem; font-weight: 700; }
input[type=range] { width: 100%; accent-color: #1d3557; cursor: pointer; }
/* view description line */
.view-desc { font-size: .82rem; color: #1d3557; font-weight: 600;
margin-bottom: .5rem; min-height: 1.3em; }
/* bar chart section */
.chart-section { margin-bottom: .5rem; }
.chart-title { font-size: .8rem; font-weight: 700; color: #555;
text-transform: uppercase; letter-spacing: .04em; margin-bottom: .35rem; }
.bars { display: flex; gap: .5rem; align-items: flex-end; height: 140px; }
.bar-group { flex: 1; display: flex; gap: 3px; align-items: flex-end; flex-direction: column; }
.bar-group-inner { width: 100%; display: flex; gap: 3px; align-items: flex-end; }
.bar { flex: 1; border-radius: 4px 4px 0 0; min-height: 3px; transition: height .25s ease; }
.bar.eq { background: #9fb2c8; }
.bar.bl { background: #1d3557; }
.bar-label { font-size: .75rem; font-weight: 700; color: #445; text-align: center;
margin-top: .15rem; }
.legend { font-size: .78rem; color: #445; margin-top: .3rem; display: flex; align-items: center; }
.leg-dot { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; }
.leg-dot.eq { background: #9fb2c8; }
.leg-dot.bl { background: #1d3557; }
/* per-asset detail cards */
.detail-row { display: flex; gap: .4rem; flex-wrap: wrap; }
.detail-card { flex: 1; min-width: 80px; background: #f0f4f8; border-radius: 8px;
padding: .4rem .5rem; text-align: center; }
.detail-card .dc-name { font-size: .72rem; font-weight: 700; color: #556; margin-bottom: .15rem; }
.detail-card .dc-eq { font-size: .75rem; color: #88a; }
.detail-card .dc-bl { font-size: .9rem; font-weight: 700; color: #1d3557; }
.detail-card .dc-delta { font-size: .72rem; font-weight: 600; }
.detail-card .dc-delta.pos { color: #0a7d33; }
.detail-card .dc-delta.neg { color: #c92f3c; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Setup: assets, market-cap weights, covariance matrix
// Asset labels (A=large-cap, B=mid-cap, C=small-cap, D=EM)
const ASSETS = ['A', 'B', 'C', 'D'];
// Market-cap weights: 40 / 30 / 20 / 10 percent
const MKT_CAP = [0.40, 0.30, 0.20, 0.10];
// Annual covariance matrix (4x4). Diagonal = variance, off-diagonal = covariances.
// Higher values = more volatile / more correlated.
const SIGMA = [
[0.040, 0.012, 0.008, 0.004],
[0.012, 0.030, 0.006, 0.003],
[0.008, 0.006, 0.020, 0.002],
[0.004, 0.003, 0.002, 0.015]
];
const DELTA = 2.5; // Risk-aversion coefficient (CAPM calibrated, typical range 2-4)
const TAU = 0.05; // Tau: uncertainty in the prior (small scalar, typically 1/T)
const N = 4;
// Matrix-vector multiply
function matVec(M, v) {
return M.map(row => row.reduce((s, mij, j) => s + mij * v[j], 0));
}
// Gauss-Jordan matrix inversion (works for any n; used for 4x4 Sigma and posterior precision)
// In-place Gauss-Jordan on a copy of M; returns the inverse.
function matInvSymm(M) {
const n = M.length;
const A = M.map(r => [...r]);
const I = Array.from({length: n}, (_, i) => Array.from({length: n}, (_, j) => (i === j ? 1 : 0)));
for (let col = 0; col < n; col++) {
let pivot = A[col][col];
for (let r = 0; r < n; r++) { A[col][r] /= pivot; I[col][r] /= pivot; }
for (let row = 0; row < n; row++) {
if (row === col) continue;
const f = A[row][col];
for (let c = 0; c < n; c++) { A[row][c] -= f * A[col][c]; I[row][c] -= f * I[col][c]; }
}
}
return I;
}
function matAdd(A, B) { return A.map((r, i) => r.map((v, j) => v + B[i][j])); }
function matScale(M, s) { return M.map(r => r.map(v => v * s)); }
function vecAdd(a, b) { return a.map((v, i) => v + b[i]); }
// Equilibrium excess returns Pi = delta * Sigma * w_mkt (CAPM reverse-optimization)
const PI = matVec(matScale(SIGMA, DELTA), MKT_CAP);
function computeBL(qVal, omegaVal) {
// Black-Litterman posterior: mu_BL = [(tauSigma)^{-1} + P^T Omega^{-1} P]^{-1} [(tauSigma)^{-1} Pi + P^T Omega^{-1} Q]
// Pick matrix P (1 view x 4 assets): A outperforms B means P = [1,-1,0,0]
// view: w_A - w_B = q (Asset A outperforms Asset B)
const P = [[1, -1, 0, 0]];
const Q = [qVal / 100];
const Omega = [[omegaVal * omegaVal]];
// Prior precision: (tau * Sigma)^{-1}
const tauSigmaInv = matInvSymm(matScale(SIGMA, TAU));
// View precision contribution to posterior
// P^T Omega^{-1} P (N x N)
const omegaInv00 = 1 / (Omega[0][0]);
const PtOiP = Array.from({length: N}, (_, i) =>
Array.from({length: N}, (_, j) => P[0][i] * omegaInv00 * P[0][j])
);
// Posterior precision = prior precision + view precision
const precPost = matAdd(tauSigmaInv, PtOiP);
const precPostInv = matInvSymm(precPost);
// Right-hand side of BL formula
// (tau Sigma)^{-1} PI + P^T Omega^{-1} Q
const rhs1 = matVec(tauSigmaInv, PI);
const pTOiQ = P[0].map(pij => pij * omegaInv00 * Q[0]);
const rhs = vecAdd(rhs1, pTOiQ);
// Posterior mean: mu_BL = precPostInv * rhs
const muBL = matVec(precPostInv, rhs);
return muBL;
}
function muToWeights(mu) {
// Mean-Variance Optimization: w proportional to Sigma^{-1} * mu_BL, then normalize to sum=1
// MVO with Sigma: w proportional to Sigma^{-1} mu (unconstrained long-only approx)
const sigmaInv = matInvSymm(SIGMA);
const raw = matVec(sigmaInv, mu);
const posRaw = raw.map(v => Math.max(v, 0));
const sum = posRaw.reduce((a, b) => a + b, 0);
return sum > 0 ? posRaw.map(v => v / sum) : MKT_CAP.slice();
}
// DOM references
const qSlider = document.getElementById('q-slider');
const confSlider = document.getElementById('conf-slider');
const qValEl = document.getElementById('q-val');
const confValEl = document.getElementById('conf-val');
const viewDesc = document.getElementById('view-desc');
const barsEl = document.getElementById('bars');
const detailEl = document.getElementById('detail-row');
function update() {
const q = parseFloat(qSlider.value);
const conf = parseInt(confSlider.value);
// Omega (view variance): scales with (1-confidence); higher confidence => lower uncertainty
// omega: view uncertainty; low confidence => high omega
const omega = 0.02 * (1 - conf / 100) + 0.001;
qValEl.textContent = (q >= 0 ? '+' : '') + q.toFixed(1) + '%';
confValEl.textContent = conf + '%';
if (q === 0) {
viewDesc.textContent = 'No active view — portfolio equals equilibrium.';
} else {
viewDesc.textContent = i18nFmt('View: Asset A will earn %q%% %dir% Asset B per year.', { q: Math.abs(q).toFixed(1), dir: q > 0 ? 'above' : 'below' });
}
const muBL = computeBL(q, omega);
const wBL = muToWeights(muBL);
const wEQ = MKT_CAP;
const maxW = Math.max(...wBL, ...wEQ, 0.01);
const CHART_H = 120;
barsEl.innerHTML = '';
detailEl.innerHTML = '';
ASSETS.forEach((name, i) => {
const hEQ = Math.round((wEQ[i] / maxW) * CHART_H);
const hBL = Math.round((wBL[i] / maxW) * CHART_H);
const grp = document.createElement('div');
grp.className = 'bar-group';
const inner = document.createElement('div');
inner.className = 'bar-group-inner';
const bEQ = document.createElement('div');
bEQ.className = 'bar eq';
bEQ.style.height = hEQ + 'px';
bEQ.title = i18nFmt('Equilibrium weight for %name%: %pct%%', { name, pct: (wEQ[i]*100).toFixed(1) });
const bBL = document.createElement('div');
bBL.className = 'bar bl';
bBL.style.height = hBL + 'px';
bBL.title = i18nFmt('BL weight for %name%: %pct%%', { name, pct: (wBL[i]*100).toFixed(1) });
inner.appendChild(bEQ);
inner.appendChild(bBL);
const lbl = document.createElement('div');
lbl.className = 'bar-label';
lbl.textContent = name;
grp.appendChild(inner);
grp.appendChild(lbl);
barsEl.appendChild(grp);
const delta = wBL[i] - wEQ[i];
const card = document.createElement('div');
card.className = 'detail-card';
card.innerHTML =
`<div class="dc-name">${name}</div>` +
`<div class="dc-eq">${i18nFmt('Eq: %pct%%', { pct: (wEQ[i]*100).toFixed(1) })}</div>` +
`<div class="dc-bl">${(wBL[i]*100).toFixed(1)}%</div>` +
`<div class="dc-delta ${delta >= 0 ? 'pos' : 'neg'}">${delta >= 0 ? '+' : ''}${(delta*100).toFixed(1)}%</div>`;
detailEl.appendChild(card);
});
}
qSlider.addEventListener('input', update);
confSlider.addEventListener('input', update);
update(); // Draw initial chart on load
Comments
Loading comments...