spb/hfchart Public
HFChart — la référence des charts haute fréquence : 14 types rendus canvas from scratch (zéro dépendance), données HF Market Data — www.hfchart.io
JavaScript 82.1%
CSS 10%
HTML 5.8%
Python 2.1%
1/* ============================================================================2 * HFChart Options Lab — application (chaîne, builder, payoff, replay)3 * Données : API HF Market Data (https://www.hfmarketdata.io)4 * Author : Simon-Pierre Boucher — contact@spboucher.ai5 * ==========================================================================*/6(function () {7 'use strict';8 const HF = window.HF;9 const M = HF.optmath;10 const D = HF.optdata;11 const $ = s => document.querySelector(s);1213 /* ------------------------------------------------------------ état */14 const state = {15 ticker: 'SPY',16 date: null, // yyyy-mm-dd (date de marché)17 expiry: null, // échéance sélectionnée18 spot: null, // {asset, date, close}19 expirations: [],20 rows: [], // chaîne de l'échéance courante21 legs: [], // {kind:'c'|'p'|'s', strike, expiry, qty, price, priceSrc, greeks, iv}22 filters: { delta: 1, dist: 20 },23 theme: 'light',24 };25 let replay = null; // {days:[{date, value, pnl, spot, greeks}], built}2627 function loadShared() {28 try { return JSON.parse(localStorage.getItem('hfchart-state') || '{}'); } catch (e) { return {}; }29 }30 function saveTheme() {31 const s = loadShared();32 s.theme = state.theme;33 localStorage.setItem('hfchart-state', JSON.stringify(s));34 }3536 function toast(msg, isErr) {37 const el = $('#toast');38 el.textContent = msg;39 el.className = 'toast show' + (isErr ? ' err' : '');40 clearTimeout(toast._t);41 toast._t = setTimeout(() => { el.className = 'toast'; }, 4000);42 }4344 const fmt$ = v => {45 if (v === Infinity) return 'Illimité';46 if (v === -Infinity) return '−Illimité';47 const sign = v < 0 ? '−' : '';48 return sign + Math.abs(v).toLocaleString('fr-CA', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' $';49 };50 const fmtPx = v => (v == null || !isFinite(v)) ? '—' :51 v.toLocaleString('fr-CA', { minimumFractionDigits: 2, maximumFractionDigits: 2 });52 const fmtIV = v => (v > 0 ? (v * 100).toFixed(1) + ' %' : '—');5354 /* tokens du thème pour les canvas */55 function tokens() {56 const cs = getComputedStyle(document.body);57 const g = n => cs.getPropertyValue(n).trim();58 return {59 surface: g('--surface'), ink: g('--ink'), ink2: g('--ink2'), muted: g('--muted'),60 grid: g('--grid'), axis: g('--axis'), up: g('--up'), dn: g('--dn'),61 accent: g('--series-1') || '#2a78d6',62 };63 }64 const alpha = (hex, a) => {65 const h = hex.replace('#', '');66 const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16);67 return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;68 };6970 /* ------------------------------------------------------------ chargement */71 async function load() {72 state.legs = [];73 replay = null;74 renderLegs();75 $('#chain-table').innerHTML = '';76 $('#ol-expiries').innerHTML = '';77 showEmpty('Chargement…');78 try {79 if (!state.date) state.date = await D.latestTradeDate(state.ticker);80 if (!state.date) { showEmpty(`Aucune donnée d'options pour ${state.ticker}.`); return; }81 $('#ol-date').value = state.date;82 const [spot, exps] = await Promise.all([83 D.spotOn(state.ticker, state.date),84 D.expirations(state.ticker, state.date),85 ]);86 state.spot = spot;87 $('#ol-spot').textContent = spot ? `${state.ticker} · ${fmtPx(spot.close)} $ (${spot.date})` : `${state.ticker} · spot inconnu`;88 state.expirations = exps;89 if (!exps.length) {90 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.`);91 syncURL();92 return;93 }94 if (!state.expiry || !exps.includes(state.expiry)) {95 // défaut : première échéance à ≥ 7 jours (sinon la plus proche)96 const target = new Date(state.date + 'T00:00:00Z');97 target.setUTCDate(target.getUTCDate() + 7);98 const t = target.toISOString().slice(0, 10);99 state.expiry = exps.find(e => e >= t) || exps[exps.length - 1];100 }101 renderExpiries();102 await loadChain();103 syncURL();104 } catch (e) {105 showEmpty('Erreur API : ' + e.message);106 toast('Erreur : ' + e.message, true);107 }108 }109110 async function loadChain() {111 showEmpty('Chargement de l’échéance ' + state.expiry + '…');112 try {113 state.rows = await D.chain(state.ticker, state.date, state.expiry);114 if (!state.rows.length) showEmpty('Aucun contrat pour cette échéance.');115 else $('#chain-empty').hidden = true;116 renderChain._scrolled = false;117 renderChain();118 if (boot.pendingPreset) { // vue partagée : ?preset=ironcondor[&replay=1]119 applyPreset(boot.pendingPreset);120 boot.pendingPreset = null;121 if (boot.pendingReplay) { boot.pendingReplay = false; runReplay(); }122 }123 } catch (e) {124 showEmpty('Erreur API : ' + e.message);125 }126 }127128 function showEmpty(msg) {129 const el = $('#chain-empty');130 el.textContent = msg;131 el.hidden = false;132 }133134 function syncURL() {135 const q = new URLSearchParams({ ticker: state.ticker, date: state.date || '' });136 if (state.expiry) q.set('expiry', state.expiry);137 history.replaceState(null, '', 'options.html?' + q.toString());138 }139140 /* ------------------------------------------------------------ échéances */141 function renderExpiries() {142 const host = $('#ol-expiries');143 host.innerHTML = '';144 for (const e of state.expirations) {145 const b = document.createElement('button');146 const dte = Math.round((new Date(e) - new Date(state.date)) / 86400000);147 b.textContent = `${e} · ${dte}j`;148 b.className = e === state.expiry ? 'active' : '';149 b.onclick = () => { state.expiry = e; renderExpiries(); loadChain(); syncURL(); };150 host.appendChild(b);151 }152 }153154 /* ------------------------------------------------------------ chaîne */155 function renderChain() {156 const spot = state.spot ? state.spot.close : null;157 const byStrike = new Map();158 for (const r of state.rows) {159 if (!byStrike.has(r.strike)) byStrike.set(r.strike, {});160 byStrike.get(r.strike)[r.call_put] = r;161 }162 const strikes = [...byStrike.keys()].sort((a, b) => a - b);163 const fD = state.filters.delta, fPct = state.filters.dist;164 const visible = strikes.filter(k => {165 const { c, p } = byStrike.get(k);166 const dOk = Math.abs((c && c.delta) || 0) <= fD || Math.abs((p && p.delta) || 0) <= fD;167 const distOk = !spot || fPct >= 100 || Math.abs(k - spot) / spot * 100 <= fPct;168 return dOk && distOk;169 });170 $('#chain-count').textContent = `${visible.length} strikes · ${state.rows.length} contrats`;171 if (state.rows.length && !visible.length) {172 showEmpty('Aucun strike ne passe les filtres — élargissez « ± spot » ou « |Δ| max ».');173 } else if (state.rows.length) {174 $('#chain-empty').hidden = true;175 }176177 const posKeys = new Set(state.legs.map(l => `${l.kind}|${l.strike}|${l.expiry}`));178 const cols = [179 ['iv', 'IV', 'xtra'], ['delta', 'Δ', ''], ['open_interest', 'OI', 'xtra'],180 ['volume', 'Vol', 'xtra'], ['bid', 'Bid', ''], ['mid', 'Mid', 'px'], ['ask', 'Ask', ''],181 ];182 let html = `<tr><th class="side-head" colspan="${cols.length}">CALLS</th><th></th>` +183 `<th class="side-head" colspan="${cols.length}">PUTS</th></tr><tr>` +184 cols.map(c => `<th class="${c[2] === 'xtra' ? 'xtra' : ''}">${c[1]}</th>`).join('') +185 '<th>Strike</th>' +186 [...cols].reverse().map(c => `<th class="${c[2] === 'xtra' ? 'xtra' : ''}">${c[1]}</th>`).join('') +187 '</tr>';188189 const cellVal = (r, key) => {190 if (!r) return '—';191 if (key === 'iv') { const iv = ((+r.bid_iv || 0) + (+r.ask_iv || 0)) / 2; return fmtIV(iv); }192 if (key === 'mid') { const rp = M.refPrice(r); return rp.source ? fmtPx(rp.price) + (rp.source === 'last' ? '*' : '') : '—'; }193 if (key === 'delta') return r.delta != null ? (+r.delta).toFixed(2) : '—';194 if (key === 'open_interest' || key === 'volume') return r[key] ? Math.round(r[key]).toLocaleString('fr-CA') : '0';195 return fmtPx(+r[key] || 0);196 };197198 let spotMarked = false;199 for (const k of visible) {200 const { c, p } = byStrike.get(k);201 const rowCls = [];202 if (spot != null && k < spot) rowCls.push('itm-c');203 if (spot != null && k > spot) rowCls.push('itm-p');204 if (spot != null && !spotMarked && k > spot) { rowCls.push('spot-row'); spotMarked = true; }205 html += `<tr class="${rowCls.join(' ')}" data-strike="${k}">`;206 for (const [key, , cls] of cols) {207 const inPos = cls === 'px' && posKeys.has(`c|${k}|${state.expiry}`);208 html += `<td class="c-side ${cls === 'xtra' ? 'xtra' : ''}${cls === 'px' ? ' px' : ''}${inPos ? ' in-pos' : ''}"` +209 `${cls === 'px' ? ` data-cp="c" data-strike="${k}"` : ''}>${cellVal(c, key)}</td>`;210 }211 html += `<td class="strike">${fmtPx(k)}</td>`;212 for (const [key, , cls] of [...cols].reverse()) {213 const inPos = cls === 'px' && posKeys.has(`p|${k}|${state.expiry}`);214 html += `<td class="p-side ${cls === 'xtra' ? 'xtra' : ''}${cls === 'px' ? ' px' : ''}${inPos ? ' in-pos' : ''}"` +215 `${cls === 'px' ? ` data-cp="p" data-strike="${k}"` : ''}>${cellVal(p, key)}</td>`;216 }217 html += '</tr>';218 }219 const table = $('#chain-table');220 table.innerHTML = html;221 // centre la vue sur la zone ATM au premier rendu de l'échéance222 if (!renderChain._scrolled) {223 const spotRow = table.querySelector('tr.spot-row');224 if (spotRow) { spotRow.scrollIntoView({ block: 'center' }); renderChain._scrolled = true; }225 }226 table.onclick = e => {227 const td = e.target.closest('td.px');228 if (!td) return;229 const strike = +td.dataset.strike;230 const cp = td.dataset.cp;231 const rec = (byStrike.get(strike) || {})[cp];232 if (!rec) return;233 openTradePop(e.clientX, e.clientY, rec);234 };235 }236237 /* menu Acheter / Vendre */238 let pop = null;239 function closePop() { if (pop) { pop.remove(); pop = null; } }240 function openTradePop(x, y, rec) {241 closePop();242 const rp = M.refPrice(rec);243 if (!rp.source) { toast('Contrat sans prix exploitable (illiquide)', true); return; }244 pop = document.createElement('div');245 pop.className = 'trade-pop';246 const label = `${rec.strike} ${rec.call_put.toUpperCase()} @ ${fmtPx(rp.price)}${rp.source === 'last' ? ' (last)' : ''}`;247 pop.innerHTML = `<span>${label}</span>` +248 '<button type="button" class="buy">Acheter +1</button>' +249 '<button type="button" class="sell">Vendre −1</button>';250 document.body.appendChild(pop);251 const r = pop.getBoundingClientRect();252 pop.style.left = Math.min(x, innerWidth - r.width - 8) + 'px';253 pop.style.top = Math.min(y + 8, innerHeight - r.height - 8) + 'px';254 pop.querySelector('.buy').onclick = () => { addLeg(rec, 1); closePop(); };255 pop.querySelector('.sell').onclick = () => { addLeg(rec, -1); closePop(); };256 setTimeout(() => document.addEventListener('click', function h(ev) {257 if (pop && !pop.contains(ev.target)) { closePop(); }258 document.removeEventListener('click', h);259 }), 0);260 }261262 /* ------------------------------------------------------------ position */263 function legOf(rec, qty) {264 const rp = M.refPrice(rec);265 return {266 kind: rec.call_put, strike: rec.strike, expiry: rec.expiry, qty,267 price: rp.price, priceSrc: rp.source,268 greeks: { delta: +rec.delta || 0, gamma: +rec.gamma || 0, theta: +rec.theta || 0, vega: +rec.vega || 0, rho: +rec.rho || 0 },269 iv: ((+rec.bid_iv || 0) + (+rec.ask_iv || 0)) / 2,270 };271 }272273 function addLeg(rec, dq) {274 const key = `${rec.call_put}|${rec.strike}|${rec.expiry}`;275 const found = state.legs.find(l => `${l.kind}|${l.strike}|${l.expiry}` === key);276 if (found) {277 found.qty += dq;278 if (found.qty === 0) state.legs = state.legs.filter(l => l !== found);279 } else {280 state.legs.push(legOf(rec, dq));281 }282 replay = null;283 renderLegs();284 renderChain();285 }286287 function addStockLeg(qty) {288 if (!state.spot) { toast('Spot inconnu — impossible d’ajouter l’action', true); return; }289 const found = state.legs.find(l => l.kind === 's');290 if (found) found.qty += qty;291 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 });292 state.legs = state.legs.filter(l => l.qty !== 0);293 replay = null;294 renderLegs();295 }296297 function renderLegs() {298 const ul = $('#legs-list');299 ul.innerHTML = '';300 $('#legs-empty').style.display = state.legs.length ? 'none' : 'block';301 for (const leg of state.legs) {302 const li = document.createElement('li');303 const long = leg.qty > 0;304 const desc = leg.kind === 's'305 ? `${state.ticker} action × ${leg.qty} @ ${fmtPx(leg.price)}`306 : `${state.ticker} ${leg.expiry} ${fmtPx(leg.strike)} ${leg.kind.toUpperCase()} × ${leg.qty > 0 ? '+' : ''}${leg.qty} @ ${fmtPx(leg.price)}`;307 li.innerHTML =308 `<span class="side ${long ? 'long' : 'short'}">${long ? 'LONG' : 'SHORT'}</span>` +309 `<span class="desc">${desc} <small>${leg.priceSrc === 'last' ? 'last' : leg.priceSrc === 'close' ? 'close' : 'mid'}${leg.iv > 0 ? ' · IV ' + fmtIV(leg.iv) : ''}</small></span>` +310 '<span class="qty-ctl"><button type="button" class="dec">−</button><button type="button" class="inc">+</button></span>' +311 '<button type="button" class="rm" title="Retirer">✕</button>';312 li.querySelector('.inc').onclick = () => { leg.qty += (leg.kind === 's' ? 100 : 1); afterLegChange(); };313 li.querySelector('.dec').onclick = () => { leg.qty -= (leg.kind === 's' ? 100 : 1); afterLegChange(); };314 li.querySelector('.rm').onclick = () => { state.legs = state.legs.filter(l => l !== leg); afterLegChange(); };315 ul.appendChild(li);316 }317 renderMetrics();318 }319320 function afterLegChange() {321 state.legs = state.legs.filter(l => l.qty !== 0);322 replay = null;323 $('#replay-body').hidden = true;324 renderLegs();325 renderChain();326 }327328 /* ------------------------------------------------------------ presets */329 function nearestByDelta(cp, target) {330 let best = null, bestD = Infinity;331 for (const r of state.rows) {332 if (r.call_put !== cp) continue;333 if (!M.refPrice(r).source) continue;334 const d = Math.abs((+r.delta || 0) - target);335 if (d < bestD) { bestD = d; best = r; }336 }337 return best;338 }339 function nearestStrike(cp, priceTarget) {340 let best = null, bestD = Infinity;341 for (const r of state.rows) {342 if (r.call_put !== cp) continue;343 if (!M.refPrice(r).source) continue;344 const d = Math.abs(r.strike - priceTarget);345 if (d < bestD) { bestD = d; best = r; }346 }347 return best;348 }349350 function applyPreset(name) {351 if (!state.rows.length) { toast('Chargez d’abord une chaîne', true); return; }352 const S = state.spot ? state.spot.close : null;353 if (!S) { toast('Spot inconnu — presets indisponibles', true); return; }354 const add = (rec, qty) => rec && addLegSilent(rec, qty);355 state.legs = [];356 switch (name) {357 case 'coveredcall':358 addStockLeg(100);359 add(nearestByDelta('c', 0.30), -1);360 break;361 case 'csp':362 add(nearestByDelta('p', -0.30), -1);363 break;364 case 'bullcall':365 add(nearestByDelta('c', 0.50), 1);366 add(nearestByDelta('c', 0.30), -1);367 break;368 case 'bearput':369 add(nearestByDelta('p', -0.50), 1);370 add(nearestByDelta('p', -0.30), -1);371 break;372 case 'ironcondor':373 add(nearestByDelta('p', -0.20), -1);374 add(nearestByDelta('p', -0.10), 1);375 add(nearestByDelta('c', 0.20), -1);376 add(nearestByDelta('c', 0.10), 1);377 break;378 case 'straddle':379 add(nearestStrike('c', S), 1);380 add(nearestStrike('p', S), 1);381 break;382 case 'strangle':383 add(nearestByDelta('c', 0.25), 1);384 add(nearestByDelta('p', -0.25), 1);385 break;386 default: return;387 }388 // dédoublonne les jambes identiques (ex. condor sur chaîne trop courte)389 const seen = new Map();390 for (const l of state.legs) {391 const k = `${l.kind}|${l.strike}|${l.expiry}`;392 if (seen.has(k)) seen.get(k).qty += l.qty;393 else seen.set(k, l);394 }395 state.legs = [...seen.values()].filter(l => l.qty !== 0);396 replay = null;397 renderLegs();398 renderChain();399 }400 function addLegSilent(rec, qty) { state.legs.push(legOf(rec, qty)); }401402 /* ------------------------------------------------------------ métriques + payoff */403 function renderMetrics() {404 const has = state.legs.length > 0;405 $('#metrics-block').hidden = !has;406 $('#payoff-block').hidden = !has;407 $('#replay-block').hidden = !has;408 if (!has) return;409 const legs = state.legs;410 const cost = M.netCost(legs);411 const ex = M.extremes(legs);412 const be = M.breakevens(legs);413 const g = M.netGreeks(legs);414 $('#metrics').innerHTML =415 metric(cost >= 0 ? 'Débit net' : 'Crédit net', fmt$(Math.abs(cost)), '') +416 metric('Profit max', fmt$(ex.maxProfit), ex.maxProfit > 0 ? 'up' : '') +417 metric('Perte max', fmt$(ex.maxLoss), ex.maxLoss < 0 ? 'dn' : '') +418 metric('Breakeven' + (be.length > 1 ? 's' : ''), be.length ? be.map(v => fmtPx(v)).join(' · ') : '—', '');419 $('#greeks').innerHTML = ['delta', 'gamma', 'theta', 'vega', 'rho'].map(k =>420 `<span><span class="k">${{ delta: 'Δ', gamma: 'Γ', theta: 'Θ/j', vega: 'ν', rho: 'ρ' }[k]}</span> <b>${g[k].toLocaleString('fr-CA', { maximumFractionDigits: 1 })}</b></span>`421 ).join('');422 drawPayoff();423 }424 const metric = (k, v, cls) => `<div class="metric"><span class="k">${k}</span><span class="v ${cls}">${v}</span></div>`;425426 /* ------------------------------------------------------------ mini moteur XY (payoff + replay) */427 function xyChart(host, cfg) {428 host.innerHTML = '';429 const canvas = document.createElement('canvas');430 host.appendChild(canvas);431 const t = tokens();432 const dpr = devicePixelRatio || 1;433 const w = host.clientWidth || 500, h = host.clientHeight || 260;434 canvas.width = w * dpr; canvas.height = h * dpr;435 canvas.style.width = w + 'px'; canvas.style.height = h + 'px';436 const ctx = canvas.getContext('2d');437 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);438 const padL = 8, padR = 56, padT = 10, padB = 24;439 const pw = w - padL - padR, ph = h - padT - padB;440 let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;441 for (const s of cfg.series) for (const p of s.pts) {442 if (p.x < x0) x0 = p.x; if (p.x > x1) x1 = p.x;443 if (s.scale === false) continue; // série indicative : n'influence pas l'échelle Y444 if (p.y < y0) y0 = p.y; if (p.y > y1) y1 = p.y;445 }446 if (!(x1 > x0)) { x0 -= 1; x1 += 1; }447 y0 = Math.min(y0, 0); y1 = Math.max(y1, 0);448 const yPad = (y1 - y0) * 0.1 || 1;449 y0 -= yPad; y1 += yPad;450 const X = v => padL + ((v - x0) / (x1 - x0)) * pw;451 const Y = v => padT + (1 - (v - y0) / (y1 - y0)) * ph;452453 ctx.fillStyle = t.surface;454 ctx.fillRect(0, 0, w, h);455 // grille + labels Y456 ctx.font = '10.5px system-ui, sans-serif';457 ctx.textBaseline = 'middle';458 const step = niceStep((y1 - y0) / Math.max(2, ph / 44));459 ctx.strokeStyle = t.grid; ctx.fillStyle = t.muted; ctx.lineWidth = 1;460 for (let v = Math.ceil(y0 / step) * step; v <= y1; v += step) {461 const y = Math.round(Y(v)) + 0.5;462 ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + pw, y); ctx.stroke();463 ctx.textAlign = 'left';464 ctx.fillText(compact(v), padL + pw + 6, y);465 }466 // ligne zéro467 ctx.strokeStyle = t.axis; ctx.lineWidth = 1.5;468 ctx.beginPath(); ctx.moveTo(padL, Math.round(Y(0)) + 0.5); ctx.lineTo(padL + pw, Math.round(Y(0)) + 0.5); ctx.stroke();469 // lignes verticales (spot, breakevens…)470 for (const vl of (cfg.vlines || [])) {471 if (vl.x < x0 || vl.x > x1) continue;472 ctx.strokeStyle = alpha(vl.color || t.muted, 0.6);473 ctx.setLineDash(vl.dash || [4, 4]);474 ctx.beginPath(); ctx.moveTo(X(vl.x), padT); ctx.lineTo(X(vl.x), padT + ph); ctx.stroke();475 ctx.setLineDash([]);476 if (vl.label) {477 ctx.fillStyle = vl.color || t.muted;478 ctx.textAlign = 'center';479 ctx.fillText(vl.label, X(vl.x), padT + 8);480 }481 }482 // séries483 for (const s of cfg.series) {484 if (s.fillZero) { // zones vert/rouge entre la courbe et 0485 for (const pos of [true, false]) {486 ctx.save();487 ctx.beginPath();488 const zy = Y(0);489 ctx.rect(padL, pos ? padT : zy, pw, pos ? zy - padT : padT + ph - zy);490 ctx.clip();491 ctx.beginPath();492 ctx.moveTo(X(s.pts[0].x), zy);493 for (const p of s.pts) ctx.lineTo(X(p.x), Y(p.y));494 ctx.lineTo(X(s.pts[s.pts.length - 1].x), zy);495 ctx.closePath();496 ctx.fillStyle = alpha(pos ? t.up : t.dn, 0.14);497 ctx.fill();498 ctx.restore();499 }500 }501 ctx.strokeStyle = s.color;502 ctx.lineWidth = s.width || 2;503 ctx.lineJoin = 'round';504 if (s.dash) ctx.setLineDash(s.dash);505 ctx.save();506 ctx.beginPath();507 ctx.rect(padL, padT, pw, ph);508 ctx.clip(); // une série hors échelle (T+0 indicative) ne déborde pas du cadre509 ctx.beginPath();510 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); });511 ctx.stroke();512 ctx.restore();513 ctx.setLineDash([]);514 }515 // marqueurs (breakevens, position du slider…)516 for (const mk of (cfg.marks || [])) {517 ctx.fillStyle = mk.color || t.ink;518 ctx.beginPath();519 ctx.arc(X(mk.x), Y(mk.y), mk.r || 4, 0, Math.PI * 2);520 ctx.fill();521 ctx.strokeStyle = t.surface; ctx.lineWidth = 1.5; ctx.stroke();522 }523 // labels X (dédoublonnés — le mapping index→date peut répéter un libellé)524 ctx.fillStyle = t.muted; ctx.textAlign = 'center';525 const nx = Math.max(2, Math.floor(pw / 90));526 let prevLabel = null;527 for (let i = 0; i <= nx; i++) {528 const v = x0 + (i / nx) * (x1 - x0);529 const label = cfg.xFmt ? cfg.xFmt(v) : compact(v);530 if (label === prevLabel) continue;531 prevLabel = label;532 ctx.fillText(label, X(v), h - padB / 2);533 }534 // crosshair535 if (cfg.onHover) {536 canvas.style.cursor = 'crosshair';537 canvas.onmousemove = e => {538 const r = canvas.getBoundingClientRect();539 const v = x0 + ((e.clientX - r.left - padL) / pw) * (x1 - x0);540 cfg.onHover(Math.max(x0, Math.min(x1, v)));541 };542 canvas.onmouseleave = () => cfg.onHover(null);543 }544 return { X, Y };545 }546 function niceStep(raw) {547 if (!(raw > 0)) return 1;548 const p = Math.pow(10, Math.floor(Math.log10(raw)));549 const m = raw / p;550 return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * p;551 }552 const compact = v => Math.abs(v) >= 1000553 ? (v / 1000).toLocaleString('fr-CA', { maximumFractionDigits: 1 }) + ' k'554 : v.toLocaleString('fr-CA', { maximumFractionDigits: Math.abs(v) < 10 ? 2 : 0 });555556 function drawPayoff() {557 const legs = state.legs;558 if (!legs.length) return;559 const S = state.spot ? state.spot.close : null;560 const ks = M.kinks(legs);561 const beAll = M.breakevens(legs);562 const ref = S || (ks.length ? (ks[0] + ks[ks.length - 1]) / 2 : 100);563 // plage X serrée sur la structure (strikes ∪ spot ∪ breakevens) + marge564 const anchors = [ref, ...ks, ...beAll];565 const aLo = Math.min(...anchors), aHi = Math.max(...anchors);566 const pad = Math.max((aHi - aLo) * 0.35, ref * 0.03);567 const lo = Math.max(0, aLo - pad), hi = aHi + pad;568 const expCurve = M.payoffCurve(legs, lo, hi).map(p => ({ x: p.s, y: p.pnl }));569 // T+0 (Taylor sur les Greeks) : valide localement — bornée à ±6 % du spot570 let t0Curve = null;571 if (S) {572 const t0Lo = Math.max(lo, S * 0.94), t0Hi = Math.min(hi, S * 1.06);573 if (t0Hi > t0Lo) t0Curve = M.pnlTodayCurve(legs, t0Lo, t0Hi, S).map(p => ({ x: p.s, y: p.pnl }));574 }575 const be = beAll.filter(b => b >= lo && b <= hi);576 const t = tokens();577 xyChart($('#payoff-wrap'), {578 series: [579 { pts: expCurve, color: t.ink, width: 2, fillZero: true },580 ...(t0Curve ? [{ pts: t0Curve, color: t.accent, width: 1.75, dash: [5, 4], scale: false }] : []),581 ],582 vlines: S ? [{ x: S, color: t.accent, label: 'spot ' + fmtPx(S) }] : [],583 marks: be.map(b => ({ x: b, y: 0, color: t.ink2 })),584 xFmt: v => fmtPx(v),585 });586 }587588 /* ------------------------------------------------------------ replay historique */589 async function runReplay() {590 if (!state.legs.length) return;591 const status = $('#replay-status');592 status.hidden = false;593 status.textContent = 'Chargement des historiques de contrats…';594 try {595 const optLegs = state.legs.filter(l => l.kind !== 's');596 const stockLeg = state.legs.find(l => l.kind === 's');597 const maxExpiry = state.legs.reduce((m, l) => l.expiry > m ? l.expiry : m, state.date);598 const hists = await Promise.all(optLegs.map(l => D.history(state.ticker, l.strike, l.expiry, l.kind)));599 const { bars: spotB } = await D.spotBars(state.ticker, state.date, maxExpiry + ' 23:59:59');600 const spotByDay = new Map(spotB.map(b => [String(b.datetime).slice(0, 10), b.close]));601602 // union des dates de marché603 const daySet = new Set([...spotByDay.keys()]);604 hists.forEach(h => h.forEach(r => daySet.add(String(r.trade_date).slice(0, 10))));605 const days = [...daySet].filter(d => d >= state.date && d <= maxExpiry).sort();606 if (!days.length) { status.textContent = 'Aucune donnée sur la période.'; return; }607608 const recByDay = hists.map(h => new Map(h.map(r => [String(r.trade_date).slice(0, 10), r])));609 const lastVal = optLegs.map(l => l.price); // report en avant si jour manquant610 const lastRec = optLegs.map(() => null);611 let lastSpot = state.spot ? state.spot.close : null;612 const series = [];613 for (const d of days) {614 if (spotByDay.has(d)) lastSpot = spotByDay.get(d);615 let value = 0, pnl = 0;616 const g = { delta: 0, gamma: 0, theta: 0, vega: 0 };617 optLegs.forEach((l, i) => {618 const rec = recByDay[i].get(d);619 if (rec) {620 const rp = M.refPrice(rec);621 if (rp.source) lastVal[i] = rp.price;622 lastRec[i] = rec;623 }624 if (d >= l.expiry && lastSpot != null) {625 lastVal[i] = M.intrinsic(l.kind, l.strike, lastSpot); // règlement à l'échéance626 }627 value += l.qty * 100 * lastVal[i];628 pnl += l.qty * 100 * (lastVal[i] - l.price);629 const rg = lastRec[i];630 if (rg && d < l.expiry) {631 g.delta += l.qty * 100 * (+rg.delta || 0);632 g.gamma += l.qty * 100 * (+rg.gamma || 0);633 g.theta += l.qty * 100 * (+rg.theta || 0);634 g.vega += l.qty * 100 * (+rg.vega || 0);635 }636 });637 if (stockLeg && lastSpot != null) {638 value += stockLeg.qty * lastSpot;639 pnl += stockLeg.qty * (lastSpot - stockLeg.price);640 g.delta += stockLeg.qty;641 }642 series.push({ date: d, value, pnl, spot: lastSpot, greeks: g });643 }644 replay = { days: series };645 status.hidden = true;646 $('#replay-body').hidden = false;647 const slider = $('#replay-slider');648 slider.max = series.length - 1;649 slider.value = series.length - 1;650 drawReplay();651 updateReplayReadout(series.length - 1);652 slider.oninput = () => { updateReplayReadout(+slider.value); drawReplay(+slider.value); };653 } catch (e) {654 status.textContent = 'Erreur replay : ' + e.message;655 toast('Erreur replay : ' + e.message, true);656 }657 }658659 function drawReplay(idx) {660 if (!replay) return;661 const t = tokens();662 const days = replay.days;663 const pts = days.map((d, i) => ({ x: i, y: d.pnl }));664 const i = idx != null ? idx : days.length - 1;665 xyChart($('#replay-wrap'), {666 series: [{ pts, color: t.accent, width: 2, fillZero: true }],667 marks: [{ x: i, y: days[i].pnl, color: t.accent, r: 5 }],668 xFmt: v => {669 const d = days[Math.max(0, Math.min(days.length - 1, Math.round(v)))];670 return d ? d.date.slice(5) : '';671 },672 onHover: v => {673 if (v == null) return;674 const k = Math.round(v);675 $('#replay-slider').value = k;676 updateReplayReadout(k);677 },678 });679 }680681 function updateReplayReadout(i) {682 const d = replay.days[i];683 if (!d) return;684 const cls = d.pnl >= 0 ? 'up' : 'dn';685 const cost = Math.abs(M.netCost(state.legs)) || 1;686 $('#replay-readout').innerHTML =687 `<span><span class="k">Date</span> <b>${d.date}</b> (${i + 1}/${replay.days.length})</span>` +688 `<span><span class="k">Spot</span> <b>${d.spot != null ? fmtPx(d.spot) : '—'}</b></span>` +689 `<span><span class="k">Valeur position</span> <b>${fmt$(d.value)}</b></span>` +690 `<span><span class="k">P&L</span> <b class="${cls}">${fmt$(d.pnl)} (${(d.pnl / cost * 100).toFixed(1)} %)</b></span>` +691 `<span><span class="k">Δ</span> <b>${d.greeks.delta.toFixed(0)}</b></span>` +692 `<span><span class="k">Θ/j</span> <b>${d.greeks.theta.toFixed(1)}</b></span>`;693 }694695 /* ------------------------------------------------------------ boot */696 function boot() {697 const shared = loadShared();698 const q = new URLSearchParams(location.search);699 state.theme = q.get('theme') || shared.theme || 'light';700 state.ticker = (q.get('ticker') || 'SPY').toUpperCase();701 state.date = q.get('date') || null;702 state.expiry = q.get('expiry') || null;703 boot.pendingPreset = q.get('preset') || null;704 boot.pendingReplay = q.get('replay') === '1';705 document.documentElement.dataset.theme = state.theme;706 $('#btn-theme').textContent = state.theme === 'dark' ? '☀︎' : '☾';707 $('#btn-theme').onclick = () => {708 state.theme = state.theme === 'dark' ? 'light' : 'dark';709 document.documentElement.dataset.theme = state.theme;710 $('#btn-theme').textContent = state.theme === 'dark' ? '☀︎' : '☾';711 saveTheme();712 renderMetrics();713 if (replay) drawReplay(+$('#replay-slider').value);714 };715 $('#ol-ticker').value = state.ticker;716 $('#ol-date').max = new Date().toISOString().slice(0, 10);717718 // autocomplete tickers719 let debounce = null;720 $('#ol-ticker').oninput = e => {721 clearTimeout(debounce);722 const v = e.target.value.trim().toUpperCase();723 if (v.length < 1) return;724 debounce = setTimeout(async () => {725 try {726 const list = await D.tickers(v);727 $('#ol-tickers').innerHTML = list.slice(0, 30).map(t => `<option value="${t}">`).join('');728 } catch (err) { /* silencieux */ }729 }, 220);730 };731 const doLoad = () => {732 const tk = $('#ol-ticker').value.trim().toUpperCase();733 if (!tk) return;734 state.ticker = tk;735 state.date = $('#ol-date').value || null;736 state.expiry = null;737 load();738 };739 $('#ol-load').onclick = doLoad;740 $('#ol-ticker').onkeydown = e => { if (e.key === 'Enter') doLoad(); };741 $('#ol-date').onchange = doLoad;742743 $('#f-delta').oninput = e => {744 state.filters.delta = +e.target.value;745 $('#f-delta-v').textContent = (+e.target.value).toFixed(2);746 renderChain();747 };748 $('#f-dist').oninput = e => {749 state.filters.dist = +e.target.value;750 $('#f-dist-v').textContent = +e.target.value >= 100 ? '∞' : e.target.value + ' %';751 renderChain();752 };753 $('#preset-select').onchange = e => { applyPreset(e.target.value); e.target.value = ''; };754 $('#legs-clear').onclick = () => { state.legs = []; afterLegChange(); };755 $('#replay-run').onclick = runReplay;756 addEventListener('resize', () => { if (state.legs.length) drawPayoff(); if (replay) drawReplay(+$('#replay-slider').value); });757758 load();759 }760761 boot();762})();763