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.
- nado_protocol/indexer_client/query.py +23 -0
- nado_protocol/indexer_client/types/models.py +1 -1
- nado_protocol/indexer_client/types/query.py +35 -0
- nado_protocol/utils/__init__.py +2 -0
- nado_protocol/utils/balance.py +249 -0
- nado_protocol/utils/margin_manager.py +841 -0
- nado_protocol/utils/math.py +26 -0
- nado_protocol/utils/order.py +23 -29
- {nado_protocol-0.1.6.dist-info → nado_protocol-0.2.0.dist-info}/METADATA +1 -1
- {nado_protocol-0.1.6.dist-info → nado_protocol-0.2.0.dist-info}/RECORD +12 -10
- {nado_protocol-0.1.6.dist-info → nado_protocol-0.2.0.dist-info}/entry_points.txt +1 -0
- {nado_protocol-0.1.6.dist-info → nado_protocol-0.2.0.dist-info}/WHEEL +0 -0
|
@@ -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)
|