SPB Git

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%
31.6 KB · 763 lines javascript
Raw Blame History
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&amp;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