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%

feat(options-lab): page dédiée — chaîne interactive, builder multi-jambes + presets, payoff (échéance + T+0), replay mark-to-market réel

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 10 h ago (Aug 10, 2026) parent a55beba

Showing 4 changed files with +1,111 and −0

added web/css/options.css +234 −0
@@ -0,0 +1,234 @@
1 +/* ============================================================================
2 + * HFChart Options Lab — styles (s'appuie sur les tokens de style.css)
3 + * Author : Simon-Pierre Boucher — contact@spboucher.ai
4 + * ==========================================================================*/
5 +
6 +#options-lab { display: flex; flex-direction: column; height: 100%; }
7 +
8 +#options-lab #topbar { align-items: center; }
9 +#options-lab .ctl { display: flex; align-items: center; gap: 6px; }
10 +#options-lab .ctl label { font-size: 11px; color: var(--muted); }
11 +#options-lab .ctl input { cursor: text; }
12 +.flex-spacer { flex: 1; }
13 +.spot-chip {
14 + font-variant-numeric: tabular-nums;
15 + font-weight: 600;
16 + background: var(--page);
17 + border: 1px solid var(--border);
18 + border-radius: 999px;
19 + padding: 3px 11px;
20 +}
21 +.spot-chip:empty { display: none; }
22 +
23 +#ol-layout {
24 + display: flex;
25 + flex: 1;
26 + min-height: 0;
27 + gap: 0;
28 +}
29 +
30 +/* ------------------------------------------------------------------ chaîne */
31 +#ol-chain {
32 + flex: 1.25;
33 + min-width: 0;
34 + display: flex;
35 + flex-direction: column;
36 + background: var(--surface);
37 + border-right: 1px solid var(--border);
38 +}
39 +.expiry-tabs {
40 + display: flex;
41 + gap: 4px;
42 + overflow-x: auto;
43 + padding: 8px 10px 4px;
44 + scrollbar-width: thin;
45 + flex: none;
46 +}
47 +.expiry-tabs button {
48 + flex: none;
49 + font-size: 11.5px;
50 + padding: 3px 9px;
51 + border-radius: 999px;
52 + font-variant-numeric: tabular-nums;
53 +}
54 +.chain-filters {
55 + display: flex;
56 + align-items: center;
57 + gap: 16px;
58 + padding: 6px 12px;
59 + border-bottom: 1px solid var(--border);
60 + flex: none;
61 + font-size: 11.5px;
62 + color: var(--ink2);
63 + flex-wrap: wrap;
64 +}
65 +.chain-filters input[type="range"] { width: 110px; vertical-align: middle; }
66 +.chain-filters b { font-variant-numeric: tabular-nums; display: inline-block; min-width: 34px; }
67 +
68 +#chain-scroll { flex: 1; overflow: auto; min-height: 0; }
69 +#chain-table {
70 + border-collapse: collapse;
71 + width: 100%;
72 + font-size: 11.5px;
73 + font-variant-numeric: tabular-nums;
74 +}
75 +#chain-table th {
76 + position: sticky;
77 + top: 0;
78 + z-index: 2;
79 + background: var(--surface);
80 + color: var(--muted);
81 + font-weight: 600;
82 + padding: 5px 7px;
83 + border-bottom: 1px solid var(--border);
84 + text-align: right;
85 + white-space: nowrap;
86 +}
87 +#chain-table th.side-head { text-align: center; color: var(--ink2); border-bottom: none; }
88 +#chain-table td {
89 + padding: 3px 7px;
90 + text-align: right;
91 + border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
92 + white-space: nowrap;
93 +}
94 +#chain-table td.strike {
95 + text-align: center;
96 + font-weight: 700;
97 + background: var(--page);
98 + border-left: 1px solid var(--border);
99 + border-right: 1px solid var(--border);
100 +}
101 +#chain-table td.px { cursor: pointer; font-weight: 600; }
102 +#chain-table td.px:hover { outline: 2px solid var(--accent-ui); outline-offset: -2px; border-radius: 4px; }
103 +#chain-table tr.itm-c td.c-side { background: color-mix(in srgb, var(--up) 7%, transparent); }
104 +#chain-table tr.itm-p td.p-side { background: color-mix(in srgb, var(--dn) 7%, transparent); }
105 +#chain-table tr.spot-row td {
106 + border-top: 2px dashed var(--accent-ui);
107 +}
108 +#chain-table td.in-pos { position: relative; }
109 +#chain-table td.in-pos::before {
110 + content: "●";
111 + position: absolute;
112 + left: 2px;
113 + font-size: 8px;
114 + color: var(--accent-ui);
115 +}
116 +.empty-state { padding: 40px 20px; text-align: center; color: var(--muted); }
117 +
118 +/* menu achat/vente sur clic d'un prix */
119 +.trade-pop {
120 + position: fixed;
121 + z-index: 80;
122 + background: var(--surface);
123 + border: 1px solid var(--border);
124 + border-radius: 10px;
125 + box-shadow: 0 10px 32px rgba(0, 0, 0, 0.22);
126 + padding: 8px;
127 + display: flex;
128 + gap: 6px;
129 + align-items: center;
130 + font-size: 12px;
131 +}
132 +.trade-pop .buy { background: var(--up); color: #fff; border-color: var(--up); }
133 +.trade-pop .sell { background: var(--dn); color: #fff; border-color: var(--dn); }
134 +
135 +/* ------------------------------------------------------------------ labo */
136 +#ol-lab {
137 + flex: 1;
138 + min-width: 380px;
139 + overflow-y: auto;
140 + padding: 12px;
141 + display: flex;
142 + flex-direction: column;
143 + gap: 12px;
144 + background: var(--page);
145 +}
146 +.lab-block {
147 + background: var(--surface);
148 + border: 1px solid var(--border);
149 + border-radius: 12px;
150 + padding: 12px 14px;
151 +}
152 +.lab-block h2 { margin: 0 0 8px; font-size: 14px; }
153 +.lab-head { display: flex; align-items: center; gap: 8px; }
154 +.lab-head h2 { flex: 1; margin: 0; }
155 +
156 +#legs-list { list-style: none; margin: 8px 0 0; padding: 0; }
157 +#legs-list li {
158 + display: flex;
159 + align-items: center;
160 + gap: 8px;
161 + padding: 6px 4px;
162 + border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
163 + font-size: 12.5px;
164 + font-variant-numeric: tabular-nums;
165 +}
166 +#legs-list .side {
167 + font-weight: 700;
168 + font-size: 10.5px;
169 + padding: 2px 7px;
170 + border-radius: 999px;
171 + color: #fff;
172 +}
173 +#legs-list .side.long { background: var(--up); }
174 +#legs-list .side.short { background: var(--dn); }
175 +#legs-list .desc { flex: 1; }
176 +#legs-list .desc small { color: var(--muted); }
177 +#legs-list .qty-ctl { display: flex; align-items: center; gap: 3px; }
178 +#legs-list .qty-ctl button { padding: 1px 7px; font-size: 13px; line-height: 1.3; }
179 +#legs-list .rm { color: var(--dn); border-color: transparent; background: none; font-size: 14px; }
180 +
181 +.metrics-grid {
182 + display: grid;
183 + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
184 + gap: 8px;
185 +}
186 +.metric {
187 + background: var(--page);
188 + border: 1px solid var(--border);
189 + border-radius: 9px;
190 + padding: 7px 10px;
191 +}
192 +.metric .k { font-size: 10.5px; color: var(--muted); display: block; }
193 +.metric .v { font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; }
194 +.metric .v.up { color: var(--up); }
195 +.metric .v.dn { color: var(--dn); }
196 +
197 +.greeks-row {
198 + display: flex;
199 + gap: 14px;
200 + flex-wrap: wrap;
201 + margin-top: 10px;
202 + font-size: 12px;
203 + font-variant-numeric: tabular-nums;
204 +}
205 +.greeks-row span b { font-weight: 700; }
206 +.greeks-row .k { color: var(--muted); }
207 +
208 +.chart-box { position: relative; width: 100%; height: 260px; }
209 +.chart-box canvas { position: absolute; inset: 0; }
210 +
211 +#replay-slider { width: 100%; margin-top: 8px; cursor: pointer; }
212 +.replay-readout {
213 + display: flex;
214 + gap: 16px;
215 + flex-wrap: wrap;
216 + font-size: 12.5px;
217 + font-variant-numeric: tabular-nums;
218 + margin-top: 6px;
219 +}
220 +.replay-readout .k { color: var(--muted); }
221 +.replay-readout b.up { color: var(--up); }
222 +.replay-readout b.dn { color: var(--dn); }
223 +
224 +#options-lab #footer { font-size: 11px; }
225 +
226 +/* ------------------------------------------------------------------ mobile */
227 +@media (max-width: 900px) {
228 + #ol-layout { flex-direction: column; overflow-y: auto; }
229 + #ol-chain { border-right: none; border-bottom: 1px solid var(--border); max-height: 54vh; flex: none; }
230 + #ol-lab { min-width: 0; overflow: visible; }
231 + /* colonnes réduites : on masque OI/Vol/IV, on garde Δ + bid/ask/mid */
232 + #chain-table .xtra { display: none; }
233 + #options-lab .ctl label { display: none; }
234 +}
modified web/index.html +1 −0
@@ -22,6 +22,7 @@
22 22 <a class="provider" href="https://www.hfmarketdata.io" target="_blank" rel="noopener">
23 23 données · hfmarketdata.io
24 24 </a>
25 + <a class="provider lab-link" href="options.html">Options Lab</a>
25 26 </div>
26 27 <div id="tf-row" class="seg" role="group" aria-label="Timeframe"></div>
27 28 <select id="type-select" aria-label="Type de chart"></select>
added web/js/options-app.js +757 −0
@@ -0,0 +1,757 @@
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.ai
5 + * ==========================================================================*/
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);
12 +
13 + /* ------------------------------------------------------------ état */
14 + const state = {
15 + ticker: 'SPY',
16 + date: null, // yyyy-mm-dd (date de marché)
17 + expiry: null, // échéance sélectionnée
18 + spot: null, // {asset, date, close}
19 + expirations: [],
20 + rows: [], // chaîne de l'échéance courante
21 + 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}
26 +
27 + 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 + }
35 +
36 + 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 + }
43 +
44 + 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) + ' %' : '—');
53 +
54 + /* 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 + };
69 +
70 + /* ------------------------------------------------------------ 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 + }
109 +
110 + 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 + }
127 +
128 + function showEmpty(msg) {
129 + const el = $('#chain-empty');
130 + el.textContent = msg;
131 + el.hidden = false;
132 + }
133 +
134 + 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 + }
139 +
140 + /* ------------------------------------------------------------ é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 + }
153 +
154 + /* ------------------------------------------------------------ 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 +
172 + const posKeys = new Set(state.legs.map(l => `${l.kind}|${l.strike}|${l.expiry}`));
173 + const cols = [
174 + ['iv', 'IV', 'xtra'], ['delta', 'Δ', ''], ['open_interest', 'OI', 'xtra'],
175 + ['volume', 'Vol', 'xtra'], ['bid', 'Bid', ''], ['mid', 'Mid', 'px'], ['ask', 'Ask', ''],
176 + ];
177 + let html = `<tr><th class="side-head" colspan="${cols.length}">CALLS</th><th></th>` +
178 + `<th class="side-head" colspan="${cols.length}">PUTS</th></tr><tr>` +
179 + cols.map(c => `<th class="${c[2] === 'xtra' ? 'xtra' : ''}">${c[1]}</th>`).join('') +
180 + '<th>Strike</th>' +
181 + [...cols].reverse().map(c => `<th class="${c[2] === 'xtra' ? 'xtra' : ''}">${c[1]}</th>`).join('') +
182 + '</tr>';
183 +
184 + const cellVal = (r, key) => {
185 + if (!r) return '—';
186 + if (key === 'iv') { const iv = ((+r.bid_iv || 0) + (+r.ask_iv || 0)) / 2; return fmtIV(iv); }
187 + if (key === 'mid') { const rp = M.refPrice(r); return rp.source ? fmtPx(rp.price) + (rp.source === 'last' ? '*' : '') : '—'; }
188 + if (key === 'delta') return r.delta != null ? (+r.delta).toFixed(2) : '—';
189 + if (key === 'open_interest' || key === 'volume') return r[key] ? Math.round(r[key]).toLocaleString('fr-CA') : '0';
190 + return fmtPx(+r[key] || 0);
191 + };
192 +
193 + let spotMarked = false;
194 + for (const k of visible) {
195 + const { c, p } = byStrike.get(k);
196 + const rowCls = [];
197 + if (spot != null && k < spot) rowCls.push('itm-c');
198 + if (spot != null && k > spot) rowCls.push('itm-p');
199 + if (spot != null && !spotMarked && k > spot) { rowCls.push('spot-row'); spotMarked = true; }
200 + html += `<tr class="${rowCls.join(' ')}" data-strike="${k}">`;
201 + for (const [key, , cls] of cols) {
202 + const inPos = cls === 'px' && posKeys.has(`c|${k}|${state.expiry}`);
203 + html += `<td class="c-side ${cls === 'xtra' ? 'xtra' : ''}${cls === 'px' ? ' px' : ''}${inPos ? ' in-pos' : ''}"` +
204 + `${cls === 'px' ? ` data-cp="c" data-strike="${k}"` : ''}>${cellVal(c, key)}</td>`;
205 + }
206 + html += `<td class="strike">${fmtPx(k)}</td>`;
207 + for (const [key, , cls] of [...cols].reverse()) {
208 + const inPos = cls === 'px' && posKeys.has(`p|${k}|${state.expiry}`);
209 + html += `<td class="p-side ${cls === 'xtra' ? 'xtra' : ''}${cls === 'px' ? ' px' : ''}${inPos ? ' in-pos' : ''}"` +
210 + `${cls === 'px' ? ` data-cp="p" data-strike="${k}"` : ''}>${cellVal(p, key)}</td>`;
211 + }
212 + html += '</tr>';
213 + }
214 + const table = $('#chain-table');
215 + table.innerHTML = html;
216 + // centre la vue sur la zone ATM au premier rendu de l'échéance
217 + if (!renderChain._scrolled) {
218 + const spotRow = table.querySelector('tr.spot-row');
219 + if (spotRow) { spotRow.scrollIntoView({ block: 'center' }); renderChain._scrolled = true; }
220 + }
221 + table.onclick = e => {
222 + const td = e.target.closest('td.px');
223 + if (!td) return;
224 + const strike = +td.dataset.strike;
225 + const cp = td.dataset.cp;
226 + const rec = (byStrike.get(strike) || {})[cp];
227 + if (!rec) return;
228 + openTradePop(e.clientX, e.clientY, rec);
229 + };
230 + }
231 +
232 + /* menu Acheter / Vendre */
233 + let pop = null;
234 + function closePop() { if (pop) { pop.remove(); pop = null; } }
235 + function openTradePop(x, y, rec) {
236 + closePop();
237 + const rp = M.refPrice(rec);
238 + if (!rp.source) { toast('Contrat sans prix exploitable (illiquide)', true); return; }
239 + pop = document.createElement('div');
240 + pop.className = 'trade-pop';
241 + const label = `${rec.strike} ${rec.call_put.toUpperCase()} @ ${fmtPx(rp.price)}${rp.source === 'last' ? ' (last)' : ''}`;
242 + pop.innerHTML = `<span>${label}</span>` +
243 + '<button type="button" class="buy">Acheter +1</button>' +
244 + '<button type="button" class="sell">Vendre −1</button>';
245 + document.body.appendChild(pop);
246 + const r = pop.getBoundingClientRect();
247 + pop.style.left = Math.min(x, innerWidth - r.width - 8) + 'px';
248 + pop.style.top = Math.min(y + 8, innerHeight - r.height - 8) + 'px';
249 + pop.querySelector('.buy').onclick = () => { addLeg(rec, 1); closePop(); };
250 + pop.querySelector('.sell').onclick = () => { addLeg(rec, -1); closePop(); };
251 + setTimeout(() => document.addEventListener('click', function h(ev) {
252 + if (pop && !pop.contains(ev.target)) { closePop(); }
253 + document.removeEventListener('click', h);
254 + }), 0);
255 + }
256 +
257 + /* ------------------------------------------------------------ position */
258 + function legOf(rec, qty) {
259 + const rp = M.refPrice(rec);
260 + return {
261 + kind: rec.call_put, strike: rec.strike, expiry: rec.expiry, qty,
262 + price: rp.price, priceSrc: rp.source,
263 + greeks: { delta: +rec.delta || 0, gamma: +rec.gamma || 0, theta: +rec.theta || 0, vega: +rec.vega || 0, rho: +rec.rho || 0 },
264 + iv: ((+rec.bid_iv || 0) + (+rec.ask_iv || 0)) / 2,
265 + };
266 + }
267 +
268 + function addLeg(rec, dq) {
269 + const key = `${rec.call_put}|${rec.strike}|${rec.expiry}`;
270 + const found = state.legs.find(l => `${l.kind}|${l.strike}|${l.expiry}` === key);
271 + if (found) {
272 + found.qty += dq;
273 + if (found.qty === 0) state.legs = state.legs.filter(l => l !== found);
274 + } else {
275 + state.legs.push(legOf(rec, dq));
276 + }
277 + replay = null;
278 + renderLegs();
279 + renderChain();
280 + }
281 +
282 + function addStockLeg(qty) {
283 + if (!state.spot) { toast('Spot inconnu — impossible d’ajouter l’action', true); return; }
284 + const found = state.legs.find(l => l.kind === 's');
285 + if (found) found.qty += qty;
286 + 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 });
287 + state.legs = state.legs.filter(l => l.qty !== 0);
288 + replay = null;
289 + renderLegs();
290 + }
291 +
292 + function renderLegs() {
293 + const ul = $('#legs-list');
294 + ul.innerHTML = '';
295 + $('#legs-empty').style.display = state.legs.length ? 'none' : 'block';
296 + for (const leg of state.legs) {
297 + const li = document.createElement('li');
298 + const long = leg.qty > 0;
299 + const desc = leg.kind === 's'
300 + ? `${state.ticker} action × ${leg.qty} @ ${fmtPx(leg.price)}`
301 + : `${state.ticker} ${leg.expiry} ${fmtPx(leg.strike)} ${leg.kind.toUpperCase()} × ${leg.qty > 0 ? '+' : ''}${leg.qty} @ ${fmtPx(leg.price)}`;
302 + li.innerHTML =
303 + `<span class="side ${long ? 'long' : 'short'}">${long ? 'LONG' : 'SHORT'}</span>` +
304 + `<span class="desc">${desc} <small>${leg.priceSrc === 'last' ? 'last' : leg.priceSrc === 'close' ? 'close' : 'mid'}${leg.iv > 0 ? ' · IV ' + fmtIV(leg.iv) : ''}</small></span>` +
305 + '<span class="qty-ctl"><button type="button" class="dec">−</button><button type="button" class="inc">+</button></span>' +
306 + '<button type="button" class="rm" title="Retirer">✕</button>';
307 + li.querySelector('.inc').onclick = () => { leg.qty += (leg.kind === 's' ? 100 : 1); afterLegChange(); };
308 + li.querySelector('.dec').onclick = () => { leg.qty -= (leg.kind === 's' ? 100 : 1); afterLegChange(); };
309 + li.querySelector('.rm').onclick = () => { state.legs = state.legs.filter(l => l !== leg); afterLegChange(); };
310 + ul.appendChild(li);
311 + }
312 + renderMetrics();
313 + }
314 +
315 + function afterLegChange() {
316 + state.legs = state.legs.filter(l => l.qty !== 0);
317 + replay = null;
318 + $('#replay-body').hidden = true;
319 + renderLegs();
320 + renderChain();
321 + }
322 +
323 + /* ------------------------------------------------------------ presets */
324 + function nearestByDelta(cp, target) {
325 + let best = null, bestD = Infinity;
326 + for (const r of state.rows) {
327 + if (r.call_put !== cp) continue;
328 + if (!M.refPrice(r).source) continue;
329 + const d = Math.abs((+r.delta || 0) - target);
330 + if (d < bestD) { bestD = d; best = r; }
331 + }
332 + return best;
333 + }
334 + function nearestStrike(cp, priceTarget) {
335 + let best = null, bestD = Infinity;
336 + for (const r of state.rows) {
337 + if (r.call_put !== cp) continue;
338 + if (!M.refPrice(r).source) continue;
339 + const d = Math.abs(r.strike - priceTarget);
340 + if (d < bestD) { bestD = d; best = r; }
341 + }
342 + return best;
343 + }
344 +
345 + function applyPreset(name) {
346 + if (!state.rows.length) { toast('Chargez d’abord une chaîne', true); return; }
347 + const S = state.spot ? state.spot.close : null;
348 + if (!S) { toast('Spot inconnu — presets indisponibles', true); return; }
349 + const add = (rec, qty) => rec && addLegSilent(rec, qty);
350 + state.legs = [];
351 + switch (name) {
352 + case 'coveredcall':
353 + addStockLeg(100);
354 + add(nearestByDelta('c', 0.30), -1);
355 + break;
356 + case 'csp':
357 + add(nearestByDelta('p', -0.30), -1);
358 + break;
359 + case 'bullcall':
360 + add(nearestByDelta('c', 0.50), 1);
361 + add(nearestByDelta('c', 0.30), -1);
362 + break;
363 + case 'bearput':
364 + add(nearestByDelta('p', -0.50), 1);
365 + add(nearestByDelta('p', -0.30), -1);
366 + break;
367 + case 'ironcondor':
368 + add(nearestByDelta('p', -0.20), -1);
369 + add(nearestByDelta('p', -0.10), 1);
370 + add(nearestByDelta('c', 0.20), -1);
371 + add(nearestByDelta('c', 0.10), 1);
372 + break;
373 + case 'straddle':
374 + add(nearestStrike('c', S), 1);
375 + add(nearestStrike('p', S), 1);
376 + break;
377 + case 'strangle':
378 + add(nearestByDelta('c', 0.25), 1);
379 + add(nearestByDelta('p', -0.25), 1);
380 + break;
381 + default: return;
382 + }
383 + // dédoublonne les jambes identiques (ex. condor sur chaîne trop courte)
384 + const seen = new Map();
385 + for (const l of state.legs) {
386 + const k = `${l.kind}|${l.strike}|${l.expiry}`;
387 + if (seen.has(k)) seen.get(k).qty += l.qty;
388 + else seen.set(k, l);
389 + }
390 + state.legs = [...seen.values()].filter(l => l.qty !== 0);
391 + replay = null;
392 + renderLegs();
393 + renderChain();
394 + }
395 + function addLegSilent(rec, qty) { state.legs.push(legOf(rec, qty)); }
396 +
397 + /* ------------------------------------------------------------ métriques + payoff */
398 + function renderMetrics() {
399 + const has = state.legs.length > 0;
400 + $('#metrics-block').hidden = !has;
401 + $('#payoff-block').hidden = !has;
402 + $('#replay-block').hidden = !has;
403 + if (!has) return;
404 + const legs = state.legs;
405 + const cost = M.netCost(legs);
406 + const ex = M.extremes(legs);
407 + const be = M.breakevens(legs);
408 + const g = M.netGreeks(legs);
409 + $('#metrics').innerHTML =
410 + metric(cost >= 0 ? 'Débit net' : 'Crédit net', fmt$(Math.abs(cost)), '') +
411 + metric('Profit max', fmt$(ex.maxProfit), ex.maxProfit > 0 ? 'up' : '') +
412 + metric('Perte max', fmt$(ex.maxLoss), ex.maxLoss < 0 ? 'dn' : '') +
413 + metric('Breakeven' + (be.length > 1 ? 's' : ''), be.length ? be.map(v => fmtPx(v)).join(' · ') : '—', '');
414 + $('#greeks').innerHTML = ['delta', 'gamma', 'theta', 'vega', 'rho'].map(k =>
415 + `<span><span class="k">${{ delta: 'Δ', gamma: 'Γ', theta: 'Θ/j', vega: 'ν', rho: 'ρ' }[k]}</span> <b>${g[k].toLocaleString('fr-CA', { maximumFractionDigits: 1 })}</b></span>`
416 + ).join('');
417 + drawPayoff();
418 + }
419 + const metric = (k, v, cls) => `<div class="metric"><span class="k">${k}</span><span class="v ${cls}">${v}</span></div>`;
420 +
421 + /* ------------------------------------------------------------ mini moteur XY (payoff + replay) */
422 + function xyChart(host, cfg) {
423 + host.innerHTML = '';
424 + const canvas = document.createElement('canvas');
425 + host.appendChild(canvas);
426 + const t = tokens();
427 + const dpr = devicePixelRatio || 1;
428 + const w = host.clientWidth || 500, h = host.clientHeight || 260;
429 + canvas.width = w * dpr; canvas.height = h * dpr;
430 + canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
431 + const ctx = canvas.getContext('2d');
432 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
433 + const padL = 8, padR = 56, padT = 10, padB = 24;
434 + const pw = w - padL - padR, ph = h - padT - padB;
435 + let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
436 + for (const s of cfg.series) for (const p of s.pts) {
437 + if (p.x < x0) x0 = p.x; if (p.x > x1) x1 = p.x;
438 + if (s.scale === false) continue; // série indicative : n'influence pas l'échelle Y
439 + if (p.y < y0) y0 = p.y; if (p.y > y1) y1 = p.y;
440 + }
441 + if (!(x1 > x0)) { x0 -= 1; x1 += 1; }
442 + y0 = Math.min(y0, 0); y1 = Math.max(y1, 0);
443 + const yPad = (y1 - y0) * 0.1 || 1;
444 + y0 -= yPad; y1 += yPad;
445 + const X = v => padL + ((v - x0) / (x1 - x0)) * pw;
446 + const Y = v => padT + (1 - (v - y0) / (y1 - y0)) * ph;
447 +
448 + ctx.fillStyle = t.surface;
449 + ctx.fillRect(0, 0, w, h);
450 + // grille + labels Y
451 + ctx.font = '10.5px system-ui, sans-serif';
452 + ctx.textBaseline = 'middle';
453 + const step = niceStep((y1 - y0) / Math.max(2, ph / 44));
454 + ctx.strokeStyle = t.grid; ctx.fillStyle = t.muted; ctx.lineWidth = 1;
455 + for (let v = Math.ceil(y0 / step) * step; v <= y1; v += step) {
456 + const y = Math.round(Y(v)) + 0.5;
457 + ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(padL + pw, y); ctx.stroke();
458 + ctx.textAlign = 'left';
459 + ctx.fillText(compact(v), padL + pw + 6, y);
460 + }
461 + // ligne zéro
462 + ctx.strokeStyle = t.axis; ctx.lineWidth = 1.5;
463 + ctx.beginPath(); ctx.moveTo(padL, Math.round(Y(0)) + 0.5); ctx.lineTo(padL + pw, Math.round(Y(0)) + 0.5); ctx.stroke();
464 + // lignes verticales (spot, breakevens…)
465 + for (const vl of (cfg.vlines || [])) {
466 + if (vl.x < x0 || vl.x > x1) continue;
467 + ctx.strokeStyle = alpha(vl.color || t.muted, 0.6);
468 + ctx.setLineDash(vl.dash || [4, 4]);
469 + ctx.beginPath(); ctx.moveTo(X(vl.x), padT); ctx.lineTo(X(vl.x), padT + ph); ctx.stroke();
470 + ctx.setLineDash([]);
471 + if (vl.label) {
472 + ctx.fillStyle = vl.color || t.muted;
473 + ctx.textAlign = 'center';
474 + ctx.fillText(vl.label, X(vl.x), padT + 8);
475 + }
476 + }
477 + // séries
478 + for (const s of cfg.series) {
479 + if (s.fillZero) { // zones vert/rouge entre la courbe et 0
480 + for (const pos of [true, false]) {
481 + ctx.save();
482 + ctx.beginPath();
483 + const zy = Y(0);
484 + ctx.rect(padL, pos ? padT : zy, pw, pos ? zy - padT : padT + ph - zy);
485 + ctx.clip();
486 + ctx.beginPath();
487 + ctx.moveTo(X(s.pts[0].x), zy);
488 + for (const p of s.pts) ctx.lineTo(X(p.x), Y(p.y));
489 + ctx.lineTo(X(s.pts[s.pts.length - 1].x), zy);
490 + ctx.closePath();
491 + ctx.fillStyle = alpha(pos ? t.up : t.dn, 0.14);
492 + ctx.fill();
493 + ctx.restore();
494 + }
495 + }
496 + ctx.strokeStyle = s.color;
497 + ctx.lineWidth = s.width || 2;
498 + ctx.lineJoin = 'round';
499 + if (s.dash) ctx.setLineDash(s.dash);
500 + ctx.save();
501 + ctx.beginPath();
502 + ctx.rect(padL, padT, pw, ph);
503 + ctx.clip(); // une série hors échelle (T+0 indicative) ne déborde pas du cadre
504 + ctx.beginPath();
505 + 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); });
506 + ctx.stroke();
507 + ctx.restore();
508 + ctx.setLineDash([]);
509 + }
510 + // marqueurs (breakevens, position du slider…)
511 + for (const mk of (cfg.marks || [])) {
512 + ctx.fillStyle = mk.color || t.ink;
513 + ctx.beginPath();
514 + ctx.arc(X(mk.x), Y(mk.y), mk.r || 4, 0, Math.PI * 2);
515 + ctx.fill();
516 + ctx.strokeStyle = t.surface; ctx.lineWidth = 1.5; ctx.stroke();
517 + }
518 + // labels X (dédoublonnés — le mapping index→date peut répéter un libellé)
519 + ctx.fillStyle = t.muted; ctx.textAlign = 'center';
520 + const nx = Math.max(2, Math.floor(pw / 90));
521 + let prevLabel = null;
522 + for (let i = 0; i <= nx; i++) {
523 + const v = x0 + (i / nx) * (x1 - x0);
524 + const label = cfg.xFmt ? cfg.xFmt(v) : compact(v);
525 + if (label === prevLabel) continue;
526 + prevLabel = label;
527 + ctx.fillText(label, X(v), h - padB / 2);
528 + }
529 + // crosshair
530 + if (cfg.onHover) {
531 + canvas.style.cursor = 'crosshair';
532 + canvas.onmousemove = e => {
533 + const r = canvas.getBoundingClientRect();
534 + const v = x0 + ((e.clientX - r.left - padL) / pw) * (x1 - x0);
535 + cfg.onHover(Math.max(x0, Math.min(x1, v)));
536 + };
537 + canvas.onmouseleave = () => cfg.onHover(null);
538 + }
539 + return { X, Y };
540 + }
541 + function niceStep(raw) {
542 + if (!(raw > 0)) return 1;
543 + const p = Math.pow(10, Math.floor(Math.log10(raw)));
544 + const m = raw / p;
545 + return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * p;
546 + }
547 + const compact = v => Math.abs(v) >= 1000
548 + ? (v / 1000).toLocaleString('fr-CA', { maximumFractionDigits: 1 }) + ' k'
549 + : v.toLocaleString('fr-CA', { maximumFractionDigits: Math.abs(v) < 10 ? 2 : 0 });
550 +
551 + function drawPayoff() {
552 + const legs = state.legs;
553 + if (!legs.length) return;
554 + const S = state.spot ? state.spot.close : null;
555 + const ks = M.kinks(legs);
556 + const beAll = M.breakevens(legs);
557 + const ref = S || (ks.length ? (ks[0] + ks[ks.length - 1]) / 2 : 100);
558 + // plage X serrée sur la structure (strikes ∪ spot ∪ breakevens) + marge
559 + const anchors = [ref, ...ks, ...beAll];
560 + const aLo = Math.min(...anchors), aHi = Math.max(...anchors);
561 + const pad = Math.max((aHi - aLo) * 0.35, ref * 0.03);
562 + const lo = Math.max(0, aLo - pad), hi = aHi + pad;
563 + const expCurve = M.payoffCurve(legs, lo, hi).map(p => ({ x: p.s, y: p.pnl }));
564 + // T+0 (Taylor sur les Greeks) : valide localement — bornée à ±6 % du spot
565 + let t0Curve = null;
566 + if (S) {
567 + const t0Lo = Math.max(lo, S * 0.94), t0Hi = Math.min(hi, S * 1.06);
568 + if (t0Hi > t0Lo) t0Curve = M.pnlTodayCurve(legs, t0Lo, t0Hi, S).map(p => ({ x: p.s, y: p.pnl }));
569 + }
570 + const be = beAll.filter(b => b >= lo && b <= hi);
571 + const t = tokens();
572 + xyChart($('#payoff-wrap'), {
573 + series: [
574 + { pts: expCurve, color: t.ink, width: 2, fillZero: true },
575 + ...(t0Curve ? [{ pts: t0Curve, color: t.accent, width: 1.75, dash: [5, 4], scale: false }] : []),
576 + ],
577 + vlines: S ? [{ x: S, color: t.accent, label: 'spot ' + fmtPx(S) }] : [],
578 + marks: be.map(b => ({ x: b, y: 0, color: t.ink2 })),
579 + xFmt: v => fmtPx(v),
580 + });
581 + }
582 +
583 + /* ------------------------------------------------------------ replay historique */
584 + async function runReplay() {
585 + if (!state.legs.length) return;
586 + const status = $('#replay-status');
587 + status.hidden = false;
588 + status.textContent = 'Chargement des historiques de contrats…';
589 + try {
590 + const optLegs = state.legs.filter(l => l.kind !== 's');
591 + const stockLeg = state.legs.find(l => l.kind === 's');
592 + const maxExpiry = state.legs.reduce((m, l) => l.expiry > m ? l.expiry : m, state.date);
593 + const hists = await Promise.all(optLegs.map(l => D.history(state.ticker, l.strike, l.expiry, l.kind)));
594 + const { bars: spotB } = await D.spotBars(state.ticker, state.date, maxExpiry + ' 23:59:59');
595 + const spotByDay = new Map(spotB.map(b => [String(b.datetime).slice(0, 10), b.close]));
596 +
597 + // union des dates de marché
598 + const daySet = new Set([...spotByDay.keys()]);
599 + hists.forEach(h => h.forEach(r => daySet.add(String(r.trade_date).slice(0, 10))));
600 + const days = [...daySet].filter(d => d >= state.date && d <= maxExpiry).sort();
601 + if (!days.length) { status.textContent = 'Aucune donnée sur la période.'; return; }
602 +
603 + const recByDay = hists.map(h => new Map(h.map(r => [String(r.trade_date).slice(0, 10), r])));
604 + const lastVal = optLegs.map(l => l.price); // report en avant si jour manquant
605 + const lastRec = optLegs.map(() => null);
606 + let lastSpot = state.spot ? state.spot.close : null;
607 + const series = [];
608 + for (const d of days) {
609 + if (spotByDay.has(d)) lastSpot = spotByDay.get(d);
610 + let value = 0, pnl = 0;
611 + const g = { delta: 0, gamma: 0, theta: 0, vega: 0 };
612 + optLegs.forEach((l, i) => {
613 + const rec = recByDay[i].get(d);
614 + if (rec) {
615 + const rp = M.refPrice(rec);
616 + if (rp.source) lastVal[i] = rp.price;
617 + lastRec[i] = rec;
618 + }
619 + if (d >= l.expiry && lastSpot != null) {
620 + lastVal[i] = M.intrinsic(l.kind, l.strike, lastSpot); // règlement à l'échéance
621 + }
622 + value += l.qty * 100 * lastVal[i];
623 + pnl += l.qty * 100 * (lastVal[i] - l.price);
624 + const rg = lastRec[i];
625 + if (rg && d < l.expiry) {
626 + g.delta += l.qty * 100 * (+rg.delta || 0);
627 + g.gamma += l.qty * 100 * (+rg.gamma || 0);
628 + g.theta += l.qty * 100 * (+rg.theta || 0);
629 + g.vega += l.qty * 100 * (+rg.vega || 0);
630 + }
631 + });
632 + if (stockLeg && lastSpot != null) {
633 + value += stockLeg.qty * lastSpot;
634 + pnl += stockLeg.qty * (lastSpot - stockLeg.price);
635 + g.delta += stockLeg.qty;
636 + }
637 + series.push({ date: d, value, pnl, spot: lastSpot, greeks: g });
638 + }
639 + replay = { days: series };
640 + status.hidden = true;
641 + $('#replay-body').hidden = false;
642 + const slider = $('#replay-slider');
643 + slider.max = series.length - 1;
644 + slider.value = series.length - 1;
645 + drawReplay();
646 + updateReplayReadout(series.length - 1);
647 + slider.oninput = () => { updateReplayReadout(+slider.value); drawReplay(+slider.value); };
648 + } catch (e) {
649 + status.textContent = 'Erreur replay : ' + e.message;
650 + toast('Erreur replay : ' + e.message, true);
651 + }
652 + }
653 +
654 + function drawReplay(idx) {
655 + if (!replay) return;
656 + const t = tokens();
657 + const days = replay.days;
658 + const pts = days.map((d, i) => ({ x: i, y: d.pnl }));
659 + const i = idx != null ? idx : days.length - 1;
660 + xyChart($('#replay-wrap'), {
661 + series: [{ pts, color: t.accent, width: 2, fillZero: true }],
662 + marks: [{ x: i, y: days[i].pnl, color: t.accent, r: 5 }],
663 + xFmt: v => {
664 + const d = days[Math.max(0, Math.min(days.length - 1, Math.round(v)))];
665 + return d ? d.date.slice(5) : '';
666 + },
667 + onHover: v => {
668 + if (v == null) return;
669 + const k = Math.round(v);
670 + $('#replay-slider').value = k;
671 + updateReplayReadout(k);
672 + },
673 + });
674 + }
675 +
676 + function updateReplayReadout(i) {
677 + const d = replay.days[i];
678 + if (!d) return;
679 + const cls = d.pnl >= 0 ? 'up' : 'dn';
680 + const cost = Math.abs(M.netCost(state.legs)) || 1;
681 + $('#replay-readout').innerHTML =
682 + `<span><span class="k">Date</span> <b>${d.date}</b> (${i + 1}/${replay.days.length})</span>` +
683 + `<span><span class="k">Spot</span> <b>${d.spot != null ? fmtPx(d.spot) : '—'}</b></span>` +
684 + `<span><span class="k">Valeur position</span> <b>${fmt$(d.value)}</b></span>` +
685 + `<span><span class="k">P&amp;L</span> <b class="${cls}">${fmt$(d.pnl)} (${(d.pnl / cost * 100).toFixed(1)} %)</b></span>` +
686 + `<span><span class="k">Δ</span> <b>${d.greeks.delta.toFixed(0)}</b></span>` +
687 + `<span><span class="k">Θ/j</span> <b>${d.greeks.theta.toFixed(1)}</b></span>`;
688 + }
689 +
690 + /* ------------------------------------------------------------ boot */
691 + function boot() {
692 + const shared = loadShared();
693 + const q = new URLSearchParams(location.search);
694 + state.theme = q.get('theme') || shared.theme || 'light';
695 + state.ticker = (q.get('ticker') || 'SPY').toUpperCase();
696 + state.date = q.get('date') || null;
697 + state.expiry = q.get('expiry') || null;
698 + boot.pendingPreset = q.get('preset') || null;
699 + boot.pendingReplay = q.get('replay') === '1';
700 + document.documentElement.dataset.theme = state.theme;
701 + $('#btn-theme').textContent = state.theme === 'dark' ? '☀︎' : '☾';
702 + $('#btn-theme').onclick = () => {
703 + state.theme = state.theme === 'dark' ? 'light' : 'dark';
704 + document.documentElement.dataset.theme = state.theme;
705 + $('#btn-theme').textContent = state.theme === 'dark' ? '☀︎' : '☾';
706 + saveTheme();
707 + renderMetrics();
708 + if (replay) drawReplay(+$('#replay-slider').value);
709 + };
710 + $('#ol-ticker').value = state.ticker;
711 + $('#ol-date').max = new Date().toISOString().slice(0, 10);
712 +
713 + // autocomplete tickers
714 + let debounce = null;
715 + $('#ol-ticker').oninput = e => {
716 + clearTimeout(debounce);
717 + const v = e.target.value.trim().toUpperCase();
718 + if (v.length < 1) return;
719 + debounce = setTimeout(async () => {
720 + try {
721 + const list = await D.tickers(v);
722 + $('#ol-tickers').innerHTML = list.slice(0, 30).map(t => `<option value="${t}">`).join('');
723 + } catch (err) { /* silencieux */ }
724 + }, 220);
725 + };
726 + const doLoad = () => {
727 + const tk = $('#ol-ticker').value.trim().toUpperCase();
728 + if (!tk) return;
729 + state.ticker = tk;
730 + state.date = $('#ol-date').value || null;
731 + state.expiry = null;
732 + load();
733 + };
734 + $('#ol-load').onclick = doLoad;
735 + $('#ol-ticker').onkeydown = e => { if (e.key === 'Enter') doLoad(); };
736 + $('#ol-date').onchange = doLoad;
737 +
738 + $('#f-delta').oninput = e => {
739 + state.filters.delta = +e.target.value;
740 + $('#f-delta-v').textContent = (+e.target.value).toFixed(2);
741 + renderChain();
742 + };
743 + $('#f-dist').oninput = e => {
744 + state.filters.dist = +e.target.value;
745 + $('#f-dist-v').textContent = +e.target.value >= 100 ? '∞' : e.target.value + ' %';
746 + renderChain();
747 + };
748 + $('#preset-select').onchange = e => { applyPreset(e.target.value); e.target.value = ''; };
749 + $('#legs-clear').onclick = () => { state.legs = []; afterLegChange(); };
750 + $('#replay-run').onclick = runReplay;
751 + addEventListener('resize', () => { if (state.legs.length) drawPayoff(); if (replay) drawReplay(+$('#replay-slider').value); });
752 +
753 + load();
754 + }
755 +
756 + boot();
757 +})();
added web/options.html +119 −0
@@ -0,0 +1,119 @@
1 +<!DOCTYPE html>
2 +<!-- ==========================================================================
3 + HFChart — Options Lab : stratégies d'options sur données historiques réelles
4 + Données : API HF Market Data — https://www.hfmarketdata.io (chaînes + Greeks depuis 2010)
5 + Author : Simon-Pierre Boucher — contact@spboucher.ai
6 +=========================================================================== -->
7 +<html lang="fr" data-theme="light" data-scheme="classic">
8 +<head>
9 + <meta charset="utf-8">
10 + <meta name="viewport" content="width=device-width, initial-scale=1">
11 + <meta name="author" content="Simon-Pierre Boucher — contact@spboucher.ai">
12 + <meta name="description" content="Options Lab — construisez et analysez des stratégies d'options sur chaînes historiques réelles (Greeks, payoff, replay mark-to-market) depuis 2010.">
13 + <title>Options Lab — HFChart</title>
14 + <link rel="stylesheet" href="css/style.css">
15 + <link rel="stylesheet" href="css/options.css">
16 +</head>
17 +<body id="options-lab">
18 + <header id="topbar">
19 + <div class="brand">
20 + <span class="logo">HF</span>
21 + <strong>Options Lab</strong>
22 + <a class="provider" href="/">← Charts</a>
23 + <a class="provider" href="https://www.hfmarketdata.io" target="_blank" rel="noopener">données · hfmarketdata.io</a>
24 + </div>
25 + <div class="ctl">
26 + <label for="ol-ticker">Sous-jacent</label>
27 + <input id="ol-ticker" type="text" list="ol-tickers" placeholder="SPY" autocomplete="off"
28 + spellcheck="false" style="width:110px;text-transform:uppercase">
29 + <datalist id="ol-tickers"></datalist>
30 + </div>
31 + <div class="ctl">
32 + <label for="ol-date">Date historique</label>
33 + <input id="ol-date" type="date" min="2010-01-04">
34 + </div>
35 + <button id="ol-load" type="button" class="primary">Charger</button>
36 + <span id="ol-spot" class="spot-chip"></span>
37 + <span class="flex-spacer"></span>
38 + <button id="btn-theme" type="button" title="Thème clair / sombre">☾</button>
39 + </header>
40 +
41 + <div id="ol-layout">
42 + <!-- ===== chaîne d'options ===== -->
43 + <section id="ol-chain">
44 + <div id="ol-expiries" class="expiry-tabs" aria-label="Échéances"></div>
45 + <div class="chain-filters">
46 + <label>|Δ| max <input id="f-delta" type="range" min="0.05" max="1" step="0.05" value="1">
47 + <b id="f-delta-v">1.00</b></label>
48 + <label>± spot <input id="f-dist" type="range" min="2" max="100" step="2" value="20">
49 + <b id="f-dist-v">20 %</b></label>
50 + <span class="dim" id="chain-count"></span>
51 + </div>
52 + <div id="chain-scroll">
53 + <table id="chain-table" aria-label="Chaîne d'options"></table>
54 + <p id="chain-empty" class="empty-state" hidden></p>
55 + </div>
56 + </section>
57 +
58 + <!-- ===== labo : position, métriques, payoff, replay ===== -->
59 + <section id="ol-lab">
60 + <div class="lab-block">
61 + <div class="lab-head">
62 + <h2>Position</h2>
63 + <select id="preset-select" aria-label="Presets de stratégie">
64 + <option value="">Presets…</option>
65 + <option value="coveredcall">Covered call</option>
66 + <option value="csp">Cash-secured put</option>
67 + <option value="bullcall">Vertical — bull call spread</option>
68 + <option value="bearput">Vertical — bear put spread</option>
69 + <option value="ironcondor">Iron condor</option>
70 + <option value="straddle">Straddle</option>
71 + <option value="strangle">Strangle</option>
72 + </select>
73 + <button id="legs-clear" type="button" title="Vider la position">Vider</button>
74 + </div>
75 + <ul id="legs-list"></ul>
76 + <p id="legs-empty" class="dim">Cliquez sur un prix dans la chaîne (côté call ou put) pour ajouter une jambe, ou choisissez un preset.</p>
77 + </div>
78 +
79 + <div class="lab-block" id="metrics-block" hidden>
80 + <h2>Métriques à l'échéance</h2>
81 + <div id="metrics" class="metrics-grid"></div>
82 + <div id="greeks" class="greeks-row"></div>
83 + </div>
84 +
85 + <div class="lab-block" id="payoff-block" hidden>
86 + <h2>Payoff <span class="dim" style="font-weight:400">— P&amp;L à l'échéance et courbe T+0 (Greeks)</span></h2>
87 + <div id="payoff-wrap" class="chart-box"></div>
88 + </div>
89 +
90 + <div class="lab-block" id="replay-block" hidden>
91 + <div class="lab-head">
92 + <h2>Replay historique</h2>
93 + <button id="replay-run" type="button" class="primary">Lancer le replay</button>
94 + </div>
95 + <p class="dim">Mark-to-market réel jour par jour (prix historiques de chaque contrat, pas une simulation).</p>
96 + <div id="replay-body" hidden>
97 + <div id="replay-wrap" class="chart-box"></div>
98 + <input id="replay-slider" type="range" min="0" max="0" value="0" step="1" aria-label="Jour du replay">
99 + <div id="replay-readout" class="replay-readout"></div>
100 + </div>
101 + <p id="replay-status" class="dim" hidden></p>
102 + </div>
103 + </section>
104 + </div>
105 +
106 + <footer id="footer">
107 + <b>Outil éducatif — ceci n'est pas un conseil en investissement.</b>
108 + Les options comportent un risque de perte totale du capital ; données de fin de journée, prix mid indicatifs.
109 + · Données <a href="https://www.hfmarketdata.io" target="_blank" rel="noopener">HF Market Data</a>
110 + · Simon-Pierre Boucher — <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>
111 + </footer>
112 +
113 + <div id="toast" class="toast"></div>
114 +
115 + <script src="js/options-math.js"></script>
116 + <script src="js/options-data.js"></script>
117 + <script src="js/options-app.js"></script>
118 +</body>
119 +</html>
120