<!-- Mean-variance portfolio demo — efficient frontier for two assets -->
<div class="controls">
<label for="weightSlider">Weight of Asset A: <span id="wVal">50%</span></label>
<input type="range" id="weightSlider" min="0" max="100" value="50" step="1" aria-label="Weight of Asset A:">
<div class="asset-info">
<span class="tag a">Asset A (high return, high risk)</span>
<span class="tag b">Asset B (low return, low risk)</span>
</div>
</div>
<canvas id="chart" width="420" height="280" aria-label="Efficient frontier chart for a two-asset portfolio"></canvas>
<div id="info" class="info-box"></div>
/* Layout and chart styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px 8px; }
.controls { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
label { font-size: .85rem; font-weight: 600; color: #444; }
input[type=range] { width: 100%; accent-color: #1d3557; cursor: pointer; }
.asset-info { display: flex; gap: 8px; }
.tag { font-size: .8rem; font-weight: 700; padding: 2px 8px; border-radius: 999px; }
.tag.a { background: #d0e8f7; color: #1a5276; }
.tag.b { background: #fde8d0; color: #784212; }
canvas { display: block; width: 100%; max-width: 420px; border-radius: 8px;
background: #f7f9fb; border: 1px solid #dde4ec; }
.info-box { margin-top: 8px; font-size: .88rem; line-height: 1.55;
background: #eef4fb; border-left: 3px solid #1d3557;
padding: 8px 12px; border-radius: 4px; min-height: 3em; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Asset parameters (annualized) */
const muA = 0.10; /* expected return of Asset A */
const muB = 0.06; /* expected return of Asset B */
const sigA = 0.20; /* volatility (std dev) of Asset A */
const sigB = 0.08; /* volatility (std dev) of Asset B */
const rho = 0.10; /* correlation between A and B */
/* Compute portfolio mean and standard deviation for weight w in Asset A */
function portfolioStats(w) {
const mu = w * muA + (1 - w) * muB;
const sig2 = w * w * sigA * sigA
+ (1 - w) * (1 - w) * sigB * sigB
+ 2 * w * (1 - w) * rho * sigA * sigB;
return { mu, sig: Math.sqrt(sig2) };
}
/* Closed-form weight that minimizes portfolio variance */
function minVarianceWeight() {
const num = sigB * sigB - rho * sigA * sigB;
const den = sigA * sigA + sigB * sigB - 2 * rho * sigA * sigB;
return Math.max(0, Math.min(1, num / den));
}
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { l: 44, r: 18, t: 18, b: 36 };
/* Render the efficient frontier chart */
function draw(wCurrent) {
ctx.clearRect(0, 0, W, H);
/* Collect all frontier points to find axis bounds */
const pts = [];
for (let i = 0; i <= 100; i++) pts.push(portfolioStats(i / 100));
const sigMin = Math.min(...pts.map(p => p.sig));
const sigMax = Math.max(...pts.map(p => p.sig));
const muMin = Math.min(...pts.map(p => p.mu));
const muMax = Math.max(...pts.map(p => p.mu));
const sx = (s) => PAD.l + (s - sigMin) / (sigMax - sigMin + 0.001) * (W - PAD.l - PAD.r);
const sy = (m) => H - PAD.b - (m - muMin) / (muMax - muMin + 0.001) * (H - PAD.t - PAD.b);
/* Draw x and y axes */
ctx.strokeStyle = '#aab4c0';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PAD.l, PAD.t); ctx.lineTo(PAD.l, H - PAD.b); ctx.stroke();
ctx.beginPath(); ctx.moveTo(PAD.l, H - PAD.b); ctx.lineTo(W - PAD.r, H - PAD.b); ctx.stroke();
/* Axis labels */
ctx.fillStyle = '#667';
ctx.font = '11px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Risk σ (std dev)', PAD.l + (W - PAD.l - PAD.r) / 2, H - 6);
ctx.save();
ctx.translate(12, PAD.t + (H - PAD.t - PAD.b) / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('Return μ', 0, 0);
ctx.restore();
/* Draw the full frontier curve */
const mvw = minVarianceWeight();
const mvPt = portfolioStats(mvw);
ctx.beginPath();
pts.forEach((p, i) => {
const x = sx(p.sig), y = sy(p.mu);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2.5;
ctx.stroke();
/* Mark the minimum-variance portfolio */
const mvx = sx(mvPt.sig), mvy = sy(mvPt.mu);
ctx.beginPath(); ctx.arc(mvx, mvy, 5, 0, 2 * Math.PI);
ctx.fillStyle = '#e63946'; ctx.fill();
ctx.fillStyle = '#e63946';
ctx.font = 'bold 10px system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Min variance', mvx + 7, mvy + 4);
/* Mark the current portfolio (orange dot) */
const cur = portfolioStats(wCurrent);
const cx_ = sx(cur.sig), cy_ = sy(cur.mu);
ctx.beginPath(); ctx.arc(cx_, cy_, 7, 0, 2 * Math.PI);
ctx.fillStyle = '#f4a261'; ctx.fill();
ctx.strokeStyle = '#784212'; ctx.lineWidth = 1.5; ctx.stroke();
/* Mark the pure-asset endpoints */
const pA = portfolioStats(1), pB = portfolioStats(0);
const drawEndpt = (p, label, color) => {
const ex = sx(p.sig), ey = sy(p.mu);
ctx.beginPath(); ctx.arc(ex, ey, 4, 0, 2 * Math.PI);
ctx.fillStyle = color; ctx.fill();
ctx.fillStyle = color; ctx.font = 'bold 10px system-ui, sans-serif';
ctx.textAlign = ex < W / 2 ? 'left' : 'right';
const ox = ex < W / 2 ? 7 : -7;
ctx.fillText(label, ex + ox, ey - 5);
};
drawEndpt(pA, 'Asset A (high return, high risk)', '#1a5276');
drawEndpt(pB, 'Asset B (low return, low risk)', '#784212');
/* Update the text info box below the chart */
const pct = Math.round(wCurrent * 100);
document.getElementById('info').innerHTML =
i18nFmt('Portfolio: <b>%wA%% Asset A</b> / <b>%wB%% Asset B</b> — Expected return: <b>%mu%%</b> · Risk: <b>%sig%%</b>', {
wA: pct,
wB: 100 - pct,
mu: (cur.mu * 100).toFixed(1),
sig: (cur.sig * 100).toFixed(1)
});
}
const slider = document.getElementById('weightSlider');
const wValEl = document.getElementById('wVal');
slider.addEventListener('input', () => {
const w = slider.value / 100;
wValEl.textContent = slider.value + '%';
draw(w);
});
draw(0.5);
<!-- Mean-variance portfolio demo — efficient frontier for two assets -->
<div class="controls">
<label for="weightSlider">Weight of Asset A: <span id="wVal">50%</span></label>
<input type="range" id="weightSlider" min="0" max="100" value="50" step="1" aria-label="Weight of Asset A:">
<div class="asset-info">
<span class="tag a">Asset A (high return, high risk)</span>
<span class="tag b">Asset B (low return, low risk)</span>
</div>
</div>
<canvas id="chart" width="420" height="280" aria-label="Efficient frontier chart for a two-asset portfolio"></canvas>
<div id="info" class="info-box"></div>
/* Layout and chart styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px 8px; }
.controls { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
label { font-size: .85rem; font-weight: 600; color: #444; }
input[type=range] { width: 100%; accent-color: #1d3557; cursor: pointer; }
.asset-info { display: flex; gap: 8px; }
.tag { font-size: .8rem; font-weight: 700; padding: 2px 8px; border-radius: 999px; }
.tag.a { background: #d0e8f7; color: #1a5276; }
.tag.b { background: #fde8d0; color: #784212; }
canvas { display: block; width: 100%; max-width: 420px; border-radius: 8px;
background: #f7f9fb; border: 1px solid #dde4ec; }
.info-box { margin-top: 8px; font-size: .88rem; line-height: 1.55;
background: #eef4fb; border-left: 3px solid #1d3557;
padding: 8px 12px; border-radius: 4px; min-height: 3em; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Asset parameters (annualized) */
const muA = 0.10; /* expected return of Asset A */
const muB = 0.06; /* expected return of Asset B */
const sigA = 0.20; /* volatility (std dev) of Asset A */
const sigB = 0.08; /* volatility (std dev) of Asset B */
const rho = 0.10; /* correlation between A and B */
/* Compute portfolio mean and standard deviation for weight w in Asset A */
function portfolioStats(w) {
const mu = w * muA + (1 - w) * muB;
const sig2 = w * w * sigA * sigA
+ (1 - w) * (1 - w) * sigB * sigB
+ 2 * w * (1 - w) * rho * sigA * sigB;
return { mu, sig: Math.sqrt(sig2) };
}
/* Closed-form weight that minimizes portfolio variance */
function minVarianceWeight() {
const num = sigB * sigB - rho * sigA * sigB;
const den = sigA * sigA + sigB * sigB - 2 * rho * sigA * sigB;
return Math.max(0, Math.min(1, num / den));
}
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const PAD = { l: 44, r: 18, t: 18, b: 36 };
/* Render the efficient frontier chart */
function draw(wCurrent) {
ctx.clearRect(0, 0, W, H);
/* Collect all frontier points to find axis bounds */
const pts = [];
for (let i = 0; i <= 100; i++) pts.push(portfolioStats(i / 100));
const sigMin = Math.min(...pts.map(p => p.sig));
const sigMax = Math.max(...pts.map(p => p.sig));
const muMin = Math.min(...pts.map(p => p.mu));
const muMax = Math.max(...pts.map(p => p.mu));
const sx = (s) => PAD.l + (s - sigMin) / (sigMax - sigMin + 0.001) * (W - PAD.l - PAD.r);
const sy = (m) => H - PAD.b - (m - muMin) / (muMax - muMin + 0.001) * (H - PAD.t - PAD.b);
/* Draw x and y axes */
ctx.strokeStyle = '#aab4c0';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(PAD.l, PAD.t); ctx.lineTo(PAD.l, H - PAD.b); ctx.stroke();
ctx.beginPath(); ctx.moveTo(PAD.l, H - PAD.b); ctx.lineTo(W - PAD.r, H - PAD.b); ctx.stroke();
/* Axis labels */
ctx.fillStyle = '#667';
ctx.font = '11px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Risk σ (std dev)', PAD.l + (W - PAD.l - PAD.r) / 2, H - 6);
ctx.save();
ctx.translate(12, PAD.t + (H - PAD.t - PAD.b) / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('Return μ', 0, 0);
ctx.restore();
/* Draw the full frontier curve */
const mvw = minVarianceWeight();
const mvPt = portfolioStats(mvw);
ctx.beginPath();
pts.forEach((p, i) => {
const x = sx(p.sig), y = sy(p.mu);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.strokeStyle = '#1d3557';
ctx.lineWidth = 2.5;
ctx.stroke();
/* Mark the minimum-variance portfolio */
const mvx = sx(mvPt.sig), mvy = sy(mvPt.mu);
ctx.beginPath(); ctx.arc(mvx, mvy, 5, 0, 2 * Math.PI);
ctx.fillStyle = '#e63946'; ctx.fill();
ctx.fillStyle = '#e63946';
ctx.font = 'bold 10px system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Min variance', mvx + 7, mvy + 4);
/* Mark the current portfolio (orange dot) */
const cur = portfolioStats(wCurrent);
const cx_ = sx(cur.sig), cy_ = sy(cur.mu);
ctx.beginPath(); ctx.arc(cx_, cy_, 7, 0, 2 * Math.PI);
ctx.fillStyle = '#f4a261'; ctx.fill();
ctx.strokeStyle = '#784212'; ctx.lineWidth = 1.5; ctx.stroke();
/* Mark the pure-asset endpoints */
const pA = portfolioStats(1), pB = portfolioStats(0);
const drawEndpt = (p, label, color) => {
const ex = sx(p.sig), ey = sy(p.mu);
ctx.beginPath(); ctx.arc(ex, ey, 4, 0, 2 * Math.PI);
ctx.fillStyle = color; ctx.fill();
ctx.fillStyle = color; ctx.font = 'bold 10px system-ui, sans-serif';
ctx.textAlign = ex < W / 2 ? 'left' : 'right';
const ox = ex < W / 2 ? 7 : -7;
ctx.fillText(label, ex + ox, ey - 5);
};
drawEndpt(pA, 'Asset A (high return, high risk)', '#1a5276');
drawEndpt(pB, 'Asset B (low return, low risk)', '#784212');
/* Update the text info box below the chart */
const pct = Math.round(wCurrent * 100);
document.getElementById('info').innerHTML =
i18nFmt('Portfolio: <b>%wA%% Asset A</b> / <b>%wB%% Asset B</b> — Expected return: <b>%mu%%</b> · Risk: <b>%sig%%</b>', {
wA: pct,
wB: 100 - pct,
mu: (cur.mu * 100).toFixed(1),
sig: (cur.sig * 100).toFixed(1)
});
}
const slider = document.getElementById('weightSlider');
const wValEl = document.getElementById('wVal');
slider.addEventListener('input', () => {
const w = slider.value / 100;
wValEl.textContent = slider.value + '%';
draw(w);
});
draw(0.5);
Comments
Loading comments...