Below is a simulated cointegrated spread between two fictional assets. The spread is generated as a mean-reverting process — it wanders but always gravitates back to zero. The z-score band shows how stretched the spread currently is.
<div class="header-row">
<span class="label">Spread (mean-reverting):</span>
<span id="zscore-badge" class="badge">z = 0.00</span>
<span id="pnl-badge" class="badge pnl">P&L: $0.00</span>
</div>
<canvas id="chart" width="540" height="180"></canvas>
<div id="status" class="status">Click Step to advance, then Enter trade when the z-score is extreme.</div>
<div class="btns">
<button id="btn-step" type="button">Step</button>
<button id="btn-enter" type="button" disabled>Enter trade</button>
<button id="btn-exit" type="button" disabled>Exit trade</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="legend">
<span class="dot spread-dot"></span>Spread
<span class="dot entry-dot"></span>Entry
<span class="dot exit-dot"></span>Exit
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.header-row { display: flex; align-items: center; gap: .5rem; margin-bottom: .4rem; flex-wrap: wrap; }
.label { font-size: .85rem; color: #555; flex: 1; }
.badge { font: 700 .85rem ui-monospace, monospace; background: #e8eef3; border-radius: 6px;
padding: .2rem .55rem; white-space: nowrap; }
.badge.pnl.pos { background: #d4f0df; color: #0a7d33; }
.badge.pnl.neg { background: #fde0e0; color: #c92f3c; }
canvas { display: block; width: 100%; max-width: 540px; border: 1px solid #dde3ea;
border-radius: 8px; background: #f8fafc; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .45rem 0; }
.status.ok { color: #0a7d33; }
.status.warn { color: #b35c00; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .45rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .4rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .4; cursor: default; }
.legend { font-size: .8rem; color: #666; display: flex; gap: .9rem; flex-wrap: wrap; align-items: center; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 3px; }
.spread-dot { background: #1d3557; }
.entry-dot { background: #e63946; }
.exit-dot { background: #2a9d8f; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Ornstein-Uhlenbeck process: dS = kappa*(mu - S)*dt + sigma*dW
// kappa=mean-reversion speed, mu=equilibrium, sigma=volatility
const KAPPA = 0.18;
const MU = 0.0;
const SIGMA = 0.22;
const DT = 1;
const WINDOW = 30;
const ENTRY_Z = 2.0;
const EXIT_Z = 0.4;
const MAX_TICKS = 200;
// Simulation state: spread series, z-scores, trade markers, P&L
let spread = [], zscores = [], entries = [], exits = [];
let inTrade = false, entrySpread = 0, tradeSide = 0, totalPnl = 0;
let rng = mulberry32(42);
function mulberry32(a) {
// Deterministic pseudo-random number generator (mulberry32)
return function() {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function randn() {
// Box-Muller transform for standard normal samples
const u = 1 - rng(), v = rng();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function generateStep(prev) {
// One Euler-Maruyama step of the OU process
return prev + KAPPA * (MU - prev) * DT + SIGMA * Math.sqrt(DT) * randn();
}
function rollingStats(arr, w) {
// Rolling mean and standard deviation over the last w observations
const slice = arr.slice(Math.max(0, arr.length - w));
const mean = slice.reduce((a, b) => a + b, 0) / slice.length;
const variance = slice.reduce((a, b) => a + (b - mean) ** 2, 0) / slice.length;
return { mean, std: Math.sqrt(variance) };
}
function computeZ() {
if (spread.length < 2) return 0;
const { mean, std } = rollingStats(spread, WINDOW);
return std < 1e-9 ? 0 : (spread[spread.length - 1] - mean) / std;
}
function init() {
// Reset simulation with a fixed seed for reproducibility
spread = [0]; zscores = [0]; entries = []; exits = [];
inTrade = false; entrySpread = 0; tradeSide = 0; totalPnl = 0;
rng = mulberry32(42);
for (let i = 1; i < 60; i++) spread.push(generateStep(spread[spread.length - 1]));
for (let i = 0; i < spread.length; i++) {
const { mean, std } = rollingStats(spread.slice(0, i + 1), WINDOW);
zscores.push(std < 1e-9 ? 0 : (spread[i] - mean) / std);
}
updateUI();
}
function step() {
if (spread.length >= MAX_TICKS) { setStatus('Simulation complete — reset to start over.', 'warn'); return; }
spread.push(generateStep(spread[spread.length - 1]));
zscores.push(computeZ());
updateUI();
}
function enterTrade() {
const z = zscores[zscores.length - 1];
if (Math.abs(z) < ENTRY_Z) { setStatus('Z-score is not extreme enough to enter (need |z| > 2).', 'warn'); return; }
inTrade = true;
entrySpread = spread[spread.length - 1];
tradeSide = z > 0 ? -1 : 1; // short the spread if z > 0 (spread above mean), long if z < 0
entries.push(spread.length - 1);
setStatus(i18nFmt('Trade entered at z = %z%. Waiting for spread to revert...', { z: z.toFixed(2) }), 'ok');
document.getElementById('btn-enter').disabled = true;
document.getElementById('btn-exit').disabled = false;
draw();
}
function exitTrade() {
const currentSpread = spread[spread.length - 1];
const pnl = tradeSide * (entrySpread - currentSpread);
totalPnl += pnl;
exits.push(spread.length - 1);
inTrade = false;
setStatus(i18nFmt('Trade closed. P&L: $%pnl%. Running total: $%total%.', { pnl: pnl.toFixed(2), total: totalPnl.toFixed(2) }),
pnl >= 0 ? 'ok' : 'bad');
document.getElementById('btn-enter').disabled = false;
document.getElementById('btn-exit').disabled = true;
updatePnlBadge();
draw();
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t; s.className = 'status ' + cls;
}
function updatePnlBadge() {
const b = document.getElementById('pnl-badge');
b.textContent = 'P&L:
Click Enter trade when the z-score crosses the trigger threshold (the dashed lines). The demo opens a short on the expensive leg and a long on the cheap one. Click Exit trade to close the position. Watch how waiting for the spread to revert earns a profit — but entering too late, after the spread has already reversed, locks in a loss. The Step button advances one period at a time so you can see the logic play out tick by tick.
+ totalPnl.toFixed(2);
b.className = 'badge pnl' + (totalPnl > 0 ? ' pos' : totalPnl < 0 ? ' neg' : '');
}
function updateUI() {
const z = zscores[zscores.length - 1];
document.getElementById('zscore-badge').textContent = 'z = ' + z.toFixed(2);
document.getElementById('btn-enter').disabled = inTrade || spread.length >= MAX_TICKS;
document.getElementById('btn-step').disabled = spread.length >= MAX_TICKS;
updatePnlBadge();
draw();
}
function draw() {
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const n = spread.length;
const minS = Math.min(...spread), maxS = Math.max(...spread);
const pad = Math.max(0.3, (maxS - minS) * 0.15);
const lo = minS - pad, hi = maxS + pad;
const toX = (i) => (i / (MAX_TICKS - 1)) * (W - 20) + 10;
const toY = (v) => H - 20 - ((v - lo) / (hi - lo)) * (H - 40);
// Draw horizontal reference lines at equilibrium and 2-sigma levels
ctx.strokeStyle = '#e4e9ef'; ctx.lineWidth = 1;
[MU, MU + SIGMA * 2, MU - SIGMA * 2].forEach(level => {
ctx.beginPath(); ctx.moveTo(10, toY(level)); ctx.lineTo(W - 10, toY(level));
ctx.stroke();
});
// Draw dashed entry-threshold lines at +/- 2 sigma
ctx.setLineDash([5, 4]); ctx.strokeStyle = '#e63946'; ctx.lineWidth = 1.2;
[SIGMA * 2, -SIGMA * 2].forEach(level => {
ctx.beginPath(); ctx.moveTo(10, toY(level)); ctx.lineTo(W - 10, toY(level));
ctx.stroke();
});
ctx.setLineDash([]);
// Draw the spread series as a line
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < n; i++) {
i === 0 ? ctx.moveTo(toX(i), toY(spread[i])) : ctx.lineTo(toX(i), toY(spread[i]));
}
ctx.stroke();
// Draw entry (red) and exit (teal) dots
entries.forEach(idx => {
const x = toX(idx), y = toY(spread[idx]);
ctx.fillStyle = '#e63946';
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
});
exits.forEach(idx => {
const x = toX(idx), y = toY(spread[idx]);
ctx.fillStyle = '#2a9d8f';
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
});
// Draw the equilibrium (zero) reference line
ctx.strokeStyle = '#8899aa'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(10, toY(MU)); ctx.lineTo(W - 10, toY(MU)); ctx.stroke();
}
document.getElementById('btn-step').addEventListener('click', step);
document.getElementById('btn-enter').addEventListener('click', enterTrade);
document.getElementById('btn-exit').addEventListener('click', exitTrade);
document.getElementById('btn-reset').addEventListener('click', () => { init(); setStatus('Click Step to advance, then Enter trade when the z-score is extreme.', ''); });
init();
<div class="header-row">
<span class="label">Spread (mean-reverting):</span>
<span id="zscore-badge" class="badge">z = 0.00</span>
<span id="pnl-badge" class="badge pnl">P&L: $0.00</span>
</div>
<canvas id="chart" width="540" height="180"></canvas>
<div id="status" class="status">Click Step to advance, then Enter trade when the z-score is extreme.</div>
<div class="btns">
<button id="btn-step" type="button">Step</button>
<button id="btn-enter" type="button" disabled>Enter trade</button>
<button id="btn-exit" type="button" disabled>Exit trade</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="legend">
<span class="dot spread-dot"></span>Spread
<span class="dot entry-dot"></span>Entry
<span class="dot exit-dot"></span>Exit
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.header-row { display: flex; align-items: center; gap: .5rem; margin-bottom: .4rem; flex-wrap: wrap; }
.label { font-size: .85rem; color: #555; flex: 1; }
.badge { font: 700 .85rem ui-monospace, monospace; background: #e8eef3; border-radius: 6px;
padding: .2rem .55rem; white-space: nowrap; }
.badge.pnl.pos { background: #d4f0df; color: #0a7d33; }
.badge.pnl.neg { background: #fde0e0; color: #c92f3c; }
canvas { display: block; width: 100%; max-width: 540px; border: 1px solid #dde3ea;
border-radius: 8px; background: #f8fafc; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .45rem 0; }
.status.ok { color: #0a7d33; }
.status.warn { color: #b35c00; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .45rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .4rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .4; cursor: default; }
.legend { font-size: .8rem; color: #666; display: flex; gap: .9rem; flex-wrap: wrap; align-items: center; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 3px; }
.spread-dot { background: #1d3557; }
.entry-dot { background: #e63946; }
.exit-dot { background: #2a9d8f; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Ornstein-Uhlenbeck process: dS = kappa*(mu - S)*dt + sigma*dW
// kappa=mean-reversion speed, mu=equilibrium, sigma=volatility
const KAPPA = 0.18;
const MU = 0.0;
const SIGMA = 0.22;
const DT = 1;
const WINDOW = 30;
const ENTRY_Z = 2.0;
const EXIT_Z = 0.4;
const MAX_TICKS = 200;
// Simulation state: spread series, z-scores, trade markers, P&L
let spread = [], zscores = [], entries = [], exits = [];
let inTrade = false, entrySpread = 0, tradeSide = 0, totalPnl = 0;
let rng = mulberry32(42);
function mulberry32(a) {
// Deterministic pseudo-random number generator (mulberry32)
return function() {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function randn() {
// Box-Muller transform for standard normal samples
const u = 1 - rng(), v = rng();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function generateStep(prev) {
// One Euler-Maruyama step of the OU process
return prev + KAPPA * (MU - prev) * DT + SIGMA * Math.sqrt(DT) * randn();
}
function rollingStats(arr, w) {
// Rolling mean and standard deviation over the last w observations
const slice = arr.slice(Math.max(0, arr.length - w));
const mean = slice.reduce((a, b) => a + b, 0) / slice.length;
const variance = slice.reduce((a, b) => a + (b - mean) ** 2, 0) / slice.length;
return { mean, std: Math.sqrt(variance) };
}
function computeZ() {
if (spread.length < 2) return 0;
const { mean, std } = rollingStats(spread, WINDOW);
return std < 1e-9 ? 0 : (spread[spread.length - 1] - mean) / std;
}
function init() {
// Reset simulation with a fixed seed for reproducibility
spread = [0]; zscores = [0]; entries = []; exits = [];
inTrade = false; entrySpread = 0; tradeSide = 0; totalPnl = 0;
rng = mulberry32(42);
for (let i = 1; i < 60; i++) spread.push(generateStep(spread[spread.length - 1]));
for (let i = 0; i < spread.length; i++) {
const { mean, std } = rollingStats(spread.slice(0, i + 1), WINDOW);
zscores.push(std < 1e-9 ? 0 : (spread[i] - mean) / std);
}
updateUI();
}
function step() {
if (spread.length >= MAX_TICKS) { setStatus('Simulation complete — reset to start over.', 'warn'); return; }
spread.push(generateStep(spread[spread.length - 1]));
zscores.push(computeZ());
updateUI();
}
function enterTrade() {
const z = zscores[zscores.length - 1];
if (Math.abs(z) < ENTRY_Z) { setStatus('Z-score is not extreme enough to enter (need |z| > 2).', 'warn'); return; }
inTrade = true;
entrySpread = spread[spread.length - 1];
tradeSide = z > 0 ? -1 : 1; // short the spread if z > 0 (spread above mean), long if z < 0
entries.push(spread.length - 1);
setStatus(i18nFmt('Trade entered at z = %z%. Waiting for spread to revert...', { z: z.toFixed(2) }), 'ok');
document.getElementById('btn-enter').disabled = true;
document.getElementById('btn-exit').disabled = false;
draw();
}
function exitTrade() {
const currentSpread = spread[spread.length - 1];
const pnl = tradeSide * (entrySpread - currentSpread);
totalPnl += pnl;
exits.push(spread.length - 1);
inTrade = false;
setStatus(i18nFmt('Trade closed. P&L: $%pnl%. Running total: $%total%.', { pnl: pnl.toFixed(2), total: totalPnl.toFixed(2) }),
pnl >= 0 ? 'ok' : 'bad');
document.getElementById('btn-enter').disabled = false;
document.getElementById('btn-exit').disabled = true;
updatePnlBadge();
draw();
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t; s.className = 'status ' + cls;
}
function updatePnlBadge() {
const b = document.getElementById('pnl-badge');
b.textContent = 'P&L:
Click Enter trade when the z-score crosses the trigger threshold (the dashed lines). The demo opens a short on the expensive leg and a long on the cheap one. Click Exit trade to close the position. Watch how waiting for the spread to revert earns a profit — but entering too late, after the spread has already reversed, locks in a loss. The Step button advances one period at a time so you can see the logic play out tick by tick.
+ totalPnl.toFixed(2);
b.className = 'badge pnl' + (totalPnl > 0 ? ' pos' : totalPnl < 0 ? ' neg' : '');
}
function updateUI() {
const z = zscores[zscores.length - 1];
document.getElementById('zscore-badge').textContent = 'z = ' + z.toFixed(2);
document.getElementById('btn-enter').disabled = inTrade || spread.length >= MAX_TICKS;
document.getElementById('btn-step').disabled = spread.length >= MAX_TICKS;
updatePnlBadge();
draw();
}
function draw() {
const canvas = document.getElementById('chart');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const n = spread.length;
const minS = Math.min(...spread), maxS = Math.max(...spread);
const pad = Math.max(0.3, (maxS - minS) * 0.15);
const lo = minS - pad, hi = maxS + pad;
const toX = (i) => (i / (MAX_TICKS - 1)) * (W - 20) + 10;
const toY = (v) => H - 20 - ((v - lo) / (hi - lo)) * (H - 40);
// Draw horizontal reference lines at equilibrium and 2-sigma levels
ctx.strokeStyle = '#e4e9ef'; ctx.lineWidth = 1;
[MU, MU + SIGMA * 2, MU - SIGMA * 2].forEach(level => {
ctx.beginPath(); ctx.moveTo(10, toY(level)); ctx.lineTo(W - 10, toY(level));
ctx.stroke();
});
// Draw dashed entry-threshold lines at +/- 2 sigma
ctx.setLineDash([5, 4]); ctx.strokeStyle = '#e63946'; ctx.lineWidth = 1.2;
[SIGMA * 2, -SIGMA * 2].forEach(level => {
ctx.beginPath(); ctx.moveTo(10, toY(level)); ctx.lineTo(W - 10, toY(level));
ctx.stroke();
});
ctx.setLineDash([]);
// Draw the spread series as a line
ctx.strokeStyle = '#1d3557'; ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < n; i++) {
i === 0 ? ctx.moveTo(toX(i), toY(spread[i])) : ctx.lineTo(toX(i), toY(spread[i]));
}
ctx.stroke();
// Draw entry (red) and exit (teal) dots
entries.forEach(idx => {
const x = toX(idx), y = toY(spread[idx]);
ctx.fillStyle = '#e63946';
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
});
exits.forEach(idx => {
const x = toX(idx), y = toY(spread[idx]);
ctx.fillStyle = '#2a9d8f';
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
});
// Draw the equilibrium (zero) reference line
ctx.strokeStyle = '#8899aa'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(10, toY(MU)); ctx.lineTo(W - 10, toY(MU)); ctx.stroke();
}
document.getElementById('btn-step').addEventListener('click', step);
document.getElementById('btn-enter').addEventListener('click', enterTrade);
document.getElementById('btn-exit').addEventListener('click', exitTrade);
document.getElementById('btn-reset').addEventListener('click', () => { init(); setStatus('Click Step to advance, then Enter trade when the z-score is extreme.', ''); });
init();
Comments
Loading comments...