/* ============================================================================ * 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 => `