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): module de calcul pur (payoff, breakevens, Greeks agrégés) + 10 tests unitaires

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

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