feat(options-lab): module de calcul pur (payoff, breakevens, Greeks agrégés) + 10 tests unitaires
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 2 changed files with +310 and −0
added
tests/options-math.test.cjs
+133 −0
@@ -0,0 +1,133 @@ | ||
| 1 | +/* ============================================================================ | |
| 2 | + * HFChart Options Lab — tests unitaires du module de calcul pur | |
| 3 | + * Author : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | + * ---------------------------------------------------------------------------- | |
| 5 | + * node --test tests/ | |
| 6 | + * ==========================================================================*/ | |
| 7 | +'use strict'; | |
| 8 | +const { test } = require('node:test'); | |
| 9 | +const assert = require('node:assert/strict'); | |
| 10 | +const M = require('../web/js/options-math.js'); | |
| 11 | + | |
| 12 | +const G0 = { delta: 0, gamma: 0, theta: 0, vega: 0, rho: 0 }; | |
| 13 | +const leg = (kind, strike, qty, price, greeks) => | |
| 14 | + ({ kind, strike, qty, price, greeks: greeks || G0 }); | |
| 15 | + | |
| 16 | +test('long call — payoff à l’échéance', () => { | |
| 17 | + const legs = [leg('c', 100, 1, 5)]; | |
| 18 | + assert.equal(M.payoffAt(legs, 90), -500); // OTM : perte = prime | |
| 19 | + assert.equal(M.payoffAt(legs, 100), -500); // ATM | |
| 20 | + assert.equal(M.payoffAt(legs, 105), 0); // breakeven | |
| 21 | + assert.equal(M.payoffAt(legs, 120), 1500); // ITM | |
| 22 | + assert.deepEqual(M.breakevens(legs), [105]); | |
| 23 | + const ex = M.extremes(legs); | |
| 24 | + assert.equal(ex.maxProfit, Infinity); | |
| 25 | + assert.equal(ex.maxLoss, -500); | |
| 26 | + assert.equal(M.netCost(legs), 500); // débit | |
| 27 | +}); | |
| 28 | + | |
| 29 | +test('short put — payoff, crédit et perte max bornée à S=0', () => { | |
| 30 | + const legs = [leg('p', 50, -1, 2)]; | |
| 31 | + assert.equal(M.netCost(legs), -200); // crédit | |
| 32 | + assert.equal(M.payoffAt(legs, 60), 200); // OTM : on garde la prime | |
| 33 | + assert.equal(M.payoffAt(legs, 48), 0); // breakeven 48 | |
| 34 | + assert.equal(M.payoffAt(legs, 0), -4800); // pire cas | |
| 35 | + assert.deepEqual(M.breakevens(legs), [48]); | |
| 36 | + const ex = M.extremes(legs); | |
| 37 | + assert.equal(ex.maxProfit, 200); | |
| 38 | + assert.equal(ex.maxLoss, -4800); | |
| 39 | +}); | |
| 40 | + | |
| 41 | +test('bull call spread — bornes et breakeven exacts', () => { | |
| 42 | + const legs = [leg('c', 100, 1, 6), leg('c', 110, -1, 2)]; | |
| 43 | + assert.equal(M.netCost(legs), 400); | |
| 44 | + assert.equal(M.payoffAt(legs, 95), -400); | |
| 45 | + assert.equal(M.payoffAt(legs, 104), 0); // BE = 100 + 4 | |
| 46 | + assert.equal(M.payoffAt(legs, 110), 600); | |
| 47 | + assert.equal(M.payoffAt(legs, 150), 600); // plafonné | |
| 48 | + assert.deepEqual(M.breakevens(legs), [104]); | |
| 49 | + const ex = M.extremes(legs); | |
| 50 | + assert.equal(ex.maxProfit, 600); | |
| 51 | + assert.equal(ex.maxLoss, -400); | |
| 52 | +}); | |
| 53 | + | |
| 54 | +test('straddle long — deux breakevens', () => { | |
| 55 | + const legs = [leg('c', 100, 1, 4), leg('p', 100, 1, 3)]; | |
| 56 | + assert.deepEqual(M.breakevens(legs), [93, 107]); | |
| 57 | + const ex = M.extremes(legs); | |
| 58 | + assert.equal(ex.maxProfit, Infinity); // côté call | |
| 59 | + assert.equal(ex.maxLoss, -700); // pile sur le strike | |
| 60 | + assert.equal(M.payoffAt(legs, 100), -700); | |
| 61 | +}); | |
| 62 | + | |
| 63 | +test('iron condor — quatre jambes, profit = crédit net, perte bornée', () => { | |
| 64 | + // put 90 short / put 85 long / call 110 short / call 115 long, crédit net 2 $ | |
| 65 | + const legs = [ | |
| 66 | + leg('p', 90, -1, 1.8), leg('p', 85, 1, 0.8), | |
| 67 | + leg('c', 110, -1, 1.6), leg('c', 115, 1, 0.6), | |
| 68 | + ]; | |
| 69 | + assert.equal(+M.netCost(legs).toFixed(2), -200); // crédit 2.00 | |
| 70 | + assert.equal(+M.payoffAt(legs, 100).toFixed(6), 200); // au centre : on garde tout | |
| 71 | + assert.equal(+M.payoffAt(legs, 85).toFixed(6), -300); // aile basse : largeur 5 − crédit 2 | |
| 72 | + assert.equal(+M.payoffAt(legs, 120).toFixed(6), -300); // aile haute | |
| 73 | + const be = M.breakevens(legs); | |
| 74 | + assert.deepEqual(be, [88, 112]); // 90−2 et 110+2 | |
| 75 | + const ex = M.extremes(legs); | |
| 76 | + assert.equal(+ex.maxProfit.toFixed(6), 200); | |
| 77 | + assert.equal(+ex.maxLoss.toFixed(6), -300); | |
| 78 | +}); | |
| 79 | + | |
| 80 | +test('covered call — jambe action + call short', () => { | |
| 81 | + const legs = [ | |
| 82 | + { kind: 's', strike: 0, qty: 100, price: 95, mult: 1, greeks: G0 }, | |
| 83 | + leg('c', 100, -1, 3), | |
| 84 | + ]; | |
| 85 | + assert.equal(M.payoffAt(legs, 100), 800); // +500 action, +300 prime | |
| 86 | + assert.equal(M.payoffAt(legs, 120), 800); // plafonné au-dessus du strike | |
| 87 | + assert.equal(M.payoffAt(legs, 92), 0); // BE = 95 − 3 | |
| 88 | + assert.deepEqual(M.breakevens(legs), [92]); | |
| 89 | + const ex = M.extremes(legs); | |
| 90 | + assert.equal(ex.maxProfit, 800); | |
| 91 | + assert.equal(ex.maxLoss, -9200); // action à 0, prime gardée | |
| 92 | +}); | |
| 93 | + | |
| 94 | +test('netGreeks — agrégation signée ×100, action delta 1', () => { | |
| 95 | + const legs = [ | |
| 96 | + leg('c', 100, 2, 4, { delta: 0.55, gamma: 0.02, theta: -0.05, vega: 0.11, rho: 0.03 }), | |
| 97 | + leg('p', 95, -1, 2, { delta: -0.30, gamma: 0.015, theta: -0.04, vega: 0.09, rho: -0.02 }), | |
| 98 | + { kind: 's', strike: 0, qty: 50, price: 98, mult: 1, greeks: G0 }, | |
| 99 | + ]; | |
| 100 | + const g = M.netGreeks(legs); | |
| 101 | + assert.equal(+g.delta.toFixed(4), 2 * 100 * 0.55 - 100 * -0.30 + 50); // 190 | |
| 102 | + assert.equal(+g.gamma.toFixed(4), +(200 * 0.02 - 100 * 0.015).toFixed(4)); | |
| 103 | + assert.equal(+g.theta.toFixed(4), +(200 * -0.05 - 100 * -0.04).toFixed(4)); | |
| 104 | + assert.equal(+g.vega.toFixed(4), +(200 * 0.11 - 100 * 0.09).toFixed(4)); | |
| 105 | +}); | |
| 106 | + | |
| 107 | +test('pnlToday — Taylor : delta, gamma, theta', () => { | |
| 108 | + const legs = [leg('c', 100, 1, 5, { delta: 0.5, gamma: 0.04, theta: -0.08, vega: 0.1, rho: 0 })]; | |
| 109 | + // dS = +2 : 100·(0.5·2 + 0.5·0.04·4) = 108 | |
| 110 | + assert.equal(+M.pnlToday(legs, 102, 100).toFixed(4), 108); | |
| 111 | + // 3 jours plus tard sans bouger : 100·(−0.08·3) = −24 | |
| 112 | + assert.equal(+M.pnlToday(legs, 100, 100, { days: 3 }).toFixed(4), -24); | |
| 113 | + // jambe action : delta pur | |
| 114 | + const stock = [{ kind: 's', strike: 0, qty: 100, price: 95, mult: 1, greeks: G0 }]; | |
| 115 | + assert.equal(M.pnlToday(stock, 97, 95), 200); | |
| 116 | +}); | |
| 117 | + | |
| 118 | +test('payoffCurve — inclut les strikes comme points exacts', () => { | |
| 119 | + const legs = [leg('c', 100, 1, 6), leg('c', 110, -1, 2)]; | |
| 120 | + const curve = M.payoffCurve(legs, 80, 130, 25); | |
| 121 | + assert.ok(curve.some(p => p.s === 100)); | |
| 122 | + assert.ok(curve.some(p => p.s === 110)); | |
| 123 | + assert.ok(curve.length >= 26); | |
| 124 | + const last = curve[curve.length - 1]; | |
| 125 | + assert.equal(last.pnl, 600); | |
| 126 | +}); | |
| 127 | + | |
| 128 | +test('refPrice — mid, fallback last, illiquide', () => { | |
| 129 | + assert.deepEqual(M.refPrice({ bid: 1.0, ask: 1.2, last_price: 1.4 }), { price: 1.1, source: 'mid' }); | |
| 130 | + assert.deepEqual(M.refPrice({ bid: 0, ask: 0, last_price: 0.9 }), { price: 0.9, source: 'last' }); | |
| 131 | + assert.deepEqual(M.refPrice({ bid: 0, ask: 0.01, last_price: 0 }), { price: 0.01, source: 'mid' }); | |
| 132 | + assert.deepEqual(M.refPrice({ bid: 0, ask: 0, last_price: 0 }), { price: 0, source: null }); | |
| 133 | +}); | |
added
web/js/options-math.js
+177 −0
@@ -0,0 +1,177 @@ | ||
| 1 | +/* ============================================================================ | |
| 2 | + * HFChart Options Lab — module de calcul PUR (payoff, breakevens, Greeks) | |
| 3 | + * Author : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | + * ---------------------------------------------------------------------------- | |
| 5 | + * Aucune dépendance, aucun accès DOM/réseau : utilisable dans le navigateur | |
| 6 | + * (window.HF.optmath) et sous Node pour les tests unitaires (module.exports). | |
| 7 | + * | |
| 8 | + * Une jambe (leg) : | |
| 9 | + * { | |
| 10 | + * kind : 'c' | 'p' | 's', // call, put, ou action (stock) | |
| 11 | + * strike : number, // 0 / ignoré pour 's' | |
| 12 | + * qty : number, // signé : + long, − short (contrats ; actions pour 's') | |
| 13 | + * price : number, // prime par action (prix d'achat pour 's') | |
| 14 | + * mult : number, // 100 par défaut, 1 pour 's' | |
| 15 | + * greeks : {delta,gamma,theta,vega,rho} // par action, côté long (API HF Market Data) | |
| 16 | + * } | |
| 17 | + * Conventions : P&L en $ pour la position ; débit net positif = on paie. | |
| 18 | + * ==========================================================================*/ | |
| 19 | +(function (root) { | |
| 20 | + 'use strict'; | |
| 21 | + | |
| 22 | + const multOf = leg => (leg.mult != null ? leg.mult : (leg.kind === 's' ? 1 : 100)); | |
| 23 | + | |
| 24 | + /* Valeur intrinsèque par action à l'échéance. */ | |
| 25 | + function intrinsic(kind, strike, S) { | |
| 26 | + if (kind === 'c') return Math.max(S - strike, 0); | |
| 27 | + if (kind === 'p') return Math.max(strike - S, 0); | |
| 28 | + return S; // 's' : l'action vaut S | |
| 29 | + } | |
| 30 | + | |
| 31 | + /* P&L de la position à l'échéance pour un spot S. */ | |
| 32 | + function payoffAt(legs, S) { | |
| 33 | + let pnl = 0; | |
| 34 | + for (const leg of legs) { | |
| 35 | + pnl += leg.qty * multOf(leg) * (intrinsic(leg.kind, leg.strike, S) - leg.price); | |
| 36 | + } | |
| 37 | + return pnl; | |
| 38 | + } | |
| 39 | + | |
| 40 | + /* Coût net d'ouverture (débit > 0, crédit < 0). */ | |
| 41 | + function netCost(legs) { | |
| 42 | + let c = 0; | |
| 43 | + for (const leg of legs) c += leg.qty * multOf(leg) * leg.price; | |
| 44 | + return c; | |
| 45 | + } | |
| 46 | + | |
| 47 | + /* Greeks agrégés de la position ($ par unité de facteur). */ | |
| 48 | + function netGreeks(legs) { | |
| 49 | + const out = { delta: 0, gamma: 0, theta: 0, vega: 0, rho: 0 }; | |
| 50 | + for (const leg of legs) { | |
| 51 | + const g = leg.greeks || {}; | |
| 52 | + const k = leg.qty * multOf(leg); | |
| 53 | + out.delta += k * (leg.kind === 's' ? 1 : (g.delta || 0)); | |
| 54 | + out.gamma += k * (g.gamma || 0); | |
| 55 | + out.theta += k * (g.theta || 0); | |
| 56 | + out.vega += k * (g.vega || 0); | |
| 57 | + out.rho += k * (g.rho || 0); | |
| 58 | + } | |
| 59 | + return out; | |
| 60 | + } | |
| 61 | + | |
| 62 | + /* Points de cassure du payoff : les strikes des jambes optionnelles. */ | |
| 63 | + function kinks(legs) { | |
| 64 | + const ks = [...new Set(legs.filter(l => l.kind !== 's').map(l => l.strike))]; | |
| 65 | + ks.sort((a, b) => a - b); | |
| 66 | + return ks; | |
| 67 | + } | |
| 68 | + | |
| 69 | + /* Points d'évaluation exacts : 0, chaque strike, et un point au-delà du | |
| 70 | + * dernier strike (le payoff est linéaire par morceaux entre ces points). */ | |
| 71 | + function evalPoints(legs) { | |
| 72 | + const ks = kinks(legs); | |
| 73 | + const far = ks.length ? ks[ks.length - 1] * 1.5 + 100 : 200; | |
| 74 | + return [0, ...ks, far]; | |
| 75 | + } | |
| 76 | + | |
| 77 | + /* Pente du payoff au-delà du dernier strike ($ par $ de spot). */ | |
| 78 | + function slopeRight(legs) { | |
| 79 | + let s = 0; | |
| 80 | + for (const leg of legs) { | |
| 81 | + if (leg.kind === 'c' || leg.kind === 's') s += leg.qty * multOf(leg); | |
| 82 | + } | |
| 83 | + return s; | |
| 84 | + } | |
| 85 | + | |
| 86 | + /* Breakevens exacts (zéros du payoff à l'échéance). */ | |
| 87 | + function breakevens(legs) { | |
| 88 | + const pts = evalPoints(legs); | |
| 89 | + const out = []; | |
| 90 | + for (let i = 1; i < pts.length; i++) { | |
| 91 | + const x0 = pts[i - 1], x1 = pts[i]; | |
| 92 | + const y0 = payoffAt(legs, x0), y1 = payoffAt(legs, x1); | |
| 93 | + if (y0 === 0 && (i === 1 || payoffAt(legs, pts[i - 2]) !== 0)) out.push(x0); | |
| 94 | + if ((y0 < 0 && y1 > 0) || (y0 > 0 && y1 < 0)) { | |
| 95 | + out.push(x0 + (x1 - x0) * (-y0) / (y1 - y0)); | |
| 96 | + } else if (y1 === 0 && i === pts.length - 1) { | |
| 97 | + out.push(x1); | |
| 98 | + } | |
| 99 | + } | |
| 100 | + // au-delà du dernier point, la pente est constante : un zéro éventuel est déjà couvert | |
| 101 | + return [...new Set(out.map(v => +v.toFixed(6)))].sort((a, b) => a - b); | |
| 102 | + } | |
| 103 | + | |
| 104 | + /* Profit max / perte max à l'échéance. Infinity si non borné à droite. */ | |
| 105 | + function extremes(legs) { | |
| 106 | + const pts = evalPoints(legs); | |
| 107 | + let maxP = -Infinity, minP = Infinity; | |
| 108 | + for (const p of pts) { | |
| 109 | + const y = payoffAt(legs, p); | |
| 110 | + if (y > maxP) maxP = y; | |
| 111 | + if (y < minP) minP = y; | |
| 112 | + } | |
| 113 | + const s = slopeRight(legs); | |
| 114 | + if (s > 1e-9) maxP = Infinity; | |
| 115 | + if (s < -1e-9) minP = -Infinity; | |
| 116 | + return { maxProfit: maxP, maxLoss: minP }; | |
| 117 | + } | |
| 118 | + | |
| 119 | + /* Courbe de payoff à l'échéance sur [sMin, sMax] — insère les strikes pour | |
| 120 | + * que les cassures soient exactes. */ | |
| 121 | + function payoffCurve(legs, sMin, sMax, n) { | |
| 122 | + n = n || 160; | |
| 123 | + const xs = new Set(); | |
| 124 | + for (let i = 0; i <= n; i++) xs.add(sMin + (i / n) * (sMax - sMin)); | |
| 125 | + for (const k of kinks(legs)) if (k > sMin && k < sMax) xs.add(k); | |
| 126 | + const sorted = [...xs].sort((a, b) => a - b); | |
| 127 | + return sorted.map(s => ({ s, pnl: payoffAt(legs, s) })); | |
| 128 | + } | |
| 129 | + | |
| 130 | + /* P&L « aujourd'hui » estimé par développement de Taylor sur les Greeks : | |
| 131 | + * ΔP&L ≈ Σ qty·mult·( Δ·dS + ½Γ·dS² + Θ·jours + ν·dIV ) | |
| 132 | + * S0 = spot de référence (jour de construction), days = jours écoulés, | |
| 133 | + * dIV = variation d'IV en points (ex. +0.02). Estimation locale : fiable | |
| 134 | + * près de S0, indicative loin de S0. */ | |
| 135 | + function pnlToday(legs, S, S0, opts) { | |
| 136 | + const days = (opts && opts.days) || 0; | |
| 137 | + const dIV = (opts && opts.dIV) || 0; | |
| 138 | + const dS = S - S0; | |
| 139 | + let pnl = 0; | |
| 140 | + for (const leg of legs) { | |
| 141 | + const g = leg.greeks || {}; | |
| 142 | + const k = leg.qty * multOf(leg); | |
| 143 | + if (leg.kind === 's') { pnl += k * dS; continue; } | |
| 144 | + pnl += k * ((g.delta || 0) * dS + 0.5 * (g.gamma || 0) * dS * dS + | |
| 145 | + (g.theta || 0) * days + (g.vega || 0) * dIV * 100); | |
| 146 | + } | |
| 147 | + return pnl; | |
| 148 | + } | |
| 149 | + | |
| 150 | + function pnlTodayCurve(legs, sMin, sMax, S0, opts, n) { | |
| 151 | + n = n || 160; | |
| 152 | + const out = []; | |
| 153 | + for (let i = 0; i <= n; i++) { | |
| 154 | + const s = sMin + (i / n) * (sMax - sMin); | |
| 155 | + out.push({ s, pnl: pnlToday(legs, s, S0, opts) }); | |
| 156 | + } | |
| 157 | + return out; | |
| 158 | + } | |
| 159 | + | |
| 160 | + /* Prix de référence d'un contrat : mid (bid+ask)/2, fallback last. | |
| 161 | + * Retourne {price, source: 'mid'|'last'|null}. */ | |
| 162 | + function refPrice(rec) { | |
| 163 | + const bid = +rec.bid || 0, ask = +rec.ask || 0, last = +rec.last_price || 0; | |
| 164 | + if (bid > 0 && ask > 0 && ask >= bid) return { price: (bid + ask) / 2, source: 'mid' }; | |
| 165 | + if (ask > 0 && bid === 0) return { price: last > 0 ? last : ask, source: last > 0 ? 'last' : 'mid' }; | |
| 166 | + if (last > 0) return { price: last, source: 'last' }; | |
| 167 | + return { price: 0, source: null }; | |
| 168 | + } | |
| 169 | + | |
| 170 | + const M = { | |
| 171 | + intrinsic, payoffAt, netCost, netGreeks, kinks, breakevens, extremes, | |
| 172 | + payoffCurve, pnlToday, pnlTodayCurve, slopeRight, refPrice, multOf, | |
| 173 | + }; | |
| 174 | + | |
| 175 | + if (typeof module !== 'undefined' && module.exports) module.exports = M; | |
| 176 | + else (root.HF = root.HF || {}).optmath = M; | |
| 177 | +})(typeof window !== 'undefined' ? window : globalThis); | |
| 178 | ||