|
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&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 |
+})(); |