From 93102d472aa37481f26cc526f6622cc9f3ae073c Mon Sep 17 00:00:00 2001 From: Aishwarya MANORE Date: Wed, 11 Mar 2026 14:15:59 +0100 Subject: [PATCH] tpluspy: Add get_user_margin_info endpoint --- tplus/client/orderbook.py | 55 ++++++++++++++ tplus/model/user_margin.py | 145 +++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 tplus/model/user_margin.py diff --git a/tplus/client/orderbook.py b/tplus/client/orderbook.py index 1ca5626..756eadc 100644 --- a/tplus/client/orderbook.py +++ b/tplus/client/orderbook.py @@ -37,6 +37,10 @@ parse_trade_event, parse_trades, ) +from tplus.model.user_margin import ( + UserMarginInfo, + parse_user_margin_info, +) from tplus.model.user_solvency import ( UserSolvency, parse_user_solvency, @@ -728,3 +732,54 @@ async def get_user_solvency(self) -> UserSolvency: parsed_data: UserSolvency = parse_user_solvency(response_data) return parsed_data + + async def get_user_margin_info( + self, + sub_accounts: list[int] | None = None, + include_positions: bool = False, + ) -> UserMarginInfo: + """ + Get detailed margin breakdown for the authenticated user (async). + + Returns margin metrics for each sub-account including: + - Account equity: Total portfolio value at mark prices (no haircuts applied) + - Available margin: IM surplus - how much margin is available for new positions + - Utilized margin: Total margin currently consumed by existing positions + - Maintenance margin surplus: Distance from liquidation (MM surplus) + - Account leverage: total_notional / equity + - Per-position breakdown (optional) + + The endpoint uses min(oracle, LTP) pricing for surplus calculations, + matching the solvency check conjunction over both price types. + + Args: + sub_accounts: Optional list of sub-account indices to include. + If None or empty, returns info for all sub-accounts. + include_positions: If True, includes per-position breakdown + with size and notional value for each position. + + Returns: + UserMarginInfo containing margin breakdown per sub-account. + + Raises: + Exception: If the API response is invalid. + """ + endpoint = f"/margin/user/{self.user.public_key}" + + params: dict[str, Any] = {} + if sub_accounts: + params["sub_account"] = sub_accounts + if include_positions: + params["include_positions"] = include_positions + + self.logger.debug( + f"Getting Margin Info for user {self.user.public_key}, " + f"sub_accounts={sub_accounts}, include_positions={include_positions}" + ) + response_data = await self._request("GET", endpoint, params=params if params else None) + + if not isinstance(response_data, dict): + raise Exception("Invalid response from get_user_margin_info.") + + parsed_data: UserMarginInfo = parse_user_margin_info(response_data) + return parsed_data diff --git a/tplus/model/user_margin.py b/tplus/model/user_margin.py new file mode 100644 index 0000000..195bc59 --- /dev/null +++ b/tplus/model/user_margin.py @@ -0,0 +1,145 @@ +""" +User margin info models for the T+ margin system. + +This module provides models to represent detailed margin information for user accounts, +including: +- Account equity: Total portfolio value at mark prices (no haircuts) +- Available margin: IM surplus - how much margin is available to open new positions +- Utilized margin: Total margin consumed by existing positions +- Maintenance margin surplus: Distance from liquidation (MM surplus) +- Per-position breakdown with notional values + +The margin system uses min(oracle, LTP) pricing to compute surpluses, +matching the solvency check conjunction over both price types. +""" + +from decimal import Decimal +from enum import Enum + +from pydantic import BaseModel + + +class PositionSide(str, Enum): + """Direction of a margin position.""" + + LONG = "Long" + SHORT = "Short" + + +class PositionMarginInfo(BaseModel): + """ + Margin details for a single position. + + Attributes: + asset_id: Asset identifier (e.g., "Index:1") + side: Position direction (Long or Short) + size: Position size as a decimal (converted from inventory decimals) + notional_value: size * mark_price + """ + + asset_id: str + side: PositionSide + size: Decimal + notional_value: Decimal + + +class AccountMarginInfo(BaseModel): + """ + Margin breakdown for a single sub-account. + + Attributes: + account_equity: Total account value at mark prices (no CF/LF haircuts). + This reflects the total portfolio value using min(oracle, LTP) pricing. + + available_margin: IM surplus - margin available to open new positions. + Computed with IM pricing and CF/LF haircuts applied. A positive value + means the account can open more positions; negative means the account + fails the IM solvency check. + + utilized_margin: Total margin consumed by existing positions. + This is the adjusted liability value with LF and IM pricing applied. + Zero when the account has no positions. + + maintenance_margin_surplus: MM surplus - distance from liquidation. + How much equity can drop before liquidation begins. A positive value + means the account is safely above liquidation threshold. + + account_leverage: total_notional / equity. None if equity is zero or negative. + Represents how leveraged the account is relative to its equity. + + is_solvent: Whether the account passes the IM solvency check. + True means available_margin >= 0. + + positions: Per-position breakdown (only present if include_positions=True). + Contains size and notional value for each position. + """ + + account_equity: Decimal + available_margin: Decimal + utilized_margin: Decimal + maintenance_margin_surplus: Decimal + account_leverage: Decimal | None + is_solvent: bool + positions: list[PositionMarginInfo] | None = None + + +class UserMarginInfo(BaseModel): + """ + Top-level margin info response containing per-account data. + + Attributes: + accounts: Mapping from sub-account index (as int) to margin info. + Keys are sub-account indices (e.g., 0 for spot, 1 for margin). + """ + + accounts: dict[int, AccountMarginInfo] + + +def parse_position_margin_info(data: dict) -> PositionMarginInfo: + """Parse a single position margin info from API response.""" + return PositionMarginInfo( + asset_id=data["asset_id"], + side=PositionSide(data["side"]), + size=Decimal(data["size"]), + notional_value=Decimal(data["notional_value"]), + ) + + +def parse_account_margin_info(data: dict) -> AccountMarginInfo: + """Parse a single account margin info from API response.""" + positions = None + if data.get("positions") is not None: + positions = [parse_position_margin_info(p) for p in data["positions"]] + + leverage = None + if data.get("account_leverage") is not None: + leverage = Decimal(data["account_leverage"]) + + return AccountMarginInfo( + account_equity=Decimal(data["account_equity"]), + available_margin=Decimal(data["available_margin"]), + utilized_margin=Decimal(data["utilized_margin"]), + maintenance_margin_surplus=Decimal(data["maintenance_margin_surplus"]), + account_leverage=leverage, + is_solvent=data["is_solvent"], + positions=positions, + ) + + +def parse_user_margin_info(data: dict) -> UserMarginInfo: + """ + Parse the user margin info API response. + + Args: + data: Raw API response dict with structure: + {"accounts": {"0": {...}, "1": {...}}} + + Returns: + UserMarginInfo with parsed account margin data. + """ + accounts: dict[int, AccountMarginInfo] = {} + + for account_id, account_data in data.get("accounts", {}).items(): + accounts[int(account_id)] = parse_account_margin_info(account_data) + + return UserMarginInfo(accounts=accounts)