From a28aa8c18f865c04490de97297be4a3643e13315 Mon Sep 17 00:00:00 2001 From: CaoKha Date: Thu, 29 Oct 2026 15:23:58 +0100 Subject: [PATCH 1/4] feat: add /v3/perp routes and GET /v3/markets Add client.perp (ticker, candles, funding settlements) and client.perp.isolated (all isolated trade routes) with typed models that take the market key (instrument + pair), plus client.get_markets(). Deprecate the client.futures methods that have a perp replacement. Fix the request signature for bodies with nested whole floats. --- CHANGELOG.md | 22 + README.md | 87 +++- examples/rest_v3.py | 222 +++++----- pyproject.toml | 2 +- .../rest/v3/_internal/__init__.py | 4 +- src/lnmarkets_sdk/rest/v3/_internal/utils.py | 34 +- .../rest/v3/http/client/__init__.py | 41 +- .../rest/v3/http/client/futures/__init__.py | 13 + .../rest/v3/http/client/futures/isolated.py | 86 +++- .../rest/v3/http/client/perp/__init__.py | 105 +++++ .../rest/v3/http/client/perp/isolated.py | 387 ++++++++++++++++++ src/lnmarkets_sdk/rest/v3/models/markets.py | 57 +++ src/lnmarkets_sdk/rest/v3/models/perp.py | 315 ++++++++++++++ .../rest/v3/tests/test_integration.py | 199 +++++++++ src/lnmarkets_sdk/rest/v3/tests/test_unit.py | 220 ++++++++++ uv.lock | 2 +- 16 files changed, 1672 insertions(+), 124 deletions(-) create mode 100644 src/lnmarkets_sdk/rest/v3/http/client/perp/__init__.py create mode 100644 src/lnmarkets_sdk/rest/v3/http/client/perp/isolated.py create mode 100644 src/lnmarkets_sdk/rest/v3/models/markets.py create mode 100644 src/lnmarkets_sdk/rest/v3/models/perp.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 88c7367..9ba0cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-10-29 + +Multi-market perp API (`/v3/perp`) and `GET /v3/markets`. + +### Added + +- `client.get_markets()` (`GET /v3/markets`): the released markets, plus the early-access markets your API key can access. Returns `Market` models (`lnmarkets_sdk.rest.v3.models.markets`). +- `client.perp` (`PerpClient`) for `/v3/perp`: `get_ticker()`, `get_candles()` and `get_funding_settlements()`. +- `client.perp.isolated` (`PerpIsolatedClient`) for `/v3/perp/isolated`: `new_trade()`, `cancel()`, `cancel_all()`, `close()`, `add_margin()`, `cash_in()`, `update_stoploss()`, `update_takeprofit()`, `remove_stoploss()`, `remove_takeprofit()`, `get_open_trades()`, `get_running_trades()`, `get_closed_trades()`, `get_canceled_trades()` and `get_funding_fees()`. +- Perp models in `lnmarkets_sdk.rest.v3.models.perp`. Every perp call takes the market key (`instrument` + `pair`). Trades, tickers, funding settlements and funding fees include the quanto fields (`quanto_adjustment_rate`, `quanto_adjustment_fee`, `sum_quanto_adjustment_fees`), the notional fields, and the `mark` price. +- Cursor pagination for the perp history reads: pass `next_cursor` back as `cursor`. The cursor is an object (`PerpCursor(value=..., id=...)`), sent as `cursor[value]` and `cursor[id]` in the query. +- The public perp routes and `get_markets()` sign the request when the client has credentials, so early-access markets work. + +### Deprecated + +- `client.futures.get_ticker()`, `get_candles()`, `get_funding_settlements()` and every `client.futures.isolated` method. They still work and raise a `DeprecationWarning` that names the `client.perp` method to use with `instrument="inverse_perp", pair="btc_usd"`. `client.futures.get_leaderboard()` and `client.futures.cross` have no perp replacement yet and are not deprecated. + +### Fixed + +- Request signature for bodies with nested whole floats (e.g. `{"stoploss": {"value": 64000.0}}`): nested values are now sent as `64000`, like the server's `JSON.stringify`, so the signature matches. +- `futures.isolated.update_takeprofit()` no longer prints its params. + ## [0.2.0] - 2026-10-26 Models re-synced with the published v3 API reference. diff --git a/README.md b/README.md index 66b4b84..7203b7a 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,15 @@ For public endpoints, you can just do this: ```python from lnmarkets_sdk.rest.v3.http.client import LNMClient +from lnmarkets_sdk.rest.v3.models.perp import PerpMarketKey import asyncio async with LNMClient() as client: - ticker = await client.futures.get_ticker() + markets = await client.get_markets() await asyncio.sleep(1) - leaderboard = await client.futures.get_leaderboard() + ticker = await client.perp.get_ticker( + PerpMarketKey(instrument="inverse_perp", pair="btc_usd") + ) ``` Remember to sleep between requests, as the rate limit is 1 requests per second for public endpoints. @@ -65,6 +68,50 @@ async with LNMClient(config) as client: ) ``` +### Perp markets + +LN Markets has more than one perp market: the Bitcoin perp (`inverse_perp` + `btc_usd`) and quanto perps such as qGOLD (`quanto_perp` + `xau_usdt`). The `client.perp` methods take the market key (`instrument` + `pair`). `client.get_markets()` lists the markets you can use; it also lists the early-access markets your API key can access. + +```python +from lnmarkets_sdk.rest.v3.models.perp import ( + GetPerpTradesParams, + PerpOrder, + PerpStoploss, + PerpTradeParams, + PerpUpdateTakeprofitParams, +) + +GOLD = {"instrument": "quanto_perp", "pair": "xau_usdt"} + +async with LNMClient(config) as client: + trade = await client.perp.isolated.new_trade( + PerpOrder( + **GOLD, + type="market", + side="buy", + margin=10_000, # or quantity=..., in the market's unit (ounces for qGOLD) + leverage=10, + stoploss=PerpStoploss(mode="trailing", value=0.05), + ) + ) + await client.perp.isolated.update_takeprofit( + PerpUpdateTakeprofitParams(id=trade.id, value=5_000, **GOLD) + ) + closed = await client.perp.isolated.close(PerpTradeParams(id=trade.id, **GOLD)) + + # Reads without a market key cover every market. + running = await client.perp.isolated.get_running_trades() + + # History reads use cursor pagination: pass `next_cursor` back as `cursor`. + params = GetPerpTradesParams(**GOLD, limit=50) + page = await client.perp.isolated.get_closed_trades(params) + while page.next_cursor: + params.cursor = page.next_cursor + page = await client.perp.isolated.get_closed_trades(params) +``` + +The `client.futures` methods that have a `client.perp` replacement (ticker, candles, funding settlements and all isolated methods) are deprecated and raise a `DeprecationWarning`. They still work and trade BTC/USD only. To move, call the `client.perp` method with the same name and `instrument="inverse_perp", pair="btc_usd"`. Note that `PerpOrder.stoploss` is an object (`PerpStoploss(mode=..., value=...)`), not a number plus `stoploss_mode`. + Check our [REST example](./examples/rest_v3.py) for more details. ### Available Methods @@ -75,6 +122,9 @@ Check our [REST example](./examples/rest_v3.py) for more details. # Ping client.ping() +# Markets +client.get_markets() + # Account šŸ”’ client.account.get_account() client.account.get_bitcoin_address() @@ -88,17 +138,40 @@ client.account.get_on_chain_deposits() client.account.get_on_chain_withdrawals() client.account.read_notifications() -# Futures -client.futures.get_ticker() +# Perp +client.perp.get_ticker() +client.perp.get_candles() +client.perp.get_funding_settlements() + +# Perp Isolated šŸ”’ +client.perp.isolated.new_trade() +client.perp.isolated.get_running_trades() +client.perp.isolated.get_open_trades() +client.perp.isolated.get_closed_trades() +client.perp.isolated.get_canceled_trades() +client.perp.isolated.close() +client.perp.isolated.cancel() +client.perp.isolated.cancel_all() +client.perp.isolated.add_margin() +client.perp.isolated.cash_in() +client.perp.isolated.update_stoploss() +client.perp.isolated.update_takeprofit() +client.perp.isolated.remove_stoploss() +client.perp.isolated.remove_takeprofit() +client.perp.isolated.get_funding_fees() + +# Futures (BTC/USD only) +client.futures.get_ticker() # deprecated, use client.perp client.futures.get_leaderboard() -client.futures.get_candles() -client.futures.get_funding_settlements() +client.futures.get_candles() # deprecated, use client.perp +client.futures.get_funding_settlements() # deprecated, use client.perp -# Futures Isolated šŸ”’ +# Futures Isolated šŸ”’ (deprecated, use client.perp.isolated) client.futures.isolated.new_trade() client.futures.isolated.get_running_trades() client.futures.isolated.get_open_trades() client.futures.isolated.get_closed_trades() +client.futures.isolated.get_canceled_trades() client.futures.isolated.close() client.futures.isolated.cancel() client.futures.isolated.cancel_all() diff --git a/examples/rest_v3.py b/examples/rest_v3.py index ce69fdb..0f87402 100644 --- a/examples/rest_v3.py +++ b/examples/rest_v3.py @@ -27,25 +27,40 @@ GetNotificationsParams, ) from lnmarkets_sdk.rest.v3.models.futures_cross import FuturesCrossOrderLimit -from lnmarkets_sdk.rest.v3.models.futures_data import ( - GetCandlesParams, - GetFundingSettlementsParams, -) -from lnmarkets_sdk.rest.v3.models.futures_isolated import ( - CloseTradeParams, - FuturesOrder, - GetClosedTradesParams, - GetIsolatedFundingFeesParams, - RemoveStoplossParams, - RemoveTakeprofitParams, - UpdateStoplossParams, - UpdateTakeprofitParams, +from lnmarkets_sdk.rest.v3.models.perp import ( + GetPerpCandlesParams, + GetPerpFundingFeesParams, + GetPerpFundingSettlementsParams, + GetPerpTradesParams, + PerpMarketFilter, + PerpMarketKey, + PerpOrder, + PerpStoploss, + PerpTradeParams, + PerpUpdateStoplossParams, + PerpUpdateTakeprofitParams, ) Network = Literal["mainnet", "signet"] load_dotenv() +BTC_USD = PerpMarketKey(instrument="inverse_perp", pair="btc_usd") + + +async def pick_market(client: LNMClient) -> PerpMarketKey: + """Pick a perp market other than BTC/USD (e.g. qGOLD), else BTC/USD. + + Which markets you can trade depends on the network and on your API key. + """ + for market in await client.get_markets(): + if market.instrument in ("inverse_perp", "quanto_perp") and ( + market.instrument, + market.pair, + ) != (BTC_USD.instrument, BTC_USD.pair): + return PerpMarketKey(instrument=market.instrument, pair=market.pair) + return BTC_USD + async def example_public_endpoints(network: Network): """Example: Make public requests without authentication.""" @@ -59,9 +74,18 @@ async def example_public_endpoints(network: Network): # All these requests share the same connection pool print("\nšŸ”„ Making multiple requests with connection reuse...") - # Get futures ticker - ticker = await client.futures.get_ticker() - print("\n--- Futures Ticker ---") + # List the markets + markets = await client.get_markets() + print("\n--- Markets ---") + for market in markets: + print(f" {market.id} ({market.display_name}): {market.status}") + + # Get a perp ticker + await asyncio.sleep(1) + market = await pick_market(client) + await asyncio.sleep(1) + ticker = await client.perp.get_ticker(market) + print(f"\n--- Perp Ticker ({market.instrument}:{market.pair}) ---") pprint(ticker.model_dump(), indent=2, width=100) # Get leaderboard @@ -88,29 +112,35 @@ async def example_public_endpoints(network: Network): print("\n--- Server Time ---") print(f"Response: {time_response}") - # Get futures candles + # Get perp candles, two pages await asyncio.sleep(1) - candles_params = GetCandlesParams( - from_="2024-01-01T00:00:00.000Z", - range="1h", - limit=5, + candles_params = GetPerpCandlesParams( + **market.model_dump(), range="1h", limit=3 ) - candles = await client.futures.get_candles(candles_params) - print("\n--- Futures Candles (Last 5) ---") - for candle in candles.data[:3]: # Show first 3 + candles = await client.perp.get_candles(candles_params) + print("\n--- Perp Candles (Last 3) ---") + for candle in candles.data: print( f"Time: {candle.time}, OHLC: {candle.open}/{candle.high}/{candle.low}/{candle.close}" ) + if candles.next_cursor: + # Pass `next_cursor` back as `cursor` to get the next page. + await asyncio.sleep(1) + candles_params.cursor = candles.next_cursor + older = await client.perp.get_candles(candles_params) + print(f"Next page starts at: {older.data[0].time if older.data else None}") # Get funding settlements await asyncio.sleep(1) - funding_settlements = await client.futures.get_funding_settlements( - GetFundingSettlementsParams(limit=5) + funding_settlements = await client.perp.get_funding_settlements( + GetPerpFundingSettlementsParams(**market.model_dump(), limit=5) ) print("\n--- Funding Settlements (Last 5) ---") for settlement in funding_settlements.data[:3]: # Show first 3 print( - f"Rate: {settlement.funding_rate}, Price: {settlement.fixing_price}, Time: {settlement.time}" + f"Rate: {settlement.funding_rate}, " + f"Quanto adjustment: {settlement.quanto_adjustment_rate}, " + f"Price: {settlement.fixing_price}, Time: {settlement.time}" ) @@ -185,43 +215,50 @@ async def example_authenticated_endpoints(network: Network): await client.account.read_notifications() print("\n--- read_notifications() called ---") - # Get running trades + # Running trades of every perp market await asyncio.sleep(1) - running_trades = await client.futures.isolated.get_running_trades() - print("\n--- Running Isolated Trades ---") + running_trades = await client.perp.isolated.get_running_trades() + print("\n--- Running Isolated Trades (all markets) ---") print(f"Count: {len(running_trades)}") for trade in running_trades[:3]: # Show first 3 side = "LONG" if trade.side == "buy" else "SHORT" print( - f" {side} - Margin: {trade.margin} sats, Leverage: {trade.leverage}x, PL: {trade.pl} sats" + f" {trade.instrument}:{trade.pair} {side} - Margin: {trade.margin} sats, " + f"Leverage: {trade.leverage}x, PL: {trade.pl} sats" ) - # Get open trades + # Open trades of one market await asyncio.sleep(1) - open_trades = await client.futures.isolated.get_open_trades() - print(f"\n--- Open Isolated Trades (Count: {len(open_trades)}) ---") + market = await pick_market(client) + open_trades = await client.perp.isolated.get_open_trades( + PerpMarketFilter(**market.model_dump()) + ) + print(f"\n--- Open Isolated Trades on {market.pair} ---") for trade in open_trades[:3]: # Show first 3 side = "LONG" if trade.side == "buy" else "SHORT" print(f" {side} - Price: {trade.price}, Quantity: {trade.quantity}") - # Get closed trades + # Closed trades await asyncio.sleep(1) - closed_trades = await client.futures.isolated.get_closed_trades( - GetClosedTradesParams(limit=5) + closed_trades = await client.perp.isolated.get_closed_trades( + GetPerpTradesParams(limit=5) ) print(f"\n--- Closed Isolated Trades (Last {len(closed_trades.data)}) ---") for trade in closed_trades.data[:3]: # Show first 3 side = "LONG" if trade.side == "buy" else "SHORT" - print(f" {side} - PL: {trade.pl} sats, Closed: {trade.closed}") + print(f" {trade.pair} {side} - PL: {trade.pl} sats") - # Get isolated funding fees + # Isolated funding fees await asyncio.sleep(1) - isolated_fees = await client.futures.isolated.get_funding_fees( - GetIsolatedFundingFeesParams(limit=5) + isolated_fees = await client.perp.isolated.get_funding_fees( + GetPerpFundingFeesParams(limit=5) ) print(f"\n--- Isolated Funding Fees (Last {len(isolated_fees.data)}) ---") for fee in isolated_fees.data[:3]: # Show first 3 - print(f"Fee: {fee.fee} sats, Time: {fee.time}") + print( + f"{fee.pair} Fee: {fee.fee} sats, " + f"Quanto: {fee.quanto_adjustment_fee} sats, Time: {fee.time}" + ) # Get cross margin position try: @@ -249,87 +286,62 @@ async def example_authenticated_endpoints(network: Network): except Exception as e: print(f"Error: {e}") - print("\n --- Open isolated market trade then update take profit ---") + print(f"\n --- Trade on {market.instrument}:{market.pair} ---") try: - new_trade_params = FuturesOrder( - type="market", - side="buy", - quantity=1, - leverage=10, - takeprofit=150_000, + # Size the trade by margin: the quantity unit depends on the market + # (USD for BTC/USD, ounces for qGOLD, ...). + opened = await client.perp.isolated.new_trade( + PerpOrder( + **market.model_dump(), + type="market", + side="buy", + margin=10_000, + leverage=10, + ) ) - opened = await client.futures.isolated.new_trade(new_trade_params) - print(f"Opened trade ID: {opened.id}, takeprofit: {opened.takeprofit}") - - await asyncio.sleep(1) - tp_params = UpdateTakeprofitParams(id=opened.id, value=200_000) - updated = await client.futures.isolated.update_takeprofit(tp_params) - print(f"New take profit: {updated.takeprofit}") + print(f"Opened trade ID: {opened.id}, quantity: {opened.quantity}") + trade = PerpTradeParams(id=opened.id, **market.model_dump()) + # Take profit above entry, stop loss between liquidation and entry. await asyncio.sleep(1) - closed = await client.futures.isolated.close(CloseTradeParams(id=opened.id)) - print(f"Closed trade {closed.id} - PL: {closed.pl} sats") - except Exception as e: - print(f"Error: {e}") - - print("\n --- Set a trailing stop, then remove stop loss and take profit ---") - try: - # Fetch the current price so the stop/take-profit levels are valid - # whatever BTC is trading at. Prices must be a multiple of 0.5. - ticker = await client.futures.get_ticker() - price = ticker.last_price - # Long trade: stop just below entry (above liquidation), take profit - # above entry. Prices must be a multiple of 0.5. - stoploss = round(price * 0.98 * 2) / 2 - takeprofit = round(price * 1.5 * 2) / 2 - print( - f"BTC price: {price} -> stoploss: {stoploss}, takeprofit: {takeprofit}" + updated = await client.perp.isolated.update_takeprofit( + PerpUpdateTakeprofitParams( + **trade.model_dump(), value=round(opened.price * 1.5) + ) ) + print(f"Take profit: {updated.takeprofit}") await asyncio.sleep(1) - opened = await client.futures.isolated.new_trade( - FuturesOrder( - type="market", - side="buy", - quantity=1, - leverage=10, - stoploss=stoploss, - takeprofit=takeprofit, + updated = await client.perp.isolated.update_stoploss( + PerpUpdateStoplossParams( + **trade.model_dump(), + stoploss=PerpStoploss( + mode="fixed", + value=round((opened.price + opened.liquidation) / 2), + ), ) ) - print( - f"Opened trade ID: {opened.id}, " - f"stoploss: {opened.stoploss}, takeprofit: {opened.takeprofit}" - ) + print(f"Stop loss: {updated.stoploss}") - # Switch to a trailing stop: `value` is a fraction of price, not an - # absolute price. The upper bound is capped by liquidation (~1/leverage), - # so keep it comfortably under that for a 10x position. + # Switch to a trailing stop: `value` is a fraction of price, not a + # price. It is capped by liquidation (~1/leverage). await asyncio.sleep(1) - trailing = await client.futures.isolated.update_stoploss( - UpdateStoplossParams(id=opened.id, value=0.05, mode="trailing") + trailing = await client.perp.isolated.update_stoploss( + PerpUpdateStoplossParams( + **trade.model_dump(), + stoploss=PerpStoploss(mode="trailing", value=0.05), + ) ) print(f"Trailing distance: {trailing.stoploss_trailing_distance}") - # Remove the stop loss (clears both fixed stop and trailing distance). + # Remove the stop loss (fixed and trailing) and the take profit. await asyncio.sleep(1) - no_sl = await client.futures.isolated.remove_stoploss( - RemoveStoplossParams(id=opened.id) - ) - print( - f"After remove_stoploss -> stoploss: {no_sl.stoploss}, " - f"trailing: {no_sl.stoploss_trailing_distance}" - ) - - # Remove the take profit. + await client.perp.isolated.remove_stoploss(trade) await asyncio.sleep(1) - no_tp = await client.futures.isolated.remove_takeprofit( - RemoveTakeprofitParams(id=opened.id) - ) - print(f"After remove_takeprofit -> takeprofit: {no_tp.takeprofit}") + await client.perp.isolated.remove_takeprofit(trade) await asyncio.sleep(1) - closed = await client.futures.isolated.close(CloseTradeParams(id=opened.id)) + closed = await client.perp.isolated.close(trade) print(f"Closed trade {closed.id} - PL: {closed.pl} sats") except Exception as e: print(f"Error: {e}") diff --git a/pyproject.toml b/pyproject.toml index 5ec0a08..c81ef4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" urls = { "Homepage" = "https://github.com/ln-markets/sdk-python", "Repository" = "https://github.com/ln-markets/sdk-python", "Bug Tracker" = "https://github.com/ln-markets/sdk-python/issues" } name = "lnmarkets-sdk" -version = "0.2.0" +version = "0.3.0" description = "LN Markets API Python SDK" readme = "README.md" license = { text = "MIT" } diff --git a/src/lnmarkets_sdk/rest/v3/_internal/__init__.py b/src/lnmarkets_sdk/rest/v3/_internal/__init__.py index 715be37..2ddb348 100644 --- a/src/lnmarkets_sdk/rest/v3/_internal/__init__.py +++ b/src/lnmarkets_sdk/rest/v3/_internal/__init__.py @@ -16,7 +16,7 @@ ) from .models import APIAuthContext, APIMethod -from .utils import create_auth_headers, prepare_params +from .utils import create_auth_headers, flatten_query, prepare_params # Statuses where the server signals it did NOT process the request, so a retry # is safe from double-submitting a non-idempotent trade. 503 = "Trading engine @@ -110,6 +110,8 @@ async def request( raise RuntimeError("Client must be used within async context manager") params_dict = prepare_params(params) + if params_dict and method in ("GET", "DELETE"): + params_dict = flatten_query(params_dict) async def _send() -> httpx.Response: client = self._client diff --git a/src/lnmarkets_sdk/rest/v3/_internal/utils.py b/src/lnmarkets_sdk/rest/v3/_internal/utils.py index fe06955..40ff604 100644 --- a/src/lnmarkets_sdk/rest/v3/_internal/utils.py +++ b/src/lnmarkets_sdk/rest/v3/_internal/utils.py @@ -3,6 +3,7 @@ import hashlib import hmac import json +import warnings from base64 import b64encode from collections.abc import Mapping from datetime import datetime @@ -24,8 +25,11 @@ def _float_to_int(value: object) -> object: + # The server signs `JSON.stringify(body)`, which writes 64000.0 as 64000. if isinstance(value, float) and value.is_integer(): return int(value) + if isinstance(value, dict): + return {key: _float_to_int(item) for key, item in value.items()} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] return value @@ -42,6 +46,32 @@ def prepare_params( return dict(params) +def flatten_query(params: Mapping[str, Any]) -> dict[str, Any]: + """Flatten nested objects into bracket keys for a query string. + + The API reads `{"cursor": {"value": v, "id": i}}` from the query as + `cursor[value]=v&cursor[id]=i`. + """ + flat: dict[str, Any] = {} + for key, value in params.items(): + if isinstance(value, Mapping): + for sub_key, sub_value in value.items(): # pyright: ignore[reportUnknownVariableType] + flat[f"{key}[{sub_key}]"] = sub_value + else: + flat[key] = value + return flat + + +def warn_deprecated(old: str, new: str) -> None: + """Warn that a `/v3/futures` method has a `/v3/perp` replacement.""" + warnings.warn( + f"{old} is deprecated, use {new} with " + "instrument='inverse_perp', pair='btc_usd' instead.", + DeprecationWarning, + stacklevel=3, + ) + + def _create_signature( secret: str, method: APIMethod, path: str, data: str ) -> tuple[str, str]: @@ -70,9 +100,7 @@ def create_auth_headers( def get_hostname(network: APINetwork) -> str: """Get API hostname based on network.""" - return ( - "api.signet.lnmarkets.com" if network == "signet" else "api.lnmarkets.com" - ) + return "api.signet.lnmarkets.com" if network == "signet" else "api.lnmarkets.com" def parse_response[T]( diff --git a/src/lnmarkets_sdk/rest/v3/http/client/__init__.py b/src/lnmarkets_sdk/rest/v3/http/client/__init__.py index 09565ff..549736a 100644 --- a/src/lnmarkets_sdk/rest/v3/http/client/__init__.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/__init__.py @@ -10,9 +10,11 @@ APIMethod, ) from lnmarkets_sdk.rest.v3._internal.utils import get_hostname, parse_response +from lnmarkets_sdk.rest.v3.models.markets import Market from .account import AccountClient from .futures import FuturesClient +from .perp import PerpClient from .synthetic_usd import SyntheticUSDClient @@ -35,7 +37,7 @@ def __init__(self, config: APIClientConfig | None = None): Example: ```python from lnmarkets_sdk.rest.v3.http.client import LNMClient, APIClientConfig, APIAuthContext - from lnmarkets_sdk.rest.v3.models.futures_isolated import FuturesOrder + from lnmarkets_sdk.rest.v3.models.perp import PerpMarketKey, PerpOrder config = APIClientConfig( authentication=APIAuthContext( @@ -52,17 +54,21 @@ def __init__(self, config: APIClientConfig | None = None): account = await client.account.get_account() # Get market ticker - ticker = await client.futures.get_ticker() + ticker = await client.perp.get_ticker( + PerpMarketKey(instrument="inverse_perp", pair="btc_usd") + ) - # Place a futures order - params = FuturesOrder( + # Place a perp order + params = PerpOrder( + instrument="inverse_perp", + pair="btc_usd", type="limit", # limit order side="buy", # buy price=100_000, quantity=1, leverage=100, ) - order = await client.futures.isolated.new_trade(params) + order = await client.perp.isolated.new_trade(params) ``` """ if config is None: @@ -84,6 +90,7 @@ def __init__(self, config: APIClientConfig | None = None): self.account = AccountClient(self) self.futures = FuturesClient(self) + self.perp = PerpClient(self) self.synthetic_usd = SyntheticUSDClient(self) async def __aenter__(self) -> "LNMClient": @@ -95,6 +102,11 @@ async def __aexit__(self, *args: object) -> None: """Exit async context manager.""" await self._base_client.__aexit__(*args) + @property + def authenticated(self) -> bool: + """Whether the client has API credentials.""" + return self._base_client.auth is not None + async def request[T]( self, method: APIMethod, @@ -153,5 +165,24 @@ async def time(self) -> str: """ return await self.request_raw("GET", "/time", credentials=False) + async def get_markets(self): + """ + List the markets: the released ones, plus the ones the API key can access. + + Example: + ```python + async with LNMClient(config) as client: + markets = await client.get_markets() + for market in markets: + print(f"{market.instrument} {market.pair}: {market.display_name}") + ``` + """ + return await self.request( + "GET", + "/markets", + credentials=self.authenticated, + response_model=list[Market], + ) + __all__ = ["APIAuthContext", "APIClientConfig", "LNMClient"] diff --git a/src/lnmarkets_sdk/rest/v3/http/client/futures/__init__.py b/src/lnmarkets_sdk/rest/v3/http/client/futures/__init__.py index 13cd712..8a9ddb3 100644 --- a/src/lnmarkets_sdk/rest/v3/http/client/futures/__init__.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/futures/__init__.py @@ -4,6 +4,7 @@ from lnmarkets_sdk.rest.v3.http.client import LNMClient from lnmarkets_sdk.rest.v3._internal.models import PaginatedResponse +from lnmarkets_sdk.rest.v3._internal.utils import warn_deprecated from lnmarkets_sdk.rest.v3.models.funding_fees import FundingSettlement from lnmarkets_sdk.rest.v3.models.futures_data import ( Candle, @@ -29,6 +30,8 @@ async def get_ticker(self): """ Get current futures ticker data. + Deprecated: use `client.perp.get_ticker()` with the BTC/USD market key. + Example: ```python async with LNMClient(config) as client: @@ -39,6 +42,7 @@ async def get_ticker(self): print(f"Best bid: {ticker.prices[0].bid_price}") ``` """ + warn_deprecated("client.futures.get_ticker()", "client.perp.get_ticker()") return await self._client.request( "GET", "/futures/ticker", @@ -71,6 +75,8 @@ async def get_candles(self, params: GetCandlesParams): """ Get OHLC candle data. + Deprecated: use `client.perp.get_candles()` with the BTC/USD market key. + Example: ```python from lnmarkets_sdk.rest.v3.models.futures_data import GetCandlesParams @@ -89,6 +95,7 @@ async def get_candles(self, params: GetCandlesParams): print(f"Next cursor: {response.next_cursor}") ``` """ + warn_deprecated("client.futures.get_candles()", "client.perp.get_candles()") return await self._client.request( "GET", "/futures/candles", @@ -103,6 +110,8 @@ async def get_funding_settlements( """ Get funding settlement history. + Deprecated: use `client.perp.get_funding_settlements()` with the BTC/USD market key. + Example: ```python from lnmarkets_sdk.rest.v3.models.futures_data import GetFundingSettlementsParams @@ -116,6 +125,10 @@ async def get_funding_settlements( print(f"Next cursor: {response.next_cursor}") ``` """ + warn_deprecated( + "client.futures.get_funding_settlements()", + "client.perp.get_funding_settlements()", + ) return await self._client.request( "GET", "/futures/funding-settlements", diff --git a/src/lnmarkets_sdk/rest/v3/http/client/futures/isolated.py b/src/lnmarkets_sdk/rest/v3/http/client/futures/isolated.py index bb79b63..7331d74 100644 --- a/src/lnmarkets_sdk/rest/v3/http/client/futures/isolated.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/futures/isolated.py @@ -4,6 +4,7 @@ from lnmarkets_sdk.rest.v3.http.client import LNMClient from lnmarkets_sdk.rest.v3._internal.models import PaginatedResponse +from lnmarkets_sdk.rest.v3._internal.utils import warn_deprecated from lnmarkets_sdk.rest.v3.models.funding_fees import FundingFees from lnmarkets_sdk.rest.v3.models.futures_isolated import ( AddMarginParams, @@ -36,6 +37,8 @@ async def new_trade( """ Open a new isolated margin futures trade. + Deprecated: use `client.perp.isolated.new_trade()` with the BTC/USD market key. + Example: ```python from lnmarkets_sdk.rest.v3.models.futures_isolated import FuturesOrder @@ -54,6 +57,9 @@ async def new_trade( print(f"Trade ID: {trade.id}") ``` """ + warn_deprecated( + "client.futures.isolated.new_trade()", "client.perp.isolated.new_trade()" + ) return await self._client.request( "POST", "/futures/isolated/trade", @@ -66,6 +72,8 @@ async def get_running_trades(self): """ Get all running isolated margin trades. + Deprecated: use `client.perp.isolated.get_running_trades()` with the BTC/USD market key. + Example: ```python async with LNMClient(config) as client: @@ -74,6 +82,10 @@ async def get_running_trades(self): print(f"Trade ID: {trade.id}, P&L: {trade.pl}") ``` """ + warn_deprecated( + "client.futures.isolated.get_running_trades()", + "client.perp.isolated.get_running_trades()", + ) return await self._client.request( "GET", "/futures/isolated/trades/running", @@ -85,6 +97,8 @@ async def get_open_trades(self): """ Get all open isolated margin trades. + Deprecated: use `client.perp.isolated.get_open_trades()` with the BTC/USD market key. + Example: ```python async with LNMClient(config) as client: @@ -93,6 +107,10 @@ async def get_open_trades(self): print(f"Trade ID: {trade.id}, Price: {trade.price}") ``` """ + warn_deprecated( + "client.futures.isolated.get_open_trades()", + "client.perp.isolated.get_open_trades()", + ) return await self._client.request( "GET", "/futures/isolated/trades/open", @@ -104,6 +122,8 @@ async def get_closed_trades(self, params: GetClosedTradesParams | None = None): """ Get closed isolated margin trades history. + Deprecated: use `client.perp.isolated.get_closed_trades()` with the BTC/USD market key. + Example: ```python from lnmarkets_sdk.rest.v3.models.futures_isolated import GetClosedTradesParams @@ -117,6 +137,10 @@ async def get_closed_trades(self, params: GetClosedTradesParams | None = None): print(f"Next cursor: {response.next_cursor}") ``` """ + warn_deprecated( + "client.futures.isolated.get_closed_trades()", + "client.perp.isolated.get_closed_trades()", + ) return await self._client.request( "GET", "/futures/isolated/trades/closed", @@ -129,6 +153,8 @@ async def close(self, params: CloseTradeParams): """ Close an isolated margin trade. + Deprecated: use `client.perp.isolated.close()` with the BTC/USD market key. + Example: ```python from lnmarkets_sdk.rest.v3.models.futures_isolated import CloseTradeParams @@ -139,6 +165,9 @@ async def close(self, params: CloseTradeParams): print(f"Closed: {closed.closed}, P&L: {closed.pl}") ``` """ + warn_deprecated( + "client.futures.isolated.close()", "client.perp.isolated.close()" + ) return await self._client.request( "POST", "/futures/isolated/trade/close", @@ -151,6 +180,8 @@ async def cancel(self, params: CancelTradeParams): """ Cancel an isolated margin trade. + Deprecated: use `client.perp.isolated.cancel()` with the BTC/USD market key. + Example: ```python from lnmarkets_sdk.rest.v3.models.futures_isolated import CancelTradeParams @@ -161,6 +192,9 @@ async def cancel(self, params: CancelTradeParams): print(f"Canceled: {canceled.canceled}") ``` """ + warn_deprecated( + "client.futures.isolated.cancel()", "client.perp.isolated.cancel()" + ) return await self._client.request( "POST", "/futures/isolated/trade/cancel", @@ -173,6 +207,8 @@ async def cancel_all(self): """ Cancel all isolated margin trades. + Deprecated: use `client.perp.isolated.cancel_all()` with the BTC/USD market key. + Example: ```python async with LNMClient(config) as client: @@ -180,6 +216,9 @@ async def cancel_all(self): print(f"Canceled {len(canceled)} trades") ``` """ + warn_deprecated( + "client.futures.isolated.cancel_all()", "client.perp.isolated.cancel_all()" + ) return await self._client.request( "POST", "/futures/isolated/trades/cancel-all", @@ -191,6 +230,8 @@ async def add_margin(self, params: AddMarginParams): """ Add margin to an isolated trade. + Deprecated: use `client.perp.isolated.add_margin()` with the BTC/USD market key. + Example: ```python from lnmarkets_sdk.rest.v3.models.futures_isolated import AddMarginParams @@ -201,6 +242,9 @@ async def add_margin(self, params: AddMarginParams): print(f"New margin: {updated.margin}") ``` """ + warn_deprecated( + "client.futures.isolated.add_margin()", "client.perp.isolated.add_margin()" + ) return await self._client.request( "POST", "/futures/isolated/trade/add-margin", @@ -213,6 +257,8 @@ async def cash_in(self, params: CashInParams): """ Cash in on an isolated trade. + Deprecated: use `client.perp.isolated.cash_in()` with the BTC/USD market key. + Example: ```python from lnmarkets_sdk.rest.v3.models.futures_isolated import CashInParams @@ -223,6 +269,9 @@ async def cash_in(self, params: CashInParams): print(f"Trade margin: {updated.margin}") ``` """ + warn_deprecated( + "client.futures.isolated.cash_in()", "client.perp.isolated.cash_in()" + ) return await self._client.request( "POST", "/futures/isolated/trade/cash-in", @@ -235,6 +284,8 @@ async def update_stoploss(self, params: UpdateStoplossParams): """ Update stop loss for an isolated trade. + Deprecated: use `client.perp.isolated.update_stoploss()` with the BTC/USD market key. + Example: ```python from lnmarkets_sdk.rest.v3.models.futures_isolated import UpdateStoplossParams @@ -245,6 +296,10 @@ async def update_stoploss(self, params: UpdateStoplossParams): print(f"New stop loss: {updated.stoploss}") ``` """ + warn_deprecated( + "client.futures.isolated.update_stoploss()", + "client.perp.isolated.update_stoploss()", + ) return await self._client.request( "PUT", "/futures/isolated/trade/stoploss", @@ -257,6 +312,8 @@ async def update_takeprofit(self, params: UpdateTakeprofitParams): """ Update take profit for an isolated trade. + Deprecated: use `client.perp.isolated.update_takeprofit()` with the BTC/USD market key. + Example: ```python from lnmarkets_sdk.rest.v3.models.futures_isolated import UpdateTakeprofitParams @@ -267,7 +324,10 @@ async def update_takeprofit(self, params: UpdateTakeprofitParams): print(f"New take profit: {updated.takeprofit}") ``` """ - print(f"params: {params}") + warn_deprecated( + "client.futures.isolated.update_takeprofit()", + "client.perp.isolated.update_takeprofit()", + ) return await self._client.request( "PUT", "/futures/isolated/trade/takeprofit", @@ -282,6 +342,8 @@ async def remove_stoploss( """ Remove the stop loss from an isolated trade. + Deprecated: use `client.perp.isolated.remove_stoploss()` with the BTC/USD market key. + Clears both the fixed stop loss and the trailing distance. Example: @@ -294,6 +356,10 @@ async def remove_stoploss( print(f"Stop loss: {updated.stoploss}") ``` """ + warn_deprecated( + "client.futures.isolated.remove_stoploss()", + "client.perp.isolated.remove_stoploss()", + ) return await self._client.request( "DELETE", "/futures/isolated/trade/stoploss", @@ -308,6 +374,8 @@ async def remove_takeprofit( """ Remove the take profit from an isolated trade. + Deprecated: use `client.perp.isolated.remove_takeprofit()` with the BTC/USD market key. + Example: ```python from lnmarkets_sdk.rest.v3.models.futures_isolated import RemoveTakeprofitParams @@ -318,6 +386,10 @@ async def remove_takeprofit( print(f"Take profit: {updated.takeprofit}") ``` """ + warn_deprecated( + "client.futures.isolated.remove_takeprofit()", + "client.perp.isolated.remove_takeprofit()", + ) return await self._client.request( "DELETE", "/futures/isolated/trade/takeprofit", @@ -332,6 +404,8 @@ async def get_funding_fees( """ Get funding fees for isolated trades. + Deprecated: use `client.perp.isolated.get_funding_fees()` with the BTC/USD market key. + Example: ```python from lnmarkets_sdk.rest.v3.models.futures_isolated import GetIsolatedFundingFeesParams @@ -345,6 +419,10 @@ async def get_funding_fees( print(f"Next cursor: {response.next_cursor}") ``` """ + warn_deprecated( + "client.futures.isolated.get_funding_fees()", + "client.perp.isolated.get_funding_fees()", + ) return await self._client.request( "GET", "/futures/isolated/funding-fees", @@ -357,6 +435,8 @@ async def get_canceled_trades(self, params: GetClosedTradesParams | None = None) """ Get canceled isolated margin trades history. + Deprecated: use `client.perp.isolated.get_canceled_trades()` with the BTC/USD market key. + Example: ```python from lnmarkets_sdk.rest.v3.models.futures_isolated import GetClosedTradesParams @@ -370,6 +450,10 @@ async def get_canceled_trades(self, params: GetClosedTradesParams | None = None) print(f"Next cursor: {response.next_cursor}") ``` """ + warn_deprecated( + "client.futures.isolated.get_canceled_trades()", + "client.perp.isolated.get_canceled_trades()", + ) return await self._client.request( "GET", "/futures/isolated/trades/canceled", diff --git a/src/lnmarkets_sdk/rest/v3/http/client/perp/__init__.py b/src/lnmarkets_sdk/rest/v3/http/client/perp/__init__.py new file mode 100644 index 0000000..1e49a2f --- /dev/null +++ b/src/lnmarkets_sdk/rest/v3/http/client/perp/__init__.py @@ -0,0 +1,105 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from lnmarkets_sdk.rest.v3.http.client import LNMClient + +from lnmarkets_sdk.rest.v3.models.perp import ( + GetPerpCandlesParams, + GetPerpFundingSettlementsParams, + PerpCandles, + PerpFundingSettlements, + PerpMarketKey, + PerpTicker, +) + +from .isolated import PerpIsolatedClient + + +class PerpClient: + """Client for perp market endpoints (`/v3/perp`). + + Every method takes the market key (`instrument` + `pair`), e.g. + `inverse_perp` + `btc_usd` for the Bitcoin perp or `quanto_perp` + + `xau_usdt` for the gold quanto perp. List the markets with + `client.get_markets()`. + """ + + def __init__(self, client: "LNMClient"): + self._client = client + self.isolated = PerpIsolatedClient(client) + + async def get_ticker(self, params: PerpMarketKey): + """ + Get the ticker of a perp market. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import PerpMarketKey + + async with LNMClient(config) as client: + ticker = await client.perp.get_ticker( + PerpMarketKey(instrument="quanto_perp", pair="xau_usdt") + ) + print(f"Mark: {ticker.mark}, Funding Rate: {ticker.funding_rate}") + ``` + """ + return await self._client.request( + "GET", + "/perp/ticker", + params=params, + credentials=self._client.authenticated, + response_model=PerpTicker, + ) + + async def get_candles(self, params: GetPerpCandlesParams): + """ + Get OHLC candles of a perp market. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import GetPerpCandlesParams + + async with LNMClient(config) as client: + params = GetPerpCandlesParams( + instrument="quanto_perp", pair="xau_usdt", range="1h", limit=100 + ) + response = await client.perp.get_candles(params) + for candle in response.data: + print(f"Time: {candle.time}, Close: {candle.close}") + if response.next_cursor: + params.cursor = response.next_cursor + next_page = await client.perp.get_candles(params) + ``` + """ + return await self._client.request( + "GET", + "/perp/candles", + params=params, + credentials=self._client.authenticated, + response_model=PerpCandles, + ) + + async def get_funding_settlements(self, params: GetPerpFundingSettlementsParams): + """ + Get the funding settlement history of a perp market. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import GetPerpFundingSettlementsParams + + async with LNMClient(config) as client: + params = GetPerpFundingSettlementsParams( + instrument="quanto_perp", pair="xau_usdt", limit=10 + ) + response = await client.perp.get_funding_settlements(params) + for settlement in response.data: + print(f"Rate: {settlement.funding_rate}") + ``` + """ + return await self._client.request( + "GET", + "/perp/funding-settlements", + params=params, + credentials=self._client.authenticated, + response_model=PerpFundingSettlements, + ) diff --git a/src/lnmarkets_sdk/rest/v3/http/client/perp/isolated.py b/src/lnmarkets_sdk/rest/v3/http/client/perp/isolated.py new file mode 100644 index 0000000..4ea691a --- /dev/null +++ b/src/lnmarkets_sdk/rest/v3/http/client/perp/isolated.py @@ -0,0 +1,387 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from lnmarkets_sdk.rest.v3.http.client import LNMClient + +from lnmarkets_sdk.rest.v3.models.perp import ( + GetPerpFundingFeesParams, + GetPerpTradesParams, + PerpAddMarginParams, + PerpCanceledTrade, + PerpCashInParams, + PerpClosedTrade, + PerpFundingFee, + PerpMarketFilter, + PerpMarketKey, + PerpOpenTrade, + PerpOrder, + PerpPaginatedResponse, + PerpRunningTrade, + PerpRunningTradeRead, + PerpTradeParams, + PerpUpdateStoplossParams, + PerpUpdateTakeprofitParams, +) + + +class PerpIsolatedClient: + """Client for isolated margin perp endpoints (`/v3/perp/isolated`).""" + + def __init__(self, client: "LNMClient"): + self._client = client + + async def new_trade(self, params: PerpOrder) -> PerpOpenTrade | PerpRunningTrade: + """ + Open a new isolated trade on a perp market. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import PerpOrder, PerpStoploss + + async with LNMClient(config) as client: + params = PerpOrder( + instrument="quanto_perp", + pair="xau_usdt", + type="market", + side="buy", + margin=10_000, # or quantity=..., in the market's quantity unit + leverage=10, + stoploss=PerpStoploss(mode="trailing", value=0.05), # optional + ) + trade = await client.perp.isolated.new_trade(params) + print(f"Trade ID: {trade.id}") + ``` + """ + return await self._client.request( + "POST", + "/perp/isolated/trade", + params=params, + credentials=True, + response_model=PerpOpenTrade | PerpRunningTrade, + ) + + async def cancel(self, params: PerpTradeParams): + """ + Cancel an open trade. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import PerpTradeParams + + async with LNMClient(config) as client: + params = PerpTradeParams(id=trade_id, instrument="quanto_perp", pair="xau_usdt") + canceled = await client.perp.isolated.cancel(params) + ``` + """ + return await self._client.request( + "POST", + "/perp/isolated/trade/cancel", + params=params, + credentials=True, + response_model=PerpCanceledTrade, + ) + + async def cancel_all(self, params: PerpMarketKey): + """ + Cancel all open trades of a perp market. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import PerpMarketKey + + async with LNMClient(config) as client: + canceled = await client.perp.isolated.cancel_all( + PerpMarketKey(instrument="quanto_perp", pair="xau_usdt") + ) + print(f"Canceled {len(canceled)} trades") + ``` + """ + return await self._client.request( + "POST", + "/perp/isolated/trades/cancel-all", + params=params, + credentials=True, + response_model=list[PerpCanceledTrade], + ) + + async def close(self, params: PerpTradeParams): + """ + Close a running trade. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import PerpTradeParams + + async with LNMClient(config) as client: + params = PerpTradeParams(id=trade_id, instrument="quanto_perp", pair="xau_usdt") + closed = await client.perp.isolated.close(params) + print(f"P&L: {closed.pl}") + ``` + """ + return await self._client.request( + "POST", + "/perp/isolated/trade/close", + params=params, + credentials=True, + response_model=PerpClosedTrade, + ) + + async def add_margin(self, params: PerpAddMarginParams): + """ + Add margin to a running trade. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import PerpAddMarginParams + + async with LNMClient(config) as client: + params = PerpAddMarginParams( + id=trade_id, amount=10_000, instrument="quanto_perp", pair="xau_usdt" + ) + updated = await client.perp.isolated.add_margin(params) + ``` + """ + return await self._client.request( + "POST", + "/perp/isolated/trade/add-margin", + params=params, + credentials=True, + response_model=PerpRunningTrade, + ) + + async def cash_in(self, params: PerpCashInParams): + """ + Cash in from a running trade (P&L first, then margin). + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import PerpCashInParams + + async with LNMClient(config) as client: + params = PerpCashInParams( + id=trade_id, amount=1_000, instrument="quanto_perp", pair="xau_usdt" + ) + updated = await client.perp.isolated.cash_in(params) + ``` + """ + return await self._client.request( + "POST", + "/perp/isolated/trade/cash-in", + params=params, + credentials=True, + response_model=PerpRunningTrade, + ) + + async def update_stoploss( + self, params: PerpUpdateStoplossParams + ) -> PerpOpenTrade | PerpRunningTrade: + """ + Update the stop loss of an open or running trade. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import PerpStoploss, PerpUpdateStoplossParams + + async with LNMClient(config) as client: + params = PerpUpdateStoplossParams( + id=trade_id, + stoploss=PerpStoploss(mode="fixed", value=2_000), + instrument="quanto_perp", + pair="xau_usdt", + ) + updated = await client.perp.isolated.update_stoploss(params) + ``` + """ + return await self._client.request( + "PUT", + "/perp/isolated/trade/stoploss", + params=params, + credentials=True, + response_model=PerpOpenTrade | PerpRunningTrade, + ) + + async def update_takeprofit( + self, params: PerpUpdateTakeprofitParams + ) -> PerpOpenTrade | PerpRunningTrade: + """ + Update the take profit of an open or running trade. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import PerpUpdateTakeprofitParams + + async with LNMClient(config) as client: + params = PerpUpdateTakeprofitParams( + id=trade_id, value=3_000, instrument="quanto_perp", pair="xau_usdt" + ) + updated = await client.perp.isolated.update_takeprofit(params) + ``` + """ + return await self._client.request( + "PUT", + "/perp/isolated/trade/takeprofit", + params=params, + credentials=True, + response_model=PerpOpenTrade | PerpRunningTrade, + ) + + async def remove_stoploss( + self, params: PerpTradeParams + ) -> PerpOpenTrade | PerpRunningTrade: + """ + Remove the stop loss (fixed and trailing) of an open or running trade. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import PerpTradeParams + + async with LNMClient(config) as client: + params = PerpTradeParams(id=trade_id, instrument="quanto_perp", pair="xau_usdt") + updated = await client.perp.isolated.remove_stoploss(params) + ``` + """ + return await self._client.request( + "DELETE", + "/perp/isolated/trade/stoploss", + params=params, + credentials=True, + response_model=PerpOpenTrade | PerpRunningTrade, + ) + + async def remove_takeprofit( + self, params: PerpTradeParams + ) -> PerpOpenTrade | PerpRunningTrade: + """ + Remove the take profit of an open or running trade. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import PerpTradeParams + + async with LNMClient(config) as client: + params = PerpTradeParams(id=trade_id, instrument="quanto_perp", pair="xau_usdt") + updated = await client.perp.isolated.remove_takeprofit(params) + ``` + """ + return await self._client.request( + "DELETE", + "/perp/isolated/trade/takeprofit", + params=params, + credentials=True, + response_model=PerpOpenTrade | PerpRunningTrade, + ) + + async def get_open_trades(self, params: PerpMarketFilter | None = None): + """ + Get the open trades of every perp market, or of one market. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import PerpMarketFilter + + async with LNMClient(config) as client: + all_markets = await client.perp.isolated.get_open_trades() + gold = await client.perp.isolated.get_open_trades( + PerpMarketFilter(instrument="quanto_perp", pair="xau_usdt") + ) + ``` + """ + return await self._client.request( + "GET", + "/perp/isolated/trades/open", + params=params, + credentials=True, + response_model=list[PerpOpenTrade], + ) + + async def get_running_trades(self, params: PerpMarketFilter | None = None): + """ + Get the running trades of every perp market, or of one market. + + `pl` is the unrealized P&L at the Mark price. + + Example: + ```python + async with LNMClient(config) as client: + trades = await client.perp.isolated.get_running_trades() + for trade in trades: + print(f"{trade.instrument}:{trade.pair} {trade.id} P&L: {trade.pl}") + ``` + """ + return await self._client.request( + "GET", + "/perp/isolated/trades/running", + params=params, + credentials=True, + response_model=list[PerpRunningTradeRead], + ) + + async def get_closed_trades(self, params: GetPerpTradesParams | None = None): + """ + Get the closed trades of every perp market, or of one market. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import GetPerpTradesParams + + async with LNMClient(config) as client: + params = GetPerpTradesParams(instrument="quanto_perp", pair="xau_usdt", limit=10) + response = await client.perp.isolated.get_closed_trades(params) + if response.next_cursor: + params.cursor = response.next_cursor + next_page = await client.perp.isolated.get_closed_trades(params) + ``` + """ + return await self._client.request( + "GET", + "/perp/isolated/trades/closed", + params=params, + credentials=True, + response_model=PerpPaginatedResponse[PerpClosedTrade], + ) + + async def get_canceled_trades(self, params: GetPerpTradesParams | None = None): + """ + Get the canceled trades of every perp market, or of one market. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import GetPerpTradesParams + + async with LNMClient(config) as client: + params = GetPerpTradesParams(limit=10) + response = await client.perp.isolated.get_canceled_trades(params) + ``` + """ + return await self._client.request( + "GET", + "/perp/isolated/trades/canceled", + params=params, + credentials=True, + response_model=PerpPaginatedResponse[PerpCanceledTrade], + ) + + async def get_funding_fees(self, params: GetPerpFundingFeesParams | None = None): + """ + Get the funding fees paid by isolated trades. + + Filter by market (`instrument` + `pair`) or by trade (`trade_id`). + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import GetPerpFundingFeesParams + + async with LNMClient(config) as client: + params = GetPerpFundingFeesParams(trade_id=trade_id, limit=10) + response = await client.perp.isolated.get_funding_fees(params) + for fee in response.data: + print(f"Fee: {fee.fee}, Quanto: {fee.quanto_adjustment_fee}") + ``` + """ + return await self._client.request( + "GET", + "/perp/isolated/funding-fees", + params=params, + credentials=True, + response_model=PerpPaginatedResponse[PerpFundingFee], + ) diff --git a/src/lnmarkets_sdk/rest/v3/models/markets.py b/src/lnmarkets_sdk/rest/v3/models/markets.py new file mode 100644 index 0000000..a722383 --- /dev/null +++ b/src/lnmarkets_sdk/rest/v3/models/markets.py @@ -0,0 +1,57 @@ +from typing import Literal + +from pydantic import BaseModel, Field, SkipValidation + +from lnmarkets_sdk.rest.v3._internal.models import BaseConfig + +MarketInstrument = Literal["inverse_perp", "quanto_perp", "quanto_spot"] +MarketStatus = Literal["hidden", "early_access:closed", "early_access:open", "released"] + + +class MarketCrossLimits(BaseModel, BaseConfig): + """Cross margin limits of a perp market.""" + + max_order_count: SkipValidation[int] + max_order_quantity: SkipValidation[float] + max_quantity_at_min_leverage: SkipValidation[float] + max_quantity_at_max_leverage: SkipValidation[float] + + +class MarketLimits(BaseModel, BaseConfig): + """Trading limits of a market.""" + + max_trade_count: SkipValidation[int] + max_order_quantity: SkipValidation[float] + max_account_quantity: SkipValidation[float] + cross: MarketCrossLimits | None = Field( + default=None, description="Cross margin limits (perp markets only)" + ) + + +class Market(BaseModel, BaseConfig): + """A market available to the caller.""" + + id: SkipValidation[str] = Field(..., description="Market id, `:`") + instrument: SkipValidation[MarketInstrument] + pair: SkipValidation[str] = Field(..., description="Pair of the market") + slug: SkipValidation[str] + symbol: SkipValidation[str] + ticker: SkipValidation[str] + display_name: SkipValidation[str] + base_asset: SkipValidation[str] + quote_asset: SkipValidation[str] + tick_size: SkipValidation[float] = Field(..., description="Price increment") + quantity_step: SkipValidation[float] = Field(..., description="Quantity increment") + quantity_unit: SkipValidation[Literal["btc", "usd", "oz", "bbl", "share"]] + limits: MarketLimits + status: SkipValidation[MarketStatus] + feature: SkipValidation[str] | None = None + funding_interval_hours: SkipValidation[int] | None = Field( + default=None, description="Hours between funding settlements (perp only)" + ) + max_leverage: SkipValidation[int] | None = Field( + default=None, description="Maximum leverage (perp only)" + ) + margin_modes: SkipValidation[list[Literal["isolated", "cross"]]] | None = Field( + default=None, description="Supported margin modes (perp only)" + ) diff --git a/src/lnmarkets_sdk/rest/v3/models/perp.py b/src/lnmarkets_sdk/rest/v3/models/perp.py new file mode 100644 index 0000000..af2b993 --- /dev/null +++ b/src/lnmarkets_sdk/rest/v3/models/perp.py @@ -0,0 +1,315 @@ +# Pydantic discriminated-union pattern intentionally narrows base-class fields +# in concrete trade variants (see futures_isolated.py). +# pyright: reportIncompatibleVariableOverride=false + +from typing import Literal + +from pydantic import BaseModel, Field, SkipValidation, model_validator + +from lnmarkets_sdk.rest.v3._internal.models import UUID, BaseConfig +from lnmarkets_sdk.rest.v3.models.futures_data import ( + Candle, + CandleResolution, + PriceBucket, + PriceChange24h, +) +from lnmarkets_sdk.rest.v3.models.futures_isolated import ( + FuturesCanceledTrade, + FuturesClosedTrade, + FuturesOpenTrade, + FuturesRunningTrade, +) + +PerpInstrument = Literal["inverse_perp", "quanto_perp"] + + +class PerpMarketKey(BaseModel, BaseConfig): + """Market key of a perp market, e.g. `inverse_perp` + `btc_usd`.""" + + instrument: PerpInstrument = Field(..., description="Instrument of the market") + pair: str = Field(..., description="Pair of the market, e.g. 'btc_usd'") + + +class PerpMarketFilter(BaseModel, BaseConfig): + """Optional market key: both fields select one market, none selects all.""" + + instrument: PerpInstrument | None = Field( + default=None, description="Instrument of the market" + ) + pair: str | None = Field(default=None, description="Pair of the market") + + @model_validator(mode="after") + def validate_market_key(self): + if (self.instrument is None) != (self.pair is None): + raise ValueError("Pass both 'instrument' and 'pair', or neither") + return self + + +class PerpCursor(BaseModel, BaseConfig): + """Pagination cursor: the sort date and id of the last row.""" + + value: str = Field(..., description="Sort date in ISO format") + id: UUID = Field(..., description="Row id") + + +class PerpCandleCursor(BaseModel, BaseConfig): + """Pagination cursor for candles: the time of the last candle.""" + + value: str = Field(..., description="Candle time in ISO format") + + +class PerpPaginatedResponse[T](BaseModel, BaseConfig): + """Paginated response of the perp routes.""" + + data: list[T] = Field(..., description="Array of items") + next_cursor: PerpCursor | None = Field( + default=None, + description="Cursor for fetching the next page, null if no more pages", + ) + + +class _PerpPageParams(BaseModel, BaseConfig): + from_: str | None = Field( + default=None, + serialization_alias="from", + validation_alias="from", + description="Start date as a string value in ISO format", + ) + to: str | None = Field( + default=None, description="End date as a string value in ISO format" + ) + limit: int = Field( + default=1000, + ge=1, + le=1000, + description="Limit of items to return (max 1000, default 1000)", + ) + cursor: PerpCursor | None = Field( + default=None, description="`next_cursor` of the previous page" + ) + + +# Public data + + +class PerpTicker(PerpMarketKey): + """Ticker of a perp market.""" + + time: SkipValidation[str] = Field(..., description="ISO date string of the ticker") + mark: SkipValidation[float] = Field( + ..., description="Mark price (in USD). Values P&L, margin and funding" + ) + last_price: SkipValidation[float] = Field( + ..., description="Weighted last trade price of the reference exchanges" + ) + prices: list[PriceBucket] = Field( + ..., description="Price buckets for different order sizes" + ) + funding_rate: SkipValidation[float] = Field(..., description="Current funding rate") + funding_time: SkipValidation[str] = Field( + ..., description="ISO date string of the next funding settlement" + ) + quanto_adjustment_rate: SkipValidation[float] = Field( + ..., + description="Quanto adjustment rate for the next funding period (0 if none)", + ) + exchanges_weights: SkipValidation[dict[str, float]] = Field( + ..., description="Weight of each reference exchange" + ) + price_change_24h: PriceChange24h | None = Field( + default=None, + alias="priceChange24h", + description="Price change over the last 24 hours", + ) + volume_24h: SkipValidation[float] | None = Field( + default=None, + alias="volume24h", + description="24h trading volume in USD of the reference exchanges", + ) + + +class GetPerpCandlesParams(PerpMarketKey): + from_: str | None = Field( + default=None, + serialization_alias="from", + validation_alias="from", + description="Start date as a string value in ISO format", + ) + to: str | None = Field( + default=None, description="End date as a string value in ISO format" + ) + range: CandleResolution = Field( + default="1m", description="Resolution of the OHLC candle" + ) + limit: int = Field( + default=1000, + ge=1, + le=1000, + description="Number of entries to return (max 1000, default 1000)", + ) + cursor: PerpCandleCursor | None = Field( + default=None, description="`next_cursor` of the previous page" + ) + + +class PerpCandles(PerpMarketKey): + """Candles of a perp market.""" + + data: list[Candle] + next_cursor: PerpCandleCursor | None = None + + +class PerpFundingSettlement(BaseModel, BaseConfig): + """Funding settlement of a perp market.""" + + id: SkipValidation[UUID] = Field(..., description="Funding settlement ID") + time: SkipValidation[str] = Field(..., description="Funding settlement time") + fixing_price: SkipValidation[float] = Field(..., description="Fixing price") + funding_rate: SkipValidation[float] = Field(..., description="Funding rate") + quanto_adjustment_rate: SkipValidation[float] = Field( + ..., description="Quanto adjustment rate (0 if none)" + ) + + +class PerpFundingSettlements(PerpPaginatedResponse[PerpFundingSettlement]): + """Funding settlements of a perp market.""" + + instrument: PerpInstrument + pair: str + + +class GetPerpFundingSettlementsParams(_PerpPageParams, PerpMarketKey): ... + + +# Isolated trades + + +class PerpStoploss(BaseModel, BaseConfig): + mode: Literal["fixed", "trailing"] = Field( + default="fixed", + description="'fixed': `value` is a price. 'trailing': `value` is a distance as a fraction of price.", + ) + value: float = Field(..., gt=0, description="Stop price or trailing distance") + + +class PerpOrder(PerpMarketKey): + leverage: float = Field(..., description="Leverage of the position") + side: Literal["buy", "sell"] = Field( + ..., description="Trade side: buy (long) or sell (short)" + ) + type: Literal["limit", "market"] = Field( + ..., description="Trade type: limit (limit) or market (market)" + ) + quantity: float | None = Field( + default=None, + gt=0, + description="Quantity of the position, in the market's quantity unit", + ) + margin: int | None = Field( + default=None, + gt=0, + description="Margin of the position (in satoshis), to size the trade from it", + ) + price: float | None = Field( + default=None, gt=0, description="Price of the limit order" + ) + stoploss: PerpStoploss | None = Field(default=None, description="Stop loss") + takeprofit: float | None = Field( + default=None, ge=0, description="Take profit price level" + ) + client_id: str | None = Field( + default=None, + max_length=64, + description="Optional custom data field (max 64 characters)", + ) + + @model_validator(mode="after") + def validate_schema(self): + if (self.quantity is None) == (self.margin is None): + raise ValueError("Exactly one of quantity or margin must be set") + if self.type == "limit" and self.price is None: + raise ValueError("'price' is required when type='limit'") + if self.type == "market" and self.price is not None: + raise ValueError("'price' must not be set when type='market'") + return self + + +class PerpTradeFields(BaseModel, BaseConfig): + """Fields a perp trade adds to a futures trade.""" + + instrument: SkipValidation[PerpInstrument] + pair: SkipValidation[str] + entry_notional_usd: SkipValidation[float] = Field( + ..., description="Notional value at entry (in USD)" + ) + exit_notional_usd: SkipValidation[float] | None = Field( + default=None, description="Notional value at exit (in USD)" + ) + entry_btc_price: SkipValidation[float] = Field( + ..., description="BTC price at entry (in USD)" + ) + sum_quanto_adjustment_fees: SkipValidation[float] = Field( + default=0.0, description="Total quanto adjustment fees paid" + ) + + +class PerpOpenTrade(PerpTradeFields, FuturesOpenTrade): ... + + +class PerpRunningTrade(PerpTradeFields, FuturesRunningTrade): ... + + +class PerpRunningTradeRead(PerpRunningTrade): + pl: SkipValidation[float] | None = Field( + default=None, description="Unrealized P&L at the Mark price, null if unknown" + ) + + +class PerpClosedTrade(PerpTradeFields, FuturesClosedTrade): + exit_notional_usd: SkipValidation[float] = 0.0 + + +class PerpCanceledTrade(PerpTradeFields, FuturesCanceledTrade): ... + + +class PerpTradeParams(PerpMarketKey): + id: UUID = Field(..., description="Trade ID") + + +class PerpAddMarginParams(PerpTradeParams): + amount: int = Field(..., gt=0, description="Amount of margin to add (in satoshis)") + + +class PerpCashInParams(PerpTradeParams): + amount: int = Field(..., gt=0, description="Amount to cash in (in satoshis)") + + +class PerpUpdateStoplossParams(PerpTradeParams): + stoploss: PerpStoploss = Field(..., description="New stop loss") + + +class PerpUpdateTakeprofitParams(PerpTradeParams): + value: float = Field(..., gt=0, description="New take profit price level") + + +class GetPerpTradesParams(_PerpPageParams, PerpMarketFilter): ... + + +class GetPerpFundingFeesParams(GetPerpTradesParams): + trade_id: UUID | None = Field(default=None, description="Trade id to filter by") + + +class PerpFundingFee(BaseModel, BaseConfig): + """Funding fee paid by an isolated perp trade.""" + + instrument: SkipValidation[PerpInstrument] + pair: SkipValidation[str] + trade_id: SkipValidation[UUID] = Field(..., description="Trade ID") + settlement_id: SkipValidation[UUID] = Field( + ..., description="Funding settlement ID" + ) + time: SkipValidation[str] = Field(..., description="Timestamp in ISO format") + fee: SkipValidation[float] = Field(..., description="Funding fee amount") + quanto_adjustment_fee: SkipValidation[float] = Field( + default=0.0, description="Quanto adjustment fee amount" + ) diff --git a/src/lnmarkets_sdk/rest/v3/tests/test_integration.py b/src/lnmarkets_sdk/rest/v3/tests/test_integration.py index 790741f..3566bac 100644 --- a/src/lnmarkets_sdk/rest/v3/tests/test_integration.py +++ b/src/lnmarkets_sdk/rest/v3/tests/test_integration.py @@ -47,6 +47,23 @@ UpdateStoplossParams, UpdateTakeprofitParams, ) +from lnmarkets_sdk.rest.v3.models.perp import ( + GetPerpCandlesParams, + GetPerpFundingFeesParams, + GetPerpFundingSettlementsParams, + GetPerpTradesParams, + PerpAddMarginParams, + PerpCashInParams, + PerpMarketFilter, + PerpMarketKey, + PerpOpenTrade, + PerpOrder, + PerpRunningTrade, + PerpStoploss, + PerpTradeParams, + PerpUpdateStoplossParams, + PerpUpdateTakeprofitParams, +) from lnmarkets_sdk.rest.v3.models.synthetic_usd import GetSwapsParams, NewSwapParams load_dotenv() @@ -696,6 +713,188 @@ async def test_get_funding_fees_isolated(self): assert fees[0].trade_id is not None +BTC_USD = PerpMarketKey(instrument="inverse_perp", pair="btc_usd") + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("public_rate_limit_delay") +class TestPerpIntegration: + """Integration tests for markets and perp data endpoints.""" + + async def test_get_markets(self): + async with LNMClient(create_public_config()) as client: + markets = await client.get_markets() + btc_usd = next(m for m in markets if m.id == "inverse_perp:btc_usd") + assert btc_usd.status == "released" + assert btc_usd.max_leverage is not None + assert btc_usd.limits.max_trade_count > 0 + + async def test_get_ticker(self): + async with LNMClient(create_public_config()) as client: + ticker = await client.perp.get_ticker(BTC_USD) + assert ticker.instrument == "inverse_perp" + assert ticker.pair == "btc_usd" + assert ticker.mark > 0 + assert ticker.last_price > 0 + assert isinstance(ticker.quanto_adjustment_rate, (int, float)) + assert len(ticker.prices) > 0 + + async def test_get_candles_paginates(self): + async with LNMClient(create_public_config()) as client: + params = GetPerpCandlesParams(**BTC_USD.model_dump(), limit=2) + first = await client.perp.get_candles(params) + assert len(first.data) == 2 + assert first.data[0].close > 0 + assert first.next_cursor is not None + + await asyncio.sleep(1) + params.cursor = first.next_cursor + second = await client.perp.get_candles(params) + assert second.data[0].time < first.data[-1].time + + async def test_get_funding_settlements_paginates(self): + async with LNMClient(create_public_config()) as client: + params = GetPerpFundingSettlementsParams(**BTC_USD.model_dump(), limit=2) + first = await client.perp.get_funding_settlements(params) + assert first.pair == "btc_usd" + assert len(first.data) == 2 + assert first.data[0].fixing_price > 0 + assert first.next_cursor is not None + + await asyncio.sleep(1) + params.cursor = first.next_cursor + second = await client.perp.get_funding_settlements(params) + assert {s.id for s in second.data}.isdisjoint({s.id for s in first.data}) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("auth_rate_limit_delay") +class TestPerpIsolatedIntegration: + """Integration tests for isolated margin perp endpoints.""" + + @requires_auth + @pytest.mark.usefixtures("require_funds") + async def test_limit_trade_cancel(self): + async with LNMClient(create_auth_config()) as client: + ticker = await client.perp.get_ticker(BTC_USD) + # Far below the market so the order rests instead of filling. + trade = await client.perp.isolated.new_trade( + PerpOrder( + **BTC_USD.model_dump(), + type="limit", + side="buy", + price=round(ticker.mark * 0.5), + quantity=1, + leverage=10, + ) + ) + assert isinstance(trade, PerpOpenTrade) + assert trade.pair == "btc_usd" + + open_trades = await client.perp.isolated.get_open_trades( + PerpMarketFilter(**BTC_USD.model_dump()) + ) + assert trade.id in {t.id for t in open_trades} + + canceled = await client.perp.isolated.cancel( + PerpTradeParams(id=trade.id, **BTC_USD.model_dump()) + ) + assert canceled.id == trade.id + assert canceled.canceled is True + + result = await client.perp.isolated.cancel_all(BTC_USD) + assert isinstance(result, list) + + page = await client.perp.isolated.get_canceled_trades( + GetPerpTradesParams(**BTC_USD.model_dump(), limit=5) + ) + assert trade.id in {t.id for t in page.data} + + @requires_auth + @pytest.mark.usefixtures("require_funds") + async def test_market_trade_lifecycle(self): + async with LNMClient(create_auth_config()) as client: + trade = await client.perp.isolated.new_trade( + PerpOrder( + **BTC_USD.model_dump(), + type="market", + side="buy", + quantity=1, + leverage=10, + ) + ) + assert isinstance(trade, PerpRunningTrade) + key = {"id": trade.id, **BTC_USD.model_dump()} + try: + running = await client.perp.isolated.get_running_trades() + assert trade.id in {t.id for t in running} + + # Must sit between the liquidation price and the current price. + stoploss = round((trade.price + trade.liquidation) / 2) + updated = await client.perp.isolated.update_stoploss( + PerpUpdateStoplossParams( + **key, stoploss=PerpStoploss(mode="fixed", value=stoploss) + ) + ) + assert updated.stoploss == stoploss + updated = await client.perp.isolated.remove_stoploss( + PerpTradeParams(**key) + ) + assert updated.stoploss == 0 + + takeprofit = round(trade.price * 1.2) + updated = await client.perp.isolated.update_takeprofit( + PerpUpdateTakeprofitParams(**key, value=takeprofit) + ) + assert updated.takeprofit == takeprofit + updated = await client.perp.isolated.remove_takeprofit( + PerpTradeParams(**key) + ) + assert updated.takeprofit == 0 + + updated = await client.perp.isolated.add_margin( + PerpAddMarginParams(**key, amount=100) + ) + assert updated.margin >= trade.margin + try: + await client.perp.isolated.cash_in( + PerpCashInParams(**key, amount=1) + ) + except APIException as e: + # Max cash-in depends on live PL and leverage bounds. + assert len(str(e)) > 0 + finally: + closed = await client.perp.isolated.close(PerpTradeParams(**key)) + assert closed.closed is True + assert closed.pair == "btc_usd" + + page = await client.perp.isolated.get_closed_trades( + GetPerpTradesParams(**BTC_USD.model_dump(), limit=5) + ) + assert trade.id in {t.id for t in page.data} + + @requires_auth + async def test_get_closed_trades_paginates(self): + async with LNMClient(create_auth_config()) as client: + params = GetPerpTradesParams(limit=1) + first = await client.perp.isolated.get_closed_trades(params) + if first.next_cursor is None: + pytest.skip("Not enough closed trades to paginate") + params.cursor = first.next_cursor + second = await client.perp.isolated.get_closed_trades(params) + assert second.data[0].id != first.data[0].id + + @requires_auth + async def test_get_funding_fees(self): + async with LNMClient(create_auth_config()) as client: + params = GetPerpFundingFeesParams(**BTC_USD.model_dump(), limit=5) + result = await client.perp.isolated.get_funding_fees(params) + assert len(result.data) <= params.limit + for fee in result.data: + assert fee.pair == "btc_usd" + assert fee.trade_id is not None + + @pytest.mark.asyncio @pytest.mark.usefixtures("auth_rate_limit_delay") class TestFuturesCrossIntegration: diff --git a/src/lnmarkets_sdk/rest/v3/tests/test_unit.py b/src/lnmarkets_sdk/rest/v3/tests/test_unit.py index f6f47cb..d262e9b 100644 --- a/src/lnmarkets_sdk/rest/v3/tests/test_unit.py +++ b/src/lnmarkets_sdk/rest/v3/tests/test_unit.py @@ -12,6 +12,7 @@ from urllib.parse import urlencode import pytest +from pydantic import ValidationError from pytest_httpx import HTTPXMock from lnmarkets_sdk.rest.v3._internal.models import APIException @@ -28,6 +29,18 @@ RemoveTakeprofitParams, UpdateStoplossParams, ) +from lnmarkets_sdk.rest.v3.models.perp import ( + GetPerpTradesParams, + PerpCursor, + PerpMarketFilter, + PerpMarketKey, + PerpOpenTrade, + PerpOrder, + PerpRunningTrade, + PerpStoploss, + PerpTradeParams, + PerpUpdateStoplossParams, +) TRADE_ID = "d0b9f9a0-4f6e-4a5a-8b7a-7f0f5f9a8a1e" SECRET = "test-secret" @@ -328,3 +341,210 @@ async def test_max_retries_zero_disables_retry(httpx_mock: HTTPXMock) -> None: await client.futures.cross.cancel_all() assert len(httpx_mock.get_requests()) == 1 + + +GOLD = {"instrument": "quanto_perp", "pair": "xau_usdt"} + + +def _perp_running_trade_payload() -> dict[str, object]: + return { + **_running_trade_payload(), + **GOLD, + "entryNotionalUsd": 4_000, + "exitNotionalUsd": None, + "entryBtcPrice": 100_000, + "sumQuantoAdjustmentFees": 0, + } + + +def _signature(method: str, path: str, data: str, timestamp: str) -> str: + payload = f"{timestamp}{method}{path}{data}" + return b64encode( + hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).digest() + ).decode() + + +async def test_perp_new_trade_posts_market_key_and_stoploss( + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response(json=_perp_running_trade_payload()) + + async with LNMClient(_auth_config()) as client: + trade = await client.perp.isolated.new_trade( + PerpOrder( + **GOLD, + type="market", + side="buy", + margin=10_000, + leverage=10, + stoploss=PerpStoploss(mode="trailing", value=0.05), + ) + ) + + assert isinstance(trade, PerpRunningTrade) + assert trade.pair == "xau_usdt" + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert request.url.path == "/v3/perp/isolated/trade" + assert json.loads(request.read()) == { + **GOLD, + "leverage": 10, + "side": "buy", + "type": "market", + "margin": 10_000, + "stoploss": {"mode": "trailing", "value": 0.05}, + } + + +async def test_perp_open_trade_parses_as_open(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + json={ + **_perp_running_trade_payload(), + "type": "limit", + "running": False, + "open": True, + "filledAt": None, + } + ) + + async with LNMClient(_auth_config()) as client: + trade = await client.perp.isolated.new_trade( + PerpOrder( + **GOLD, type="limit", side="buy", price=2_000, quantity=1, leverage=10 + ) + ) + + assert isinstance(trade, PerpOpenTrade) + + +async def test_perp_closed_trades_flatten_cursor_in_signed_query( + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response(json={"data": [], "nextCursor": None}) + cursor = PerpCursor(value="2026-01-01T00:00:00.000Z", id=TRADE_ID) + + async with LNMClient(_auth_config()) as client: + await client.perp.isolated.get_closed_trades( + GetPerpTradesParams(**GOLD, limit=10, cursor=cursor) + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/v3/perp/isolated/trades/closed" + assert request.url.params["cursor[value]"] == "2026-01-01T00:00:00.000Z" + assert request.url.params["cursor[id]"] == TRADE_ID + assert request.url.params["instrument"] == "quanto_perp" + assert "cursor" not in request.url.params + + # The server signs over `url.search`, so the signature must match the query sent. + query = f"?{request.url.query.decode()}" + timestamp = request.headers["lnm-access-timestamp"] + expected = _signature("get", "/v3/perp/isolated/trades/closed", query, timestamp) + assert request.headers["lnm-access-signature"] == expected + + +async def test_perp_closed_trades_parse_next_cursor(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + json={ + "data": [], + "nextCursor": {"value": "2026-01-01T00:00:00.000Z", "id": TRADE_ID}, + } + ) + + async with LNMClient(_auth_config()) as client: + page = await client.perp.isolated.get_closed_trades() + + assert page.next_cursor == PerpCursor(value="2026-01-01T00:00:00.000Z", id=TRADE_ID) + + +async def test_perp_remove_stoploss_sends_market_key_in_query( + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response(json=_perp_running_trade_payload()) + + async with LNMClient(_auth_config()) as client: + await client.perp.isolated.remove_stoploss(PerpTradeParams(id=TRADE_ID, **GOLD)) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "DELETE" + assert request.url.path == "/v3/perp/isolated/trade/stoploss" + assert dict(request.url.params) == {"id": TRADE_ID, **GOLD} + assert request.read() == b"" + + +async def test_perp_get_ticker_is_public_without_credentials( + httpx_mock: HTTPXMock, +) -> None: + httpx_mock.add_response( + json={ + **GOLD, + "time": "2026-01-01T00:00:00.000Z", + "mark": 2_000, + "lastPrice": 2_001, + "volume24h": 1_000_000, + "prices": [], + "fundingRate": 0.0001, + "fundingTime": "2026-01-01T08:00:00.000Z", + "quantoAdjustmentRate": 0.00005, + "exchangesWeights": {"binance": 1}, + } + ) + + async with LNMClient(APIClientConfig(network="signet")) as client: + ticker = await client.perp.get_ticker(PerpMarketKey(**GOLD)) + + assert ticker.mark == 2_000 + assert ticker.quanto_adjustment_rate == 0.00005 + assert ticker.volume_24h == 1_000_000 + + request = httpx_mock.get_request() + assert request is not None + assert dict(request.url.params) == GOLD + assert "lnm-access-key" not in request.headers + + +async def test_perp_public_routes_sign_when_credentials_are_set( + httpx_mock: HTTPXMock, +) -> None: + # Early-access markets are only served to an API key that can access them. + httpx_mock.add_response(json=[]) + + async with LNMClient(_auth_config()) as client: + markets = await client.get_markets() + + assert markets == [] + request = httpx_mock.get_request() + assert request is not None + assert request.url.path == "/v3/markets" + assert request.headers["lnm-access-key"] == "test-key" + + +def test_perp_market_filter_needs_both_keys() -> None: + with pytest.raises(ValidationError): + PerpMarketFilter(instrument="quanto_perp") + with pytest.raises(ValidationError): + GetPerpTradesParams(pair="xau_usdt") + assert prepare_params(PerpMarketFilter()) == {} + + +async def test_futures_methods_warn_deprecated(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(json=[]) + + async with LNMClient(_auth_config()) as client: + with pytest.warns( + DeprecationWarning, match=r"client\.perp\.isolated\.get_running_trades\(\)" + ): + await client.futures.isolated.get_running_trades() + + +def test_nested_whole_floats_serialize_as_ints() -> None: + # The server signs `JSON.stringify(body)`, which writes 64000.0 as 64000. + params = PerpUpdateStoplossParams( + id=TRADE_ID, **GOLD, stoploss=PerpStoploss(value=64_000) + ) + body = prepare_params(params) + assert body is not None + assert json.dumps(body["stoploss"]) == '{"mode": "fixed", "value": 64000}' diff --git a/uv.lock b/uv.lock index d131ec7..cbc4eaf 100644 --- a/uv.lock +++ b/uv.lock @@ -196,7 +196,7 @@ wheels = [ [[package]] name = "lnmarkets-sdk" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 1eeef8c3cc239e146070424a392d8580739ed0c5 Mon Sep 17 00:00:00 2001 From: CaoKha Date: Mon, 2 Nov 2026 12:12:31 +0100 Subject: [PATCH 2/4] fix(examples): correct env var names and outdated print headers The REST example read {NETWORK}_API_KEY_SECRET/_API_KEY_PASSPHRASE while .env.example, the docstring and the tests use _API_SECRET/_API_PASSPHRASE, so the authenticated run always skipped. Fetch only as many items as the headers announce (funding settlements, closed trades, funding fees) and label truncated lists as "first 3". --- README.md | 2 +- examples/rest_v3.py | 41 ++++++++++++++++++++++------------------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 7203b7a..a5c10b9 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ async with LNMClient() as client: ) ``` -Remember to sleep between requests, as the rate limit is 1 requests per second for public endpoints. +Remember to sleep between requests, as the rate limit is 1 request per second for public endpoints. For endpoints that need authentication, you need to create an instance of the `LNMClient` class and provide your API credentials: diff --git a/examples/rest_v3.py b/examples/rest_v3.py index 0f87402..fa29e0a 100644 --- a/examples/rest_v3.py +++ b/examples/rest_v3.py @@ -10,7 +10,7 @@ Authenticated run reads creds by network: mainnet -> MAINNET_API_KEY, MAINNET_API_SECRET, MAINNET_API_PASSPHRASE - signet -> SIGNET_API_KEY, SIGNET_API_SECRET, SIGNET_API_PASSPHRASE + signet -> SIGNET_API_KEY, SIGNET_API_SECRET, SIGNET_API_PASSPHRASE """ import asyncio @@ -133,10 +133,10 @@ async def example_public_endpoints(network: Network): # Get funding settlements await asyncio.sleep(1) funding_settlements = await client.perp.get_funding_settlements( - GetPerpFundingSettlementsParams(**market.model_dump(), limit=5) + GetPerpFundingSettlementsParams(**market.model_dump(), limit=3) ) - print("\n--- Funding Settlements (Last 5) ---") - for settlement in funding_settlements.data[:3]: # Show first 3 + print("\n--- Funding Settlements (Last 3) ---") + for settlement in funding_settlements.data: print( f"Rate: {settlement.funding_rate}, " f"Quanto adjustment: {settlement.quanto_adjustment_rate}, " @@ -152,14 +152,14 @@ async def example_authenticated_endpoints(network: Network): prefix = network.upper() key = os.getenv(f"{prefix}_API_KEY") - secret = os.getenv(f"{prefix}_API_KEY_SECRET") - passphrase = os.getenv(f"{prefix}_API_KEY_PASSPHRASE") + secret = os.getenv(f"{prefix}_API_SECRET") + passphrase = os.getenv(f"{prefix}_API_PASSPHRASE") if not (key and secret and passphrase): print("\nāš ļø Skipping authenticated example:") print( - f" Please set {prefix}_API_KEY, {prefix}_API_KEY_SECRET, " - f"and {prefix}_API_KEY_PASSPHRASE" + f" Please set {prefix}_API_KEY, {prefix}_API_SECRET, " + f"and {prefix}_API_PASSPHRASE" ) return @@ -189,7 +189,7 @@ async def example_authenticated_endpoints(network: Network): print("\n--- Bitcoin Deposit Address ---") print(f"Address: {btc_address.address}") - # Get lightning deposits (last 5) + # Get lightning deposits (up to 5) deposits_response = await client.account.get_lightning_deposits( GetLightningDepositsParams(from_="1970-01-01T00:00:00.000Z", limit=5) ) @@ -202,7 +202,7 @@ async def example_authenticated_endpoints(network: Network): # List current notifications. Notifications fire server-side on async # events (deposit settled, withdrawal processed, trade closed by SL/TP, # funding settlements, system announcements) — not synchronously from - # the client. List may be empty on a fresh testnet account. + # the client. List may be empty on a fresh signet account. await asyncio.sleep(1) notifs = await client.account.get_notifications(GetNotificationsParams(limit=5)) unread = sum(1 for n in notifs.data if not n.read) @@ -218,9 +218,9 @@ async def example_authenticated_endpoints(network: Network): # Running trades of every perp market await asyncio.sleep(1) running_trades = await client.perp.isolated.get_running_trades() - print("\n--- Running Isolated Trades (all markets) ---") + print("\n--- Running Isolated Trades (all markets, first 3) ---") print(f"Count: {len(running_trades)}") - for trade in running_trades[:3]: # Show first 3 + for trade in running_trades[:3]: side = "LONG" if trade.side == "buy" else "SHORT" print( f" {trade.instrument}:{trade.pair} {side} - Margin: {trade.margin} sats, " @@ -233,28 +233,31 @@ async def example_authenticated_endpoints(network: Network): open_trades = await client.perp.isolated.get_open_trades( PerpMarketFilter(**market.model_dump()) ) - print(f"\n--- Open Isolated Trades on {market.pair} ---") - for trade in open_trades[:3]: # Show first 3 + print( + f"\n--- Open Isolated Trades on {market.instrument}:{market.pair} " + f"(first 3 of {len(open_trades)}) ---" + ) + for trade in open_trades[:3]: side = "LONG" if trade.side == "buy" else "SHORT" print(f" {side} - Price: {trade.price}, Quantity: {trade.quantity}") # Closed trades await asyncio.sleep(1) closed_trades = await client.perp.isolated.get_closed_trades( - GetPerpTradesParams(limit=5) + GetPerpTradesParams(limit=3) ) print(f"\n--- Closed Isolated Trades (Last {len(closed_trades.data)}) ---") - for trade in closed_trades.data[:3]: # Show first 3 + for trade in closed_trades.data: side = "LONG" if trade.side == "buy" else "SHORT" print(f" {trade.pair} {side} - PL: {trade.pl} sats") # Isolated funding fees await asyncio.sleep(1) isolated_fees = await client.perp.isolated.get_funding_fees( - GetPerpFundingFeesParams(limit=5) + GetPerpFundingFeesParams(limit=3) ) print(f"\n--- Isolated Funding Fees (Last {len(isolated_fees.data)}) ---") - for fee in isolated_fees.data[:3]: # Show first 3 + for fee in isolated_fees.data: print( f"{fee.pair} Fee: {fee.fee} sats, " f"Quanto: {fee.quanto_adjustment_fee} sats, Time: {fee.time}" @@ -286,7 +289,7 @@ async def example_authenticated_endpoints(network: Network): except Exception as e: print(f"Error: {e}") - print(f"\n --- Trade on {market.instrument}:{market.pair} ---") + print(f"\n--- Trade on {market.instrument}:{market.pair} ---") try: # Size the trade by margin: the quantity unit depends on the market # (USD for BTC/USD, ounces for qGOLD, ...). From b99f349d58b22f80df28e4986672d8d57f46e0c0 Mon Sep 17 00:00:00 2001 From: CaoKha Date: Mon, 2 Nov 2026 17:22:08 +0100 Subject: [PATCH 3/4] feat: mark replaced futures methods with @deprecated Use typing_extensions.deprecated (PEP 702) on the client.futures methods that have a client.perp replacement, so IDEs and type checkers strike the call through and show the perp method to use. The decorator also raises the runtime DeprecationWarning, which replaces the warn_deprecated helper. Leaderboard and cross margin have no /perp route yet and stay unmarked. --- CHANGELOG.md | 2 +- pyproject.toml | 1 + src/lnmarkets_sdk/rest/v3/_internal/utils.py | 11 -- .../rest/v3/http/client/futures/__init__.py | 18 ++-- .../rest/v3/http/client/futures/isolated.py | 102 ++++++++---------- src/lnmarkets_sdk/rest/v3/tests/test_unit.py | 32 ++++++ uv.lock | 4 +- 7 files changed, 95 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba0cd1..55ebd50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ Multi-market perp API (`/v3/perp`) and `GET /v3/markets`. ### Deprecated -- `client.futures.get_ticker()`, `get_candles()`, `get_funding_settlements()` and every `client.futures.isolated` method. They still work and raise a `DeprecationWarning` that names the `client.perp` method to use with `instrument="inverse_perp", pair="btc_usd"`. `client.futures.get_leaderboard()` and `client.futures.cross` have no perp replacement yet and are not deprecated. +- `client.futures.get_ticker()`, `get_candles()`, `get_funding_settlements()` and every `client.futures.isolated` method. They still work. They are marked with `@deprecated` (PEP 702), so IDEs and type checkers strike the call through and show the `client.perp` method to use, and at runtime they raise a `DeprecationWarning` with the same hint: call it with `instrument="inverse_perp", pair="btc_usd"`. `client.futures.get_leaderboard()` and `client.futures.cross` have no perp replacement yet and are not deprecated. ### Fixed diff --git a/pyproject.toml b/pyproject.toml index c81ef4a..90946a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "orjson>=3.10.0", "pygments>=2.20.0", "tenacity>=9.1.4", + "typing-extensions>=4.12", ] diff --git a/src/lnmarkets_sdk/rest/v3/_internal/utils.py b/src/lnmarkets_sdk/rest/v3/_internal/utils.py index 40ff604..d954951 100644 --- a/src/lnmarkets_sdk/rest/v3/_internal/utils.py +++ b/src/lnmarkets_sdk/rest/v3/_internal/utils.py @@ -3,7 +3,6 @@ import hashlib import hmac import json -import warnings from base64 import b64encode from collections.abc import Mapping from datetime import datetime @@ -62,16 +61,6 @@ def flatten_query(params: Mapping[str, Any]) -> dict[str, Any]: return flat -def warn_deprecated(old: str, new: str) -> None: - """Warn that a `/v3/futures` method has a `/v3/perp` replacement.""" - warnings.warn( - f"{old} is deprecated, use {new} with " - "instrument='inverse_perp', pair='btc_usd' instead.", - DeprecationWarning, - stacklevel=3, - ) - - def _create_signature( secret: str, method: APIMethod, path: str, data: str ) -> tuple[str, str]: diff --git a/src/lnmarkets_sdk/rest/v3/http/client/futures/__init__.py b/src/lnmarkets_sdk/rest/v3/http/client/futures/__init__.py index 8a9ddb3..45c263b 100644 --- a/src/lnmarkets_sdk/rest/v3/http/client/futures/__init__.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/futures/__init__.py @@ -3,8 +3,9 @@ if TYPE_CHECKING: from lnmarkets_sdk.rest.v3.http.client import LNMClient +from typing_extensions import deprecated + from lnmarkets_sdk.rest.v3._internal.models import PaginatedResponse -from lnmarkets_sdk.rest.v3._internal.utils import warn_deprecated from lnmarkets_sdk.rest.v3.models.funding_fees import FundingSettlement from lnmarkets_sdk.rest.v3.models.futures_data import ( Candle, @@ -26,6 +27,9 @@ def __init__(self, client: "LNMClient"): self.isolated = FuturesIsolatedClient(client) self.cross = FuturesCrossClient(client) + @deprecated( + "client.futures.get_ticker() is deprecated, use client.perp.get_ticker() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def get_ticker(self): """ Get current futures ticker data. @@ -42,7 +46,6 @@ async def get_ticker(self): print(f"Best bid: {ticker.prices[0].bid_price}") ``` """ - warn_deprecated("client.futures.get_ticker()", "client.perp.get_ticker()") return await self._client.request( "GET", "/futures/ticker", @@ -71,6 +74,9 @@ async def get_leaderboard(self): response_model=Leaderboard, ) + @deprecated( + "client.futures.get_candles() is deprecated, use client.perp.get_candles() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def get_candles(self, params: GetCandlesParams): """ Get OHLC candle data. @@ -95,7 +101,6 @@ async def get_candles(self, params: GetCandlesParams): print(f"Next cursor: {response.next_cursor}") ``` """ - warn_deprecated("client.futures.get_candles()", "client.perp.get_candles()") return await self._client.request( "GET", "/futures/candles", @@ -104,6 +109,9 @@ async def get_candles(self, params: GetCandlesParams): response_model=PaginatedResponse[Candle], ) + @deprecated( + "client.futures.get_funding_settlements() is deprecated, use client.perp.get_funding_settlements() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def get_funding_settlements( self, params: GetFundingSettlementsParams | None = None ): @@ -125,10 +133,6 @@ async def get_funding_settlements( print(f"Next cursor: {response.next_cursor}") ``` """ - warn_deprecated( - "client.futures.get_funding_settlements()", - "client.perp.get_funding_settlements()", - ) return await self._client.request( "GET", "/futures/funding-settlements", diff --git a/src/lnmarkets_sdk/rest/v3/http/client/futures/isolated.py b/src/lnmarkets_sdk/rest/v3/http/client/futures/isolated.py index 7331d74..dbac34a 100644 --- a/src/lnmarkets_sdk/rest/v3/http/client/futures/isolated.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/futures/isolated.py @@ -3,8 +3,9 @@ if TYPE_CHECKING: from lnmarkets_sdk.rest.v3.http.client import LNMClient +from typing_extensions import deprecated + from lnmarkets_sdk.rest.v3._internal.models import PaginatedResponse -from lnmarkets_sdk.rest.v3._internal.utils import warn_deprecated from lnmarkets_sdk.rest.v3.models.funding_fees import FundingFees from lnmarkets_sdk.rest.v3.models.futures_isolated import ( AddMarginParams, @@ -31,6 +32,9 @@ class FuturesIsolatedClient: def __init__(self, client: "LNMClient"): self._client = client + @deprecated( + "client.futures.isolated.new_trade() is deprecated, use client.perp.isolated.new_trade() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def new_trade( self, params: FuturesOrder ) -> FuturesRunningTrade | FuturesOpenTrade: @@ -57,9 +61,6 @@ async def new_trade( print(f"Trade ID: {trade.id}") ``` """ - warn_deprecated( - "client.futures.isolated.new_trade()", "client.perp.isolated.new_trade()" - ) return await self._client.request( "POST", "/futures/isolated/trade", @@ -68,6 +69,9 @@ async def new_trade( response_model=FuturesRunningTrade | FuturesOpenTrade, ) + @deprecated( + "client.futures.isolated.get_running_trades() is deprecated, use client.perp.isolated.get_running_trades() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def get_running_trades(self): """ Get all running isolated margin trades. @@ -82,10 +86,6 @@ async def get_running_trades(self): print(f"Trade ID: {trade.id}, P&L: {trade.pl}") ``` """ - warn_deprecated( - "client.futures.isolated.get_running_trades()", - "client.perp.isolated.get_running_trades()", - ) return await self._client.request( "GET", "/futures/isolated/trades/running", @@ -93,6 +93,9 @@ async def get_running_trades(self): response_model=list[FuturesRunningTrade], ) + @deprecated( + "client.futures.isolated.get_open_trades() is deprecated, use client.perp.isolated.get_open_trades() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def get_open_trades(self): """ Get all open isolated margin trades. @@ -107,10 +110,6 @@ async def get_open_trades(self): print(f"Trade ID: {trade.id}, Price: {trade.price}") ``` """ - warn_deprecated( - "client.futures.isolated.get_open_trades()", - "client.perp.isolated.get_open_trades()", - ) return await self._client.request( "GET", "/futures/isolated/trades/open", @@ -118,6 +117,9 @@ async def get_open_trades(self): response_model=list[FuturesOpenTrade], ) + @deprecated( + "client.futures.isolated.get_closed_trades() is deprecated, use client.perp.isolated.get_closed_trades() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def get_closed_trades(self, params: GetClosedTradesParams | None = None): """ Get closed isolated margin trades history. @@ -137,10 +139,6 @@ async def get_closed_trades(self, params: GetClosedTradesParams | None = None): print(f"Next cursor: {response.next_cursor}") ``` """ - warn_deprecated( - "client.futures.isolated.get_closed_trades()", - "client.perp.isolated.get_closed_trades()", - ) return await self._client.request( "GET", "/futures/isolated/trades/closed", @@ -149,6 +147,9 @@ async def get_closed_trades(self, params: GetClosedTradesParams | None = None): response_model=PaginatedResponse[FuturesClosedTrade | FuturesCanceledTrade], ) + @deprecated( + "client.futures.isolated.close() is deprecated, use client.perp.isolated.close() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def close(self, params: CloseTradeParams): """ Close an isolated margin trade. @@ -165,9 +166,6 @@ async def close(self, params: CloseTradeParams): print(f"Closed: {closed.closed}, P&L: {closed.pl}") ``` """ - warn_deprecated( - "client.futures.isolated.close()", "client.perp.isolated.close()" - ) return await self._client.request( "POST", "/futures/isolated/trade/close", @@ -176,6 +174,9 @@ async def close(self, params: CloseTradeParams): response_model=FuturesClosedTrade, ) + @deprecated( + "client.futures.isolated.cancel() is deprecated, use client.perp.isolated.cancel() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def cancel(self, params: CancelTradeParams): """ Cancel an isolated margin trade. @@ -192,9 +193,6 @@ async def cancel(self, params: CancelTradeParams): print(f"Canceled: {canceled.canceled}") ``` """ - warn_deprecated( - "client.futures.isolated.cancel()", "client.perp.isolated.cancel()" - ) return await self._client.request( "POST", "/futures/isolated/trade/cancel", @@ -203,6 +201,9 @@ async def cancel(self, params: CancelTradeParams): response_model=FuturesCanceledTrade, ) + @deprecated( + "client.futures.isolated.cancel_all() is deprecated, use client.perp.isolated.cancel_all() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def cancel_all(self): """ Cancel all isolated margin trades. @@ -216,9 +217,6 @@ async def cancel_all(self): print(f"Canceled {len(canceled)} trades") ``` """ - warn_deprecated( - "client.futures.isolated.cancel_all()", "client.perp.isolated.cancel_all()" - ) return await self._client.request( "POST", "/futures/isolated/trades/cancel-all", @@ -226,6 +224,9 @@ async def cancel_all(self): response_model=list[FuturesCanceledTrade], ) + @deprecated( + "client.futures.isolated.add_margin() is deprecated, use client.perp.isolated.add_margin() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def add_margin(self, params: AddMarginParams): """ Add margin to an isolated trade. @@ -242,9 +243,6 @@ async def add_margin(self, params: AddMarginParams): print(f"New margin: {updated.margin}") ``` """ - warn_deprecated( - "client.futures.isolated.add_margin()", "client.perp.isolated.add_margin()" - ) return await self._client.request( "POST", "/futures/isolated/trade/add-margin", @@ -253,6 +251,9 @@ async def add_margin(self, params: AddMarginParams): response_model=FuturesRunningTrade, ) + @deprecated( + "client.futures.isolated.cash_in() is deprecated, use client.perp.isolated.cash_in() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def cash_in(self, params: CashInParams): """ Cash in on an isolated trade. @@ -269,9 +270,6 @@ async def cash_in(self, params: CashInParams): print(f"Trade margin: {updated.margin}") ``` """ - warn_deprecated( - "client.futures.isolated.cash_in()", "client.perp.isolated.cash_in()" - ) return await self._client.request( "POST", "/futures/isolated/trade/cash-in", @@ -280,6 +278,9 @@ async def cash_in(self, params: CashInParams): response_model=FuturesRunningTrade, ) + @deprecated( + "client.futures.isolated.update_stoploss() is deprecated, use client.perp.isolated.update_stoploss() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def update_stoploss(self, params: UpdateStoplossParams): """ Update stop loss for an isolated trade. @@ -296,10 +297,6 @@ async def update_stoploss(self, params: UpdateStoplossParams): print(f"New stop loss: {updated.stoploss}") ``` """ - warn_deprecated( - "client.futures.isolated.update_stoploss()", - "client.perp.isolated.update_stoploss()", - ) return await self._client.request( "PUT", "/futures/isolated/trade/stoploss", @@ -308,6 +305,9 @@ async def update_stoploss(self, params: UpdateStoplossParams): response_model=FuturesRunningTrade, ) + @deprecated( + "client.futures.isolated.update_takeprofit() is deprecated, use client.perp.isolated.update_takeprofit() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def update_takeprofit(self, params: UpdateTakeprofitParams): """ Update take profit for an isolated trade. @@ -324,10 +324,6 @@ async def update_takeprofit(self, params: UpdateTakeprofitParams): print(f"New take profit: {updated.takeprofit}") ``` """ - warn_deprecated( - "client.futures.isolated.update_takeprofit()", - "client.perp.isolated.update_takeprofit()", - ) return await self._client.request( "PUT", "/futures/isolated/trade/takeprofit", @@ -336,6 +332,9 @@ async def update_takeprofit(self, params: UpdateTakeprofitParams): response_model=FuturesRunningTrade, ) + @deprecated( + "client.futures.isolated.remove_stoploss() is deprecated, use client.perp.isolated.remove_stoploss() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def remove_stoploss( self, params: RemoveStoplossParams ) -> FuturesRunningTrade | FuturesOpenTrade: @@ -356,10 +355,6 @@ async def remove_stoploss( print(f"Stop loss: {updated.stoploss}") ``` """ - warn_deprecated( - "client.futures.isolated.remove_stoploss()", - "client.perp.isolated.remove_stoploss()", - ) return await self._client.request( "DELETE", "/futures/isolated/trade/stoploss", @@ -368,6 +363,9 @@ async def remove_stoploss( response_model=FuturesRunningTrade | FuturesOpenTrade, ) + @deprecated( + "client.futures.isolated.remove_takeprofit() is deprecated, use client.perp.isolated.remove_takeprofit() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def remove_takeprofit( self, params: RemoveTakeprofitParams ) -> FuturesRunningTrade | FuturesOpenTrade: @@ -386,10 +384,6 @@ async def remove_takeprofit( print(f"Take profit: {updated.takeprofit}") ``` """ - warn_deprecated( - "client.futures.isolated.remove_takeprofit()", - "client.perp.isolated.remove_takeprofit()", - ) return await self._client.request( "DELETE", "/futures/isolated/trade/takeprofit", @@ -398,6 +392,9 @@ async def remove_takeprofit( response_model=FuturesRunningTrade | FuturesOpenTrade, ) + @deprecated( + "client.futures.isolated.get_funding_fees() is deprecated, use client.perp.isolated.get_funding_fees() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def get_funding_fees( self, params: GetIsolatedFundingFeesParams | None = None ): @@ -419,10 +416,6 @@ async def get_funding_fees( print(f"Next cursor: {response.next_cursor}") ``` """ - warn_deprecated( - "client.futures.isolated.get_funding_fees()", - "client.perp.isolated.get_funding_fees()", - ) return await self._client.request( "GET", "/futures/isolated/funding-fees", @@ -431,6 +424,9 @@ async def get_funding_fees( response_model=PaginatedResponse[FundingFees], ) + @deprecated( + "client.futures.isolated.get_canceled_trades() is deprecated, use client.perp.isolated.get_canceled_trades() with instrument='inverse_perp', pair='btc_usd' instead." + ) async def get_canceled_trades(self, params: GetClosedTradesParams | None = None): """ Get canceled isolated margin trades history. @@ -450,10 +446,6 @@ async def get_canceled_trades(self, params: GetClosedTradesParams | None = None) print(f"Next cursor: {response.next_cursor}") ``` """ - warn_deprecated( - "client.futures.isolated.get_canceled_trades()", - "client.perp.isolated.get_canceled_trades()", - ) return await self._client.request( "GET", "/futures/isolated/trades/canceled", diff --git a/src/lnmarkets_sdk/rest/v3/tests/test_unit.py b/src/lnmarkets_sdk/rest/v3/tests/test_unit.py index d262e9b..66a6d9b 100644 --- a/src/lnmarkets_sdk/rest/v3/tests/test_unit.py +++ b/src/lnmarkets_sdk/rest/v3/tests/test_unit.py @@ -540,6 +540,38 @@ async def test_futures_methods_warn_deprecated(httpx_mock: HTTPXMock) -> None: await client.futures.isolated.get_running_trades() +def test_futures_methods_marked_deprecated_for_type_checkers() -> None: + # `@deprecated` sets `__deprecated__`, which IDEs and type checkers read + # to strike the call through and show the `client.perp` hint. + from lnmarkets_sdk.rest.v3.http.client.futures import FuturesClient + from lnmarkets_sdk.rest.v3.http.client.futures.cross import FuturesCrossClient + from lnmarkets_sdk.rest.v3.http.client.futures.isolated import ( + FuturesIsolatedClient, + ) + from lnmarkets_sdk.rest.v3.http.client.perp import PerpClient + from lnmarkets_sdk.rest.v3.http.client.perp.isolated import PerpIsolatedClient + + replaced = { + FuturesClient: ( + PerpClient, + ["get_ticker", "get_candles", "get_funding_settlements"], + ), + FuturesIsolatedClient: ( + PerpIsolatedClient, + [n for n in vars(FuturesIsolatedClient) if not n.startswith("_")], + ), + } + for old_cls, (new_cls, names) in replaced.items(): + for name in names: + message = getattr(old_cls, name).__deprecated__ + assert hasattr(new_cls, name), name + assert f".{name}()" in message and "client.perp" in message, message + + assert not hasattr(FuturesClient.get_leaderboard, "__deprecated__") + for name, member in vars(FuturesCrossClient).items(): + assert not hasattr(member, "__deprecated__"), name + + def test_nested_whole_floats_serialize_as_ints() -> None: # The server signs `JSON.stringify(body)`, which writes 64000.0 as 64000. params = PerpUpdateStoplossParams( diff --git a/uv.lock b/uv.lock index cbc4eaf..c64a235 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.12" [[package]] @@ -205,6 +205,7 @@ dependencies = [ { name = "pygments" }, { name = "requests" }, { name = "tenacity" }, + { name = "typing-extensions" }, { name = "websockets" }, ] @@ -237,6 +238,7 @@ requires-dist = [ { name = "pygments", specifier = ">=2.20.0" }, { name = "requests", specifier = ">=2.32.3" }, { name = "tenacity", specifier = ">=9.1.4" }, + { name = "typing-extensions", specifier = ">=4.12" }, { name = "websockets", specifier = ">=12.0" }, ] From ae46821a74ff1a32c5567618d09441fc01d9a4f1 Mon Sep 17 00:00:00 2001 From: CaoKha Date: Mon, 2 Nov 2026 17:22:08 +0100 Subject: [PATCH 4/4] feat(perp): add isolated get_trade() and trade label GET /v3/perp/isolated/trade fetches one trade of any market by id or by client_id. The trade variants skip validation of their state flags, so the response is parsed with a discriminator on running/closed/canceled rather than a plain union. request() now also accepts a prebuilt TypeAdapter. PerpOrder and perp trades carry label, the shared tag. On /perp, client_id is a unique client order ID (reuse fails with 409 CONFLICT). --- CHANGELOG.md | 4 +- README.md | 3 +- src/lnmarkets_sdk/rest/v3/_internal/utils.py | 6 +- .../rest/v3/http/client/__init__.py | 4 +- .../rest/v3/http/client/perp/isolated.py | 30 +++++ src/lnmarkets_sdk/rest/v3/models/perp.py | 72 +++++++++++- src/lnmarkets_sdk/rest/v3/tests/test_unit.py | 103 ++++++++++++++++++ 7 files changed, 212 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55ebd50..a5297cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,10 @@ Multi-market perp API (`/v3/perp`) and `GET /v3/markets`. - `client.get_markets()` (`GET /v3/markets`): the released markets, plus the early-access markets your API key can access. Returns `Market` models (`lnmarkets_sdk.rest.v3.models.markets`). - `client.perp` (`PerpClient`) for `/v3/perp`: `get_ticker()`, `get_candles()` and `get_funding_settlements()`. -- `client.perp.isolated` (`PerpIsolatedClient`) for `/v3/perp/isolated`: `new_trade()`, `cancel()`, `cancel_all()`, `close()`, `add_margin()`, `cash_in()`, `update_stoploss()`, `update_takeprofit()`, `remove_stoploss()`, `remove_takeprofit()`, `get_open_trades()`, `get_running_trades()`, `get_closed_trades()`, `get_canceled_trades()` and `get_funding_fees()`. +- `client.perp.isolated` (`PerpIsolatedClient`) for `/v3/perp/isolated`: `new_trade()`, `get_trade()`, `cancel()`, `cancel_all()`, `close()`, `add_margin()`, `cash_in()`, `update_stoploss()`, `update_takeprofit()`, `remove_stoploss()`, `remove_takeprofit()`, `get_open_trades()`, `get_running_trades()`, `get_closed_trades()`, `get_canceled_trades()` and `get_funding_fees()`. - Perp models in `lnmarkets_sdk.rest.v3.models.perp`. Every perp call takes the market key (`instrument` + `pair`). Trades, tickers, funding settlements and funding fees include the quanto fields (`quanto_adjustment_rate`, `quanto_adjustment_fee`, `sum_quanto_adjustment_fees`), the notional fields, and the `mark` price. +- `client.perp.isolated.get_trade()` (`GET /v3/perp/isolated/trade`): one trade in any state and of any market, by `id` or by `client_id` (`GetPerpTradeParams`). Returns the variant that matches the trade state. +- `label` on `PerpOrder` and on perp trades: a free tag several trades can share. On `/perp`, `client_id` is a unique client order ID: reusing one fails with HTTP 409 `CONFLICT`, and older trades' tags now show as `label`. - Cursor pagination for the perp history reads: pass `next_cursor` back as `cursor`. The cursor is an object (`PerpCursor(value=..., id=...)`), sent as `cursor[value]` and `cursor[id]` in the query. - The public perp routes and `get_markets()` sign the request when the client has credentials, so early-access markets work. diff --git a/README.md b/README.md index a5c10b9..b9212a5 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ async with LNMClient(config) as client: page = await client.perp.isolated.get_closed_trades(params) ``` -The `client.futures` methods that have a `client.perp` replacement (ticker, candles, funding settlements and all isolated methods) are deprecated and raise a `DeprecationWarning`. They still work and trade BTC/USD only. To move, call the `client.perp` method with the same name and `instrument="inverse_perp", pair="btc_usd"`. Note that `PerpOrder.stoploss` is an object (`PerpStoploss(mode=..., value=...)`), not a number plus `stoploss_mode`. +The `client.futures` methods that have a `client.perp` replacement (ticker, candles, funding settlements and all isolated methods) are deprecated and raise a `DeprecationWarning`. They still work and trade BTC/USD only. To move, call the `client.perp` method with the same name and `instrument="inverse_perp", pair="btc_usd"`. Note that `PerpOrder.stoploss` is an object (`PerpStoploss(mode=..., value=...)`), not a number plus `stoploss_mode`. On `client.perp`, `client_id` is a unique client order ID (reusing one fails with HTTP 409 `CONFLICT`): put tags that several trades share in `label`, and fetch a trade by either key with `client.perp.isolated.get_trade()`. Check our [REST example](./examples/rest_v3.py) for more details. @@ -145,6 +145,7 @@ client.perp.get_funding_settlements() # Perp Isolated šŸ”’ client.perp.isolated.new_trade() +client.perp.isolated.get_trade() client.perp.isolated.get_running_trades() client.perp.isolated.get_open_trades() client.perp.isolated.get_closed_trades() diff --git a/src/lnmarkets_sdk/rest/v3/_internal/utils.py b/src/lnmarkets_sdk/rest/v3/_internal/utils.py index d954951..b591176 100644 --- a/src/lnmarkets_sdk/rest/v3/_internal/utils.py +++ b/src/lnmarkets_sdk/rest/v3/_internal/utils.py @@ -94,7 +94,7 @@ def get_hostname(network: APINetwork) -> str: def parse_response[T]( response: httpx.Response, - model: type[T] | UnionType | None = None, + model: type[T] | UnionType | TypeAdapter[T] | None = None, ) -> T: """Parse and validate API response.""" if not response.is_success: @@ -121,7 +121,9 @@ def parse_response[T]( return data try: - adapter: TypeAdapter[T] = TypeAdapter(model) + adapter: TypeAdapter[T] = ( + model if isinstance(model, TypeAdapter) else TypeAdapter(model) + ) return adapter.validate_python(data) except ValidationError as e: raise APIValidationException( diff --git a/src/lnmarkets_sdk/rest/v3/http/client/__init__.py b/src/lnmarkets_sdk/rest/v3/http/client/__init__.py index 549736a..a1abbb0 100644 --- a/src/lnmarkets_sdk/rest/v3/http/client/__init__.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/__init__.py @@ -1,7 +1,7 @@ from collections.abc import Mapping from types import UnionType -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter from lnmarkets_sdk.rest.v3._internal import BaseClient from lnmarkets_sdk.rest.v3._internal.models import ( @@ -113,7 +113,7 @@ async def request[T]( path: str, params: BaseModel | Mapping[str, object] | None = None, credentials: bool = False, - response_model: type[T] | UnionType | None = None, + response_model: type[T] | UnionType | TypeAdapter[T] | None = None, ) -> T: """ Make an HTTP request to the API with automatic validation. diff --git a/src/lnmarkets_sdk/rest/v3/http/client/perp/isolated.py b/src/lnmarkets_sdk/rest/v3/http/client/perp/isolated.py index 4ea691a..249bad1 100644 --- a/src/lnmarkets_sdk/rest/v3/http/client/perp/isolated.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/perp/isolated.py @@ -4,7 +4,9 @@ from lnmarkets_sdk.rest.v3.http.client import LNMClient from lnmarkets_sdk.rest.v3.models.perp import ( + PERP_TRADE_ADAPTER, GetPerpFundingFeesParams, + GetPerpTradeParams, GetPerpTradesParams, PerpAddMarginParams, PerpCanceledTrade, @@ -18,6 +20,7 @@ PerpPaginatedResponse, PerpRunningTrade, PerpRunningTradeRead, + PerpTrade, PerpTradeParams, PerpUpdateStoplossParams, PerpUpdateTakeprofitParams, @@ -60,6 +63,33 @@ async def new_trade(self, params: PerpOrder) -> PerpOpenTrade | PerpRunningTrade response_model=PerpOpenTrade | PerpRunningTrade, ) + async def get_trade(self, params: GetPerpTradeParams) -> PerpTrade: + """ + Get one of your trades, in any state and of any perp market, by its `id` + or its `client_id` (pass exactly one). + + After a timeout, resend the order with the same `client_id`: a 409 + `CONFLICT` means the first one went through, and this fetches it. + + Example: + ```python + from lnmarkets_sdk.rest.v3.models.perp import GetPerpTradeParams + + async with LNMClient(config) as client: + trade = await client.perp.isolated.get_trade( + GetPerpTradeParams(client_id="my-order-0001") + ) + print(f"{trade.instrument}:{trade.pair} running={trade.running}") + ``` + """ + return await self._client.request( + "GET", + "/perp/isolated/trade", + params=params, + credentials=True, + response_model=PERP_TRADE_ADAPTER, + ) + async def cancel(self, params: PerpTradeParams): """ Cancel an open trade. diff --git a/src/lnmarkets_sdk/rest/v3/models/perp.py b/src/lnmarkets_sdk/rest/v3/models/perp.py index af2b993..302528c 100644 --- a/src/lnmarkets_sdk/rest/v3/models/perp.py +++ b/src/lnmarkets_sdk/rest/v3/models/perp.py @@ -2,9 +2,17 @@ # in concrete trade variants (see futures_isolated.py). # pyright: reportIncompatibleVariableOverride=false -from typing import Literal - -from pydantic import BaseModel, Field, SkipValidation, model_validator +from typing import Annotated, Literal, cast + +from pydantic import ( + BaseModel, + Discriminator, + Field, + SkipValidation, + Tag, + TypeAdapter, + model_validator, +) from lnmarkets_sdk.rest.v3._internal.models import UUID, BaseConfig from lnmarkets_sdk.rest.v3.models.futures_data import ( @@ -220,7 +228,17 @@ class PerpOrder(PerpMarketKey): client_id: str | None = Field( default=None, max_length=64, - description="Optional custom data field (max 64 characters)", + description=( + "Your client order ID (max 64 characters). Unique across all your " + "isolated trades and markets: reusing one fails with HTTP 409 " + "CONFLICT and no trade is placed. Use `label` to tag several trades." + ), + ) + label: str | None = Field( + default=None, + min_length=1, + max_length=64, + description="Free tag that several trades can share, e.g. a strategy name", ) @model_validator(mode="after") @@ -251,6 +269,9 @@ class PerpTradeFields(BaseModel, BaseConfig): sum_quanto_adjustment_fees: SkipValidation[float] = Field( default=0.0, description="Total quanto adjustment fees paid" ) + label: SkipValidation[str] | None = Field( + default=None, description="Free tag given when the trade was placed" + ) class PerpOpenTrade(PerpTradeFields, FuturesOpenTrade): ... @@ -272,6 +293,49 @@ class PerpClosedTrade(PerpTradeFields, FuturesClosedTrade): class PerpCanceledTrade(PerpTradeFields, FuturesCanceledTrade): ... +def _trade_state(value: object) -> str: + flags = cast("dict[str, object]", value if isinstance(value, dict) else vars(value)) + for state in ("canceled", "closed", "running"): + if flags.get(state): + return state + return "open" + + +type PerpTrade = ( + PerpOpenTrade | PerpRunningTradeRead | PerpClosedTrade | PerpCanceledTrade +) +"""A trade in any state, as `client.perp.isolated.get_trade()` returns it.""" + +# The trade variants skip validation of their state flags, so a plain union +# would always pick the first one: pick the variant by the flags instead. +PERP_TRADE_ADAPTER: TypeAdapter[PerpTrade] = TypeAdapter( + Annotated[ + Annotated[PerpOpenTrade, Tag("open")] + | Annotated[PerpRunningTradeRead, Tag("running")] + | Annotated[PerpClosedTrade, Tag("closed")] + | Annotated[PerpCanceledTrade, Tag("canceled")], + Discriminator(_trade_state), + ] +) + + +class GetPerpTradeParams(BaseModel, BaseConfig): + """Look up one trade, of any market, by `id` or by `client_id` (exactly one).""" + + id: UUID | None = Field(default=None, description="Trade ID") + client_id: str | None = Field( + default=None, + max_length=64, + description="Client ID given when the trade was placed", + ) + + @model_validator(mode="after") + def validate_schema(self): + if (self.id is None) == (self.client_id is None): + raise ValueError("Exactly one of id or client_id must be set") + return self + + class PerpTradeParams(PerpMarketKey): id: UUID = Field(..., description="Trade ID") diff --git a/src/lnmarkets_sdk/rest/v3/tests/test_unit.py b/src/lnmarkets_sdk/rest/v3/tests/test_unit.py index 66a6d9b..78ad36c 100644 --- a/src/lnmarkets_sdk/rest/v3/tests/test_unit.py +++ b/src/lnmarkets_sdk/rest/v3/tests/test_unit.py @@ -30,13 +30,17 @@ UpdateStoplossParams, ) from lnmarkets_sdk.rest.v3.models.perp import ( + GetPerpTradeParams, GetPerpTradesParams, + PerpCanceledTrade, + PerpClosedTrade, PerpCursor, PerpMarketFilter, PerpMarketKey, PerpOpenTrade, PerpOrder, PerpRunningTrade, + PerpRunningTradeRead, PerpStoploss, PerpTradeParams, PerpUpdateStoplossParams, @@ -419,6 +423,105 @@ async def test_perp_open_trade_parses_as_open(httpx_mock: HTTPXMock) -> None: assert isinstance(trade, PerpOpenTrade) +async def test_perp_new_trade_sends_client_id_and_label(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response( + json={**_perp_running_trade_payload(), "clientId": "order-1", "label": "grid"} + ) + + async with LNMClient(_auth_config()) as client: + trade = await client.perp.isolated.new_trade( + PerpOrder( + **GOLD, + type="market", + side="buy", + quantity=1, + leverage=10, + client_id="order-1", + label="grid", + ) + ) + + assert (trade.client_id, trade.label) == ("order-1", "grid") + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.read()) + assert (body["clientId"], body["label"]) == ("order-1", "grid") + + +def test_perp_order_rejects_empty_label() -> None: + with pytest.raises(ValidationError): + PerpOrder(**GOLD, type="market", side="buy", quantity=1, leverage=10, label="") + + +async def test_perp_get_trade_by_client_id_signs_query(httpx_mock: HTTPXMock) -> None: + httpx_mock.add_response(json=_perp_running_trade_payload()) + + async with LNMClient(_auth_config()) as client: + trade = await client.perp.isolated.get_trade( + GetPerpTradeParams(client_id="order-1") + ) + + assert isinstance(trade, PerpRunningTradeRead) + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert request.url.path == "/v3/perp/isolated/trade" + assert request.url.query.decode() == "clientId=order-1" + timestamp = request.headers["lnm-access-timestamp"] + expected = _signature( + "get", "/v3/perp/isolated/trade", "?clientId=order-1", timestamp + ) + assert request.headers["lnm-access-signature"] == expected + + +@pytest.mark.parametrize( + ("flags", "expected"), + [ + ( + {"open": True, "running": False, "type": "limit", "filledAt": None}, + PerpOpenTrade, + ), + ({}, PerpRunningTradeRead), + ( + { + "running": False, + "closed": True, + "closedAt": "2026-01-02T00:00:00.000Z", + "exitPrice": 101_000, + "exitNotionalUsd": 4_040, + }, + PerpClosedTrade, + ), + ( + { + "running": False, + "canceled": True, + "type": "limit", + "filledAt": None, + "closedAt": "2026-01-02T00:00:00.000Z", + }, + PerpCanceledTrade, + ), + ], +) +async def test_perp_get_trade_parses_each_state( + httpx_mock: HTTPXMock, flags: dict[str, object], expected: type +) -> None: + httpx_mock.add_response(json={**_perp_running_trade_payload(), **flags}) + + async with LNMClient(_auth_config()) as client: + trade = await client.perp.isolated.get_trade(GetPerpTradeParams(id=TRADE_ID)) + + assert type(trade) is expected + + +def test_get_perp_trade_params_needs_exactly_one_key() -> None: + with pytest.raises(ValidationError): + GetPerpTradeParams() + with pytest.raises(ValidationError): + GetPerpTradeParams(id=TRADE_ID, client_id="order-1") + + async def test_perp_closed_trades_flatten_cursor_in_signed_query( httpx_mock: HTTPXMock, ) -> None: