@fullstackcraftllc/floe 0.0.14 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/client/FloeClient.d.ts +5 -1
- package/dist/client/FloeClient.js +54 -0
- package/dist/client/brokers/IBKRClient.d.ts +324 -0
- package/dist/client/brokers/IBKRClient.js +797 -0
- package/dist/hedgeflow/charm.d.ts +23 -0
- package/dist/hedgeflow/charm.js +113 -0
- package/dist/hedgeflow/curve.d.ts +27 -0
- package/dist/hedgeflow/curve.js +315 -0
- package/dist/hedgeflow/index.d.ts +33 -0
- package/dist/hedgeflow/index.js +52 -0
- package/dist/hedgeflow/regime.d.ts +7 -0
- package/dist/hedgeflow/regime.js +99 -0
- package/dist/hedgeflow/types.d.ts +185 -0
- package/dist/hedgeflow/types.js +2 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +19 -19
- package/dist/iv/index.d.ts +52 -0
- package/dist/iv/index.js +287 -0
- package/dist/iv/types.d.ts +40 -0
- package/dist/iv/types.js +2 -0
- package/dist/rv/index.d.ts +26 -0
- package/dist/rv/index.js +81 -0
- package/dist/rv/types.d.ts +32 -0
- package/dist/rv/types.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ExposurePerExpiry } from '../types';
|
|
2
|
+
import { CharmIntegralConfig, CharmIntegral } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Compute the charm integral from now until expiration.
|
|
5
|
+
*
|
|
6
|
+
* The charm integral represents the cumulative expected delta change
|
|
7
|
+
* from time decay alone — i.e., what happens to dealer hedging
|
|
8
|
+
* regardless of price movement. This is the "unconditional" pressure.
|
|
9
|
+
*
|
|
10
|
+
* The dollar CEX values already incorporate Black-Scholes time decay
|
|
11
|
+
* acceleration (charm ∝ 1/√T near expiry), so no additional time
|
|
12
|
+
* weighting is needed. Larger CEX values near expiry are the math
|
|
13
|
+
* doing its job, not something we need to amplify.
|
|
14
|
+
*
|
|
15
|
+
* When open interest changes intraday (detected via live OI tracking),
|
|
16
|
+
* this function should be recomputed with updated exposures to reflect
|
|
17
|
+
* the new charm landscape.
|
|
18
|
+
*
|
|
19
|
+
* @param exposures - Current per-strike exposure data (with live OI if available)
|
|
20
|
+
* @param config - Optional time step configuration
|
|
21
|
+
* @returns Charm integral analysis with cumulative curve and per-strike breakdown
|
|
22
|
+
*/
|
|
23
|
+
export declare function computeCharmIntegral(exposures: ExposurePerExpiry, config?: CharmIntegralConfig): CharmIntegral;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeCharmIntegral = computeCharmIntegral;
|
|
4
|
+
/**
|
|
5
|
+
* Compute the charm integral from now until expiration.
|
|
6
|
+
*
|
|
7
|
+
* The charm integral represents the cumulative expected delta change
|
|
8
|
+
* from time decay alone — i.e., what happens to dealer hedging
|
|
9
|
+
* regardless of price movement. This is the "unconditional" pressure.
|
|
10
|
+
*
|
|
11
|
+
* The dollar CEX values already incorporate Black-Scholes time decay
|
|
12
|
+
* acceleration (charm ∝ 1/√T near expiry), so no additional time
|
|
13
|
+
* weighting is needed. Larger CEX values near expiry are the math
|
|
14
|
+
* doing its job, not something we need to amplify.
|
|
15
|
+
*
|
|
16
|
+
* When open interest changes intraday (detected via live OI tracking),
|
|
17
|
+
* this function should be recomputed with updated exposures to reflect
|
|
18
|
+
* the new charm landscape.
|
|
19
|
+
*
|
|
20
|
+
* @param exposures - Current per-strike exposure data (with live OI if available)
|
|
21
|
+
* @param config - Optional time step configuration
|
|
22
|
+
* @returns Charm integral analysis with cumulative curve and per-strike breakdown
|
|
23
|
+
*/
|
|
24
|
+
function computeCharmIntegral(exposures, config = {}) {
|
|
25
|
+
const { timeStepMinutes = 15 } = config;
|
|
26
|
+
const spot = exposures.spotPrice;
|
|
27
|
+
const expiration = exposures.expiration;
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const msRemaining = expiration - now;
|
|
30
|
+
const minutesRemaining = Math.max(0, msRemaining / 60000);
|
|
31
|
+
// Per-strike charm breakdown
|
|
32
|
+
const totalAbsCharm = exposures.strikeExposures.reduce((sum, s) => sum + Math.abs(s.charmExposure), 0);
|
|
33
|
+
const strikeContributions = exposures.strikeExposures
|
|
34
|
+
.filter(s => s.charmExposure !== 0)
|
|
35
|
+
.map(s => ({
|
|
36
|
+
strike: s.strikePrice,
|
|
37
|
+
charmExposure: s.charmExposure,
|
|
38
|
+
fractionOfTotal: totalAbsCharm > 0
|
|
39
|
+
? Math.abs(s.charmExposure) / totalAbsCharm
|
|
40
|
+
: 0,
|
|
41
|
+
}))
|
|
42
|
+
.sort((a, b) => Math.abs(b.charmExposure) - Math.abs(a.charmExposure));
|
|
43
|
+
// Build time-bucketed charm curve
|
|
44
|
+
// The total CEX is already a per-day rate. For sub-day intervals,
|
|
45
|
+
// we scale by the fraction of the day each bucket represents.
|
|
46
|
+
const totalCEX = exposures.totalCharmExposure;
|
|
47
|
+
const buckets = [];
|
|
48
|
+
let cumulativeCEX = 0;
|
|
49
|
+
if (minutesRemaining <= 0) {
|
|
50
|
+
// Already expired
|
|
51
|
+
return {
|
|
52
|
+
spot,
|
|
53
|
+
expiration,
|
|
54
|
+
computedAt: now,
|
|
55
|
+
minutesRemaining: 0,
|
|
56
|
+
totalCharmToClose: 0,
|
|
57
|
+
direction: 'neutral',
|
|
58
|
+
buckets: [],
|
|
59
|
+
strikeContributions,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Walk from now backward toward expiry in timeStepMinutes increments.
|
|
63
|
+
// At each step, the charm exposure intensifies because the remaining
|
|
64
|
+
// options are closer to expiry.
|
|
65
|
+
//
|
|
66
|
+
// Since CEX is already the dollar exposure rate that incorporates the
|
|
67
|
+
// current time to expiry, and charm accelerates as 1/√T, we model
|
|
68
|
+
// the instantaneous charm at t minutes remaining as:
|
|
69
|
+
//
|
|
70
|
+
// CEX(t) ≈ totalCEX * √(minutesRemaining / t)
|
|
71
|
+
//
|
|
72
|
+
// This scaling comes from charm ∝ 1/√T: if current charm is computed
|
|
73
|
+
// at T minutes out, then at t < T minutes it will be larger by √(T/t).
|
|
74
|
+
//
|
|
75
|
+
// The integral of CEX(t) dt from t=minutesRemaining down to t=0
|
|
76
|
+
// gives the total expected delta change, but note the integral of
|
|
77
|
+
// 1/√t diverges — in practice, charm exposure is bounded because
|
|
78
|
+
// deep ITM/OTM options have vanishing charm. We cap at t=1 minute.
|
|
79
|
+
for (let t = minutesRemaining; t >= Math.max(1, timeStepMinutes); t -= timeStepMinutes) {
|
|
80
|
+
// Charm at this time: scale by √(minutesRemaining / t)
|
|
81
|
+
const timeScaling = Math.sqrt(minutesRemaining / t);
|
|
82
|
+
const instantCEX = totalCEX * timeScaling;
|
|
83
|
+
// Each bucket represents timeStepMinutes of elapsed time.
|
|
84
|
+
// Convert to fraction of a trading day for the integral.
|
|
85
|
+
// 6.5 hours = 390 minutes in a standard session.
|
|
86
|
+
const bucketFractionOfDay = timeStepMinutes / 390;
|
|
87
|
+
const bucketContribution = instantCEX * bucketFractionOfDay;
|
|
88
|
+
cumulativeCEX += bucketContribution;
|
|
89
|
+
buckets.push({
|
|
90
|
+
minutesRemaining: t,
|
|
91
|
+
instantaneousCEX: instantCEX,
|
|
92
|
+
cumulativeCEX,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// Determine direction
|
|
96
|
+
let direction = 'neutral';
|
|
97
|
+
if (cumulativeCEX > 0) {
|
|
98
|
+
direction = 'buying';
|
|
99
|
+
}
|
|
100
|
+
else if (cumulativeCEX < 0) {
|
|
101
|
+
direction = 'selling';
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
spot,
|
|
105
|
+
expiration,
|
|
106
|
+
computedAt: now,
|
|
107
|
+
minutesRemaining,
|
|
108
|
+
totalCharmToClose: cumulativeCEX,
|
|
109
|
+
direction,
|
|
110
|
+
buckets,
|
|
111
|
+
strikeContributions,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ExposurePerExpiry, IVSurface } from '../types';
|
|
2
|
+
import { HedgeImpulseConfig, HedgeImpulseCurve } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Compute the hedge impulse curve across a price grid.
|
|
5
|
+
*
|
|
6
|
+
* The hedge impulse H(S) at each price level S combines gamma and vanna
|
|
7
|
+
* exposures via the empirical spot-vol coupling relationship:
|
|
8
|
+
*
|
|
9
|
+
* H(S) = GEX_smoothed(S) - (k / S) * VEX_smoothed(S)
|
|
10
|
+
*
|
|
11
|
+
* where:
|
|
12
|
+
* - GEX_smoothed(S) is the kernel-smoothed gamma exposure at S
|
|
13
|
+
* - VEX_smoothed(S) is the kernel-smoothed vanna exposure at S
|
|
14
|
+
* - k is the spot-vol coupling coefficient derived from the IV surface
|
|
15
|
+
*
|
|
16
|
+
* Positive H(S) means a spot move toward S triggers dealer buying that
|
|
17
|
+
* dampens the move (mean-reversion / "wall" behavior).
|
|
18
|
+
*
|
|
19
|
+
* Negative H(S) means a spot move toward S triggers dealer selling that
|
|
20
|
+
* amplifies the move (trend acceleration / "vacuum" behavior).
|
|
21
|
+
*
|
|
22
|
+
* @param exposures - Per-strike gamma, vanna, charm exposures
|
|
23
|
+
* @param ivSurface - The IV surface for regime derivation
|
|
24
|
+
* @param config - Optional configuration for grid and kernel parameters
|
|
25
|
+
* @returns Complete hedge impulse curve analysis
|
|
26
|
+
*/
|
|
27
|
+
export declare function computeHedgeImpulseCurve(exposures: ExposurePerExpiry, ivSurface: IVSurface, config?: HedgeImpulseConfig): HedgeImpulseCurve;
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeHedgeImpulseCurve = computeHedgeImpulseCurve;
|
|
4
|
+
const regime_1 = require("./regime");
|
|
5
|
+
/**
|
|
6
|
+
* Detect the modal (most common) strike spacing from an array of strikes.
|
|
7
|
+
* Handles irregular spacing by finding the most frequent gap.
|
|
8
|
+
*/
|
|
9
|
+
function detectStrikeSpacing(strikes) {
|
|
10
|
+
if (strikes.length < 2)
|
|
11
|
+
return 1;
|
|
12
|
+
const gaps = [];
|
|
13
|
+
for (let i = 1; i < strikes.length; i++) {
|
|
14
|
+
const gap = Math.abs(strikes[i] - strikes[i - 1]);
|
|
15
|
+
if (gap > 0)
|
|
16
|
+
gaps.push(gap);
|
|
17
|
+
}
|
|
18
|
+
if (gaps.length === 0)
|
|
19
|
+
return 1;
|
|
20
|
+
// Find modal gap (most common spacing)
|
|
21
|
+
const gapCounts = new Map();
|
|
22
|
+
for (const gap of gaps) {
|
|
23
|
+
// Round to avoid floating point issues
|
|
24
|
+
const rounded = Math.round(gap * 100) / 100;
|
|
25
|
+
gapCounts.set(rounded, (gapCounts.get(rounded) || 0) + 1);
|
|
26
|
+
}
|
|
27
|
+
let modalGap = gaps[0];
|
|
28
|
+
let maxCount = 0;
|
|
29
|
+
for (const [gap, count] of gapCounts) {
|
|
30
|
+
if (count > maxCount) {
|
|
31
|
+
maxCount = count;
|
|
32
|
+
modalGap = gap;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return modalGap;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Derive the spot-vol coupling coefficient k from the IV surface.
|
|
39
|
+
*
|
|
40
|
+
* From stochastic vol models, the skew encodes the spot-vol correlation:
|
|
41
|
+
* skew ≈ rho_SV * volOfVol / atmIV
|
|
42
|
+
*
|
|
43
|
+
* The spot-vol coupling for the dealer hedge equation is:
|
|
44
|
+
* dSigma ≈ -k * (dS/S)
|
|
45
|
+
*
|
|
46
|
+
* So k = -rho_SV * volOfVol * sqrt(252) = -impliedSpotVolCorr * atmIV * sqrt(252)
|
|
47
|
+
*
|
|
48
|
+
* For equity indices this typically lands in the range 4-12.
|
|
49
|
+
*/
|
|
50
|
+
function deriveSpotVolCoupling(regimeParams) {
|
|
51
|
+
const { impliedSpotVolCorr, atmIV } = regimeParams;
|
|
52
|
+
// k = -correlation * atmIV * annualization
|
|
53
|
+
// The negative sign is because negative correlation (spot down = vol up)
|
|
54
|
+
// should produce a positive k (so that -k/S * Vanna has the right sign)
|
|
55
|
+
const k = -impliedSpotVolCorr * atmIV * Math.sqrt(252);
|
|
56
|
+
// Clamp to reasonable range (2 to 20)
|
|
57
|
+
return Math.max(2, Math.min(20, k));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Apply Gaussian kernel smoothing to map strike-space exposures
|
|
61
|
+
* into price-space at a given evaluation point.
|
|
62
|
+
*
|
|
63
|
+
* weight(K, S) = exp(-((K - S) / lambda)^2)
|
|
64
|
+
*
|
|
65
|
+
* @param strikes - Strike prices where exposures are defined
|
|
66
|
+
* @param values - Exposure values at each strike
|
|
67
|
+
* @param evalPrice - Price level to evaluate at
|
|
68
|
+
* @param lambda - Kernel width in price units
|
|
69
|
+
* @returns Smoothed exposure value at evalPrice
|
|
70
|
+
*/
|
|
71
|
+
function kernelSmooth(strikes, values, evalPrice, lambda) {
|
|
72
|
+
let weightedSum = 0;
|
|
73
|
+
let weightSum = 0;
|
|
74
|
+
for (let i = 0; i < strikes.length; i++) {
|
|
75
|
+
const dist = (strikes[i] - evalPrice) / lambda;
|
|
76
|
+
const weight = Math.exp(-(dist * dist));
|
|
77
|
+
weightedSum += values[i] * weight;
|
|
78
|
+
weightSum += weight;
|
|
79
|
+
}
|
|
80
|
+
return weightSum > 0 ? weightedSum / weightSum : 0;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Compute the hedge impulse curve across a price grid.
|
|
84
|
+
*
|
|
85
|
+
* The hedge impulse H(S) at each price level S combines gamma and vanna
|
|
86
|
+
* exposures via the empirical spot-vol coupling relationship:
|
|
87
|
+
*
|
|
88
|
+
* H(S) = GEX_smoothed(S) - (k / S) * VEX_smoothed(S)
|
|
89
|
+
*
|
|
90
|
+
* where:
|
|
91
|
+
* - GEX_smoothed(S) is the kernel-smoothed gamma exposure at S
|
|
92
|
+
* - VEX_smoothed(S) is the kernel-smoothed vanna exposure at S
|
|
93
|
+
* - k is the spot-vol coupling coefficient derived from the IV surface
|
|
94
|
+
*
|
|
95
|
+
* Positive H(S) means a spot move toward S triggers dealer buying that
|
|
96
|
+
* dampens the move (mean-reversion / "wall" behavior).
|
|
97
|
+
*
|
|
98
|
+
* Negative H(S) means a spot move toward S triggers dealer selling that
|
|
99
|
+
* amplifies the move (trend acceleration / "vacuum" behavior).
|
|
100
|
+
*
|
|
101
|
+
* @param exposures - Per-strike gamma, vanna, charm exposures
|
|
102
|
+
* @param ivSurface - The IV surface for regime derivation
|
|
103
|
+
* @param config - Optional configuration for grid and kernel parameters
|
|
104
|
+
* @returns Complete hedge impulse curve analysis
|
|
105
|
+
*/
|
|
106
|
+
function computeHedgeImpulseCurve(exposures, ivSurface, config = {}) {
|
|
107
|
+
const { rangePercent = 3, stepPercent = 0.05, kernelWidthStrikes = 2, } = config;
|
|
108
|
+
const spot = exposures.spotPrice;
|
|
109
|
+
// Derive regime params and spot-vol coupling from IV surface
|
|
110
|
+
const regimeParams = (0, regime_1.deriveRegimeParams)(ivSurface, spot);
|
|
111
|
+
const k = deriveSpotVolCoupling(regimeParams);
|
|
112
|
+
// Extract strike-space data
|
|
113
|
+
const strikes = exposures.strikeExposures.map(s => s.strikePrice);
|
|
114
|
+
const gexValues = exposures.strikeExposures.map(s => s.gammaExposure);
|
|
115
|
+
const vexValues = exposures.strikeExposures.map(s => s.vannaExposure);
|
|
116
|
+
// Detect strike spacing and compute kernel width in price units
|
|
117
|
+
const strikeSpacing = detectStrikeSpacing(strikes);
|
|
118
|
+
const lambda = kernelWidthStrikes * strikeSpacing;
|
|
119
|
+
// Build price grid
|
|
120
|
+
const gridMin = spot * (1 - rangePercent / 100);
|
|
121
|
+
const gridMax = spot * (1 + rangePercent / 100);
|
|
122
|
+
const gridStep = spot * (stepPercent / 100);
|
|
123
|
+
const curve = [];
|
|
124
|
+
for (let price = gridMin; price <= gridMax; price += gridStep) {
|
|
125
|
+
const gamma = kernelSmooth(strikes, gexValues, price, lambda);
|
|
126
|
+
const vanna = kernelSmooth(strikes, vexValues, price, lambda);
|
|
127
|
+
// H(S) = Gamma(S) - (k / S) * Vanna(S)
|
|
128
|
+
const impulse = gamma - (k / price) * vanna;
|
|
129
|
+
curve.push({ price, gamma, vanna, impulse });
|
|
130
|
+
}
|
|
131
|
+
// Compute impulse at current spot
|
|
132
|
+
const impulseAtSpot = interpolateImpulseAtPrice(curve, spot);
|
|
133
|
+
// Compute slope at current spot (dH/dS via central difference)
|
|
134
|
+
const slopeAtSpot = computeSlopeAtPrice(curve, spot);
|
|
135
|
+
// Find zero crossings
|
|
136
|
+
const zeroCrossings = findZeroCrossings(curve);
|
|
137
|
+
// Find local extrema
|
|
138
|
+
const extrema = findExtrema(curve);
|
|
139
|
+
// Compute directional asymmetry
|
|
140
|
+
const asymmetry = computeAsymmetry(curve, spot);
|
|
141
|
+
// Classify regime
|
|
142
|
+
const regime = classifyRegime(impulseAtSpot, slopeAtSpot, asymmetry, curve, spot);
|
|
143
|
+
// Find nearest attractors (positive impulse basins)
|
|
144
|
+
const basinsAbove = extrema.filter(e => e.type === 'basin' && e.price > spot);
|
|
145
|
+
const basinsBelow = extrema.filter(e => e.type === 'basin' && e.price < spot);
|
|
146
|
+
const nearestAttractorAbove = basinsAbove.length > 0
|
|
147
|
+
? basinsAbove.reduce((a, b) => a.price < b.price ? a : b).price
|
|
148
|
+
: null;
|
|
149
|
+
const nearestAttractorBelow = basinsBelow.length > 0
|
|
150
|
+
? basinsBelow.reduce((a, b) => a.price > b.price ? a : b).price
|
|
151
|
+
: null;
|
|
152
|
+
return {
|
|
153
|
+
spot,
|
|
154
|
+
expiration: exposures.expiration,
|
|
155
|
+
computedAt: Date.now(),
|
|
156
|
+
spotVolCoupling: k,
|
|
157
|
+
kernelWidth: lambda,
|
|
158
|
+
strikeSpacing,
|
|
159
|
+
curve,
|
|
160
|
+
impulseAtSpot,
|
|
161
|
+
slopeAtSpot,
|
|
162
|
+
zeroCrossings,
|
|
163
|
+
extrema,
|
|
164
|
+
asymmetry,
|
|
165
|
+
regime,
|
|
166
|
+
nearestAttractorAbove,
|
|
167
|
+
nearestAttractorBelow,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Interpolate impulse value at an arbitrary price within the curve
|
|
172
|
+
*/
|
|
173
|
+
function interpolateImpulseAtPrice(curve, price) {
|
|
174
|
+
if (curve.length === 0)
|
|
175
|
+
return 0;
|
|
176
|
+
if (price <= curve[0].price)
|
|
177
|
+
return curve[0].impulse;
|
|
178
|
+
if (price >= curve[curve.length - 1].price)
|
|
179
|
+
return curve[curve.length - 1].impulse;
|
|
180
|
+
for (let i = 0; i < curve.length - 1; i++) {
|
|
181
|
+
if (curve[i].price <= price && curve[i + 1].price >= price) {
|
|
182
|
+
const t = (price - curve[i].price) / (curve[i + 1].price - curve[i].price);
|
|
183
|
+
return curve[i].impulse + t * (curve[i + 1].impulse - curve[i].impulse);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Compute slope of the impulse curve at a given price via central difference
|
|
190
|
+
*/
|
|
191
|
+
function computeSlopeAtPrice(curve, price) {
|
|
192
|
+
if (curve.length < 3)
|
|
193
|
+
return 0;
|
|
194
|
+
// Find bracketing points
|
|
195
|
+
const step = curve[1].price - curve[0].price;
|
|
196
|
+
const above = interpolateImpulseAtPrice(curve, price + step);
|
|
197
|
+
const below = interpolateImpulseAtPrice(curve, price - step);
|
|
198
|
+
return (above - below) / (2 * step);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Find all zero crossings of the impulse curve
|
|
202
|
+
*/
|
|
203
|
+
function findZeroCrossings(curve) {
|
|
204
|
+
const crossings = [];
|
|
205
|
+
for (let i = 0; i < curve.length - 1; i++) {
|
|
206
|
+
const a = curve[i].impulse;
|
|
207
|
+
const b = curve[i + 1].impulse;
|
|
208
|
+
if (a * b < 0) {
|
|
209
|
+
// Linear interpolation for crossing price
|
|
210
|
+
const t = Math.abs(a) / (Math.abs(a) + Math.abs(b));
|
|
211
|
+
const crossPrice = curve[i].price + t * (curve[i + 1].price - curve[i].price);
|
|
212
|
+
crossings.push({
|
|
213
|
+
price: crossPrice,
|
|
214
|
+
direction: b > a ? 'rising' : 'falling',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return crossings;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Find local extrema (basins and peaks) of the impulse curve
|
|
222
|
+
*/
|
|
223
|
+
function findExtrema(curve) {
|
|
224
|
+
const extrema = [];
|
|
225
|
+
for (let i = 1; i < curve.length - 1; i++) {
|
|
226
|
+
const prev = curve[i - 1].impulse;
|
|
227
|
+
const curr = curve[i].impulse;
|
|
228
|
+
const next = curve[i + 1].impulse;
|
|
229
|
+
// Local maximum
|
|
230
|
+
if (curr > prev && curr > next && curr > 0) {
|
|
231
|
+
extrema.push({
|
|
232
|
+
price: curve[i].price,
|
|
233
|
+
impulse: curr,
|
|
234
|
+
type: 'basin', // positive max = attractor
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
// Local minimum
|
|
238
|
+
if (curr < prev && curr < next && curr < 0) {
|
|
239
|
+
extrema.push({
|
|
240
|
+
price: curve[i].price,
|
|
241
|
+
impulse: curr,
|
|
242
|
+
type: 'peak', // negative min = accelerator
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return extrema;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Compute directional asymmetry by integrating impulse above and below spot.
|
|
250
|
+
*
|
|
251
|
+
* The integration uses the trapezoidal rule over the default range of ±0.5% of spot.
|
|
252
|
+
* The side with more negative impulse is the path of least resistance.
|
|
253
|
+
*/
|
|
254
|
+
function computeAsymmetry(curve, spot, integrationRangePercent = 0.5) {
|
|
255
|
+
const rangePrice = spot * (integrationRangePercent / 100);
|
|
256
|
+
// Integrate from spot to spot + range (upside)
|
|
257
|
+
let upsideIntegral = 0;
|
|
258
|
+
let downsideIntegral = 0;
|
|
259
|
+
const step = curve.length > 1 ? curve[1].price - curve[0].price : 1;
|
|
260
|
+
for (const point of curve) {
|
|
261
|
+
if (point.price > spot && point.price <= spot + rangePrice) {
|
|
262
|
+
upsideIntegral += point.impulse * step;
|
|
263
|
+
}
|
|
264
|
+
if (point.price < spot && point.price >= spot - rangePrice) {
|
|
265
|
+
downsideIntegral += point.impulse * step;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// More negative integral = more acceleration = path of least resistance
|
|
269
|
+
let bias = 'neutral';
|
|
270
|
+
const threshold = Math.max(Math.abs(upsideIntegral), Math.abs(downsideIntegral)) * 0.1;
|
|
271
|
+
if (upsideIntegral < downsideIntegral - threshold) {
|
|
272
|
+
bias = 'up'; // Upside has more negative impulse = price gets pulled up
|
|
273
|
+
}
|
|
274
|
+
else if (downsideIntegral < upsideIntegral - threshold) {
|
|
275
|
+
bias = 'down'; // Downside has more negative impulse = price gets pulled down
|
|
276
|
+
}
|
|
277
|
+
const denominator = Math.abs(downsideIntegral) || 1e-10;
|
|
278
|
+
const asymmetryRatio = Math.abs(upsideIntegral) / denominator;
|
|
279
|
+
return {
|
|
280
|
+
upside: upsideIntegral,
|
|
281
|
+
downside: downsideIntegral,
|
|
282
|
+
integrationRangePercent,
|
|
283
|
+
bias,
|
|
284
|
+
asymmetryRatio,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Classify the current regime based on the impulse curve characteristics
|
|
289
|
+
*/
|
|
290
|
+
function classifyRegime(impulseAtSpot, slopeAtSpot, asymmetry, curve, spot) {
|
|
291
|
+
// Compute a threshold based on the curve's overall scale
|
|
292
|
+
const impulseValues = curve.map(p => Math.abs(p.impulse));
|
|
293
|
+
const meanAbsImpulse = impulseValues.reduce((a, b) => a + b, 0) / impulseValues.length;
|
|
294
|
+
if (meanAbsImpulse === 0)
|
|
295
|
+
return 'neutral';
|
|
296
|
+
const normalizedAtSpot = impulseAtSpot / meanAbsImpulse;
|
|
297
|
+
// Strong positive impulse at spot = pinned
|
|
298
|
+
if (normalizedAtSpot > 0.5) {
|
|
299
|
+
return 'pinned';
|
|
300
|
+
}
|
|
301
|
+
// Negative impulse at spot = expansion potential
|
|
302
|
+
if (normalizedAtSpot < -0.3) {
|
|
303
|
+
if (asymmetry.bias === 'up')
|
|
304
|
+
return 'squeeze-up';
|
|
305
|
+
if (asymmetry.bias === 'down')
|
|
306
|
+
return 'squeeze-down';
|
|
307
|
+
return 'expansion';
|
|
308
|
+
}
|
|
309
|
+
// Weak impulse at spot but strong asymmetry
|
|
310
|
+
if (asymmetry.bias === 'up' && asymmetry.asymmetryRatio > 1.5)
|
|
311
|
+
return 'squeeze-up';
|
|
312
|
+
if (asymmetry.bias === 'down' && asymmetry.asymmetryRatio > 1.5)
|
|
313
|
+
return 'squeeze-down';
|
|
314
|
+
return 'neutral';
|
|
315
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ExposurePerExpiry, IVSurface } from '../types';
|
|
2
|
+
import { HedgeImpulseConfig, CharmIntegralConfig, HedgeFlowAnalysis } from './types';
|
|
3
|
+
export type { MarketRegime, RegimeParams, HedgeImpulseConfig, HedgeImpulsePoint, HedgeImpulseCurve, ZeroCrossing, ImpulseExtremum, DirectionalAsymmetry, ImpulseRegime, CharmIntegralConfig, CharmBucket, CharmIntegral, HedgeFlowAnalysis, } from './types';
|
|
4
|
+
export { deriveRegimeParams, interpolateIVAtStrike } from './regime';
|
|
5
|
+
export { computeHedgeImpulseCurve } from './curve';
|
|
6
|
+
export { computeCharmIntegral } from './charm';
|
|
7
|
+
/**
|
|
8
|
+
* Compute a complete hedge flow analysis for a single expiration.
|
|
9
|
+
*
|
|
10
|
+
* This combines the hedge impulse curve (conditional: what happens if
|
|
11
|
+
* price moves) with the charm integral (unconditional: what happens
|
|
12
|
+
* from time passage alone).
|
|
13
|
+
*
|
|
14
|
+
* The two analyses are intentionally kept separate because they answer
|
|
15
|
+
* orthogonal questions:
|
|
16
|
+
*
|
|
17
|
+
* - Impulse curve: "If spot moves to price S, do dealers amplify or
|
|
18
|
+
* dampen the move?" (Left panel)
|
|
19
|
+
* - Charm integral: "If spot does nothing, where does time decay
|
|
20
|
+
* push things?" (Right panel)
|
|
21
|
+
*
|
|
22
|
+
* Both update in real-time as:
|
|
23
|
+
* - Spot price changes → impulse curve re-evaluates k and kernel positions
|
|
24
|
+
* - Option quotes change → IV surface updates → regime params change
|
|
25
|
+
* - Open interest changes → exposure recalculation → both panels update
|
|
26
|
+
*
|
|
27
|
+
* @param exposures - Per-strike gamma, vanna, charm exposures for one expiration
|
|
28
|
+
* @param ivSurface - IV surface for the same expiration (used for regime derivation)
|
|
29
|
+
* @param impulseConfig - Optional config for the impulse curve grid and kernel
|
|
30
|
+
* @param charmConfig - Optional config for the charm integral time stepping
|
|
31
|
+
* @returns Combined hedge flow analysis
|
|
32
|
+
*/
|
|
33
|
+
export declare function analyzeHedgeFlow(exposures: ExposurePerExpiry, ivSurface: IVSurface, impulseConfig?: HedgeImpulseConfig, charmConfig?: CharmIntegralConfig): HedgeFlowAnalysis;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeCharmIntegral = exports.computeHedgeImpulseCurve = exports.interpolateIVAtStrike = exports.deriveRegimeParams = void 0;
|
|
4
|
+
exports.analyzeHedgeFlow = analyzeHedgeFlow;
|
|
5
|
+
const regime_1 = require("./regime");
|
|
6
|
+
const curve_1 = require("./curve");
|
|
7
|
+
const charm_1 = require("./charm");
|
|
8
|
+
// Re-export regime functions
|
|
9
|
+
var regime_2 = require("./regime");
|
|
10
|
+
Object.defineProperty(exports, "deriveRegimeParams", { enumerable: true, get: function () { return regime_2.deriveRegimeParams; } });
|
|
11
|
+
Object.defineProperty(exports, "interpolateIVAtStrike", { enumerable: true, get: function () { return regime_2.interpolateIVAtStrike; } });
|
|
12
|
+
// Re-export computation functions
|
|
13
|
+
var curve_2 = require("./curve");
|
|
14
|
+
Object.defineProperty(exports, "computeHedgeImpulseCurve", { enumerable: true, get: function () { return curve_2.computeHedgeImpulseCurve; } });
|
|
15
|
+
var charm_2 = require("./charm");
|
|
16
|
+
Object.defineProperty(exports, "computeCharmIntegral", { enumerable: true, get: function () { return charm_2.computeCharmIntegral; } });
|
|
17
|
+
/**
|
|
18
|
+
* Compute a complete hedge flow analysis for a single expiration.
|
|
19
|
+
*
|
|
20
|
+
* This combines the hedge impulse curve (conditional: what happens if
|
|
21
|
+
* price moves) with the charm integral (unconditional: what happens
|
|
22
|
+
* from time passage alone).
|
|
23
|
+
*
|
|
24
|
+
* The two analyses are intentionally kept separate because they answer
|
|
25
|
+
* orthogonal questions:
|
|
26
|
+
*
|
|
27
|
+
* - Impulse curve: "If spot moves to price S, do dealers amplify or
|
|
28
|
+
* dampen the move?" (Left panel)
|
|
29
|
+
* - Charm integral: "If spot does nothing, where does time decay
|
|
30
|
+
* push things?" (Right panel)
|
|
31
|
+
*
|
|
32
|
+
* Both update in real-time as:
|
|
33
|
+
* - Spot price changes → impulse curve re-evaluates k and kernel positions
|
|
34
|
+
* - Option quotes change → IV surface updates → regime params change
|
|
35
|
+
* - Open interest changes → exposure recalculation → both panels update
|
|
36
|
+
*
|
|
37
|
+
* @param exposures - Per-strike gamma, vanna, charm exposures for one expiration
|
|
38
|
+
* @param ivSurface - IV surface for the same expiration (used for regime derivation)
|
|
39
|
+
* @param impulseConfig - Optional config for the impulse curve grid and kernel
|
|
40
|
+
* @param charmConfig - Optional config for the charm integral time stepping
|
|
41
|
+
* @returns Combined hedge flow analysis
|
|
42
|
+
*/
|
|
43
|
+
function analyzeHedgeFlow(exposures, ivSurface, impulseConfig = {}, charmConfig = {}) {
|
|
44
|
+
const regimeParams = (0, regime_1.deriveRegimeParams)(ivSurface, exposures.spotPrice);
|
|
45
|
+
const impulseCurve = (0, curve_1.computeHedgeImpulseCurve)(exposures, ivSurface, impulseConfig);
|
|
46
|
+
const charmIntegral = (0, charm_1.computeCharmIntegral)(exposures, charmConfig);
|
|
47
|
+
return {
|
|
48
|
+
impulseCurve,
|
|
49
|
+
charmIntegral,
|
|
50
|
+
regimeParams,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { IVSurface } from '../types';
|
|
2
|
+
import { RegimeParams } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Derive regime parameters from the IV surface
|
|
5
|
+
*/
|
|
6
|
+
export declare function deriveRegimeParams(ivSurface: IVSurface, spot: number): RegimeParams;
|
|
7
|
+
export declare function interpolateIVAtStrike(strikes: number[], ivs: number[], targetStrike: number): number;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deriveRegimeParams = deriveRegimeParams;
|
|
4
|
+
exports.interpolateIVAtStrike = interpolateIVAtStrike;
|
|
5
|
+
/**
|
|
6
|
+
* Derive regime parameters from the IV surface
|
|
7
|
+
*/
|
|
8
|
+
function deriveRegimeParams(ivSurface, spot) {
|
|
9
|
+
const { strikes, smoothedIVs } = ivSurface;
|
|
10
|
+
const atmIV = interpolateIVAtStrike(strikes, smoothedIVs, spot) / 100;
|
|
11
|
+
const skew = calculateSkewAtSpot(strikes, smoothedIVs, spot);
|
|
12
|
+
const impliedSpotVolCorr = skewToCorrelation(skew);
|
|
13
|
+
const curvature = calculateCurvatureAtSpot(strikes, smoothedIVs, spot);
|
|
14
|
+
const impliedVolOfVol = curvatureToVolOfVol(curvature, atmIV);
|
|
15
|
+
const regime = ivToRegime(atmIV);
|
|
16
|
+
const expectedDailySpotMove = atmIV / Math.sqrt(252);
|
|
17
|
+
const expectedDailyVolMove = impliedVolOfVol / Math.sqrt(252);
|
|
18
|
+
return {
|
|
19
|
+
atmIV,
|
|
20
|
+
impliedSpotVolCorr,
|
|
21
|
+
impliedVolOfVol,
|
|
22
|
+
regime,
|
|
23
|
+
expectedDailySpotMove,
|
|
24
|
+
expectedDailyVolMove,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function skewToCorrelation(skew) {
|
|
28
|
+
const SKEW_TO_CORR_SCALE = 0.15;
|
|
29
|
+
return Math.max(-0.95, Math.min(0.5, skew * SKEW_TO_CORR_SCALE));
|
|
30
|
+
}
|
|
31
|
+
function curvatureToVolOfVol(curvature, atmIV) {
|
|
32
|
+
const VOL_OF_VOL_SCALE = 2.0;
|
|
33
|
+
return Math.sqrt(Math.abs(curvature)) * VOL_OF_VOL_SCALE * atmIV;
|
|
34
|
+
}
|
|
35
|
+
function ivToRegime(atmIV) {
|
|
36
|
+
if (atmIV < 0.15)
|
|
37
|
+
return 'calm';
|
|
38
|
+
if (atmIV < 0.20)
|
|
39
|
+
return 'normal';
|
|
40
|
+
if (atmIV < 0.35)
|
|
41
|
+
return 'stressed';
|
|
42
|
+
return 'crisis';
|
|
43
|
+
}
|
|
44
|
+
function interpolateIVAtStrike(strikes, ivs, targetStrike) {
|
|
45
|
+
if (strikes.length === 0 || ivs.length === 0)
|
|
46
|
+
return 20;
|
|
47
|
+
if (strikes.length === 1)
|
|
48
|
+
return ivs[0];
|
|
49
|
+
let lower = 0;
|
|
50
|
+
let upper = strikes.length - 1;
|
|
51
|
+
for (let i = 0; i < strikes.length - 1; i++) {
|
|
52
|
+
if (strikes[i] <= targetStrike && strikes[i + 1] >= targetStrike) {
|
|
53
|
+
lower = i;
|
|
54
|
+
upper = i + 1;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (targetStrike <= strikes[0])
|
|
59
|
+
return ivs[0];
|
|
60
|
+
if (targetStrike >= strikes[strikes.length - 1])
|
|
61
|
+
return ivs[ivs.length - 1];
|
|
62
|
+
const t = (targetStrike - strikes[lower]) / (strikes[upper] - strikes[lower]);
|
|
63
|
+
return ivs[lower] + t * (ivs[upper] - ivs[lower]);
|
|
64
|
+
}
|
|
65
|
+
function calculateSkewAtSpot(strikes, ivs, spot) {
|
|
66
|
+
if (strikes.length < 2)
|
|
67
|
+
return 0;
|
|
68
|
+
let lowerIdx = 0;
|
|
69
|
+
let upperIdx = strikes.length - 1;
|
|
70
|
+
for (let i = 0; i < strikes.length - 1; i++) {
|
|
71
|
+
if (strikes[i] <= spot && strikes[i + 1] >= spot) {
|
|
72
|
+
lowerIdx = i;
|
|
73
|
+
upperIdx = i + 1;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const dIV = ivs[upperIdx] - ivs[lowerIdx];
|
|
78
|
+
const dK = strikes[upperIdx] - strikes[lowerIdx];
|
|
79
|
+
return dK > 0 ? (dIV / dK) * spot : 0;
|
|
80
|
+
}
|
|
81
|
+
function calculateCurvatureAtSpot(strikes, ivs, spot) {
|
|
82
|
+
if (strikes.length < 3)
|
|
83
|
+
return 0;
|
|
84
|
+
let centerIdx = 0;
|
|
85
|
+
for (let i = 0; i < strikes.length; i++) {
|
|
86
|
+
if (Math.abs(strikes[i] - spot) < Math.abs(strikes[centerIdx] - spot)) {
|
|
87
|
+
centerIdx = i;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (centerIdx === 0 || centerIdx === strikes.length - 1)
|
|
91
|
+
return 0;
|
|
92
|
+
const h = (strikes[centerIdx + 1] - strikes[centerIdx - 1]) / 2;
|
|
93
|
+
if (h <= 0)
|
|
94
|
+
return 0;
|
|
95
|
+
const ivMinus = ivs[centerIdx - 1];
|
|
96
|
+
const iv = ivs[centerIdx];
|
|
97
|
+
const ivPlus = ivs[centerIdx + 1];
|
|
98
|
+
return ((ivPlus - 2 * iv + ivMinus) / (h * h)) * spot * spot;
|
|
99
|
+
}
|