@strkfarm/sdk 2.0.0-dev.28 → 2.0.0-dev.29

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,730 @@
1
+ // ============================================================
2
+ // rebalance.ts — Delta-Neutral Rebalancing Solver
3
+ // ============================================================
4
+ //
5
+ // Manages two matched BTC positions across Extended (leveraged perp)
6
+ // and Vesu (collateral/debt lending), ensuring:
7
+ // 1. Margin health on both protocols
8
+ // 2. Equal BTC exposure on both sides
9
+ //
10
+ // Architecture:
11
+ // Phase 1 — Try to fix margin deficits using 5 funding sources
12
+ // Phase 2 — Unified position + margin solver
13
+ // Simultaneously resolves position imbalance AND any
14
+ // remaining margin deficits from Phase 1 by finding
15
+ // the optimal common position F that satisfies both
16
+ // Extended margin and Vesu HF constraints.
17
+ //
18
+ // Key leverage mechanics:
19
+ // Extended: depositing $x of margin creates $x × leverage of position.
20
+ // → cost to create gapBtc = gapBtc × price / leverage
21
+ // → margin released by closing = closeBtc × price / leverage
22
+ //
23
+ // Vesu: depositing $x of equity creates $x / (1 - targetLTV) of position.
24
+ // → cost to create gapBtc = gapBtc × price × (1 - targetLTV)
25
+ // → equity freed by closing = closeBtc × price × (1 - currentLTV)
26
+ // (because debt proportional to the close must be repaid first)
27
+ //
28
+ // All functions are pure except drawFunds which mutates the pool
29
+ // to track remaining balances across sequential draws.
30
+ // ============================================================
31
+
32
+ // ---- Types ----
33
+
34
+ interface RebalanceConfig {
35
+ positionPrecision: number; // decimal places for BTC rounding, e.g. 4
36
+ hfBuffer: number; // HF tolerance before acting, e.g. 0.05
37
+ }
38
+
39
+ export interface ExtendedState {
40
+ positionBtc: number; // current BTC position size
41
+ equity: number; // current equity in USD
42
+ avlWithdraw: number; // withdrawable surplus in USD (0 if equity < idealMargin)
43
+ upnl: number; // unrealized PnL in USD (0 if equity < idealMargin)
44
+ leverage: number; // target leverage, typically 3
45
+ }
46
+
47
+ export interface VesuState {
48
+ positionBtc: number; // BTC collateral position
49
+ debt: number; // outstanding debt in debt-token units
50
+ debtPrice: number; // USD price per debt token
51
+ maxLTV: number; // liquidation LTV threshold, e.g. 0.8
52
+ targetHF: number; // target health factor, e.g. 1.25
53
+ }
54
+
55
+ /** Mutable pool of available funding sources, drawn in priority order. */
56
+ export interface FundingPool {
57
+ vaUsd: number; // yield aggregator (VA / Troves) balance
58
+ walletUsd: number; // hot wallet balance
59
+ vesuBorrowCapacity: number; // remaining borrow headroom on Vesu, in USD
60
+ extAvlWithdraw: number; // withdrawable from Extended
61
+ extUpnl: number; // realizable UPNL from Extended
62
+ }
63
+
64
+ type FundKey = keyof FundingPool;
65
+
66
+ interface RebalanceInputs {
67
+ ext: ExtendedState;
68
+ vesu: VesuState;
69
+ btcPrice: number;
70
+ funding: FundingPool;
71
+ config: RebalanceConfig;
72
+ }
73
+
74
+ /** All deltas are signed: negative = reducing/withdrawing, positive = increasing/depositing. */
75
+ interface RebalanceDeltas {
76
+ dExtPosition: number; // BTC change on Extended (negative = close)
77
+ dVesuPosition: number; // BTC change on Vesu (negative = close)
78
+ dVesuDebt: number; // debt-token change on Vesu (negative = repay)
79
+ dExtAvlWithdraw: number; // USD change in Extended withdrawable equity
80
+ dExtUpnl: number; // USD change in Extended UPNL (negative = realized)
81
+ dVaUsd: number; // USD change in VA balance (negative = withdrawn)
82
+ dWalletUsd: number; // USD change in wallet (negative = withdrawn)
83
+ dVesuBorrowCapacity: number; // USD change in Vesu borrow room (negative = used)
84
+ dTransferVesuToExt: number; // net USD transferred from Vesu to Extended
85
+ // positive = Vesu sends to Extended
86
+ // negative = Extended sends to Vesu
87
+ }
88
+
89
+ // ---- Precision helpers ----
90
+
91
+ /** Ceil a BTC amount up to `precision` decimal places (used when closing positions). */
92
+ function ceilBtc(v: number, precision: number): number {
93
+ const f = 10 ** precision;
94
+ return Math.ceil(v * f) / f;
95
+ }
96
+
97
+ /** Floor a BTC amount down to `precision` decimal places (used for target positions). */
98
+ function floorBtc(v: number, precision: number): number {
99
+ const f = 10 ** precision;
100
+ return Math.floor(v * f) / f;
101
+ }
102
+
103
+ /** Check if a BTC amount is below the smallest representable unit. */
104
+ function isNegligible(btc: number, precision: number): boolean {
105
+ return Math.abs(btc) < 10 ** -precision;
106
+ }
107
+
108
+ // ---- Health computations ----
109
+
110
+ /**
111
+ * Minimum equity required on Extended for a given position.
112
+ * idealMargin = positionValue / leverage
113
+ */
114
+ function computeExtIdealMargin(posBtc: number, leverage: number, price: number): number {
115
+ return (posBtc * price) / leverage;
116
+ }
117
+
118
+ /**
119
+ * How much USD equity Extended is short of its ideal margin.
120
+ * Returns 0 if equity is sufficient.
121
+ */
122
+ function computeExtDeficit(ext: ExtendedState, price: number): number {
123
+ return Math.max(0, computeExtIdealMargin(ext.positionBtc, ext.leverage, price) - ext.equity);
124
+ }
125
+
126
+ /**
127
+ * Vesu health factor: HF = maxLTV / currentLTV.
128
+ * Higher is healthier. Returns Infinity if no debt or no collateral.
129
+ */
130
+ function computeVesuHF(vesu: VesuState, price: number): number {
131
+ const collateralUsd = vesu.positionBtc * price;
132
+ if (collateralUsd === 0) return Infinity;
133
+ const ltv = (vesu.debt * vesu.debtPrice) / collateralUsd;
134
+ if (ltv === 0) return Infinity;
135
+ return vesu.maxLTV / ltv;
136
+ }
137
+
138
+ /**
139
+ * Debt (in token units) that must be repaid to bring Vesu HF back to targetHF,
140
+ * assuming collateral position stays constant.
141
+ *
142
+ * Derivation: targetHF = maxLTV / targetLTV
143
+ * → targetLTV = maxLTV / targetHF
144
+ * → requiredDebt = targetLTV × collateralUsd / debtPrice
145
+ * → repay = currentDebt - requiredDebt
146
+ */
147
+ function computeVesuDebtRepay(vesu: VesuState, price: number): number {
148
+ const targetLTV = vesu.maxLTV / vesu.targetHF;
149
+ const collateralUsd = vesu.positionBtc * price;
150
+ const requiredDebt = (targetLTV * collateralUsd) / vesu.debtPrice;
151
+ return Math.max(0, vesu.debt - requiredDebt);
152
+ }
153
+
154
+ /**
155
+ * Compute the target LTV for Vesu based on its targetHF.
156
+ * targetLTV = maxLTV / targetHF
157
+ *
158
+ * This is the LTV we aim to maintain after any position changes.
159
+ * Used to compute equity costs, debt changes, and the unified solver.
160
+ */
161
+ function computeVesuTargetLTV(vesu: VesuState): number {
162
+ return vesu.maxLTV / vesu.targetHF;
163
+ }
164
+
165
+ /**
166
+ * Compute the USD equity cost to grow Vesu position by `gapBtc`.
167
+ *
168
+ * Vesu is a leveraged lending position. Depositing $x as collateral,
169
+ * borrowing at targetLTV, recycling proceeds → converges to:
170
+ *
171
+ * totalPosition = equity / (1 - targetLTV)
172
+ * → equityCost = gapBtc × btcPrice × (1 - targetLTV)
173
+ */
174
+ function computeVesuGrowthCost(gapBtc: number, vesu: VesuState, price: number): number {
175
+ const targetLTV = computeVesuTargetLTV(vesu);
176
+ return gapBtc * price * (1 - targetLTV);
177
+ }
178
+
179
+ /**
180
+ * Compute new debt (in token units) created when growing Vesu by `gapBtc`.
181
+ * newDebtTokens = gapBtc × btcPrice × targetLTV / debtPrice
182
+ */
183
+ function computeVesuGrowthDebt(gapBtc: number, vesu: VesuState, price: number): number {
184
+ const targetLTV = computeVesuTargetLTV(vesu);
185
+ return (gapBtc * price * targetLTV) / vesu.debtPrice;
186
+ }
187
+
188
+ /**
189
+ * Given available USD equity, compute max BTC position growth on Vesu.
190
+ * gapBtc = equityUsd / (btcPrice × (1 - targetLTV))
191
+ */
192
+ function computeVesuGrowthFromEquity(equityUsd: number, vesu: VesuState, price: number): number {
193
+ const targetLTV = computeVesuTargetLTV(vesu);
194
+ return equityUsd / (price * (1 - targetLTV));
195
+ }
196
+
197
+ // ---- Delta helpers ----
198
+
199
+ function emptyDeltas(): RebalanceDeltas {
200
+ return {
201
+ dExtPosition: 0, dVesuPosition: 0, dVesuDebt: 0,
202
+ dExtAvlWithdraw: 0, dExtUpnl: 0, dVaUsd: 0,
203
+ dWalletUsd: 0, dVesuBorrowCapacity: 0, dTransferVesuToExt: 0,
204
+ };
205
+ }
206
+
207
+ /** Element-wise addition of two delta sets. */
208
+ function mergeDeltas(a: RebalanceDeltas, b: RebalanceDeltas): RebalanceDeltas {
209
+ return {
210
+ dExtPosition: a.dExtPosition + b.dExtPosition,
211
+ dVesuPosition: a.dVesuPosition + b.dVesuPosition,
212
+ dVesuDebt: a.dVesuDebt + b.dVesuDebt,
213
+ dExtAvlWithdraw: a.dExtAvlWithdraw + b.dExtAvlWithdraw,
214
+ dExtUpnl: a.dExtUpnl + b.dExtUpnl,
215
+ dVaUsd: a.dVaUsd + b.dVaUsd,
216
+ dWalletUsd: a.dWalletUsd + b.dWalletUsd,
217
+ dVesuBorrowCapacity: a.dVesuBorrowCapacity + b.dVesuBorrowCapacity,
218
+ dTransferVesuToExt: a.dTransferVesuToExt + b.dTransferVesuToExt,
219
+ };
220
+ }
221
+
222
+ // ---- Fund drawing engine ----
223
+
224
+ interface DrawResult {
225
+ draws: Partial<Record<FundKey, number>>; // how much was taken from each source
226
+ filled: number; // total USD successfully drawn
227
+ unmet: number; // remaining USD still needed
228
+ }
229
+
230
+ /**
231
+ * Draw `need` USD from funding sources in strict priority order.
232
+ * Mutates `pool` balances so subsequent draws see reduced availability.
233
+ * Each source contributes min(available, remaining_need).
234
+ */
235
+ function drawFunds(need: number, keys: FundKey[], pool: FundingPool): DrawResult {
236
+ const draws: Partial<Record<FundKey, number>> = {};
237
+ let unmet = need;
238
+ for (const k of keys) {
239
+ if (unmet <= 0) break;
240
+ const avail = pool[k];
241
+ if (avail <= 0) continue;
242
+ const take = Math.min(avail, unmet);
243
+ draws[k] = take;
244
+ pool[k] -= take;
245
+ unmet -= take;
246
+ }
247
+ return { draws, filled: need - unmet, unmet };
248
+ }
249
+
250
+ /** Sum draw amounts for a subset of keys (e.g. all Extended-sourced draws). */
251
+ function sumKeys(draws: Partial<Record<FundKey, number>>, keys: FundKey[]): number {
252
+ return keys.reduce((s, k) => s + (draws[k] ?? 0), 0);
253
+ }
254
+
255
+ /**
256
+ * Record fund draws as output deltas.
257
+ * sign = -1: sources are being drained (normal case).
258
+ * sign = +1: sources are being credited (e.g. freed margin returning).
259
+ */
260
+ function applyDrawsToDeltas(d: RebalanceDeltas, draws: Partial<Record<FundKey, number>>, sign: number) {
261
+ d.dVaUsd += sign * (draws.vaUsd ?? 0);
262
+ d.dWalletUsd += sign * (draws.walletUsd ?? 0);
263
+ d.dVesuBorrowCapacity += sign * (draws.vesuBorrowCapacity ?? 0);
264
+ d.dExtAvlWithdraw += sign * (draws.extAvlWithdraw ?? 0);
265
+ d.dExtUpnl += sign * (draws.extUpnl ?? 0);
266
+ }
267
+
268
+ // ============================================================
269
+ // PHASE 1: Margin health (best-effort from funding sources)
270
+ // ============================================================
271
+ //
272
+ // Step 1a: Extended equity deficit → draw wallet → VA → vesuBorrowable
273
+ // Step 1b: Vesu HF below threshold → repay debt from VA → wallet → extAvlWith → extUpnl
274
+ //
275
+ // Returns deltas + any unmet deficit amounts. Phase 2 handles the rest.
276
+ // ============================================================
277
+
278
+ interface Phase1Result {
279
+ deltas: RebalanceDeltas;
280
+ extDeficitRemaining: number; // USD still needed for Extended margin
281
+ vesuRepayRemaining: number; // USD still needed for Vesu debt repayment
282
+ }
283
+
284
+ /**
285
+ * Fix Extended margin deficit by drawing from external sources.
286
+ * Priority: wallet → VA → vesuBorrowCapacity.
287
+ * Funds received increase Extended equity (dExtAvlWithdraw).
288
+ */
289
+ function fixExtMargin(ext: ExtendedState, price: number, pool: FundingPool): { d: RebalanceDeltas; unmet: number } {
290
+ const d = emptyDeltas();
291
+ const deficit = computeExtDeficit(ext, price);
292
+ if (deficit <= 0) return { d, unmet: 0 };
293
+
294
+ const { draws, filled, unmet } = drawFunds(deficit, ['walletUsd', 'vaUsd', 'vesuBorrowCapacity'], pool);
295
+ applyDrawsToDeltas(d, draws, -1);
296
+
297
+ // All filled amount lands as Extended equity
298
+ d.dExtAvlWithdraw += filled;
299
+
300
+ // Funds borrowed from Vesu and sent to Extended = positive transfer
301
+ const fromVesu = draws.vesuBorrowCapacity ?? 0;
302
+ if (fromVesu > 0) d.dTransferVesuToExt += fromVesu;
303
+
304
+ return { d, unmet };
305
+ }
306
+
307
+ /**
308
+ * Fix Vesu health factor by repaying debt from available sources.
309
+ * Only triggers if HF < targetHF - hfBuffer (avoids churn near target).
310
+ * Priority: VA → wallet → extAvlWithdraw → extUpnl.
311
+ * Repaid amount reduces Vesu debt (dVesuDebt).
312
+ */
313
+ function fixVesuMargin(vesu: VesuState, price: number, pool: FundingPool, hfBuffer: number): { d: RebalanceDeltas; unmet: number } {
314
+ const d = emptyDeltas();
315
+ const hf = computeVesuHF(vesu, price);
316
+
317
+ // Only act if HF is meaningfully below target (buffer prevents churn)
318
+ if (hf >= vesu.targetHF - hfBuffer) return { d, unmet: 0 };
319
+
320
+ const repayTokens = computeVesuDebtRepay(vesu, price);
321
+ const repayUsd = repayTokens * vesu.debtPrice;
322
+
323
+ const { draws, filled, unmet } = drawFunds(repayUsd, ['vaUsd', 'walletUsd', 'extAvlWithdraw', 'extUpnl'], pool);
324
+ applyDrawsToDeltas(d, draws, -1);
325
+
326
+ // Convert filled USD back to debt tokens for the repayment delta
327
+ d.dVesuDebt -= filled / vesu.debtPrice;
328
+
329
+ // Funds withdrawn from Extended and sent to Vesu = negative transfer
330
+ const fromExt = sumKeys(draws, ['extAvlWithdraw', 'extUpnl']);
331
+ if (fromExt > 0) d.dTransferVesuToExt -= fromExt;
332
+
333
+ return { d, unmet };
334
+ }
335
+
336
+ /**
337
+ * Phase 1 orchestrator: fix Extended margin, then Vesu margin.
338
+ * Extended draws wallet/VA first; Vesu draws from remaining pool.
339
+ * Returns combined deltas and any unresolved deficits.
340
+ */
341
+ function phase1(ext: ExtendedState, vesu: VesuState, price: number, pool: FundingPool, config: RebalanceConfig): Phase1Result {
342
+ const extResult = fixExtMargin(ext, price, pool);
343
+ const vesuResult = fixVesuMargin(vesu, price, pool, config.hfBuffer);
344
+ return {
345
+ deltas: mergeDeltas(extResult.d, vesuResult.d),
346
+ extDeficitRemaining: extResult.unmet,
347
+ vesuRepayRemaining: vesuResult.unmet,
348
+ };
349
+ }
350
+
351
+ // ============================================================
352
+ // PHASE 2: Unified position + margin solver
353
+ // ============================================================
354
+ //
355
+ // This phase simultaneously resolves:
356
+ // a) Position imbalance (extPos ≠ vesuPos)
357
+ // b) Any remaining margin deficits from Phase 1
358
+ //
359
+ // It works by finding F, the optimal final common position for both
360
+ // sides, such that both Extended margin and Vesu HF are satisfied.
361
+ //
362
+ // The approach has two steps:
363
+ // Step 1: Try to close the gap using available funding sources
364
+ // (grow the lagging side toward the leading side).
365
+ // Step 2: Whatever gap or deficit remains, solve for F using the
366
+ // unified equation that accounts for both protocols'
367
+ // leverage, existing positions, and existing equity/debt.
368
+ //
369
+ // ---- DERIVATION OF THE UNIFIED EQUATION ----
370
+ //
371
+ // After Phase 1, we have effective state:
372
+ // extPos, vesuPos — BTC positions
373
+ // extEquity — Extended equity in USD
374
+ // vesuDebt — Vesu debt in token units
375
+ //
376
+ // We want to find F (final common position) such that both sides
377
+ // can sustain it. Both dx = F - extPos and dy = F - vesuPos can
378
+ // be positive (grow) or negative (shrink).
379
+ //
380
+ // CONSTRAINT 1 — Position equality:
381
+ // extPos + dx = vesuPos + dy = F (by definition)
382
+ //
383
+ // CONSTRAINT 2 — Extended margin health:
384
+ // The equity available to Extended after the rebalance must cover
385
+ // the ideal margin on the final position.
386
+ //
387
+ // extEquity + T ≥ F × price / extLeverage
388
+ //
389
+ // Where T is the net transfer from Vesu to Extended.
390
+ // - If T > 0: Vesu sends surplus equity to Extended
391
+ // - If T < 0: Extended sends surplus margin to Vesu
392
+ //
393
+ // CONSTRAINT 3 — Vesu LTV at target:
394
+ // After the rebalance, remaining debt on remaining collateral
395
+ // must be at targetLTV:
396
+ //
397
+ // (vesuDebt - debtRepay) × debtPrice = targetLTV × F × price
398
+ //
399
+ // Solving for debtRepay:
400
+ // debtRepay = vesuDebt - (targetLTV × F × price / debtPrice)
401
+ //
402
+ // CONSTRAINT 4 — Vesu fund conservation:
403
+ // When Vesu position changes by dy, the collateral value change
404
+ // must balance between debt change and transfer:
405
+ //
406
+ // (vesuPos - F) × price = debtRepay × debtPrice + T
407
+ //
408
+ // This says: collateral freed (if closing) = debt repaid + transfer out.
409
+ // (If growing, both sides flip sign and it still holds.)
410
+ //
411
+ // SOLVING:
412
+ // Substitute debtRepay from C3 into C4:
413
+ //
414
+ // T = (vesuPos - F) × price - (vesuDebt - targetLTV × F × price / debtPrice) × debtPrice
415
+ // T = (vesuPos - F) × price - vesuDebt × debtPrice + targetLTV × F × price
416
+ // T = vesuPos × price - F × price - vesuDebt × debtPrice + targetLTV × F × price
417
+ // T = vesuPos × price - vesuDebt × debtPrice - F × price × (1 - targetLTV)
418
+ //
419
+ // Now substitute T into C2 (as equality for the optimal F):
420
+ //
421
+ // extEquity + vesuPos × price - vesuDebt × debtPrice - F × price × (1 - targetLTV)
422
+ // = F × price / extLeverage
423
+ //
424
+ // extEquity + vesuPos × price - vesuDebt × debtPrice
425
+ // = F × price × (1 - targetLTV + 1/extLeverage)
426
+ //
427
+ // Let k = 1 - targetLTV + 1/extLeverage
428
+ //
429
+ // F = (extEquity + vesuPos × price - vesuDebt × debtPrice) / (price × k)
430
+ //
431
+ // INTUITION:
432
+ // The numerator is the total "real equity" in the system:
433
+ // extEquity (what Extended actually owns)
434
+ // + vesuPos × price (Vesu collateral value)
435
+ // - vesuDebt × debtPrice (minus what Vesu owes)
436
+ // This is the combined net worth across both protocols.
437
+ //
438
+ // The denominator (price × k) represents the "equity cost per BTC
439
+ // of matched position" — maintaining 1 BTC on Extended costs
440
+ // price/leverage in margin, and maintaining 1 BTC on Vesu costs
441
+ // price × (1 - targetLTV) in equity. Together: price × k.
442
+ //
443
+ // So F = totalEquity / costPerBtc — the maximum matched position
444
+ // the system can afford.
445
+ //
446
+ // DERIVED QUANTITIES:
447
+ // Once F is known:
448
+ // dx = F - extPos (Extended position change)
449
+ // dy = F - vesuPos (Vesu position change)
450
+ // debtRepay = vesuDebt - targetLTV × F × price / debtPrice
451
+ // T = vesuPos × price - vesuDebt × debtPrice - F × price × (1 - targetLTV)
452
+ //
453
+ // If debtRepay < 0, Vesu takes on more debt (position grew).
454
+ // If T < 0, Extended sends funds to Vesu.
455
+ // dx and dy can each be positive or negative independently.
456
+ // ============================================================
457
+
458
+ /**
459
+ * Solve for the optimal final common position F.
460
+ *
461
+ * F = (extEquity + vesuPos × price - vesuDebt × debtPrice) / (price × k)
462
+ * where k = 1 - targetLTV + 1/extLeverage
463
+ *
464
+ * This is the largest matched position that both protocols can sustain
465
+ * given their combined net equity.
466
+ */
467
+ function solveUnifiedF(
468
+ extPos: number, vesuPos: number, extEquity: number,
469
+ vesuDebt: number, vesu: VesuState, extLev: number, price: number,
470
+ ): number {
471
+ const targetLTV = computeVesuTargetLTV(vesu);
472
+
473
+ // k = equity cost per BTC of matched position, divided by price
474
+ // Extended needs price/lev per BTC, Vesu needs price×(1-tLTV) per BTC
475
+ const k = 1 - targetLTV + 1 / extLev;
476
+
477
+ // Numerator = total real equity across both protocols
478
+ const totalEquity = extEquity + vesuPos * price - vesuDebt * vesu.debtPrice;
479
+
480
+ return totalEquity / (price * k);
481
+ }
482
+
483
+ /**
484
+ * Compute the net transfer T given the final position F.
485
+ *
486
+ * T = vesuPos × price - vesuDebt × debtPrice - F × price × (1 - targetLTV)
487
+ *
488
+ * Positive T means Vesu sends surplus to Extended.
489
+ * Negative T means Extended sends surplus to Vesu.
490
+ */
491
+ function solveTransfer(
492
+ vesuPos: number, vesuDebt: number, F: number,
493
+ vesu: VesuState, price: number,
494
+ ): number {
495
+ const targetLTV = computeVesuTargetLTV(vesu);
496
+ return vesuPos * price - vesuDebt * vesu.debtPrice - F * price * (1 - targetLTV);
497
+ }
498
+
499
+ /**
500
+ * Compute the debt repayment (in token units) for the final position F.
501
+ *
502
+ * debtRepay = vesuDebt - targetLTV × F × price / debtPrice
503
+ *
504
+ * Positive = debt repaid. Negative = new debt taken on (position grew).
505
+ */
506
+ function solveDebtRepay(
507
+ vesuDebt: number, F: number,
508
+ vesu: VesuState, price: number,
509
+ ): number {
510
+ const targetLTV = computeVesuTargetLTV(vesu);
511
+ return vesuDebt - (targetLTV * F * price / vesu.debtPrice);
512
+ }
513
+
514
+ /**
515
+ * Apply rounding to the final position F.
516
+ *
517
+ * For positions that shrink: ceil the close amount (close slightly more, safer).
518
+ * For positions that grow: floor the growth (grow slightly less, safer).
519
+ *
520
+ * After rounding, both sides must still land on the same final position.
521
+ * If rounding causes mismatch, align to the more conservative (smaller) value.
522
+ */
523
+ function roundFinalPosition(
524
+ extPos: number, vesuPos: number, rawF: number, precision: number,
525
+ ): number {
526
+ // If F < extPos (Extended shrinks), the close amount is ceiled,
527
+ // so effective F is reduced: extPos - ceil(extPos - F)
528
+ // If F < vesuPos (Vesu shrinks), same logic.
529
+ // If F > a position (growth), floor the growth.
530
+
531
+ let fFromExt: number;
532
+ if (rawF < extPos) {
533
+ const closeExt = ceilBtc(extPos - rawF, precision);
534
+ fFromExt = extPos - closeExt;
535
+ } else {
536
+ const growExt = floorBtc(rawF - extPos, precision);
537
+ fFromExt = extPos + growExt;
538
+ }
539
+
540
+ let fFromVesu: number;
541
+ if (rawF < vesuPos) {
542
+ const closeVesu = ceilBtc(vesuPos - rawF, precision);
543
+ fFromVesu = vesuPos - closeVesu;
544
+ } else {
545
+ const growVesu = floorBtc(rawF - vesuPos, precision);
546
+ fFromVesu = vesuPos + growVesu;
547
+ }
548
+
549
+ // Align to the more conservative (smaller) final position
550
+ return Math.max(0, Math.min(fFromExt, fFromVesu));
551
+ }
552
+
553
+ /**
554
+ * Phase 2: Unified position + margin solver.
555
+ *
556
+ * First tries to close the position gap using available funding sources.
557
+ * Then solves the unified equation for any remaining gap + margin deficits.
558
+ *
559
+ * The function handles all combinations:
560
+ * - Gap only (margins healthy): grows lagging side from funds, or converges
561
+ * - Deficit only (positions matched): equally reduces both sides
562
+ * - Both gap and deficit: finds F that resolves both simultaneously
563
+ */
564
+ function phase2(
565
+ extPos: number, vesuPos: number, extEquity: number,
566
+ vesuDebt: number, vesu: VesuState, extLev: number, price: number,
567
+ pool: FundingPool, precision: number,
568
+ ): RebalanceDeltas {
569
+ const d = emptyDeltas();
570
+ const targetLTV = computeVesuTargetLTV(vesu);
571
+
572
+ // ---- Step 1: Try to close gap using available funding sources ----
573
+ //
574
+ // Attempt to grow the lagging side to match the leading side,
575
+ // drawing from the funding pool. This is the "easy" path —
576
+ // if funds are sufficient, no position reduction needed.
577
+
578
+ const imbalance = extPos - vesuPos; // positive = Extended is larger
579
+ let fundedGrowthBtc = 0; // how much the lagging side actually grew
580
+
581
+ if (!isNegligible(imbalance, precision)) {
582
+ if (imbalance > 0) {
583
+ // Extended is larger — try to grow Vesu
584
+ // Vesu growth is leveraged: equity cost = gapBtc × price × (1 - targetLTV)
585
+ const equityCostUsd = computeVesuGrowthCost(imbalance, vesu, price);
586
+ const { draws, filled } = drawFunds(equityCostUsd, ['vaUsd', 'walletUsd', 'extAvlWithdraw', 'extUpnl'], pool);
587
+ applyDrawsToDeltas(d, draws, -1);
588
+
589
+ // Convert filled equity into leveraged position growth
590
+ const grownBtc = computeVesuGrowthFromEquity(filled, vesu, price);
591
+ d.dVesuPosition += grownBtc;
592
+ fundedGrowthBtc = grownBtc;
593
+
594
+ // New debt created alongside the leveraged growth
595
+ d.dVesuDebt += computeVesuGrowthDebt(grownBtc, vesu, price);
596
+
597
+ // Track ext → vesu fund movement
598
+ const fromExt = sumKeys(draws, ['extAvlWithdraw', 'extUpnl']);
599
+ if (fromExt > 0) d.dTransferVesuToExt -= fromExt;
600
+
601
+ } else {
602
+ // Vesu is larger — try to grow Extended
603
+ // Extended growth is leveraged: margin cost = gapBtc × price / extLeverage
604
+ const absImbalance = -imbalance;
605
+ const marginCostUsd = absImbalance * price / extLev;
606
+ const { draws, filled } = drawFunds(marginCostUsd, ['walletUsd', 'vaUsd', 'vesuBorrowCapacity'], pool);
607
+ applyDrawsToDeltas(d, draws, -1);
608
+
609
+ // Each $1 of margin creates extLeverage/price BTC of position
610
+ const grownBtc = filled * extLev / price;
611
+ d.dExtPosition += grownBtc;
612
+ fundedGrowthBtc = grownBtc;
613
+
614
+ // Track vesu → ext fund movement
615
+ const fromVesu = draws.vesuBorrowCapacity ?? 0;
616
+ if (fromVesu > 0) d.dTransferVesuToExt += fromVesu;
617
+ }
618
+ }
619
+
620
+ // ---- Step 2: Solve unified equation for remaining gap + deficits ----
621
+ //
622
+ // After Step 1, compute the effective state. There may still be:
623
+ // a) Remaining position imbalance (funds weren't enough to fully close gap)
624
+ // b) Margin deficits (carried over from Phase 1)
625
+ // c) Both
626
+ //
627
+ // The unified equation solves for the optimal F that handles everything.
628
+ // If Step 1 fully resolved the gap AND margins were already healthy,
629
+ // F will equal the current (now-matched) position and no further action needed.
630
+
631
+ const effExtPos = extPos + d.dExtPosition;
632
+ const effVesuPos = vesuPos + d.dVesuPosition;
633
+ const effExtEquity = extEquity; // equity hasn't changed (draws came from pool, not equity)
634
+ const effVesuDebt = vesuDebt + d.dVesuDebt;
635
+
636
+ // Compute what F the system can actually sustain
637
+ const rawF = solveUnifiedF(effExtPos, effVesuPos, effExtEquity, effVesuDebt, vesu, extLev, price);
638
+
639
+ // If F ≥ both effective positions, everything is already healthy — no reduction needed.
640
+ // F might even be larger (meaning we could grow more, but we've exhausted funding sources).
641
+ // Cap F at the larger of the two effective positions (can't grow without funds).
642
+ const cappedF = Math.min(rawF, Math.max(effExtPos, effVesuPos));
643
+
644
+ // Also cap F at the smaller of the two if there's no remaining imbalance to close
645
+ // (if positions are matched, F can't grow either side without funds)
646
+ const maxGrowableTo = Math.max(effExtPos, effVesuPos);
647
+ const targetF = Math.max(0, Math.min(cappedF, maxGrowableTo));
648
+
649
+ // Check if further action is needed
650
+ const remainingImbalance = effExtPos - effVesuPos;
651
+ const extNeedsMore = effExtEquity < computeExtIdealMargin(effExtPos, extLev, price);
652
+ const vesuCurrentLTV = effVesuPos > 0
653
+ ? (effVesuDebt * vesu.debtPrice) / (effVesuPos * price)
654
+ : 0;
655
+ const vesuNeedsMore = vesuCurrentLTV > targetLTV;
656
+
657
+ const needsFurtherAction = !isNegligible(remainingImbalance, precision) || extNeedsMore || vesuNeedsMore;
658
+
659
+ if (!needsFurtherAction) {
660
+ return d;
661
+ }
662
+
663
+ // Apply rounding to F
664
+ const F = roundFinalPosition(effExtPos, effVesuPos, targetF, precision);
665
+
666
+ // Compute position changes relative to effective (post-Step 1) positions
667
+ const dx = F - effExtPos; // Extended position change (negative = shrink)
668
+ const dy = F - effVesuPos; // Vesu position change (negative = shrink)
669
+
670
+ if (isNegligible(dx, precision) && isNegligible(dy, precision)) {
671
+ return d;
672
+ }
673
+
674
+ d.dExtPosition += dx;
675
+ d.dVesuPosition += dy;
676
+
677
+ // Compute debt change: positive debtRepay = debt reduced
678
+ // debtRepay = currentDebt - targetLTV × F × price / debtPrice
679
+ const debtRepay = solveDebtRepay(effVesuDebt, F, vesu, price);
680
+ d.dVesuDebt -= debtRepay; // negative debtRepay means new debt (dVesuDebt increases)
681
+
682
+ // Compute transfer: positive = Vesu → Extended
683
+ const T = solveTransfer(effVesuPos, effVesuDebt, F, vesu, price);
684
+ d.dTransferVesuToExt += T;
685
+
686
+ // Record the transfer's effect on Extended equity
687
+ // T > 0: Extended receives funds → equity increases
688
+ // T < 0: Extended sends funds → equity decreases
689
+ d.dExtAvlWithdraw += T;
690
+
691
+ return d;
692
+ }
693
+
694
+ // ============================================================
695
+ // ORCHESTRATOR
696
+ // ============================================================
697
+
698
+ /**
699
+ * Main entry point.
700
+ *
701
+ * Phase 1: best-effort margin repair from funding sources.
702
+ * Phase 2: unified solver for position matching + remaining deficits.
703
+ *
704
+ * The funding pool is cloned so the caller's data is not mutated.
705
+ * Phase 1 drains from the pool; Phase 2 draws from whatever remains,
706
+ * then solves the unified equation for anything left.
707
+ */
708
+ function rebalance(inputs: RebalanceInputs): RebalanceDeltas {
709
+ const { ext, vesu, btcPrice, config } = inputs;
710
+ const pool: FundingPool = { ...inputs.funding };
711
+
712
+ // Phase 1: best-effort margin repair from funding sources
713
+ const p1 = phase1(ext, vesu, btcPrice, pool, config);
714
+
715
+ // Phase 2: unified position + margin solver
716
+ // Uses effective state after Phase 1, plus remaining pool for gap-closing
717
+ const effExtPos = ext.positionBtc + p1.deltas.dExtPosition;
718
+ const effVesuPos = vesu.positionBtc + p1.deltas.dVesuPosition;
719
+ const effExtEquity = ext.equity + p1.deltas.dExtAvlWithdraw + p1.deltas.dExtUpnl;
720
+ const effVesuDebt = vesu.debt + p1.deltas.dVesuDebt;
721
+
722
+ const p2 = phase2(
723
+ effExtPos, effVesuPos, effExtEquity, effVesuDebt,
724
+ vesu, ext.leverage, btcPrice, pool, config.positionPrecision,
725
+ );
726
+
727
+ return mergeDeltas(p1.deltas, p2);
728
+ }
729
+
730
+ export { rebalance, RebalanceInputs, RebalanceDeltas, RebalanceConfig };