nado-protocol 0.1.6__py3-none-any.whl → 0.2.0__py3-none-any.whl

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,841 @@
1
+ """
2
+ Margin Manager - Comprehensive margin calculations for Nado Protocol.
3
+
4
+ This module calculates all margin-related metrics including health, margin usage,
5
+ leverage, and position-level details. All calculations use oracle prices.
6
+
7
+ Key Concepts:
8
+ - Health Types: Initial (strictest), Maintenance (liquidation), Unweighted (raw)
9
+ - Cross Margin: Shared across all positions
10
+ - Isolated Margin: Dedicated per position (perp only, USDT only)
11
+ - Health = Assets - Liabilities, calculated per balance using oracle_price * weight
12
+ """
13
+
14
+ from decimal import Decimal
15
+ from time import time
16
+ from typing import Optional, TYPE_CHECKING
17
+ from pydantic import BaseModel
18
+ from nado_protocol.engine_client.types.models import (
19
+ SpotProduct,
20
+ PerpProduct,
21
+ SpotProductBalance,
22
+ PerpProductBalance,
23
+ SubaccountHealth,
24
+ IsolatedPosition,
25
+ )
26
+ from nado_protocol.engine_client.types.query import SubaccountInfoData
27
+ from nado_protocol.indexer_client.types.models import IndexerEvent
28
+ from nado_protocol.indexer_client.types.query import IndexerAccountSnapshotsParams
29
+ from nado_protocol.utils.bytes32 import subaccount_to_hex
30
+
31
+ if TYPE_CHECKING:
32
+ from nado_protocol.client import NadoClient
33
+
34
+
35
+ TEN_TO_18 = Decimal(10) ** 18
36
+
37
+
38
+ def _from_x18_decimal(value: int | str) -> Decimal:
39
+ """Convert an x18 fixed-point integer (str or int) to Decimal without precision loss."""
40
+ return Decimal(str(value)) / TEN_TO_18
41
+
42
+
43
+ class HealthMetrics(BaseModel):
44
+ """Initial and maintenance health metrics."""
45
+
46
+ initial: Decimal
47
+ maintenance: Decimal
48
+
49
+
50
+ class MarginUsageFractions(BaseModel):
51
+ """Margin usage as a fraction [0, 1]."""
52
+
53
+ initial: Decimal
54
+ maintenance: Decimal
55
+
56
+
57
+ class BalanceWithProduct(BaseModel):
58
+ """Balance combined with its product information."""
59
+
60
+ product_id: int
61
+ amount: Decimal
62
+ oracle_price: Decimal
63
+ long_weight_initial: Decimal
64
+ long_weight_maintenance: Decimal
65
+ short_weight_initial: Decimal
66
+ short_weight_maintenance: Decimal
67
+ balance_type: str # "spot" or "perp"
68
+ v_quote_balance: Optional[Decimal] = None
69
+
70
+ class Config:
71
+ arbitrary_types_allowed = True
72
+
73
+
74
+ class CrossPositionMetrics(BaseModel):
75
+ """Metrics for a cross margin position."""
76
+
77
+ product_id: int
78
+ symbol: str
79
+ position_size: Decimal
80
+ notional_value: Decimal
81
+ est_pnl: Optional[Decimal] # Estimated PnL (requires indexer data)
82
+ unsettled: Decimal # Unsettled quote (v_quote_balance)
83
+ margin_used: Decimal
84
+ initial_health: Decimal
85
+ maintenance_health: Decimal
86
+ long_weight_initial: Decimal
87
+ long_weight_maintenance: Decimal
88
+ short_weight_initial: Decimal
89
+ short_weight_maintenance: Decimal
90
+
91
+ class Config:
92
+ arbitrary_types_allowed = True
93
+
94
+
95
+ class IsolatedPositionMetrics(BaseModel):
96
+ """Metrics for an isolated margin position."""
97
+
98
+ product_id: int
99
+ symbol: str
100
+ position_size: Decimal
101
+ notional_value: Decimal
102
+ net_margin: Decimal
103
+ leverage: Decimal
104
+ initial_health: Decimal
105
+ maintenance_health: Decimal
106
+
107
+ class Config:
108
+ arbitrary_types_allowed = True
109
+
110
+
111
+ class AccountSummary(BaseModel):
112
+ """Complete account margin summary."""
113
+
114
+ # Overall health
115
+ initial_health: Decimal
116
+ maintenance_health: Decimal
117
+ unweighted_health: Decimal
118
+
119
+ # Margin usage [0, 1]
120
+ margin_usage_fraction: Decimal
121
+ maint_margin_usage_fraction: Decimal
122
+
123
+ # Available margins
124
+ funds_available: Decimal
125
+ funds_until_liquidation: Decimal
126
+
127
+ # Portfolio metrics
128
+ portfolio_value: Decimal
129
+ account_leverage: Decimal
130
+
131
+ # Positions
132
+ cross_positions: list[CrossPositionMetrics]
133
+ isolated_positions: list[IsolatedPositionMetrics]
134
+ spot_positions: list[BalanceWithProduct]
135
+
136
+ # Spot balances
137
+ total_spot_deposits: Decimal
138
+ total_spot_borrows: Decimal
139
+
140
+ class Config:
141
+ arbitrary_types_allowed = True
142
+
143
+
144
+ class MarginManager:
145
+ """
146
+ Comprehensive margin calculator for Nado Protocol.
147
+
148
+ Calculates all margin metrics for a subaccount including health, margin usage,
149
+ leverage, and position-level details. Matches TypeScript SDK implementation.
150
+ """
151
+
152
+ QUOTE_PRODUCT_ID = 0 # USDT product ID
153
+
154
+ def __init__(
155
+ self,
156
+ subaccount_info: SubaccountInfoData,
157
+ isolated_positions: Optional[list[IsolatedPosition]] = None,
158
+ indexer_snapshot_events: Optional[list[IndexerEvent]] = None,
159
+ ):
160
+ """
161
+ Initialize margin manager with subaccount data.
162
+
163
+ Args:
164
+ subaccount_info: Subaccount information from engine
165
+ isolated_positions: List of isolated positions (if any)
166
+ indexer_snapshot_events: Optional indexer events for Est. PnL calculations
167
+ """
168
+ self.subaccount_info = subaccount_info
169
+ self.isolated_positions = isolated_positions or []
170
+ self.indexer_events = indexer_snapshot_events or []
171
+
172
+ @classmethod
173
+ def from_client(
174
+ cls,
175
+ client: "NadoClient",
176
+ *,
177
+ subaccount: Optional[str] = None,
178
+ subaccount_name: str = "default",
179
+ include_indexer_events: bool = True,
180
+ snapshot_timestamp: Optional[int] = None,
181
+ snapshot_isolated: Optional[bool] = False,
182
+ snapshot_active_only: bool = True,
183
+ ) -> "MarginManager":
184
+ """
185
+ Initialize a MarginManager by fetching data via a NadoClient.
186
+
187
+ Args:
188
+ client: Configured Nado client with engine/indexer connectivity.
189
+ subaccount: Optional subaccount hex (bytes32). If omitted, derives the default
190
+ subaccount using the client's signer and ``subaccount_name``.
191
+ subaccount_name: Subaccount suffix (e.g. ``default``) used when deriving the
192
+ subaccount hex. Ignored when ``subaccount`` is provided.
193
+ include_indexer_events: When True (default), fetch indexer snapshot balances
194
+ for estimated PnL calculations.
195
+ snapshot_timestamp: Epoch seconds to request from the indexer. Defaults to
196
+ ``int(time.time())`` when indexer data is requested.
197
+ snapshot_isolated: Passed through to the indexer request to limit snapshots
198
+ to isolated (True), cross (False), or all (None) balances. Defaults to
199
+ ``False`` to match cross-margin behaviour.
200
+ snapshot_active_only: When True (default), enables the indexer's ``active``
201
+ filter so only live balances are returned.
202
+
203
+ Returns:
204
+ MarginManager instance populated with fresh engine and optional indexer data.
205
+ """
206
+
207
+ engine_client = client.context.engine_client
208
+
209
+ resolved_subaccount = subaccount
210
+ if resolved_subaccount is None:
211
+ signer = client.context.signer
212
+ if signer is None:
213
+ raise ValueError(
214
+ "subaccount must be provided when the client has no signer"
215
+ )
216
+ resolved_subaccount = subaccount_to_hex(signer.address, subaccount_name)
217
+
218
+ subaccount_info = engine_client.get_subaccount_info(resolved_subaccount)
219
+ isolated_positions_data = engine_client.get_isolated_positions(
220
+ resolved_subaccount
221
+ )
222
+ isolated_positions = isolated_positions_data.isolated_positions
223
+
224
+ indexer_events: list[IndexerEvent] = []
225
+ if include_indexer_events:
226
+ requested_timestamp = snapshot_timestamp or int(time())
227
+ indexer_events = cls._fetch_snapshot_events(
228
+ client,
229
+ resolved_subaccount,
230
+ requested_timestamp,
231
+ snapshot_isolated,
232
+ snapshot_active_only,
233
+ )
234
+
235
+ return cls(
236
+ subaccount_info,
237
+ isolated_positions,
238
+ indexer_snapshot_events=indexer_events,
239
+ )
240
+
241
+ @staticmethod
242
+ def _fetch_snapshot_events(
243
+ client: "NadoClient",
244
+ subaccount: str,
245
+ timestamp: int,
246
+ isolated: Optional[bool],
247
+ active_only: bool,
248
+ ) -> list[IndexerEvent]:
249
+ snapshot_response = (
250
+ client.context.indexer_client.get_multi_subaccount_snapshots(
251
+ IndexerAccountSnapshotsParams(
252
+ subaccounts=[subaccount],
253
+ timestamps=[timestamp],
254
+ isolated=isolated,
255
+ active=active_only,
256
+ )
257
+ )
258
+ )
259
+
260
+ snapshots_map = snapshot_response.snapshots or {}
261
+ if not snapshots_map:
262
+ return []
263
+
264
+ snapshots_for_subaccount = snapshots_map.get(subaccount) or next(
265
+ iter(snapshots_map.values())
266
+ )
267
+ if not snapshots_for_subaccount:
268
+ return []
269
+
270
+ latest_key = max(snapshots_for_subaccount.keys(), key=int)
271
+ events = snapshots_for_subaccount.get(latest_key, [])
272
+ return list(events) if events else []
273
+
274
+ def calculate_account_summary(self) -> AccountSummary:
275
+ """
276
+ Calculate complete account margin summary.
277
+
278
+ Returns:
279
+ AccountSummary with all margin calculations
280
+ """
281
+ # Parse health from subaccount info
282
+ # healths is a list: [initial, maintenance, unweighted]
283
+ initial_health = self._parse_health(self.subaccount_info.healths[0])
284
+ maint_health = self._parse_health(self.subaccount_info.healths[1])
285
+ unweighted_health = self._parse_health(self.subaccount_info.healths[2])
286
+
287
+ # Calculate margin usage
288
+ margin_usage = self.calculate_margin_usage_fractions(
289
+ initial_health, maint_health, unweighted_health
290
+ )
291
+
292
+ # Process all balances
293
+ spot_balances = self._create_spot_balances()
294
+ perp_balances = self._create_perp_balances()
295
+
296
+ # Calculate cross position metrics
297
+ cross_positions: list[CrossPositionMetrics] = []
298
+ for balance in perp_balances:
299
+ if balance.amount != 0:
300
+ cross_metric = self.calculate_cross_position_metrics(balance)
301
+ cross_positions.append(cross_metric)
302
+
303
+ # Calculate isolated position metrics
304
+ isolated_position_metrics: list[IsolatedPositionMetrics] = []
305
+ total_iso_net_margin = Decimal(0)
306
+ for iso_pos in self.isolated_positions:
307
+ isolated_metric = self.calculate_isolated_position_metrics(iso_pos)
308
+ isolated_position_metrics.append(isolated_metric)
309
+ total_iso_net_margin += isolated_metric.net_margin
310
+
311
+ # Calculate spot metrics
312
+ total_deposits = Decimal(0)
313
+ total_borrows = Decimal(0)
314
+ for balance in spot_balances:
315
+ value = self.calculate_spot_balance_value(balance)
316
+ if value > 0:
317
+ total_deposits += value
318
+ else:
319
+ total_borrows += abs(value)
320
+
321
+ # Calculate leverage
322
+ leverage = self.calculate_account_leverage(
323
+ spot_balances + perp_balances, unweighted_health
324
+ )
325
+
326
+ # Portfolio value = cross value + isolated net margins
327
+ portfolio_value = unweighted_health + total_iso_net_margin
328
+
329
+ return AccountSummary(
330
+ initial_health=initial_health,
331
+ maintenance_health=maint_health,
332
+ unweighted_health=unweighted_health,
333
+ margin_usage_fraction=margin_usage.initial,
334
+ maint_margin_usage_fraction=margin_usage.maintenance,
335
+ funds_available=max(Decimal(0), initial_health),
336
+ funds_until_liquidation=max(Decimal(0), maint_health),
337
+ portfolio_value=portfolio_value,
338
+ account_leverage=leverage,
339
+ cross_positions=cross_positions,
340
+ isolated_positions=isolated_position_metrics,
341
+ spot_positions=spot_balances,
342
+ total_spot_deposits=total_deposits,
343
+ total_spot_borrows=total_borrows,
344
+ )
345
+
346
+ def calculate_spot_balance_value(self, balance: BalanceWithProduct) -> Decimal:
347
+ """
348
+ Calculate quote value of a spot balance.
349
+
350
+ Formula: amount * oracle_price
351
+ """
352
+ return balance.amount * balance.oracle_price
353
+
354
+ def calculate_perp_balance_notional_value(
355
+ self, balance: BalanceWithProduct
356
+ ) -> Decimal:
357
+ """
358
+ Calculate notional value of a perp position.
359
+
360
+ Formula: abs(amount * oracle_price)
361
+ """
362
+ return abs(balance.amount * balance.oracle_price)
363
+
364
+ def calculate_perp_balance_value(self, balance: BalanceWithProduct) -> Decimal:
365
+ """
366
+ Calculate true quote value of a perp balance (unrealized PnL).
367
+
368
+ Formula: (amount * oracle_price) + v_quote_balance
369
+ """
370
+ if balance.v_quote_balance is None:
371
+ raise ValueError("Perp balance must have v_quote_balance")
372
+ return (balance.amount * balance.oracle_price) + balance.v_quote_balance
373
+
374
+ def calculate_spot_balance_health(
375
+ self, balance: BalanceWithProduct
376
+ ) -> HealthMetrics:
377
+ """
378
+ Calculate health contribution for a spot balance.
379
+
380
+ Formula: amount * oracle_price * weight
381
+ (weight is long_weight if amount >= 0, else short_weight)
382
+ """
383
+ weights = self._get_health_weights(balance)
384
+ value = balance.amount * balance.oracle_price
385
+
386
+ return HealthMetrics(
387
+ initial=value * weights.initial, maintenance=value * weights.maintenance
388
+ )
389
+
390
+ def calculate_perp_balance_health_without_pnl(
391
+ self, balance: BalanceWithProduct
392
+ ) -> HealthMetrics:
393
+ """
394
+ Calculate perp balance health WITHOUT the impact of unsettled PnL.
395
+
396
+ Shows "margin used" by the position, excluding PnL.
397
+ Formula: -1 * abs(notional_value) * (1 - long_weight)
398
+ """
399
+ initial_leverage_adjustment = Decimal(1) - balance.long_weight_initial
400
+ maint_leverage_adjustment = Decimal(1) - balance.long_weight_maintenance
401
+
402
+ base_margin_value = abs(balance.amount) * balance.oracle_price
403
+
404
+ return HealthMetrics(
405
+ initial=base_margin_value * initial_leverage_adjustment * Decimal(-1),
406
+ maintenance=base_margin_value * maint_leverage_adjustment * Decimal(-1),
407
+ )
408
+
409
+ def calculate_cross_position_margin_without_pnl(
410
+ self, balance: BalanceWithProduct
411
+ ) -> Decimal:
412
+ """
413
+ Calculate margin used for a cross position excluding unsettled PnL impact.
414
+
415
+ Used in margin manager "Margin Used" column.
416
+ Formula: max(0, -(initial_health - perp_value))
417
+ """
418
+ health_with_pnl = self.calculate_spot_balance_health(balance).initial
419
+ perp_value = self.calculate_perp_balance_value(balance)
420
+
421
+ without_unsettled_pnl = health_with_pnl - perp_value
422
+ return max(Decimal(0), -without_unsettled_pnl)
423
+
424
+ def calculate_isolated_position_net_margin(
425
+ self, base_balance: BalanceWithProduct, quote_balance: BalanceWithProduct
426
+ ) -> Decimal:
427
+ """
428
+ Calculate net margin in an isolated position.
429
+
430
+ Formula: quote_amount + (base_amount * oracle_price + v_quote_balance)
431
+ """
432
+ total_margin = quote_balance.amount
433
+ unsettled_quote = self.calculate_perp_balance_value(base_balance)
434
+ return total_margin + unsettled_quote
435
+
436
+ def calculate_isolated_position_leverage(
437
+ self, base_balance: BalanceWithProduct, net_margin: Decimal
438
+ ) -> Decimal:
439
+ """
440
+ Calculate leverage for an isolated position.
441
+
442
+ Formula: notional_value / net_margin
443
+ """
444
+ if net_margin == 0:
445
+ return Decimal(0)
446
+
447
+ notional_value = self.calculate_perp_balance_notional_value(base_balance)
448
+ return notional_value / net_margin
449
+
450
+ def calculate_margin_usage_fractions(
451
+ self, initial_health: Decimal, maint_health: Decimal, unweighted_health: Decimal
452
+ ) -> MarginUsageFractions:
453
+ """
454
+ Calculate margin usage fractions bounded to [0, 1].
455
+
456
+ Formula: (unweighted_health - health) / unweighted_health
457
+ Returns 0 if no borrows/perps or unweighted_health is 0.
458
+ """
459
+ if unweighted_health == 0:
460
+ return MarginUsageFractions(initial=Decimal(0), maintenance=Decimal(0))
461
+
462
+ if not self._has_borrows_or_perps():
463
+ return MarginUsageFractions(initial=Decimal(0), maintenance=Decimal(0))
464
+
465
+ initial_usage = (unweighted_health - initial_health) / unweighted_health
466
+ maint_usage = (unweighted_health - maint_health) / unweighted_health
467
+
468
+ # If health is negative, max out margin usage
469
+ return MarginUsageFractions(
470
+ initial=(
471
+ Decimal(1) if initial_health < 0 else min(initial_usage, Decimal(1))
472
+ ),
473
+ maintenance=(
474
+ Decimal(1) if maint_health < 0 else min(maint_usage, Decimal(1))
475
+ ),
476
+ )
477
+
478
+ def calculate_account_leverage(
479
+ self, balances: list[BalanceWithProduct], unweighted_health: Decimal
480
+ ) -> Decimal:
481
+ """
482
+ Calculate overall account leverage.
483
+
484
+ Formula: sum(abs(unweighted health for non-quote balances)) / unweighted_health
485
+ """
486
+ if unweighted_health == 0:
487
+ return Decimal(0)
488
+
489
+ if not self._has_borrows_or_perps():
490
+ return Decimal(0)
491
+
492
+ numerator = Decimal(0)
493
+ for balance in balances:
494
+ if balance.product_id == self.QUOTE_PRODUCT_ID:
495
+ continue
496
+
497
+ if self._is_zero_health(balance):
498
+ continue
499
+
500
+ if balance.balance_type == "spot":
501
+ value = abs(balance.amount * balance.oracle_price)
502
+ else:
503
+ value = self.calculate_perp_balance_notional_value(balance)
504
+
505
+ numerator += value
506
+
507
+ return numerator / unweighted_health
508
+
509
+ def calculate_cross_position_metrics(
510
+ self, balance: BalanceWithProduct
511
+ ) -> CrossPositionMetrics:
512
+ """Calculate all metrics for a cross margin position."""
513
+ notional = self.calculate_perp_balance_notional_value(balance)
514
+ health_metrics = self.calculate_spot_balance_health(balance)
515
+ margin_used = abs(
516
+ self.calculate_perp_balance_health_without_pnl(balance).initial
517
+ )
518
+
519
+ # Unsettled = full perp balance value (amount × oracle_price + v_quote_balance)
520
+ # This represents the unrealized PnL
521
+ unsettled = self.calculate_perp_balance_value(balance)
522
+
523
+ # Calculate Est. PnL if indexer data is available
524
+ # Formula: (amount × oracle_price) - netEntryUnrealized
525
+ # where netEntryUnrealized excludes funding, fees, slippage
526
+ est_pnl = self._calculate_est_pnl(balance)
527
+
528
+ return CrossPositionMetrics(
529
+ product_id=balance.product_id,
530
+ symbol=f"Product_{balance.product_id}",
531
+ position_size=balance.amount,
532
+ notional_value=notional,
533
+ est_pnl=est_pnl,
534
+ unsettled=unsettled,
535
+ margin_used=margin_used,
536
+ initial_health=health_metrics.initial,
537
+ maintenance_health=health_metrics.maintenance,
538
+ long_weight_initial=balance.long_weight_initial,
539
+ long_weight_maintenance=balance.long_weight_maintenance,
540
+ short_weight_initial=balance.short_weight_initial,
541
+ short_weight_maintenance=balance.short_weight_maintenance,
542
+ )
543
+
544
+ def _calculate_est_pnl(self, balance: BalanceWithProduct) -> Optional[Decimal]:
545
+ """
546
+ Calculate estimated PnL if indexer snapshot is available.
547
+
548
+ Formula: (position_amount × oracle_price) - netEntryUnrealized
549
+
550
+ Returns None if indexer data is not available.
551
+ """
552
+ if not self.indexer_events or balance.product_id == self.QUOTE_PRODUCT_ID:
553
+ return None
554
+
555
+ for event in self.indexer_events:
556
+ if event.product_id != balance.product_id:
557
+ continue
558
+ if event.isolated:
559
+ continue
560
+
561
+ try:
562
+ net_entry_int = int(event.net_entry_unrealized)
563
+ except (TypeError, ValueError):
564
+ continue
565
+
566
+ net_entry_unrealized = Decimal(net_entry_int) / Decimal(10**18)
567
+
568
+ current_value = balance.amount * balance.oracle_price
569
+ return current_value - net_entry_unrealized
570
+
571
+ return None
572
+
573
+ def calculate_isolated_position_metrics(
574
+ self, iso_pos: IsolatedPosition
575
+ ) -> IsolatedPositionMetrics:
576
+ """Calculate all metrics for an isolated position."""
577
+ base_balance = self._create_balance_from_isolated(iso_pos, is_base=True)
578
+ quote_balance = self._create_balance_from_isolated(iso_pos, is_base=False)
579
+
580
+ net_margin = self.calculate_isolated_position_net_margin(
581
+ base_balance, quote_balance
582
+ )
583
+ leverage = self.calculate_isolated_position_leverage(base_balance, net_margin)
584
+ notional = self.calculate_perp_balance_notional_value(base_balance)
585
+
586
+ initial_health = (
587
+ self._parse_health(iso_pos.healths[0]) if iso_pos.healths else Decimal(0)
588
+ )
589
+ maint_health = (
590
+ self._parse_health(iso_pos.healths[1])
591
+ if len(iso_pos.healths) > 1
592
+ else Decimal(0)
593
+ )
594
+
595
+ return IsolatedPositionMetrics(
596
+ product_id=base_balance.product_id,
597
+ symbol=f"Product_{base_balance.product_id}",
598
+ position_size=base_balance.amount,
599
+ notional_value=notional,
600
+ net_margin=net_margin,
601
+ leverage=leverage,
602
+ initial_health=initial_health,
603
+ maintenance_health=maint_health,
604
+ )
605
+
606
+ # Helper methods
607
+
608
+ def _get_health_weights(self, balance: BalanceWithProduct) -> HealthMetrics:
609
+ """Get appropriate weights based on position direction."""
610
+ if balance.amount >= 0:
611
+ return HealthMetrics(
612
+ initial=balance.long_weight_initial,
613
+ maintenance=balance.long_weight_maintenance,
614
+ )
615
+ else:
616
+ return HealthMetrics(
617
+ initial=balance.short_weight_initial,
618
+ maintenance=balance.short_weight_maintenance,
619
+ )
620
+
621
+ def _has_borrows_or_perps(self) -> bool:
622
+ """Check if account has any borrows or perp positions."""
623
+ for spot_bal in self.subaccount_info.spot_balances:
624
+ amount = _from_x18_decimal(spot_bal.balance.amount)
625
+ if amount < 0:
626
+ return True
627
+
628
+ for perp_bal in self.subaccount_info.perp_balances:
629
+ amount = _from_x18_decimal(perp_bal.balance.amount)
630
+ if amount != 0:
631
+ return True
632
+
633
+ return False
634
+
635
+ def _is_zero_health(self, balance: BalanceWithProduct) -> bool:
636
+ """Check if product has zero health (long_weight=0, short_weight=2)."""
637
+ return balance.long_weight_initial == 0 and balance.short_weight_initial == 2
638
+
639
+ def _parse_health(self, health: SubaccountHealth) -> Decimal:
640
+ """Parse health from SubaccountHealth model."""
641
+ return _from_x18_decimal(health.health)
642
+
643
+ def _create_spot_balances(self) -> list[BalanceWithProduct]:
644
+ """Create BalanceWithProduct objects for all spot balances."""
645
+ balances: list[BalanceWithProduct] = []
646
+ for spot_bal, spot_prod in zip(
647
+ self.subaccount_info.spot_balances, self.subaccount_info.spot_products
648
+ ):
649
+ balance = self._create_balance_with_product(spot_bal, spot_prod, "spot")
650
+ balances.append(balance)
651
+ return balances
652
+
653
+ def _create_perp_balances(self) -> list[BalanceWithProduct]:
654
+ """Create BalanceWithProduct objects for all perp balances."""
655
+ balances: list[BalanceWithProduct] = []
656
+ for perp_bal, perp_prod in zip(
657
+ self.subaccount_info.perp_balances, self.subaccount_info.perp_products
658
+ ):
659
+ balance = self._create_balance_with_product(perp_bal, perp_prod, "perp")
660
+ balances.append(balance)
661
+ return balances
662
+
663
+ def _create_balance_with_product(
664
+ self,
665
+ balance: SpotProductBalance | PerpProductBalance,
666
+ product: SpotProduct | PerpProduct,
667
+ balance_type: str,
668
+ ) -> BalanceWithProduct:
669
+ """Create a BalanceWithProduct from raw balance and product data."""
670
+ amount = _from_x18_decimal(balance.balance.amount)
671
+ oracle_price = _from_x18_decimal(product.oracle_price_x18)
672
+
673
+ v_quote = None
674
+ if balance_type == "perp":
675
+ assert isinstance(
676
+ balance, PerpProductBalance
677
+ ), "Perp balances must be PerpProductBalance"
678
+ v_quote = _from_x18_decimal(balance.balance.v_quote_balance)
679
+
680
+ return BalanceWithProduct(
681
+ product_id=balance.product_id,
682
+ amount=amount,
683
+ oracle_price=oracle_price,
684
+ long_weight_initial=_from_x18_decimal(product.risk.long_weight_initial_x18),
685
+ long_weight_maintenance=_from_x18_decimal(
686
+ product.risk.long_weight_maintenance_x18
687
+ ),
688
+ short_weight_initial=_from_x18_decimal(
689
+ product.risk.short_weight_initial_x18
690
+ ),
691
+ short_weight_maintenance=_from_x18_decimal(
692
+ product.risk.short_weight_maintenance_x18
693
+ ),
694
+ balance_type=balance_type,
695
+ v_quote_balance=v_quote,
696
+ )
697
+
698
+ def _create_balance_from_isolated(
699
+ self, iso_pos: IsolatedPosition, is_base: bool
700
+ ) -> BalanceWithProduct:
701
+ """Create BalanceWithProduct from isolated position data."""
702
+ if is_base:
703
+ perp_balance: PerpProductBalance = iso_pos.base_balance
704
+ perp_product: PerpProduct = iso_pos.base_product
705
+ return self._create_balance_with_product(perp_balance, perp_product, "perp")
706
+
707
+ spot_balance: SpotProductBalance = iso_pos.quote_balance
708
+ spot_product: SpotProduct = iso_pos.quote_product
709
+ return self._create_balance_with_product(spot_balance, spot_product, "spot")
710
+
711
+
712
+ def print_account_summary(summary: AccountSummary) -> None:
713
+ """Print formatted account summary matching UI layout."""
714
+ print("\n" + "=" * 80)
715
+ print("MARGIN MANAGER")
716
+ print("=" * 80)
717
+
718
+ # Overview
719
+ initial_margin_used = summary.unweighted_health - summary.initial_health
720
+ print("\n━━━ Overview ━━━")
721
+ print(f"Total Equity: ${summary.portfolio_value:,.2f}")
722
+ print(f"Initial Margin Used: ${initial_margin_used:,.2f}")
723
+ print(f"Initial Margin Available: ${summary.funds_available:,.2f}")
724
+ print(f"Leverage: {summary.account_leverage:.2f}x")
725
+
726
+ # 1. Unified Margin Section
727
+ print("\n━━━ UNIFIED MARGIN ━━━")
728
+ print(f"Margin Usage: {summary.margin_usage_fraction * 100:.2f}%")
729
+ print(
730
+ f"Maint. Margin Usage: {summary.maint_margin_usage_fraction * 100:.2f}%"
731
+ )
732
+ print(f"Available Margin: ${summary.funds_available:,.2f}")
733
+ print(f"Funds Until Liquidation: ${summary.funds_until_liquidation:,.2f}")
734
+
735
+ # USDT0 Balance
736
+ total_unsettled = sum(pos.unsettled for pos in summary.cross_positions)
737
+ cash_balance = summary.total_spot_deposits - summary.total_spot_borrows
738
+ net_balance = cash_balance + total_unsettled
739
+
740
+ print("\n┌─ USDT0 Balance")
741
+ print(f"│ Cash Balance: ${cash_balance:,.2f}")
742
+ print(f"│ Unsettled PnL: ${total_unsettled:,.2f}")
743
+ print(f"│ Net Balance: ${net_balance:,.2f}")
744
+ print(f"│ Init. Weight / Margin: 1.00 / ${net_balance:,.2f}")
745
+ print(f"│ Maint. Weight / Margin: 1.00 / ${net_balance:,.2f}")
746
+
747
+ # 2. Spot Balances
748
+ print("\n┌─ Balances")
749
+ spot_shown = False
750
+ for spot_pos in summary.spot_positions:
751
+ if spot_pos.amount == 0:
752
+ continue
753
+ spot_shown = True
754
+ balance_type = "Deposit" if spot_pos.amount > 0 else "Borrow"
755
+ value = abs(spot_pos.amount * spot_pos.oracle_price)
756
+
757
+ # Use appropriate weight based on position direction
758
+ if spot_pos.amount > 0: # Deposit (asset)
759
+ init_weight = spot_pos.long_weight_initial
760
+ maint_weight = spot_pos.long_weight_maintenance
761
+ else: # Borrow (liability)
762
+ init_weight = spot_pos.short_weight_initial
763
+ maint_weight = spot_pos.short_weight_maintenance
764
+
765
+ init_margin = value * init_weight
766
+ maint_margin = value * maint_weight
767
+
768
+ print(f"│ Product_{spot_pos.product_id} ({balance_type})")
769
+ print(f"│ Balance: {abs(spot_pos.amount):,.4f}")
770
+ print(f"│ Value: ${value:,.2f}")
771
+ print(f"│ Init. Weight / Margin: {init_weight:.2f} / ${init_margin:,.2f}")
772
+ print(f"│ Maint. Weight / Margin: {maint_weight:.2f} / ${maint_margin:,.2f}")
773
+
774
+ if not spot_shown:
775
+ print("│ No spot balances")
776
+
777
+ # 3. Perps
778
+ print("\n┌─ Perps")
779
+ if summary.cross_positions:
780
+ for cross_pos in summary.cross_positions:
781
+ position_type = "Long" if cross_pos.position_size > 0 else "Short"
782
+ print(f"│ {cross_pos.symbol} ({position_type} / Cross)")
783
+ print(f"│ Position: {cross_pos.position_size:,.3f}")
784
+ print(f"│ Notional: ${cross_pos.notional_value:,.2f}")
785
+
786
+ if cross_pos.est_pnl is not None:
787
+ pnl_sign = "+" if cross_pos.est_pnl >= 0 else ""
788
+ print(f"│ Est. PnL: {pnl_sign}${cross_pos.est_pnl:,.2f}")
789
+ else:
790
+ print(f"│ Est. PnL: N/A")
791
+
792
+ print(f"│ Unsettled: {cross_pos.unsettled:,.2f} USDT0")
793
+
794
+ # Use correct weight based on position direction
795
+ if cross_pos.position_size > 0: # Long
796
+ init_weight = cross_pos.long_weight_initial
797
+ maint_weight = cross_pos.long_weight_maintenance
798
+ else: # Short
799
+ init_weight = cross_pos.short_weight_initial
800
+ maint_weight = cross_pos.short_weight_maintenance
801
+
802
+ # Margin = notional × |1 - weight|
803
+ # For longs: weight < 1, so (1 - weight) > 0
804
+ # For shorts: weight > 1, so (1 - weight) < 0, we need abs
805
+ init_margin = cross_pos.notional_value * abs(1 - init_weight)
806
+ maint_margin = cross_pos.notional_value * abs(1 - maint_weight)
807
+
808
+ print(
809
+ f"│ Init. Weight / Margin: {init_weight:.2f} / ${init_margin:,.2f}"
810
+ )
811
+ print(
812
+ f"│ Maint. Weight / Margin: {maint_weight:.2f} / ${maint_margin:,.2f}"
813
+ )
814
+ else:
815
+ print("│ No perp positions")
816
+
817
+ # Spreads
818
+ print("\n┌─ Spreads")
819
+ print("│ No spreads")
820
+
821
+ # 4. Isolated Positions
822
+ print("\n━━━ ISOLATED POSITIONS ━━━")
823
+ total_isolated_margin = sum(pos.net_margin for pos in summary.isolated_positions)
824
+ print(f"Total Margin in Isolated Positions: ${total_isolated_margin:,.2f}")
825
+
826
+ if summary.isolated_positions:
827
+ print("\n┌─ Perps")
828
+ for iso_pos in summary.isolated_positions:
829
+ position_type = "Long" if iso_pos.position_size > 0 else "Short"
830
+ print(f"│ {iso_pos.symbol} ({position_type} / Isolated)")
831
+ print(f"│ Position: {iso_pos.position_size:,.3f}")
832
+ print(f"│ Notional: ${iso_pos.notional_value:,.2f}")
833
+ print(f"│ Margin: ${iso_pos.net_margin:,.2f}")
834
+ print(f"│ Leverage: {iso_pos.leverage:.2f}x")
835
+ print(f"│ Init. Health: ${iso_pos.initial_health:,.2f}")
836
+ print(f"│ Maint. Health: ${iso_pos.maintenance_health:,.2f}")
837
+ else:
838
+ print("\n┌─ Perps")
839
+ print("│ No isolated positions")
840
+
841
+ print("\n" + "=" * 80)