/* ============================================================================
* HFChart Options Lab — application (chaîne, builder, payoff, replay)
* Données : API HF Market Data (https://www.hfmarketdata.io)
* Author : Simon-Pierre Boucher — contact@spboucher.ai
* ==========================================================================*/
(function () {
'use strict';
const HF = window.HF;
const M = HF.optmath;
const D = HF.optdata;
const $ = s => document.querySelector(s);
/* ------------------------------------------------------------ état */
const state = {
ticker: 'SPY',
date: null, // yyyy-mm-dd (date de marché)
expiry: null, // échéance sélectionnée
spot: null, // {asset, date, close}
expirations: [],
rows: [], // chaîne de l'échéance courante
legs: [], // {kind:'c'|'p'|'s', strike, expiry, qty, price, priceSrc, greeks, iv}
filters: { delta: 1, dist: 20 },
theme: 'light',
};
let replay = null; // {days:[{date, value, pnl, spot, greeks}], built}
function loadShared() {
try { return JSON.parse(localStorage.getItem('hfchart-state') || '{}'); } catch (e) { return {}; }
}
function saveTheme() {
const s = loadShared();
s.theme = state.theme;
localStorage.setItem('hfchart-state', JSON.stringify(s));
}
function toast(msg, isErr) {
const el = $('#toast');
el.textContent = msg;
el.className = 'toast show' + (isErr ? ' err' : '');
clearTimeout(toast._t);
toast._t = setTimeout(() => { el.className = 'toast'; }, 4000);
}
const fmt$ = v => {
if (v === Infinity) return 'Illimité';
if (v === -Infinity) return '−Illimité';
const sign = v < 0 ? '−' : '';
return sign + Math.abs(v).toLocaleString('fr-CA', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' $';
};
const fmtPx = v => (v == null || !isFinite(v)) ? '—' :
v.toLocaleString('fr-CA', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const fmtIV = v => (v > 0 ? (v * 100).toFixed(1) + ' %' : '—');
/* tokens du thème pour les canvas */
function tokens() {
const cs = getComputedStyle(document.body);
const g = n => cs.getPropertyValue(n).trim();
return {
surface: g('--surface'), ink: g('--ink'), ink2: g('--ink2'), muted: g('--muted'),
grid: g('--grid'), axis: g('--axis'), up: g('--up'), dn: g('--dn'),
accent: g('--series-1') || '#2a78d6',
};
}
const alpha = (hex, a) => {
const h = hex.replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
};
/* ------------------------------------------------------------ chargement */
async function load() {
state.legs = [];
replay = null;
renderLegs();
$('#chain-table').innerHTML = '';
$('#ol-expiries').innerHTML = '';
showEmpty('Chargement…');
try {
if (!state.date) state.date = await D.latestTradeDate(state.ticker);
if (!state.date) { showEmpty(`Aucune donnée d'options pour ${state.ticker}.`); return; }
$('#ol-date').value = state.date;
const [spot, exps] = await Promise.all([
D.spotOn(state.ticker, state.date),
D.expirations(state.ticker, state.date),
]);
state.spot = spot;
$('#ol-spot').textContent = spot ? `${state.ticker} · ${fmtPx(spot.close)} $ (${spot.date})` : `${state.ticker} · spot inconnu`;
state.expirations = exps;
if (!exps.length) {
showEmpty(`Pas de chaîne pour ${state.ticker} le ${state.date} — jour férié, week-end ou ticker sans options ce jour-là. Essayez une autre date.`);
syncURL();
return;
}
if (!state.expiry || !exps.includes(state.expiry)) {
// défaut : première échéance à ≥ 7 jours (sinon la plus proche)
const target = new Date(state.date + 'T00:00:00Z');
target.setUTCDate(target.getUTCDate() + 7);
const t = target.toISOString().slice(0, 10);
state.expiry = exps.find(e => e >= t) || exps[exps.length - 1];
}
renderExpiries();
await loadChain();
syncURL();
} catch (e) {
showEmpty('Erreur API : ' + e.message);
toast('Erreur : ' + e.message, true);
}
}
async function loadChain() {
showEmpty('Chargement de l’échéance ' + state.expiry + '…');
try {
state.rows = await D.chain(state.ticker, state.date, state.expiry);
if (!state.rows.length) showEmpty('Aucun contrat pour cette échéance.');
else $('#chain-empty').hidden = true;
renderChain._scrolled = false;
renderChain();
if (boot.pendingPreset) { // vue partagée : ?preset=ironcondor[&replay=1]
applyPreset(boot.pendingPreset);
boot.pendingPreset = null;
if (boot.pendingReplay) { boot.pendingReplay = false; runReplay(); }
}
} catch (e) {
showEmpty('Erreur API : ' + e.message);
}
}
function showEmpty(msg) {
const el = $('#chain-empty');
el.textContent = msg;
el.hidden = false;
}
function syncURL() {
const q = new URLSearchParams({ ticker: state.ticker, date: state.date || '' });
if (state.expiry) q.set('expiry', state.expiry);
history.replaceState(null, '', 'options.html?' + q.toString());
}
/* ------------------------------------------------------------ échéances */
function renderExpiries() {
const host = $('#ol-expiries');
host.innerHTML = '';
for (const e of state.expirations) {
const b = document.createElement('button');
const dte = Math.round((new Date(e) - new Date(state.date)) / 86400000);
b.textContent = `${e} · ${dte}j`;
b.className = e === state.expiry ? 'active' : '';
b.onclick = () => { state.expiry = e; renderExpiries(); loadChain(); syncURL(); };
host.appendChild(b);
}
}
/* ------------------------------------------------------------ chaîne */
function renderChain() {
const spot = state.spot ? state.spot.close : null;
const byStrike = new Map();
for (const r of state.rows) {
if (!byStrike.has(r.strike)) byStrike.set(r.strike, {});
byStrike.get(r.strike)[r.call_put] = r;
}
const strikes = [...byStrike.keys()].sort((a, b) => a - b);
const fD = state.filters.delta, fPct = state.filters.dist;
const visible = strikes.filter(k => {
const { c, p } = byStrike.get(k);
const dOk = Math.abs((c && c.delta) || 0) <= fD || Math.abs((p && p.delta) || 0) <= fD;
const distOk = !spot || fPct >= 100 || Math.abs(k - spot) / spot * 100 <= fPct;
return dOk && distOk;
});
$('#chain-count').textContent = `${visible.length} strikes · ${state.rows.length} contrats`;
if (state.rows.length && !visible.length) {
showEmpty('Aucun strike ne passe les filtres — élargissez « ± spot » ou « |Δ| max ».');
} else if (state.rows.length) {
$('#chain-empty').hidden = true;
}
const posKeys = new Set(state.legs.map(l => `${l.kind}|${l.strike}|${l.expiry}`));
const cols = [
['iv', 'IV', 'xtra'], ['delta', 'Δ', ''], ['open_interest', 'OI', 'xtra'],
['volume', 'Vol', 'xtra'], ['bid', 'Bid', ''], ['mid', 'Mid', 'px'], ['ask', 'Ask', ''],
];
let html = `
| CALLS | | ` +
`PUTS |
|---|
` +
cols.map(c => `| ${c[1]} | `).join('') +
'Strike | ' +
[...cols].reverse().map(c => `${c[1]} | `).join('') +
'
';
const cellVal = (r, key) => {
if (!r) return '—';
if (key === 'iv') { const iv = ((+r.bid_iv || 0) + (+r.ask_iv || 0)) / 2; return fmtIV(iv); }
if (key === 'mid') { const rp = M.refPrice(r); return rp.source ? fmtPx(rp.price) + (rp.source === 'last' ? '*' : '') : '—'; }
if (key === 'delta') return r.delta != null ? (+r.delta).toFixed(2) : '—';
if (key === 'open_interest' || key === 'volume') return r[key] ? Math.round(r[key]).toLocaleString('fr-CA') : '0';
return fmtPx(+r[key] || 0);
};
let spotMarked = false;
for (const k of visible) {
const { c, p } = byStrike.get(k);
const rowCls = [];
if (spot != null && k < spot) rowCls.push('itm-c');
if (spot != null && k > spot) rowCls.push('itm-p');
if (spot != null && !spotMarked && k > spot) { rowCls.push('spot-row'); spotMarked = true; }
html += ``;
for (const [key, , cls] of cols) {
const inPos = cls === 'px' && posKeys.has(`c|${k}|${state.expiry}`);
html += `| ${cellVal(c, key)} | `;
}
html += `${fmtPx(k)} | `;
for (const [key, , cls] of [...cols].reverse()) {
const inPos = cls === 'px' && posKeys.has(`p|${k}|${state.expiry}`);
html += `${cellVal(p, key)} | `;
}
html += '
';
}
const table = $('#chain-table');
table.innerHTML = html;
// centre la vue sur la zone ATM au premier rendu de l'échéance
if (!renderChain._scrolled) {
const spotRow = table.querySelector('tr.spot-row');
if (spotRow) { spotRow.scrollIntoView({ block: 'center' }); renderChain._scrolled = true; }
}
table.onclick = e => {
const td = e.target.closest('td.px');
if (!td) return;
const strike = +td.dataset.strike;
const cp = td.dataset.cp;
const rec = (byStrike.get(strike) || {})[cp];
if (!rec) return;
openTradePop(e.clientX, e.clientY, rec);
};
}
/* menu Acheter / Vendre */
let pop = null;
function closePop() { if (pop) { pop.remove(); pop = null; } }
function openTradePop(x, y, rec) {
closePop();
const rp = M.refPrice(rec);
if (!rp.source) { toast('Contrat sans prix exploitable (illiquide)', true); return; }
pop = document.createElement('div');
pop.className = 'trade-pop';
const label = `${rec.strike} ${rec.call_put.toUpperCase()} @ ${fmtPx(rp.price)}${rp.source === 'last' ? ' (last)' : ''}`;
pop.innerHTML = `${label}` +
'' +
'';
document.body.appendChild(pop);
const r = pop.getBoundingClientRect();
pop.style.left = Math.min(x, innerWidth - r.width - 8) + 'px';
pop.style.top = Math.min(y + 8, innerHeight - r.height - 8) + 'px';
pop.querySelector('.buy').onclick = () => { addLeg(rec, 1); closePop(); };
pop.querySelector('.sell').onclick = () => { addLeg(rec, -1); closePop(); };
setTimeout(() => document.addEventListener('click', function h(ev) {
if (pop && !pop.contains(ev.target)) { closePop(); }
document.removeEventListener('click', h);
}), 0);
}
/* ------------------------------------------------------------ position */
function legOf(rec, qty) {
const rp = M.refPrice(rec);
return {
kind: rec.call_put, strike: rec.strike, expiry: rec.expiry, qty,
price: rp.price, priceSrc: rp.source,
greeks: { delta: +rec.delta || 0, gamma: +rec.gamma || 0, theta: +rec.theta || 0, vega: +rec.vega || 0, rho: +rec.rho || 0 },
iv: ((+rec.bid_iv || 0) + (+rec.ask_iv || 0)) / 2,
};
}
function addLeg(rec, dq) {
const key = `${rec.call_put}|${rec.strike}|${rec.expiry}`;
const found = state.legs.find(l => `${l.kind}|${l.strike}|${l.expiry}` === key);
if (found) {
found.qty += dq;
if (found.qty === 0) state.legs = state.legs.filter(l => l !== found);
} else {
state.legs.push(legOf(rec, dq));
}
replay = null;
renderLegs();
renderChain();
}
function addStockLeg(qty) {
if (!state.spot) { toast('Spot inconnu — impossible d’ajouter l’action', true); return; }
const found = state.legs.find(l => l.kind === 's');
if (found) found.qty += qty;
else state.legs.push({ kind: 's', strike: 0, expiry: state.expiry, qty, price: state.spot.close, priceSrc: 'close', mult: 1, greeks: { delta: 1, gamma: 0, theta: 0, vega: 0, rho: 0 }, iv: 0 });
state.legs = state.legs.filter(l => l.qty !== 0);
replay = null;
renderLegs();
}
function renderLegs() {
const ul = $('#legs-list');
ul.innerHTML = '';
$('#legs-empty').style.display = state.legs.length ? 'none' : 'block';
for (const leg of state.legs) {
const li = document.createElement('li');
const long = leg.qty > 0;
const desc = leg.kind === 's'
? `${state.ticker} action × ${leg.qty} @ ${fmtPx(leg.price)}`
: `${state.ticker} ${leg.expiry} ${fmtPx(leg.strike)} ${leg.kind.toUpperCase()} × ${leg.qty > 0 ? '+' : ''}${leg.qty} @ ${fmtPx(leg.price)}`;
li.innerHTML =
`${long ? 'LONG' : 'SHORT'}` +
`${desc} ${leg.priceSrc === 'last' ? 'last' : leg.priceSrc === 'close' ? 'close' : 'mid'}${leg.iv > 0 ? ' · IV ' + fmtIV(leg.iv) : ''}` +
'' +
'';
li.querySelector('.inc').onclick = () => { leg.qty += (leg.kind === 's' ? 100 : 1); afterLegChange(); };
li.querySelector('.dec').onclick = () => { leg.qty -= (leg.kind === 's' ? 100 : 1); afterLegChange(); };
li.querySelector('.rm').onclick = () => { state.legs = state.legs.filter(l => l !== leg); afterLegChange(); };
ul.appendChild(li);
}
renderMetrics();
}
function afterLegChange() {
state.legs = state.legs.filter(l => l.qty !== 0);
replay = null;
$('#replay-body').hidden = true;
renderLegs();
renderChain();
}
/* ------------------------------------------------------------ presets */
function nearestByDelta(cp, target) {
let best = null, bestD = Infinity;
for (const r of state.rows) {
if (r.call_put !== cp) continue;
if (!M.refPrice(r).source) continue;
const d = Math.abs((+r.delta || 0) - target);
if (d < bestD) { bestD = d; best = r; }
}
return best;
}
function nearestStrike(cp, priceTarget) {
let best = null, bestD = Infinity;
for (const r of state.rows) {
if (r.call_put !== cp) continue;
if (!M.refPrice(r).source) continue;
const d = Math.abs(r.strike - priceTarget);
if (d < bestD) { bestD = d; best = r; }
}
return best;
}
function applyPreset(name) {
if (!state.rows.length) { toast('Chargez d’abord une chaîne', true); return; }
const S = state.spot ? state.spot.close : null;
if (!S) { toast('Spot inconnu — presets indisponibles', true); return; }
const add = (rec, qty) => rec && addLegSilent(rec, qty);
state.legs = [];
switch (name) {
case 'coveredcall':
addStockLeg(100);
add(nearestByDelta('c', 0.30), -1);
break;
case 'csp':
add(nearestByDelta('p', -0.30), -1);
break;
case 'bullcall':
add(nearestByDelta('c', 0.50), 1);
add(nearestByDelta('c', 0.30), -1);
break;
case 'bearput':
add(nearestByDelta('p', -0.50), 1);
add(nearestByDelta('p', -0.30), -1);
break;
case 'ironcondor':
add(nearestByDelta('p', -0.20), -1);
add(nearestByDelta('p', -0.10), 1);
add(nearestByDelta('c', 0.20), -1);
add(nearestByDelta('c', 0.10), 1);
break;
case 'straddle':
add(nearestStrike('c', S), 1);
add(nearestStrike('p', S), 1);
break;
case 'strangle':
add(nearestByDelta('c', 0.25), 1);
add(nearestByDelta('p', -0.25), 1);
break;
default: return;
}
// dédoublonne les jambes identiques (ex. condor sur chaîne trop courte)
const seen = new Map();
for (const l of state.legs) {
const k = `${l.kind}|${l.strike}|${l.expiry}`;
if (seen.has(k)) seen.get(k).qty += l.qty;
else seen.set(k, l);
}
state.legs = [...seen.values()].filter(l => l.qty !== 0);
replay = null;
renderLegs();
renderChain();
}
function addLegSilent(rec, qty) { state.legs.push(legOf(rec, qty)); }
/* ------------------------------------------------------------ métriques + payoff */
function renderMetrics() {
const has = state.legs.length > 0;
$('#metrics-block').hidden = !has;
$('#payoff-block').hidden = !has;
$('#replay-block').hidden = !has;
if (!has) return;
const legs = state.legs;
const cost = M.netCost(legs);
const ex = M.extremes(legs);
const be = M.breakevens(legs);
const g = M.netGreeks(legs);
$('#metrics').innerHTML =
metric(cost >= 0 ? 'Débit net' : 'Crédit net', fmt$(Math.abs(cost)), '') +
metric('Profit max', fmt$(ex.maxProfit), ex.maxProfit > 0 ? 'up' : '') +
metric('Perte max', fmt$(ex.maxLoss), ex.maxLoss < 0 ? 'dn' : '') +
metric('Breakeven' + (be.length > 1 ? 's' : ''), be.length ? be.map(v => fmtPx(v)).join(' · ') : '—', '');
$('#greeks').innerHTML = ['delta', 'gamma', 'theta', 'vega', 'rho'].map(k =>
`${{ delta: 'Δ', gamma: 'Γ', theta: 'Θ/j', vega: 'ν', rho: 'ρ' }[k]} ${g[k].toLocaleString('fr-CA', { maximumFractionDigits: 1 })}`
).join('');
drawPayoff();
}
const metric = (k, v, cls) => `${k}${v}
`;
/* ------------------------------------------------------------ mini moteur XY (payoff + replay) */
function xyChart(host, cfg) {
host.innerHTML = '';
const canvas = document.createElement('canvas');
host.appendChild(canvas);
const t = tokens();
const dpr = devicePixelRatio || 1;
const w = host.clientWidth || 500, h = host.clientHeight || 260;
canvas.width = w * dpr; canvas.height = h * dpr;
canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const padL = 8, padR = 56, padT = 10, padB = 24;
const pw = w - padL - padR, ph = h - padT - padB;
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
for (const s of cfg.series) for (const p of s.pts) {
if (p.x < x0) x0 = p.x; if (p.x > x1) x1 = p.x;
if (s.scale === false) continue; // série indicative : n'influence pas l'échelle Y
if (p.y < y0) y0 = p.y; if (p.y > y1) y1 = p.y;
}
if (!(x1 > x0)) { x0 -= 1; x1 += 1; }
y0 = Math.min(y0, 0); y1 = Math.max(y1, 0);
const yPad = (y1 - y0) * 0.1 || 1;
y0 -= yPad; y1 += yPad;
const X = v => padL + ((v - x0) / (x1 - x0)) * pw;
const Y = v => padT + (1 - (v - y0) / (y1 - y0)) * ph;
ctx.fillStyle = t.surface;
ctx.fillRect(0, 0, w, h);
// grille + labels Y
ctx.font = '10.5px system-ui, sans-serif';
ctx.textBaseline = 'middle';
const step = niceStep((y1 - y0) / Math.max(2, ph / 44));
ctx.strokeStyle = t.grid; ctx.fillStyle = t.muted; ctx.lineWidth = 1;
for (let v = Math.ceil(y0 / step) * step; v <= y1; v += step) {
const y = Math.round(Y(v)) + 0.5;
ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + pw, y); ctx.stroke();
ctx.textAlign = 'left';
ctx.fillText(compact(v), padL + pw + 6, y);
}
// ligne zéro
ctx.strokeStyle = t.axis; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(padL, Math.round(Y(0)) + 0.5); ctx.lineTo(padL + pw, Math.round(Y(0)) + 0.5); ctx.stroke();
// lignes verticales (spot, breakevens…)
for (const vl of (cfg.vlines || [])) {
if (vl.x < x0 || vl.x > x1) continue;
ctx.strokeStyle = alpha(vl.color || t.muted, 0.6);
ctx.setLineDash(vl.dash || [4, 4]);
ctx.beginPath(); ctx.moveTo(X(vl.x), padT); ctx.lineTo(X(vl.x), padT + ph); ctx.stroke();
ctx.setLineDash([]);
if (vl.label) {
ctx.fillStyle = vl.color || t.muted;
ctx.textAlign = 'center';
ctx.fillText(vl.label, X(vl.x), padT + 8);
}
}
// séries
for (const s of cfg.series) {
if (s.fillZero) { // zones vert/rouge entre la courbe et 0
for (const pos of [true, false]) {
ctx.save();
ctx.beginPath();
const zy = Y(0);
ctx.rect(padL, pos ? padT : zy, pw, pos ? zy - padT : padT + ph - zy);
ctx.clip();
ctx.beginPath();
ctx.moveTo(X(s.pts[0].x), zy);
for (const p of s.pts) ctx.lineTo(X(p.x), Y(p.y));
ctx.lineTo(X(s.pts[s.pts.length - 1].x), zy);
ctx.closePath();
ctx.fillStyle = alpha(pos ? t.up : t.dn, 0.14);
ctx.fill();
ctx.restore();
}
}
ctx.strokeStyle = s.color;
ctx.lineWidth = s.width || 2;
ctx.lineJoin = 'round';
if (s.dash) ctx.setLineDash(s.dash);
ctx.save();
ctx.beginPath();
ctx.rect(padL, padT, pw, ph);
ctx.clip(); // une série hors échelle (T+0 indicative) ne déborde pas du cadre
ctx.beginPath();
s.pts.forEach((p, i) => { const x = X(p.x), y = Y(p.y); if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y); });
ctx.stroke();
ctx.restore();
ctx.setLineDash([]);
}
// marqueurs (breakevens, position du slider…)
for (const mk of (cfg.marks || [])) {
ctx.fillStyle = mk.color || t.ink;
ctx.beginPath();
ctx.arc(X(mk.x), Y(mk.y), mk.r || 4, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = t.surface; ctx.lineWidth = 1.5; ctx.stroke();
}
// labels X (dédoublonnés — le mapping index→date peut répéter un libellé)
ctx.fillStyle = t.muted; ctx.textAlign = 'center';
const nx = Math.max(2, Math.floor(pw / 90));
let prevLabel = null;
for (let i = 0; i <= nx; i++) {
const v = x0 + (i / nx) * (x1 - x0);
const label = cfg.xFmt ? cfg.xFmt(v) : compact(v);
if (label === prevLabel) continue;
prevLabel = label;
ctx.fillText(label, X(v), h - padB / 2);
}
// crosshair
if (cfg.onHover) {
canvas.style.cursor = 'crosshair';
canvas.onmousemove = e => {
const r = canvas.getBoundingClientRect();
const v = x0 + ((e.clientX - r.left - padL) / pw) * (x1 - x0);
cfg.onHover(Math.max(x0, Math.min(x1, v)));
};
canvas.onmouseleave = () => cfg.onHover(null);
}
return { X, Y };
}
function niceStep(raw) {
if (!(raw > 0)) return 1;
const p = Math.pow(10, Math.floor(Math.log10(raw)));
const m = raw / p;
return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * p;
}
const compact = v => Math.abs(v) >= 1000
? (v / 1000).toLocaleString('fr-CA', { maximumFractionDigits: 1 }) + ' k'
: v.toLocaleString('fr-CA', { maximumFractionDigits: Math.abs(v) < 10 ? 2 : 0 });
function drawPayoff() {
const legs = state.legs;
if (!legs.length) return;
const S = state.spot ? state.spot.close : null;
const ks = M.kinks(legs);
const beAll = M.breakevens(legs);
const ref = S || (ks.length ? (ks[0] + ks[ks.length - 1]) / 2 : 100);
// plage X serrée sur la structure (strikes ∪ spot ∪ breakevens) + marge
const anchors = [ref, ...ks, ...beAll];
const aLo = Math.min(...anchors), aHi = Math.max(...anchors);
const pad = Math.max((aHi - aLo) * 0.35, ref * 0.03);
const lo = Math.max(0, aLo - pad), hi = aHi + pad;
const expCurve = M.payoffCurve(legs, lo, hi).map(p => ({ x: p.s, y: p.pnl }));
// T+0 (Taylor sur les Greeks) : valide localement — bornée à ±6 % du spot
let t0Curve = null;
if (S) {
const t0Lo = Math.max(lo, S * 0.94), t0Hi = Math.min(hi, S * 1.06);
if (t0Hi > t0Lo) t0Curve = M.pnlTodayCurve(legs, t0Lo, t0Hi, S).map(p => ({ x: p.s, y: p.pnl }));
}
const be = beAll.filter(b => b >= lo && b <= hi);
const t = tokens();
xyChart($('#payoff-wrap'), {
series: [
{ pts: expCurve, color: t.ink, width: 2, fillZero: true },
...(t0Curve ? [{ pts: t0Curve, color: t.accent, width: 1.75, dash: [5, 4], scale: false }] : []),
],
vlines: S ? [{ x: S, color: t.accent, label: 'spot ' + fmtPx(S) }] : [],
marks: be.map(b => ({ x: b, y: 0, color: t.ink2 })),
xFmt: v => fmtPx(v),
});
}
/* ------------------------------------------------------------ replay historique */
async function runReplay() {
if (!state.legs.length) return;
const status = $('#replay-status');
status.hidden = false;
status.textContent = 'Chargement des historiques de contrats…';
try {
const optLegs = state.legs.filter(l => l.kind !== 's');
const stockLeg = state.legs.find(l => l.kind === 's');
const maxExpiry = state.legs.reduce((m, l) => l.expiry > m ? l.expiry : m, state.date);
const hists = await Promise.all(optLegs.map(l => D.history(state.ticker, l.strike, l.expiry, l.kind)));
const { bars: spotB } = await D.spotBars(state.ticker, state.date, maxExpiry + ' 23:59:59');
const spotByDay = new Map(spotB.map(b => [String(b.datetime).slice(0, 10), b.close]));
// union des dates de marché
const daySet = new Set([...spotByDay.keys()]);
hists.forEach(h => h.forEach(r => daySet.add(String(r.trade_date).slice(0, 10))));
const days = [...daySet].filter(d => d >= state.date && d <= maxExpiry).sort();
if (!days.length) { status.textContent = 'Aucune donnée sur la période.'; return; }
const recByDay = hists.map(h => new Map(h.map(r => [String(r.trade_date).slice(0, 10), r])));
const lastVal = optLegs.map(l => l.price); // report en avant si jour manquant
const lastRec = optLegs.map(() => null);
let lastSpot = state.spot ? state.spot.close : null;
const series = [];
for (const d of days) {
if (spotByDay.has(d)) lastSpot = spotByDay.get(d);
let value = 0, pnl = 0;
const g = { delta: 0, gamma: 0, theta: 0, vega: 0 };
optLegs.forEach((l, i) => {
const rec = recByDay[i].get(d);
if (rec) {
const rp = M.refPrice(rec);
if (rp.source) lastVal[i] = rp.price;
lastRec[i] = rec;
}
if (d >= l.expiry && lastSpot != null) {
lastVal[i] = M.intrinsic(l.kind, l.strike, lastSpot); // règlement à l'échéance
}
value += l.qty * 100 * lastVal[i];
pnl += l.qty * 100 * (lastVal[i] - l.price);
const rg = lastRec[i];
if (rg && d < l.expiry) {
g.delta += l.qty * 100 * (+rg.delta || 0);
g.gamma += l.qty * 100 * (+rg.gamma || 0);
g.theta += l.qty * 100 * (+rg.theta || 0);
g.vega += l.qty * 100 * (+rg.vega || 0);
}
});
if (stockLeg && lastSpot != null) {
value += stockLeg.qty * lastSpot;
pnl += stockLeg.qty * (lastSpot - stockLeg.price);
g.delta += stockLeg.qty;
}
series.push({ date: d, value, pnl, spot: lastSpot, greeks: g });
}
replay = { days: series };
status.hidden = true;
$('#replay-body').hidden = false;
const slider = $('#replay-slider');
slider.max = series.length - 1;
slider.value = series.length - 1;
drawReplay();
updateReplayReadout(series.length - 1);
slider.oninput = () => { updateReplayReadout(+slider.value); drawReplay(+slider.value); };
} catch (e) {
status.textContent = 'Erreur replay : ' + e.message;
toast('Erreur replay : ' + e.message, true);
}
}
function drawReplay(idx) {
if (!replay) return;
const t = tokens();
const days = replay.days;
const pts = days.map((d, i) => ({ x: i, y: d.pnl }));
const i = idx != null ? idx : days.length - 1;
xyChart($('#replay-wrap'), {
series: [{ pts, color: t.accent, width: 2, fillZero: true }],
marks: [{ x: i, y: days[i].pnl, color: t.accent, r: 5 }],
xFmt: v => {
const d = days[Math.max(0, Math.min(days.length - 1, Math.round(v)))];
return d ? d.date.slice(5) : '';
},
onHover: v => {
if (v == null) return;
const k = Math.round(v);
$('#replay-slider').value = k;
updateReplayReadout(k);
},
});
}
function updateReplayReadout(i) {
const d = replay.days[i];
if (!d) return;
const cls = d.pnl >= 0 ? 'up' : 'dn';
const cost = Math.abs(M.netCost(state.legs)) || 1;
$('#replay-readout').innerHTML =
`Date ${d.date} (${i + 1}/${replay.days.length})` +
`Spot ${d.spot != null ? fmtPx(d.spot) : '—'}` +
`Valeur position ${fmt$(d.value)}` +
`P&L ${fmt$(d.pnl)} (${(d.pnl / cost * 100).toFixed(1)} %)` +
`Δ ${d.greeks.delta.toFixed(0)}` +
`Θ/j ${d.greeks.theta.toFixed(1)}`;
}
/* ------------------------------------------------------------ boot */
function boot() {
const shared = loadShared();
const q = new URLSearchParams(location.search);
state.theme = q.get('theme') || shared.theme || 'light';
state.ticker = (q.get('ticker') || 'SPY').toUpperCase();
state.date = q.get('date') || null;
state.expiry = q.get('expiry') || null;
boot.pendingPreset = q.get('preset') || null;
boot.pendingReplay = q.get('replay') === '1';
document.documentElement.dataset.theme = state.theme;
$('#btn-theme').textContent = state.theme === 'dark' ? '☀︎' : '☾';
$('#btn-theme').onclick = () => {
state.theme = state.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = state.theme;
$('#btn-theme').textContent = state.theme === 'dark' ? '☀︎' : '☾';
saveTheme();
renderMetrics();
if (replay) drawReplay(+$('#replay-slider').value);
};
$('#ol-ticker').value = state.ticker;
$('#ol-date').max = new Date().toISOString().slice(0, 10);
// autocomplete tickers
let debounce = null;
$('#ol-ticker').oninput = e => {
clearTimeout(debounce);
const v = e.target.value.trim().toUpperCase();
if (v.length < 1) return;
debounce = setTimeout(async () => {
try {
const list = await D.tickers(v);
$('#ol-tickers').innerHTML = list.slice(0, 30).map(t => `