nado-protocol 0.1.7__py3-none-any.whl → 0.2.2__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.
@@ -49,6 +49,8 @@ from nado_protocol.indexer_client.types.query import (
49
49
  IndexerMerkleProofsData,
50
50
  IndexerInterestAndFundingParams,
51
51
  IndexerInterestAndFundingData,
52
+ IndexerAccountSnapshotsParams,
53
+ IndexerAccountSnapshotsData,
52
54
  IndexerTickersData,
53
55
  IndexerPerpContractsData,
54
56
  IndexerHistoricalTradesData,
@@ -459,3 +461,24 @@ class IndexerQueryClient:
459
461
  if max_trade_id is not None:
460
462
  url += f"&max_trade_id={max_trade_id}"
461
463
  return ensure_data_type(self._query_v2(url), list)
464
+
465
+ def get_multi_subaccount_snapshots(
466
+ self, params: IndexerAccountSnapshotsParams
467
+ ) -> IndexerAccountSnapshotsData:
468
+ """
469
+ Retrieves subaccount snapshots at specified timestamps.
470
+ Each snapshot is a view of the subaccount's balances at that point in time,
471
+ with tracked variables for interest, funding, etc.
472
+
473
+ Args:
474
+ params (IndexerAccountSnapshotsParams): Parameters specifying subaccounts,
475
+ timestamps, and whether to include isolated positions.
476
+
477
+ Returns:
478
+ IndexerAccountSnapshotsData: Dict mapping subaccount hex -> timestamp -> snapshot data.
479
+ Each snapshot contains balances with trackedVars including netEntryUnrealized.
480
+ """
481
+ return ensure_data_type(
482
+ self.query(IndexerAccountSnapshotsParams.parse_obj(params)).data,
483
+ IndexerAccountSnapshotsData,
484
+ )
@@ -182,7 +182,7 @@ class IndexerEventTrackedData(NadoBaseModel):
182
182
  net_funding_cumulative: str
183
183
  net_entry_unrealized: str
184
184
  net_entry_cumulative: str
185
- cumulative_volume: str
185
+ quote_volume_cumulative: str
186
186
 
187
187
 
188
188
  class IndexerEvent(IndexerBaseModel, IndexerEventTrackedData):
@@ -49,6 +49,7 @@ class IndexerQueryType(StrEnum):
49
49
  REFERRAL_CODE = "referral_code"
50
50
  SUBACCOUNTS = "subaccounts"
51
51
  USDC_PRICE = "usdc_price"
52
+ ACCOUNT_SNAPSHOTS = "account_snapshots"
52
53
  # TODO: revise once this endpoint is live
53
54
  TOKEN_MERKLE_PROOFS = "token_merkle_proofs"
54
55
  FOUNDATION_REWARDS_MERKLE_PROOFS = "foundation_rewards_merkle_proofs"
@@ -292,6 +293,17 @@ class IndexerInterestAndFundingParams(NadoBaseModel):
292
293
  limit: int
293
294
 
294
295
 
296
+ class IndexerAccountSnapshotsParams(NadoBaseModel):
297
+ """
298
+ Parameters for querying account snapshots.
299
+ """
300
+
301
+ subaccounts: list[str]
302
+ timestamps: list[int]
303
+ isolated: Optional[bool] = None
304
+ active: Optional[bool] = None
305
+
306
+
295
307
  IndexerParams = Union[
296
308
  IndexerSubaccountHistoricalOrdersParams,
297
309
  IndexerHistoricalOrdersByDigestParams,
@@ -314,6 +326,7 @@ IndexerParams = Union[
314
326
  IndexerTokenMerkleProofsParams,
315
327
  IndexerFoundationRewardsMerkleProofsParams,
316
328
  IndexerInterestAndFundingParams,
329
+ IndexerAccountSnapshotsParams,
317
330
  ]
318
331
 
319
332
 
@@ -487,6 +500,14 @@ class IndexerInterestAndFundingRequest(NadoBaseModel):
487
500
  interest_and_funding: IndexerInterestAndFundingParams
488
501
 
489
502
 
503
+ class IndexerAccountSnapshotsRequest(NadoBaseModel):
504
+ """
505
+ Request object for querying account snapshots.
506
+ """
507
+
508
+ account_snapshots: IndexerAccountSnapshotsParams
509
+
510
+
490
511
  IndexerRequest = Union[
491
512
  IndexerHistoricalOrdersRequest,
492
513
  IndexerMatchesRequest,
@@ -508,6 +529,7 @@ IndexerRequest = Union[
508
529
  IndexerTokenMerkleProofsRequest,
509
530
  IndexerFoundationRewardsMerkleProofsRequest,
510
531
  IndexerInterestAndFundingRequest,
532
+ IndexerAccountSnapshotsRequest,
511
533
  ]
512
534
 
513
535
 
@@ -681,6 +703,14 @@ class IndexerInterestAndFundingData(NadoBaseModel):
681
703
  IndexerLiquidationFeedData = list[IndexerLiquidatableAccount]
682
704
 
683
705
 
706
+ class IndexerAccountSnapshotsData(NadoBaseModel):
707
+ """
708
+ Data object for subaccount snapshots grouped by subaccount and timestamp.
709
+ """
710
+
711
+ snapshots: Dict[str, Dict[str, list[IndexerEvent]]]
712
+
713
+
684
714
  IndexerResponseData = Union[
685
715
  IndexerHistoricalOrdersData,
686
716
  IndexerMatchesData,
@@ -702,6 +732,7 @@ IndexerResponseData = Union[
702
732
  IndexerInterestAndFundingData,
703
733
  IndexerLiquidationFeedData,
704
734
  IndexerFundingRatesData,
735
+ IndexerAccountSnapshotsData,
705
736
  ]
706
737
 
707
738
 
@@ -809,6 +840,10 @@ def to_indexer_request(params: IndexerParams) -> IndexerRequest:
809
840
  IndexerInterestAndFundingRequest,
810
841
  IndexerQueryType.INTEREST_AND_FUNDING.value,
811
842
  ),
843
+ IndexerAccountSnapshotsParams: (
844
+ IndexerAccountSnapshotsRequest,
845
+ IndexerQueryType.ACCOUNT_SNAPSHOTS.value,
846
+ ),
812
847
  }
813
848
 
814
849
  RequestClass, field_name = indexer_request_mapping[type(params)]
@@ -14,9 +14,9 @@ class NadoBackendURL(StrEnum):
14
14
  DEVNET_TRIGGER = "http://localhost:8080"
15
15
 
16
16
  # testnets
17
- TESTNET_GATEWAY = "https://gateway.test.nado-backend.xyz/v1"
18
- TESTNET_INDEXER = "https://archive.test.nado-backend.xyz/v1"
19
- TESTNET_TRIGGER = "https://trigger.test.nado-backend.xyz/v1"
17
+ TESTNET_GATEWAY = "https://gateway.test.nado.xyz/v1"
18
+ TESTNET_INDEXER = "https://archive.test.nado.xyz/v1"
19
+ TESTNET_TRIGGER = "https://trigger.test.nado.xyz/v1"
20
20
 
21
21
 
22
22
  PrivateKey = str
@@ -0,0 +1,249 @@
1
+ """
2
+ Balance Value Calculation Utilities
3
+ """
4
+
5
+ from decimal import Decimal
6
+ from typing import Union
7
+ from nado_protocol.engine_client.types.models import (
8
+ SpotProduct,
9
+ PerpProduct,
10
+ SpotProductBalance,
11
+ PerpProductBalance,
12
+ )
13
+ from nado_protocol.utils.math import from_x18
14
+
15
+
16
+ def calculate_spot_balance_value(
17
+ amount: Union[Decimal, float, str], oracle_price: Union[Decimal, float, str]
18
+ ) -> Decimal:
19
+ """
20
+ Calculate the quote value of a spot balance.
21
+
22
+ Formula: amount * oracle_price
23
+
24
+ This is used for:
25
+ - Calculating health contributions
26
+ - Determining deposits vs borrows
27
+ - Portfolio value calculations
28
+
29
+ Args:
30
+ amount: Token amount (can be negative for borrows)
31
+ oracle_price: Oracle price in quote currency
32
+
33
+ Returns:
34
+ Value in quote currency (positive for deposits, negative for borrows)
35
+
36
+ Example:
37
+ >>> calculate_spot_balance_value(100, 2000) # 100 ETH at $2000
38
+ Decimal('200000')
39
+ >>> calculate_spot_balance_value(-50, 2000) # 50 ETH borrowed
40
+ Decimal('-100000')
41
+ """
42
+ amount_dec = Decimal(str(amount))
43
+ price_dec = Decimal(str(oracle_price))
44
+ return amount_dec * price_dec
45
+
46
+
47
+ def calculate_perp_balance_notional_value(
48
+ amount: Union[Decimal, float, str], oracle_price: Union[Decimal, float, str]
49
+ ) -> Decimal:
50
+ """
51
+ Calculate the notional value of a perp position.
52
+
53
+ Formula: abs(amount * oracle_price)
54
+
55
+ This represents the total size of the position in quote currency terms,
56
+ regardless of direction (long or short).
57
+
58
+ Args:
59
+ amount: Position size (positive for long, negative for short)
60
+ oracle_price: Oracle price in quote currency
61
+
62
+ Returns:
63
+ Absolute notional value in quote currency
64
+
65
+ Example:
66
+ >>> calculate_perp_balance_notional_value(10, 50000) # 10 BTC long
67
+ Decimal('500000')
68
+ >>> calculate_perp_balance_notional_value(-10, 50000) # 10 BTC short
69
+ Decimal('500000')
70
+ """
71
+ amount_dec = Decimal(str(amount))
72
+ price_dec = Decimal(str(oracle_price))
73
+ return abs(amount_dec * price_dec)
74
+
75
+
76
+ def calculate_perp_balance_value(
77
+ amount: Union[Decimal, float, str],
78
+ oracle_price: Union[Decimal, float, str],
79
+ v_quote_balance: Union[Decimal, float, str],
80
+ ) -> Decimal:
81
+ """
82
+ Calculate the true quote value of a perp balance (unrealized PnL).
83
+
84
+ Formula: (amount * oracle_price) + v_quote_balance
85
+
86
+ The v_quote_balance represents:
87
+ - Unrealized PnL from price changes
88
+ - Accumulated funding payments
89
+ - Entry cost adjustments
90
+
91
+ This value is what would be added to your balance if the position were closed.
92
+
93
+ Args:
94
+ amount: Position size
95
+ oracle_price: Oracle price in quote currency
96
+ v_quote_balance: Virtual quote balance (unsettled PnL)
97
+
98
+ Returns:
99
+ Total value in quote currency (can be positive or negative)
100
+
101
+ Example:
102
+ >>> # Long 10 BTC at $50k, now at $51k, with funding
103
+ >>> calculate_perp_balance_value(10, 51000, -500000)
104
+ Decimal('10000') # $10k profit
105
+ """
106
+ amount_dec = Decimal(str(amount))
107
+ price_dec = Decimal(str(oracle_price))
108
+ v_quote_dec = Decimal(str(v_quote_balance))
109
+ return (amount_dec * price_dec) + v_quote_dec
110
+
111
+
112
+ def parse_spot_balance_value(
113
+ balance: SpotProductBalance, product: SpotProduct
114
+ ) -> Decimal:
115
+ """
116
+ Parse spot balance value from raw SDK types.
117
+
118
+ This is a convenience function that extracts values from the SDK types
119
+ and calls calculate_spot_balance_value.
120
+
121
+ Args:
122
+ balance: Spot balance from subaccount info
123
+ product: Spot product information
124
+
125
+ Returns:
126
+ Balance value in quote currency
127
+ """
128
+ amount = Decimal(from_x18(int(balance.balance.amount)))
129
+ oracle_price = Decimal(from_x18(int(product.oracle_price_x18)))
130
+ return calculate_spot_balance_value(amount, oracle_price)
131
+
132
+
133
+ def parse_perp_balance_notional_value(
134
+ balance: PerpProductBalance, product: PerpProduct
135
+ ) -> Decimal:
136
+ """
137
+ Parse perp notional value from raw SDK types.
138
+
139
+ Args:
140
+ balance: Perp balance from subaccount info
141
+ product: Perp product information
142
+
143
+ Returns:
144
+ Notional value in quote currency
145
+ """
146
+ amount = Decimal(from_x18(int(balance.balance.amount)))
147
+ oracle_price = Decimal(from_x18(int(product.oracle_price_x18)))
148
+ return calculate_perp_balance_notional_value(amount, oracle_price)
149
+
150
+
151
+ def parse_perp_balance_value(
152
+ balance: PerpProductBalance, product: PerpProduct
153
+ ) -> Decimal:
154
+ """
155
+ Parse perp balance value (unrealized PnL) from raw SDK types.
156
+
157
+ Args:
158
+ balance: Perp balance from subaccount info
159
+ product: Perp product information
160
+
161
+ Returns:
162
+ Balance value in quote currency
163
+ """
164
+ amount = Decimal(from_x18(int(balance.balance.amount)))
165
+ oracle_price = Decimal(from_x18(int(product.oracle_price_x18)))
166
+ v_quote = Decimal(from_x18(int(balance.balance.v_quote_balance)))
167
+ return calculate_perp_balance_value(amount, oracle_price, v_quote)
168
+
169
+
170
+ def calculate_total_spot_deposits_and_borrows(
171
+ balances: list[tuple[SpotProductBalance, SpotProduct]]
172
+ ) -> tuple[Decimal, Decimal]:
173
+ """
174
+ Calculate total spot deposits and borrows across all balances.
175
+
176
+ Args:
177
+ balances: List of (balance, product) tuples
178
+
179
+ Returns:
180
+ Tuple of (total_deposits, total_borrows) in quote currency
181
+ Both values are positive (borrows is absolute value)
182
+
183
+ Example:
184
+ >>> balances = [(usdt_balance, usdt_product), (eth_balance, eth_product)]
185
+ >>> deposits, borrows = calculate_total_spot_deposits_and_borrows(balances)
186
+ >>> deposits # Total deposits
187
+ Decimal('10000')
188
+ >>> borrows # Total borrows (absolute value)
189
+ Decimal('5000')
190
+ """
191
+ total_deposits = Decimal(0)
192
+ total_borrows = Decimal(0)
193
+
194
+ for balance, product in balances:
195
+ value = parse_spot_balance_value(balance, product)
196
+ if value > 0:
197
+ total_deposits += value
198
+ else:
199
+ total_borrows += abs(value)
200
+
201
+ return total_deposits, total_borrows
202
+
203
+
204
+ def calculate_total_perp_notional(
205
+ balances: list[tuple[PerpProductBalance, PerpProduct]]
206
+ ) -> Decimal:
207
+ """
208
+ Calculate total notional value across all perp positions.
209
+
210
+ Args:
211
+ balances: List of (balance, product) tuples
212
+
213
+ Returns:
214
+ Total notional value in quote currency
215
+
216
+ Example:
217
+ >>> balances = [(btc_perp_balance, btc_perp_product)]
218
+ >>> total = calculate_total_perp_notional(balances)
219
+ >>> total
220
+ Decimal('500000') # Total position size
221
+ """
222
+ total = Decimal(0)
223
+ for balance, product in balances:
224
+ total += parse_perp_balance_notional_value(balance, product)
225
+ return total
226
+
227
+
228
+ def calculate_total_perp_value(
229
+ balances: list[tuple[PerpProductBalance, PerpProduct]]
230
+ ) -> Decimal:
231
+ """
232
+ Calculate total unrealized PnL across all perp positions.
233
+
234
+ Args:
235
+ balances: List of (balance, product) tuples
236
+
237
+ Returns:
238
+ Total unrealized PnL in quote currency (can be positive or negative)
239
+
240
+ Example:
241
+ >>> balances = [(btc_perp_balance, btc_perp_product)]
242
+ >>> total_pnl = calculate_total_perp_value(balances)
243
+ >>> total_pnl
244
+ Decimal('10000') # $10k unrealized profit
245
+ """
246
+ total = Decimal(0)
247
+ for balance, product in balances:
248
+ total += parse_perp_balance_value(balance, product)
249
+ return total
@@ -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, Union, 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: Union[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: Union[SpotProductBalance, PerpProductBalance],
666
+ product: Union[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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nado-protocol
3
- Version: 0.1.7
3
+ Version: 0.2.2
4
4
  Summary: Nado Protocol SDK
5
5
  Keywords: nado protocol,nado sdk,nado protocol api
6
6
  Author: Jeury Mejia
@@ -47,10 +47,10 @@ nado_protocol/engine_client/types/models.py,sha256=hf9HD8rct2XZIqPznfydBq7jqKiJ3
47
47
  nado_protocol/engine_client/types/query.py,sha256=N6kPuWSUowMeZnUKpxF9NnMKZBKKe-0Q_B9ccFZkjQA,12185
48
48
  nado_protocol/engine_client/types/stream.py,sha256=F_AKqU4pClzUcM6D9Dc79f6RmraahJzj2-hQSXtQ0vQ,159
49
49
  nado_protocol/indexer_client/__init__.py,sha256=ea2exePtguCxJsBlisOQPtnFk8hRmFugeQAuSAaPhoc,922
50
- nado_protocol/indexer_client/query.py,sha256=onhEAoKtBIB_fPJcNLKJg8PnEiQ5qLtTGDwHRryLXhE,16124
50
+ nado_protocol/indexer_client/query.py,sha256=Tm4IYmU0T_9ayFTce8SMRPi_NN8QgyHDr4rFC3h7hSw,17102
51
51
  nado_protocol/indexer_client/types/__init__.py,sha256=r3-jxMjrFNbA1nMRSGZjsE3qypmyWab6k20_gasdwL4,3548
52
- nado_protocol/indexer_client/types/models.py,sha256=sh65JwaOeV8r2lENaVHopnoyP3-9VeqUa0qF4k84Tes,7527
53
- nado_protocol/indexer_client/types/query.py,sha256=WX7CzVzh239wbv4tQdPBKVal5wH6JPOEN0Bd_DPvzdI,18939
52
+ nado_protocol/indexer_client/types/models.py,sha256=vD1YHHeK15hcU5_O4al7001Fi-gTl6EARMuR-Y8stOc,7533
53
+ nado_protocol/indexer_client/types/query.py,sha256=JlI2TXjezTSgvaP8zzb3bO9GADzeQkay1CScO954RAw,19857
54
54
  nado_protocol/trigger_client/__init__.py,sha256=kD_WJWGOCDwX7GvGF5VGZibWR2uPYRcWpIWht31PYR4,545
55
55
  nado_protocol/trigger_client/execute.py,sha256=VkVla3SF6MX-ZJC_wZG72em41MPAKX-jv1_Lh4ydezU,15089
56
56
  nado_protocol/trigger_client/query.py,sha256=7M7opYEddNo0Wf9VQ7rha-WaoFQVv5F5OI-YLSRWrpk,2705
@@ -59,13 +59,15 @@ nado_protocol/trigger_client/types/execute.py,sha256=Ij_gCl3ZzhouWF7JxEY_U6hUbe9
59
59
  nado_protocol/trigger_client/types/models.py,sha256=ZVDF3MFWoR39JBaTmSOTl1WnRnw46hjX-WN_a-g6zKk,1638
60
60
  nado_protocol/trigger_client/types/query.py,sha256=O6qhFLL2IREHc_mf-jFMyuVSzH1RuwjM-8noolLmEaQ,5288
61
61
  nado_protocol/utils/__init__.py,sha256=heFEgVHHce8nAqQcmEMR69aZ8aaJAYXukJ2-W_KTFwY,1446
62
- nado_protocol/utils/backend.py,sha256=UqmHN_jmTAOnRiUQcUTZUyTGeM7FwjauwSH5h8UxiLQ,3781
62
+ nado_protocol/utils/backend.py,sha256=aYSmXuY8fO2Xmsem1UaVkjwSBm_--IkP2nLaRFFggAk,3757
63
+ nado_protocol/utils/balance.py,sha256=Pf6tdcxZSn2Ou8M3XJnBIhsMB85-E7VkZDUIaMuEIbg,7435
63
64
  nado_protocol/utils/bytes32.py,sha256=FHeHs9Sf-S7GdFTF6ikuz_sEkM-fpRW-g3kbZawlMd4,5299
64
65
  nado_protocol/utils/enum.py,sha256=_Ij7Ai1H_Bj0OPBjmLhGvQjATXYyqD0DLqpUC-br99s,99
65
66
  nado_protocol/utils/exceptions.py,sha256=j5U-7JEQRnMTe_nFOrF2x-9kaYKgTrPBG9XkwXVm0cI,1644
66
67
  nado_protocol/utils/execute.py,sha256=zK3j2oTzFQlm-075thB_53iePjXpBnKF02RMEAqzl8I,11794
67
68
  nado_protocol/utils/expiration.py,sha256=8g0KI8v-Vnoj8rr-8ik3RxmGp9fXdiVbCVGof_2CDm4,532
68
69
  nado_protocol/utils/interest.py,sha256=pl5N2s7sux9Z0orPthdmFkldljIPwFTpLx1XC9k1oQk,2498
70
+ nado_protocol/utils/margin_manager.py,sha256=N5jFrcOZLLARWKi4JHwarKcxBqWsw-uPjMeOVdl20bo,31792
69
71
  nado_protocol/utils/math.py,sha256=TyuMb9ZpwfjLh5fMrNnr_QQWSl3bVZnjODglcxT_XTI,1771
70
72
  nado_protocol/utils/model.py,sha256=feU7cp0mSgNp-Z0dmC6VSFA6Mkjv3rNfoXdqsoZ7uGU,2087
71
73
  nado_protocol/utils/nonce.py,sha256=DHNn5mzXdJRope8QLcDCMFe7Bk4PXLoR9VaFopfse7I,886
@@ -73,7 +75,7 @@ nado_protocol/utils/order.py,sha256=Q9TlcotvnB395dPhaKpn0EeN1WNTkpYBTUovlirr1_Y,
73
75
  nado_protocol/utils/subaccount.py,sha256=WJ7lgU2RekuzJAZH-hhCTbIBlRsl2oHozBm7OEMRV74,495
74
76
  nado_protocol/utils/time.py,sha256=tEwmrkc5VdzKLlgkJIAq2ce-nhrduJZNtVPydrrnTHs,360
75
77
  nado_protocol/utils/twap.py,sha256=hfBVK0CBa8m4uBArxTnNRoJr3o1rJucyopR_8_9gkOo,6197
76
- nado_protocol-0.1.7.dist-info/METADATA,sha256=AFlOXkYb9J5SwHQbJp0ZClgJr77ccxgvY4I1M7YaeHI,9558
77
- nado_protocol-0.1.7.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
78
- nado_protocol-0.1.7.dist-info/entry_points.txt,sha256=7xMbwQYtf2zfvzWdBaw5d5hp5TTv5Xia5WPsqxkvKuU,300
79
- nado_protocol-0.1.7.dist-info/RECORD,,
78
+ nado_protocol-0.2.2.dist-info/METADATA,sha256=1AfhycOzMdvGX7EfySXt4szIMP1dbl5R80mSNKXy-hI,9558
79
+ nado_protocol-0.2.2.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
80
+ nado_protocol-0.2.2.dist-info/entry_points.txt,sha256=Df0O9lFc-m0SyOh6_d9FHeG1OT-esxGm-p_z7rTT9h0,340
81
+ nado_protocol-0.2.2.dist-info/RECORD,,
@@ -3,6 +3,7 @@ client-sanity=sanity.nado_client:run
3
3
  contracts-sanity=sanity.contracts:run
4
4
  engine-sanity=sanity.engine_client:run
5
5
  indexer-sanity=sanity.indexer_client:run
6
+ margin-sanity=sanity.margin_manager:run
6
7
  rewards-sanity=sanity.rewards:run
7
8
  signing-sanity=sanity.signing:run
8
9
  test=pytest:main