@hypelens/hypelens-agent-rail 0.1.1

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.
@@ -0,0 +1,68 @@
1
+ // HypeLens Module 3 — SIGNER ADAPTER over the vendored SDK (window.HLSDK).
2
+ // -----------------------------------------------------------------------------
3
+ // This is the ONLY place that produces signatures, and it does so ONLY through
4
+ // the vendored @nktkas/hyperliquid signing subset. It hand-rolls NOTHING. If
5
+ // the SDK is absent it FAILS CLOSED. Before every L1 send it re-derives the
6
+ // action hash TWICE via the SDK and asserts equality (guards against accidental
7
+ // action mutation / non-deterministic key order). Exposes window.HLX3.signer.
8
+ (function (g) {
9
+ 'use strict';
10
+ const X3 = g.HLX3 = g.HLX3 || {};
11
+
12
+ const SDK_METHODS = ['randomPrivateKey', 'addressFromPrivateKey', 'hashL1Action', 'signL1Action', 'userSignedTypedData', 'orderToWire'];
13
+ function sdk() { const s = g.HLSDK; if (!s) throw new Error('signing SDK not vendored — placement disabled'); return s; }
14
+
15
+ // SELF-TEST (runs at load): all 6 adapter methods present + hashL1Action is
16
+ // deterministic (sync string). Fail-closed — cached so ready() reflects it.
17
+ let _selfTest = null;
18
+ function selfTest() {
19
+ try {
20
+ const s = g.HLSDK;
21
+ if (!s) return (_selfTest = { ok: false, error: 'window.HLSDK is null — signing SDK not vendored' });
22
+ for (const m of SDK_METHODS) if (typeof s[m] !== 'function') return (_selfTest = { ok: false, error: 'HLSDK missing method: ' + m });
23
+ const action = { type: 'order', orders: [{ a: 0, b: true, p: '1', s: '1', r: false, t: { limit: { tif: 'Gtc' } } }], grouping: 'na' };
24
+ const h1 = s.hashL1Action(action, 1700000000000, true, null);
25
+ const h2 = s.hashL1Action(action, 1700000000000, true, null);
26
+ if (typeof h1 !== 'string' || !h1 || h1 !== h2) return (_selfTest = { ok: false, error: 'hashL1Action is not deterministic (or not a string) — refusing to enable placement' });
27
+ return (_selfTest = { ok: true, hash: h1 });
28
+ } catch (e) { return (_selfTest = { ok: false, error: 'self-test threw: ' + (e && e.message ? e.message : e) }); }
29
+ }
30
+ function ready() { return (_selfTest || selfTest()).ok; }
31
+ function lastError() { return (_selfTest || selfTest()).error || null; }
32
+
33
+ // Deterministic-hash gate: the SAME action + nonce MUST hash identically twice.
34
+ function assertDeterministicHash(action, nonce, isTestnet, vaultAddress) {
35
+ const s = sdk();
36
+ const h1 = s.hashL1Action(action, nonce, isTestnet, vaultAddress || null);
37
+ const h2 = s.hashL1Action(action, nonce, isTestnet, vaultAddress || null);
38
+ if (!h1 || h1 !== h2) throw new Error('action-hash verification FAILED (non-deterministic) — refusing to sign');
39
+ return h1;
40
+ }
41
+
42
+ // Sign an L1 (agent) action. `privateKey` comes from the vault at sign time.
43
+ // viem signs ASYNCHRONOUSLY → signL1Action returns a Promise: await it.
44
+ // Returns { signature, action, nonce, hash }.
45
+ async function signL1(privateKey, action, nonce, isTestnet, vaultAddress) {
46
+ const s = sdk();
47
+ const hash = assertDeterministicHash(action, nonce, isTestnet, vaultAddress);
48
+ const signature = await s.signL1Action(privateKey, action, nonce, isTestnet, vaultAddress || null);
49
+ if (!signature || signature.r == null || signature.s == null || signature.v == null) throw new Error('SDK returned an invalid signature');
50
+ return { signature, action, nonce, hash };
51
+ }
52
+
53
+ // Build the exact EIP-712 typed-data payload for a user-signed action (the
54
+ // MASTER wallet signs this via the page's window.ethereum bridge — the
55
+ // extension never sees the master key). Returns { domain, types, primaryType, message }.
56
+ function userTypedData(built) {
57
+ const s = sdk();
58
+ if (typeof s.userSignedTypedData === 'function') return s.userSignedTypedData(built.action, built.action.signatureChainId);
59
+ // Fallback to the exact payload the builder already assembled (types + domain
60
+ // are pinned in hl-actions.js). The SDK path is preferred when present.
61
+ return { domain: built.domain, types: built.types, primaryType: built.primaryType, message: built.action };
62
+ }
63
+
64
+ X3.signer = { ready, lastError, selfTest, signL1, userTypedData, assertDeterministicHash, addressFromPrivateKey: (pk) => sdk().addressFromPrivateKey(pk) };
65
+ // Run the self-test once at load and surface the result in the console so a
66
+ // broken/absent SDK is obvious. Placement stays fail-closed on failure.
67
+ try { const r = selfTest(); if (r.ok) console.log('[HypeLens] signing SDK self-test PASSED (hash', r.hash.slice(0, 10) + '…)'); else console.warn('[HypeLens] signing SDK self-test FAILED —', r.error); } catch (e) {}
68
+ })(typeof window !== 'undefined' ? window : globalThis);
@@ -0,0 +1,344 @@
1
+ // HypeLens view-model — the DATA-SHAPE CONTRACT + shared math the whole UI
2
+ // consumes. Loaded into BOTH the content-script world and the popup (as a
3
+ // plain <script> before their own JS), exposing `window.HLVM`.
4
+ // ---------------------------------------------------------------------
5
+ // Hero = LIQUIDATION INTELLIGENCE + SMART-MONEY POSITIONING + the
6
+ // LIQ-AWARE LEVERAGE tool (keep your liq price clear of the walls big
7
+ // books target). Funding is a one-line footnote.
8
+ //
9
+ // smartMoney + liqClusters come from a backend-precomputed JSON (see
10
+ // worker/aggregate-intel.mjs) fetched by background.js. When that JSON is
11
+ // unavailable the UI falls back to clearly-labelled PLACEHOLDER data.
12
+ // Funding is always live from the HL info row.
13
+ //
14
+ // ┌── CONTRACT (HLVM.buildViewModel -> this shape) ────────────────────┐
15
+ // │ { │
16
+ // │ coin, markPx, maxLeverage, mmf, // mmf = maint margin frac │
17
+ // │ smartMoney:{ side, pctShort, netUsd, nWallets, nProfitable, │
18
+ // │ recentEntries:[{addr,side,sizeUsd,liqPx,roi, │
19
+ // │ pnlLabel,agoMin}], source }, │
20
+ // │ liq:{ clusters:[{price,sizeUsd,side,distPct}], nearest, │
21
+ // │ totalBelowUsd, totalAboveUsd, source }, │
22
+ // │ funding:{ apr, perDayPer1k, fundingHr, side, premiumPct, source},│
23
+ // │ isHyperp, placeholder │
24
+ // │ } │
25
+ // │ Backend JSON per coin: { markPx, smartMoney:{side,pctShort,netUsd, │
26
+ // │ nWallets,nProfitable,topEntries:[...]}, liqClusters:[{price, │
27
+ // │ sizeUsd,side}] } — see worker/aggregate-intel.mjs. │
28
+ // └────────────────────────────────────────────────────────────────────┘
29
+
30
+ (function (g) {
31
+ 'use strict';
32
+
33
+ // ---- formatting ----
34
+ function moneyPerDayPer1k(fundingHr) { return fundingHr == null ? null : fundingHr * 24 * 1000; }
35
+ function fmtMoney(n) {
36
+ if (n == null || isNaN(n)) return '—';
37
+ const v = Math.abs(n);
38
+ if (v >= 100) return '$' + v.toFixed(0);
39
+ if (v >= 10) return '$' + v.toFixed(1);
40
+ return '$' + v.toFixed(2);
41
+ }
42
+ function fmtUsd(n) {
43
+ if (n == null || isNaN(n)) return '—';
44
+ const abs = Math.abs(n), s = n < 0 ? '-' : '';
45
+ if (abs >= 1e9) return s + '$' + (abs / 1e9).toFixed(2) + 'B';
46
+ if (abs >= 1e6) return s + '$' + (abs / 1e6).toFixed(1) + 'M';
47
+ if (abs >= 1e3) return s + '$' + (abs / 1e3).toFixed(1) + 'K';
48
+ return s + '$' + abs.toFixed(0);
49
+ }
50
+ function fmtApr(n) { return n == null || isNaN(n) ? '—' : (n > 0 ? '+' : '') + Number(n).toFixed(1) + '%'; }
51
+ function fmtPrice(p) {
52
+ if (p == null || isNaN(p)) return '—';
53
+ const v = Math.abs(p);
54
+ if (v >= 1000) return '$' + p.toFixed(0);
55
+ if (v >= 1) return '$' + p.toFixed(2);
56
+ return '$' + p.toFixed(4);
57
+ }
58
+ function fmtPrem(n) { return n == null ? '—' : (n > 0 ? '+' : '') + n.toFixed(3) + '%'; }
59
+ function signClass(n) { return n == null ? '' : n > 0 ? 'pos' : n < 0 ? 'neg' : ''; }
60
+ function shortAddr(a) { if (!a) return '0x…'; return a.length > 12 ? a.slice(0, 6) + '…' + a.slice(-4) : a; }
61
+ function agoLabel(min) {
62
+ if (min == null) return '';
63
+ if (min < 60) return Math.round(min) + 'm ago';
64
+ const h = min / 60;
65
+ if (h < 24) return h.toFixed(h < 10 ? 1 : 0) + 'h ago';
66
+ return Math.round(h / 24) + 'd ago';
67
+ }
68
+
69
+ // ========================================================================
70
+ // LIQ-AWARE LEVERAGE math (the killer feature). Pure functions, shared.
71
+ // ========================================================================
72
+ // Maintenance-margin fraction. HL doesn't cheaply expose the tiered mmf,
73
+ // so we approximate mmf ≈ 1/(2·maxLeverage) (a standard first-pass; refine
74
+ // when meta exposes the margin table). Used only to place the liq marker,
75
+ // never to execute anything.
76
+ function maintMarginFraction(maxLeverage) {
77
+ return maxLeverage && maxLeverage > 0 ? 1 / (2 * maxLeverage) : 0.05;
78
+ }
79
+ // Liquidation price at entry E, leverage L, direction, maint-margin mmf.
80
+ // long ≈ E·(1 − 1/L + mmf) short ≈ E·(1 + 1/L − mmf)
81
+ // (operator-specified approximation; mark used as entry proxy.)
82
+ function liqPrice(entry, leverage, dir, mmf) {
83
+ if (!entry || !leverage) return null;
84
+ return dir === 'short'
85
+ ? entry * (1 + 1 / leverage - mmf)
86
+ : entry * (1 - 1 / leverage + mmf);
87
+ }
88
+ // A long's liq sits BELOW mark → it can be hunted into LONG-liq walls;
89
+ // a short's liq sits ABOVE mark → hunted into SHORT-liq walls. Return the
90
+ // biggest cluster within `band` (default ±1.5%) of the liq price, else null.
91
+ function huntRiskCluster(liqPx, clusters, dir, band) {
92
+ band = band || 0.015;
93
+ if (liqPx == null || !clusters) return null;
94
+ const rel = clusters.filter((c) => (dir === 'long' ? c.side === 'long' : c.side === 'short'));
95
+ let hit = null;
96
+ for (const c of rel) {
97
+ if (Math.abs(c.price - liqPx) / liqPx <= band) {
98
+ if (!hit || c.sizeUsd > hit.sizeUsd) hit = c;
99
+ }
100
+ }
101
+ return hit;
102
+ }
103
+ // Descriptive helper: the highest leverage AT OR BELOW the user's current
104
+ // at which the computed liq price sits clear of every cluster. This DESCRIBES
105
+ // the data ("leverage where your liq sits clear of walls: ≤Nx") — it is NOT a
106
+ // recommendation to trade at that leverage. Scans currentL → 1.
107
+ function suggestClearLeverage(entry, dir, mmf, clusters, currentL, maxL) {
108
+ // contract: AT OR BELOW current. currentL 0/negative means "nothing below" —
109
+ // NOT "scan from max" (`0` is falsy; `currentL || maxL` violated the contract).
110
+ if (currentL != null && currentL < 1) return null;
111
+ const top = Math.min(Math.floor(currentL != null ? currentL : (maxL || 1)), Math.floor(maxL || 50));
112
+ for (let L = top; L >= 1; L--) {
113
+ const lp = liqPrice(entry, L, dir, mmf);
114
+ if (!huntRiskCluster(lp, clusters, dir)) return { lev: L, liqPx: lp };
115
+ }
116
+ return null;
117
+ }
118
+ // Full evaluation for a given user input.
119
+ function evalLeverage(vm, input) {
120
+ if (!vm || !vm.markPx) return null;
121
+ const dir = input.dir === 'short' ? 'short' : 'long';
122
+ const L = Math.max(1, Math.min(Number(input.leverage) || 1, vm.maxLeverage || 50));
123
+ const entry = vm.markPx;
124
+ const lp = liqPrice(entry, L, dir, vm.mmf);
125
+ const hit = huntRiskCluster(lp, vm.liq.clusters, dir);
126
+ const clear = hit ? suggestClearLeverage(entry, dir, vm.mmf, vm.liq.clusters, L - 1, vm.maxLeverage) : null;
127
+ return {
128
+ dir, leverage: L, sizeUsd: Number(input.sizeUsd) || 1000,
129
+ margin: input.margin === 'cross' ? 'cross' : 'isolated',
130
+ liqPx: lp,
131
+ liqDistPct: entry ? ((lp - entry) / entry) * 100 : null,
132
+ cluster: hit, // cluster the liq sits inside, or null
133
+ inWall: Boolean(hit), // liq lands inside a crowded cluster
134
+ suggest: clear // { lev, liqPx } at-or-below current that sits clear, or null
135
+ };
136
+ }
137
+
138
+ // ---- funding leg (LIVE) ----
139
+ function fundingLeg(row) {
140
+ if (!row) return null;
141
+ return {
142
+ apr: row.aprPct,
143
+ perDayPer1k: moneyPerDayPer1k(row.fundingHr),
144
+ fundingHr: row.fundingHr,
145
+ side: row.fundingHr == null || row.fundingHr === 0 ? 'funding flat'
146
+ : row.fundingHr > 0 ? 'longs pay shorts' : 'shorts pay longs',
147
+ premiumPct: row.premiumPct,
148
+ source: 'live'
149
+ };
150
+ }
151
+
152
+ // ---- normalize REAL whale intel (from background getCoinIntel) ----
153
+ function normSmart(sm) {
154
+ return {
155
+ side: sm.side || (sm.pctShort >= 55 ? 'short' : sm.pctShort <= 45 ? 'long' : 'mixed'),
156
+ pctShort: sm.pctShort, netUsd: sm.netUsd,
157
+ nWallets: sm.nWallets, nProfitable: sm.nProfitable != null ? sm.nProfitable : null,
158
+ source: sm.source || 'live' // 'live' (crawl) or 'sample' (bundled snapshot)
159
+ };
160
+ }
161
+ function normLiq(wallsRaw, markPx) {
162
+ const clusters = (wallsRaw || []).map((c) => ({
163
+ price: c.price, sizeUsd: c.sizeUsd, side: c.side,
164
+ distPct: markPx ? ((c.price - markPx) / markPx) * 100 : null
165
+ })).sort((a, b) => b.price - a.price);
166
+ return finishLiq(clusters);
167
+ }
168
+ function finishLiq(clusters) {
169
+ const nearest = clusters.slice().sort((a, b) => Math.abs(a.distPct || 1e9) - Math.abs(b.distPct || 1e9))[0] || null;
170
+ const totalBelowUsd = clusters.filter((c) => c.side === 'long').reduce((a, c) => a + c.sizeUsd, 0);
171
+ const totalAboveUsd = clusters.filter((c) => c.side === 'short').reduce((a, c) => a + c.sizeUsd, 0);
172
+ return { clusters, nearest, totalBelowUsd, totalAboveUsd, source: 'live' };
173
+ }
174
+ const LOADING_LIQ = { clusters: [], nearest: null, totalBelowUsd: 0, totalAboveUsd: 0, source: 'loading' };
175
+ const LOADING_SM = { side: 'mixed', pctShort: 50, netUsd: 0, nWallets: 0, nProfitable: 0, source: 'loading' };
176
+
177
+ // ---- the one function the UI calls ----
178
+ // opts: { coin, row (HL info row), intel (REAL whale intel from
179
+ // getCoinIntel: { loading, walls:[{price,sizeUsd,side}], smartMoney }) }
180
+ function buildViewModel(opts) {
181
+ const coin = (opts.coin || '').toUpperCase();
182
+ const row = opts.row || null;
183
+ const intel = opts.intel || null;
184
+ const markPx = row ? row.markPx : null;
185
+ const maxLeverage = row ? row.maxLeverage : 50;
186
+ let smartMoney = LOADING_SM, liq = LOADING_LIQ, positions = [];
187
+ if (intel && !intel.loading && intel.smartMoney) {
188
+ smartMoney = normSmart(intel.smartMoney);
189
+ liq = normLiq(intel.walls || [], markPx);
190
+ positions = (intel.positions || []).map((p) => ({ price: p.price, sizeUsd: p.sizeUsd, side: p.side, addr: p.addr, pnl: p.pnl }));
191
+ }
192
+ // REAL liquidation LEVELS for the VPVR-style profile — populated even while
193
+ // the live whale crawl is still running (bundled snapshot fallback), so the
194
+ // profile renders instantly. Each: { price(=liqPx), sizeUsd(=notional), side }.
195
+ const liqLevels = (intel && Array.isArray(intel.levels))
196
+ ? intel.levels.map((l) => ({ price: l.price, sizeUsd: l.sizeUsd, side: l.side === 'long' ? 'long' : 'short' }))
197
+ : [];
198
+ const funding = fundingLeg(row);
199
+ return {
200
+ coin, markPx, maxLeverage, mmf: maintMarginFraction(maxLeverage),
201
+ dataAsOf: Date.now(), smartMoney, liq, positions, liqLevels,
202
+ liqLevelsSource: intel ? (intel.levelsSource || null) : null, funding,
203
+ // STALENESS HONESTY (v0.21.1): where the levels came from + how fresh —
204
+ // drives the chart-foot badge and verdict-confidence degradation.
205
+ levelsMeta: intel ? {
206
+ source: intel.levelsSource || null,
207
+ bundleUpdated: intel.bundleUpdated || null,
208
+ bundleStale: Boolean(intel.bundleStale),
209
+ coveragePct: intel.coveragePct != null ? intel.coveragePct : null, // feed: REAL per-coin % of OI
210
+ feedUpdated: intel.feedUpdated || null,
211
+ crawl: intel.crawl || null
212
+ } : null,
213
+ oiNtl: row ? row.oiNtl : null, dayNtlVlm: row ? row.dayNtlVlm : null,
214
+ isHyperp: row ? Boolean(row.isHyperp) : false, loading: liq.source === 'loading'
215
+ };
216
+ }
217
+
218
+ // ========================================================================
219
+ // VOLATILITY (honest, computed from candles — never a fake "% liquidation").
220
+ // ========================================================================
221
+ // Typical 1-day move as a fraction (0.0136 = 1.36%) from candle log returns.
222
+ function dailyMovePct(candles, interval) {
223
+ if (!candles || candles.length < 3) return null;
224
+ const rets = [];
225
+ for (let i = 1; i < candles.length; i++) { const a = candles[i - 1].c, b = candles[i].c; if (a > 0 && b > 0) rets.push(Math.log(b / a)); }
226
+ if (rets.length < 2) return null;
227
+ const mu = rets.reduce((x, y) => x + y, 0) / rets.length;
228
+ const sd = Math.sqrt(rets.reduce((x, y) => x + (y - mu) * (y - mu), 0) / rets.length);
229
+ const perDay = interval === '15m' ? 96 : interval === '4h' ? 6 : interval === '1d' ? 1 : 24; // default 1h
230
+ return sd * Math.sqrt(perDay);
231
+ }
232
+ // How many typical daily moves a price level sits from mark.
233
+ function volDistance(level, mark, dmp) { if (!level || !mark || !dmp) return null; return Math.abs(level - mark) / mark / dmp; }
234
+ // Risk color from vol-distance: RED ≤1 move · ORANGE ≤2.5 · GREEN beyond.
235
+ function volColor(d) { if (d == null) return 'green'; return d <= 1.0 ? 'red' : d <= 2.5 ? 'orange' : 'green'; }
236
+ function erf(x) { const t = 1 / (1 + 0.3275911 * Math.abs(x)); const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x); return x >= 0 ? y : -y; }
237
+ // Barrier-touch approximation ~2·(1−Φ(d)) — VOLATILITY ESTIMATE, NOT a
238
+ // prediction, and NEVER to be labelled "liquidation chance" as fact.
239
+ function reachEstimate(d) { if (d == null) return null; const Phi = (x) => 0.5 * (1 + erf(x / Math.SQRT2)); return Math.max(0, Math.min(1, 2 * (1 - Phi(d)))); }
240
+
241
+ // ========================================================================
242
+ // LIQUIDATION CASCADE ("gravity") — the PREDICTIVE layer. Only possible
243
+ // because HL is on-chain and we have REAL per-wallet liq prices + notionals:
244
+ // price entering a cluster forces those liquidations → forced market orders
245
+ // push price further → can reach the NEXT cluster → chain reaction. This is a
246
+ // MODEL: the impact coefficient is an estimate, never present terminalPx as
247
+ // certain. Coinglass/Hyblock can't do this from estimated data.
248
+ // ========================================================================
249
+ const CASCADE_K = 0.6; // impact coefficient (TUNABLE): dumping N notional
250
+ // moves price ~ k·N / marketDepth. Conservative.
251
+ const CASCADE_MAX_STEP = 0.06; // clamp any single cluster's impact to ≤6% (a lone
252
+ // huge wall can't teleport price across the book).
253
+ const CASCADE_BAND = 0.35; // only consider clusters within ±35% of mark.
254
+ function cascadeDepth(vm) {
255
+ if (vm.oiNtl && vm.oiNtl > 0) return { depth: vm.oiNtl, source: 'oi' }; // open interest USD — best proxy
256
+ if (vm.dayNtlVlm && vm.dayNtlVlm > 0) return { depth: vm.dayNtlVlm, source: 'vlm' }; // 24h volume USD fallback
257
+ // last-resort proxy: 4×Σ(tracked liq). OVERSTATES impact by 1/(4·coverage) when
258
+ // tracked liqs are a thin slice of true OI (exactly when oi/vlm are missing) —
259
+ // callers must treat source 'proxy' as LOW-CONFIDENCE: no red alarms off it.
260
+ const t = (vm.liqLevels || []).reduce((s, l) => s + (l.sizeUsd || 0), 0);
261
+ return { depth: t > 0 ? t * 4 : 0, source: 'proxy' };
262
+ }
263
+ // dir: 'down' = long-liq cascade below mark; 'up' = short-squeeze above mark.
264
+ function computeCascade(vm, dir, opts) {
265
+ opts = opts || {};
266
+ const mark = vm && vm.markPx;
267
+ if (!mark || !Array.isArray(vm.liqLevels) || !vm.liqLevels.length) return null;
268
+ const down = dir !== 'up';
269
+ const k = opts.k != null ? opts.k : CASCADE_K;
270
+ const maxStep = opts.maxStep != null ? opts.maxStep : CASCADE_MAX_STEP;
271
+ const band = opts.band != null ? opts.band : CASCADE_BAND;
272
+ const dd = opts.depth != null ? { depth: opts.depth, source: opts.depthSource || 'oi' } : cascadeDepth(vm);
273
+ const depth = dd.depth, depthSource = dd.source;
274
+ if (!depth || depth <= 0) return null;
275
+ // bucket the real liq levels on the relevant side into clusters
276
+ const bw = mark * (opts.bucketFrac || 0.0025);
277
+ const bins = new Map();
278
+ for (const l of vm.liqLevels) {
279
+ const p = l.price, n = l.sizeUsd || 0;
280
+ if (p == null || n <= 0) continue;
281
+ if (down ? !(p < mark) : !(p > mark)) continue;
282
+ if (Math.abs(p - mark) / mark > band) continue;
283
+ const key = Math.round(p / bw), b = bins.get(key) || { wpx: 0, usd: 0 };
284
+ b.usd += n; b.wpx += p * n; bins.set(key, b);
285
+ }
286
+ let clusters = [];
287
+ for (const b of bins.values()) clusters.push({ price: b.wpx / b.usd, usd: b.usd });
288
+ if (!clusters.length) return null;
289
+ // nearest → farthest from mark (down: highest price first; up: lowest first)
290
+ clusters.sort((a, b) => down ? b.price - a.price : a.price - b.price);
291
+ const biggest = clusters.slice().sort((a, b) => b.usd - a.usd)[0];
292
+ // walk price from mark into the side; each fired cluster's impact may reach
293
+ // the next → self-sustaining chain.
294
+ const hops = [];
295
+ let price = mark, total = 0;
296
+ for (let i = 0; i < clusters.length; i++) {
297
+ const c = clusters[i];
298
+ if (i > 0) {
299
+ const reached = down ? price <= c.price : price >= c.price;
300
+ if (!reached) break; // chain stalls: prior impact didn't reach this wall
301
+ }
302
+ hops.push({ price: c.price, usd: c.usd });
303
+ total += c.usd;
304
+ const impact = Math.min(maxStep, k * c.usd / depth);
305
+ price = down ? c.price * (1 - impact) : c.price * (1 + impact);
306
+ }
307
+ const chain = hops.length >= 2; // self-sustaining = ≥2 walls fired in sequence
308
+ if (chain) {
309
+ const triggerPx = hops[0].price, terminalPx = price;
310
+ return {
311
+ dir: down ? 'down' : 'up', chain: true, isolated: false,
312
+ triggerPx, terminalPx, totalLiqUsd: total, hops,
313
+ dropFrac: Math.abs(terminalPx - mark) / mark,
314
+ biggestWall: biggest, depth, depthSource, k
315
+ };
316
+ }
317
+ // no chain — report the single biggest wall + its ISOLATED impact
318
+ const impact = Math.min(maxStep, k * biggest.usd / depth);
319
+ const terminalPx = down ? biggest.price * (1 - impact) : biggest.price * (1 + impact);
320
+ return {
321
+ dir: down ? 'down' : 'up', chain: false, isolated: true,
322
+ triggerPx: biggest.price, terminalPx, totalLiqUsd: biggest.usd,
323
+ hops: [{ price: biggest.price, usd: biggest.usd }],
324
+ dropFrac: Math.abs(terminalPx - mark) / mark,
325
+ biggestWall: biggest, depth, depthSource, k
326
+ };
327
+ }
328
+ // Does a cascade sweep THROUGH a given price (e.g. the user's liq)? True when
329
+ // liqPx lies between triggerPx and terminalPx inclusive — the chain blows past it.
330
+ function cascadeHitsPrice(cascade, liqPx) {
331
+ if (!cascade || liqPx == null) return false;
332
+ const a = Math.min(cascade.triggerPx, cascade.terminalPx), b = Math.max(cascade.triggerPx, cascade.terminalPx);
333
+ return liqPx >= a && liqPx <= b;
334
+ }
335
+
336
+ g.HLVM = {
337
+ CONTRACT_VERSION: '0.5',
338
+ dailyMovePct, volDistance, volColor, reachEstimate,
339
+ moneyPerDayPer1k, fmtMoney, fmtUsd, fmtApr, fmtPrice, fmtPrem, signClass, shortAddr, agoLabel,
340
+ maintMarginFraction, liqPrice, huntRiskCluster, suggestClearLeverage, evalLeverage,
341
+ computeCascade, cascadeHitsPrice, cascadeDepth, CASCADE_K,
342
+ fundingLeg, buildViewModel
343
+ };
344
+ })(typeof window !== 'undefined' ? window : this);