diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c65f686..22c70ea 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -43,6 +43,6 @@ jobs: uv run --isolated --python=3.13 pytest uv run --isolated --python=3.14 pytest env: - TEST_API_KEY: ${{ secrets.TEST_API_KEY }} - TEST_API_SECRET: ${{ secrets.TEST_API_SECRET }} - TEST_API_PASSPHRASE: ${{ secrets.TEST_API_PASSPHRASE }} + TESTNET4_API_KEY: ${{ secrets.TESTNET4_API_KEY }} + TESTNET4_API_KEY_SECRET: ${{ secrets.TESTNET4_API_KEY_SECRET }} + TESTNET4_API_KEY_PASSPHRASE: ${{ secrets.TESTNET4_API_KEY_PASSPHRASE }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5f3143f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +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.0.20] - 2026-05-13 + +### Added + +- `stream/v1` WebSocket client (`stream_v1` package, models, auth, public, subscription). +- `account.read_notifications()` method — maps to `PUT /account/notifications` (mark all as read). +- `examples/stream_v1.py` demonstrating WebSocket usage. + +### Changed + +- Package layout: `lnmarkets_sdk.rest_v3` → `lnmarkets_sdk.rest.v3`. +- Renamed `examples/basic.py` → `examples/rest_v3.py`. +- `examples/rest_v3.py`: take-profit update now opens a real isolated market trade and uses its ID instead of hardcoded UUID. + +### Removed + +- `AccountClient.withdraw_internal` (endpoint not present in api-rest-v3). +- `AccountClient.get_internal_deposits` (endpoint not present in api-rest-v3). +- `AccountClient.get_internal_withdrawals` (endpoint not present in api-rest-v3). +- Associated models: `WithdrawInternalParams`, `WithdrawInternalResponse`, `InternalDeposit`, `InternalWithdrawal`, `GetInternalDepositsParams`, `GetInternalWithdrawalsParams`. + +### Fixed + +- `examples/rest_v3.py`: `update_takeprofit` no longer hits hardcoded trade ID that returned `400 You do not own this trade`. diff --git a/README.md b/README.md index 668618d..f2af023 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ -# LN Markets SDK v3 +# LN Markets SDK [![CI](https://github.com/ln-markets/sdk-python/actions/workflows/check.yml/badge.svg)](https://github.com/ln-markets/sdk-python/actions/workflows/check.yml) -This is the Python version of the LN Markets API SDK. It provides a client-based interface for interacting with the LN Markets API. +Python SDK for the LN Markets API. Ships two clients: -## Usage +- **REST v3** (`lnmarkets_sdk.rest.v3`) — request/response API. +- **Stream v1** (`lnmarkets_sdk.stream.v1`) — JSON-RPC WebSocket with subscriptions. + +## REST v3 For public endpoints, you can just do this: ```python -from lnmarkets_sdk.v3.http.client import LNMClient +from lnmarkets_sdk.rest.v3.http.client import LNMClient import asyncio async with LNMClient() as client: @@ -23,7 +26,7 @@ Remember to sleep between requests, as the rate limit is 1 requests per second f For endpoints that need authentication, you need to create an instance of the `LNMClient` class and provide your API credentials: ```python -from lnmarkets_sdk.v3.http.client import APIAuthContext, APIClientConfig, LNMClient +from lnmarkets_sdk.rest.v3.http.client import APIAuthContext, APIClientConfig, LNMClient config = APIClientConfig( authentication=APIAuthContext( @@ -39,12 +42,12 @@ async with LNMClient(config) as client: account = await client.account.get_account() ``` -For endpoints that requires input parameters, you can find the corresponding models in the `lnmarkets_sdk.models` module. +For endpoints that requires input parameters, you can find the corresponding models in the `lnmarkets_sdk.rest.v3.models` module. ```python -from lnmarkets_sdk.v3.http.client import APIAuthContext, APIClientConfig, LNMClient -from lnmarkets_sdk.v3.models.account import GetLightningDepositsParams +from lnmarkets_sdk.rest.v3.http.client import APIAuthContext, APIClientConfig, LNMClient +from lnmarkets_sdk.rest.v3.models.account import GetLightningDepositsParams config = APIClientConfig( authentication=APIAuthContext( @@ -62,9 +65,9 @@ async with LNMClient(config) as client: ) ``` -Check our [example](./examples/basic.py) for more details. +Check our [REST example](./examples/rest_v3.py) for more details. -## Available Methods +### Available Methods 🔒 = requires API credentials @@ -78,14 +81,12 @@ client.account.get_bitcoin_address() client.account.add_bitcoin_address() client.account.deposit_lightning() client.account.withdraw_lightning() -client.account.withdraw_internal() client.account.withdraw_on_chain() client.account.get_lightning_deposits() client.account.get_lightning_withdrawals() -client.account.get_internal_deposits() -client.account.get_internal_withdrawals() client.account.get_on_chain_deposits() client.account.get_on_chain_withdrawals() +client.account.read_notifications() # Futures client.futures.get_ticker() @@ -131,9 +132,114 @@ client.synthetic_usd.get_swaps() # 🔒 client.synthetic_usd.new_swap() # 🔒 ``` +## Stream v1 + +WebSocket JSON-RPC client. Connect, subscribe to topics, register event listeners. + +Public stream: + +```python +import asyncio +from lnmarkets_sdk.stream.v1 import StreamClientConfig, create_stream_client +from lnmarkets_sdk.stream.v1.models import SubscribeParams + +async def main(): + config = StreamClientConfig(network="mainnet") + client = create_stream_client(config) + + client.on( + "futures/inverse/btc_usd/ticker", + lambda data: print(data["lastPrice"]), + ) + + await client.connect() + await client.subscription.subscribe( + SubscribeParams(topics=["futures/inverse/btc_usd/ticker"]), + ) + await asyncio.sleep(30) + await client.close() + +asyncio.run(main()) +``` + +Authenticated stream (private topics: trades, orders, position, wallet): + +```python +from lnmarkets_sdk.stream.v1 import StreamClientConfig, create_stream_client +from lnmarkets_sdk.stream.v1.models import AuthenticateParams, SubscribeParams + +config = StreamClientConfig( + network="mainnet", + reconnect_enabled=True, + reconnect_interval=5.0, + max_reconnect_attempts=5, +) +client = create_stream_client(config) + +await client.connect() +await client.auth.authenticate( + AuthenticateParams(key=your_key, secret=your_secret, passphrase=your_passphrase), +) +await client.subscription.subscribe( + SubscribeParams(topics=[ + "futures/inverse/btc_usd/isolated/trades", + "wallet/deposit", + "wallet/withdrawal", + ]), +) +``` + +Server rate-limits to 10 messages/sec per socket — pace RPC calls accordingly. + +Check our [Stream example](./examples/stream_v1.py) for full RPC + topic coverage. + +### Available RPCs and Topics + +🔒 = requires authentication + +```python +# Public RPCs +client.public.hello() +client.public.ping() +client.public.time() + +# Auth RPCs 🔒 +client.auth.authenticate() +client.auth.whoami() + +# Subscription RPCs +client.subscription.subscribe() +client.subscription.unsubscribe() +client.subscription.unsubscribe_all() + +# Lifecycle events +client.on("open", ...) +client.on("close", ...) +client.on("error", ...) +client.on("reconnected", ...) + +# Public topics +"announcements" +"futures/inverse/btc_usd/ticker" +"futures/inverse/btc_usd/lastPrice" +"futures/inverse/btc_usd/index" +"futures/inverse/btc_usd/buckets" +"futures/inverse/btc_usd/funding" +"futures/inverse/btc_usd/ohlc/1m" +"futures/inverse/btc_usd/ohlc/5m" + +# Private topics 🔒 +"futures/inverse/btc_usd/isolated/trades" +"futures/inverse/btc_usd/cross/orders" +"futures/inverse/btc_usd/cross/position" +"wallet/deposit" +"wallet/withdrawal" +``` + ## API Reference -For full API documentation, see: [LNM API v3 Documentation](https://api.lnmarkets.com/v3/) +- [REST](https://docs.lnmarkets.com/en/api) +- [STREAM](https://docs.lnmarkets.com/en/stream) ## Contributing diff --git a/examples/basic.py b/examples/rest_v3.py similarity index 64% rename from examples/basic.py rename to examples/rest_v3.py index 753d9d1..d6185f7 100644 --- a/examples/basic.py +++ b/examples/rest_v3.py @@ -1,39 +1,59 @@ -""" -Example usage of the v3 client-based API. +"""REST v3 runnable example — public endpoints + optional authenticated flow. + +Creds loaded from `.env` via `python-dotenv`. + +Run: + uv run examples/rest_v3.py # public-only, testnet4 + uv run examples/rest_v3.py --auth # authenticated, testnet4 + uv run examples/rest_v3.py --mainnet + uv run examples/rest_v3.py --mainnet --auth + +Authenticated run reads creds by network: + mainnet -> MAINNET_API_KEY, MAINNET_API_KEY_SECRET, MAINNET_API_KEY_PASSPHRASE + testnet4 -> TESTNET4_API_KEY, TESTNET4_API_KEY_SECRET, TESTNET4_API_KEY_PASSPHRASE """ import asyncio import os +import sys from pprint import pprint +from typing import Literal from dotenv import load_dotenv -from lnmarkets_sdk.v3.http.client import APIAuthContext, APIClientConfig, LNMClient -from lnmarkets_sdk.v3.models.account import GetLightningDepositsParams -from lnmarkets_sdk.v3.models.futures_cross import FuturesCrossOrderLimit -from lnmarkets_sdk.v3.models.futures_data import ( +from lnmarkets_sdk.rest.v3.http.client import APIAuthContext, APIClientConfig, LNMClient +from lnmarkets_sdk.rest.v3.models.account import ( + GetLightningDepositsParams, + 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.v3.models.futures_isolated import ( +from lnmarkets_sdk.rest.v3.models.futures_isolated import ( + CloseTradeParams, + FuturesOrder, GetClosedTradesParams, GetIsolatedFundingFeesParams, UpdateTakeprofitParams, ) -from lnmarkets_sdk.v3.models.oracle import GetLastPriceParams +from lnmarkets_sdk.rest.v3.models.oracle import GetLastPriceParams + +Network = Literal["mainnet", "testnet4"] load_dotenv() -async def example_public_endpoints(): +async def example_public_endpoints(network: Network): """Example: Make public requests without authentication.""" print("\n" + "=" * 80) - print("PUBLIC ENDPOINTS EXAMPLE") + print(f"PUBLIC ENDPOINTS EXAMPLE ({network})") print("=" * 80) # Create client without authentication for public endpoints # The httpx.AsyncClient is created once and reuses connections - async with LNMClient(APIClientConfig(network="mainnet")) as client: + async with LNMClient(APIClientConfig(network=network)) as client: # All these requests share the same connection pool print("\n🔄 Making multiple requests with connection reuse...") @@ -105,22 +125,23 @@ async def example_public_endpoints(): print(f"Price: {price.last_price}, Time: {price.time}") -async def example_authenticated_endpoints(): +async def example_authenticated_endpoints(network: Network): """Example: Use authenticated endpoints with credentials.""" print("\n" + "=" * 80) - print("AUTHENTICATED ENDPOINTS EXAMPLE") + print(f"AUTHENTICATED ENDPOINTS EXAMPLE ({network})") print("=" * 80) - key = os.getenv("V3_API_KEY") - secret = os.getenv("V3_API_KEY_SECRET") - passphrase = os.getenv("V3_API_KEY_PASSPHRASE") - print(f"key: {key}") - print(f"secret: {secret}") - print(f"passphrase: {passphrase}") + 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") if not (key and secret and passphrase): print("\n⚠️ Skipping authenticated example:") - print(" Please set V3_API_KEY, V3_API_KEY_SECRET, and V3_API_KEY_PASSPHRASE") + print( + f" Please set {prefix}_API_KEY, {prefix}_API_KEY_SECRET, " + f"and {prefix}_API_KEY_PASSPHRASE" + ) return # Create config with authentication and custom timeout @@ -130,7 +151,7 @@ async def example_authenticated_endpoints(): secret=secret, passphrase=passphrase, ), - network="mainnet", + network=network, timeout=60.0, # 60 second timeout (default is 30s) ) @@ -159,7 +180,24 @@ async def example_authenticated_endpoints(): for deposit in deposits_response.data: print(f"Deposits {deposit.amount} sats at {deposit.created_at}") + # 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. + 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) + print(f"\n--- Notifications (unread: {unread}/{len(notifs.data)}) ---") + for n in notifs.data[:3]: + print(f" [{'x' if n.read else ' '}] {n.message}") + + # Mark all notifications as read (no-op if list is empty) + await asyncio.sleep(1) + await client.account.read_notifications() + print("\n--- read_notifications() called ---") + # Get running trades + await asyncio.sleep(1) running_trades = await client.futures.isolated.get_running_trades() print("\n--- Running Isolated Trades ---") print(f"Count: {len(running_trades)}") @@ -174,7 +212,7 @@ async def example_authenticated_endpoints(): open_trades = await client.futures.isolated.get_open_trades() print(f"\n--- Open Isolated Trades (Count: {len(open_trades)}) ---") for trade in open_trades[:3]: # Show first 3 - side = "LONG" if trade.side == "b" else "SHORT" + side = "LONG" if trade.side == "buy" else "SHORT" print(f" {side} - Price: {trade.price}, Quantity: {trade.quantity}") # Get closed trades @@ -184,7 +222,7 @@ async def example_authenticated_endpoints(): ) 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 == "b" else "SHORT" + side = "LONG" if trade.side == "buy" else "SHORT" print(f" {side} - PL: {trade.pl} sats, Closed: {trade.closed}") # Get isolated funding fees @@ -222,21 +260,42 @@ async def example_authenticated_endpoints(): except Exception as e: print(f"Error: {e}") - print("\n --- Update take profit ---") - trade_id = "41ee6f7e-7cee-4c3b-b9f3-962d4b3b97c6" - params = UpdateTakeprofitParams(id=trade_id, value=100_000) - updated = await client.futures.isolated.update_takeprofit(params) - print(f"New take profit: {updated.takeprofit}") + print("\n --- Open isolated market trade then update take profit ---") + try: + new_trade_params = FuturesOrder( + type="market", + side="buy", + quantity=1, + leverage=10, + takeprofit=150_000, + ) + 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}") + + 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}") async def main(): """Run all examples.""" + network: Network = "mainnet" if "--mainnet" in sys.argv else "testnet4" + want_auth = "--auth" in sys.argv + print("\n" + "=" * 80) - print("LN MARKETS V3 CLIENT EXAMPLES") + print(f"LN MARKETS V3 CLIENT EXAMPLES (network={network}, auth={want_auth})") print("=" * 80) - await example_public_endpoints() - await example_authenticated_endpoints() + await example_public_endpoints(network) + if want_auth: + await example_authenticated_endpoints(network) print("\n" + "=" * 80) print("EXAMPLES COMPLETE") diff --git a/examples/stream_v1.py b/examples/stream_v1.py new file mode 100644 index 0000000..3224199 --- /dev/null +++ b/examples/stream_v1.py @@ -0,0 +1,353 @@ +"""Stream-v1 runnable example — exercises every public RPC + every topic listener. + +RPC methods covered: hello, ping, time, authenticate, whoami, + subscribe, unsubscribe, unsubscribe_all. + +Run (public-only, mainnet): + uv run examples/stream_v1.py + +Authenticated run reads creds by network: + mainnet -> MAINNET_API_KEY, MAINNET_API_KEY_SECRET, MAINNET_API_KEY_PASSPHRASE + testnet4 -> TESTNET4_API_KEY, TESTNET4_API_KEY_SECRET, TESTNET4_API_KEY_PASSPHRASE + +Creds loaded from `.env` via `python-dotenv`: + uv run examples/stream_v1.py --auth + +Defaults to mainnet. Pass --testnet4 to opt in: + uv run examples/stream_v1.py --testnet4 + uv run examples/stream_v1.py --testnet4 --auth +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import signal +import sys +from typing import Any + +from dotenv import load_dotenv + +from lnmarkets_sdk.stream.v1 import ( + StreamClientConfig, + create_stream_client, +) +from lnmarkets_sdk.stream.v1.models import ( + AuthenticateParams, + HelloParams, + SubscribeParams, + Topic, + UnsubscribeParams, +) + +load_dotenv() + + +def resolve_creds(network: str) -> tuple[str, str, str]: + if network == "testnet4": + key_var, secret_var, pass_var = ( + "TESTNET4_API_KEY", + "TESTNET4_API_KEY_SECRET", + "TESTNET4_API_KEY_PASSPHRASE", + ) + else: + key_var, secret_var, pass_var = ( + "MAINNET_API_KEY", + "MAINNET_API_KEY_SECRET", + "MAINNET_API_KEY_PASSPHRASE", + ) + key = os.environ.get(key_var) + secret = os.environ.get(secret_var) + passphrase = os.environ.get(pass_var) + if not key or not secret or not passphrase: + print( + f"--auth requires {key_var}, {secret_var}, {pass_var} env vars " + f"(network={network})", + file=sys.stderr, + ) + sys.exit(1) + return key, secret, passphrase + + +async def main() -> None: + network = "mainnet" if "--mainnet" in sys.argv else "testnet4" + want_auth = "--auth" in sys.argv + + if want_auth: + resolve_creds(network) + + config = StreamClientConfig( + network=network, + reconnect_interval=5.0, + reconnect_enabled=True, + max_reconnect_attempts=5, + ) + client = create_stream_client(config) + + # --------------------------------------------------------------------- + # Lifecycle listeners + # --------------------------------------------------------------------- + + client.on("open", lambda: print(f"[open] connected to {network}")) + client.on( + "close", + lambda code, reason: print(f"[close] code={code} reason={reason or '(empty)'}"), + ) + client.on("error", lambda err: print(f"[error] {err}", file=sys.stderr)) + client.on( + "reconnected", + lambda event: print( + f"[reconnected] after {event['attempts']} attempt(s) " + "— replay subscriptions here" + ), + ) + + # --------------------------------------------------------------------- + # Public topic listeners + # --------------------------------------------------------------------- + + def handle_announcements(event: dict[str, Any]) -> None: + if "title" in event: + print( + f"[announcements] ADD id={event.get('id')} title={event.get('title')}" + ) + else: + print(f"[announcements] REMOVE id={event.get('id')}") + + client.on("announcements", handle_announcements) + + client.on( + "futures/inverse/btc_usd/ticker", + lambda data: print( + f"[ticker] time={data.get('time')} " + f"lastPrice={data.get('lastPrice')} " + f"fundingRate={data.get('funding', {}).get('rate'):.6f}" + ), + ) + client.on( + "futures/inverse/btc_usd/lastPrice", + lambda data: print( + f"[lastPrice] time={data.get('time')} lastPrice={data.get('lastPrice')}" + ), + ) + client.on( + "futures/inverse/btc_usd/index", + lambda data: print( + f"[index] time={data.get('time')} index={data.get('index')}" + ), + ) + client.on( + "futures/inverse/btc_usd/buckets", + lambda data: print( + f"[buckets] time={data.get('time')} count={len(data.get('buckets', []))}" + ), + ) + client.on( + "futures/inverse/btc_usd/funding", + lambda data: print( + f"[funding] pair={data.get('pair')} " + f"rate={data.get('current', {}).get('rate'):.6f} " + f"time={data.get('current', {}).get('time')}" + ), + ) + client.on( + "futures/inverse/btc_usd/ohlc/1m", + lambda candle: print( + f"[ohlc/1m] o={candle.get('open')} h={candle.get('high')} " + f"l={candle.get('low')} c={candle.get('close')} v={candle.get('volume')}" + ), + ) + client.on( + "futures/inverse/btc_usd/ohlc/5m", + lambda candle: print( + f"[ohlc/5m] o={candle.get('open')} h={candle.get('high')} " + f"l={candle.get('low')} c={candle.get('close')} v={candle.get('volume')}" + ), + ) + + # --------------------------------------------------------------------- + # Private topic listeners (only useful when authenticated) + # --------------------------------------------------------------------- + + if want_auth: + + def handle_isolated_trades(event: dict[str, Any]) -> None: + kind = event.get("event") + trade = event.get("trade", {}) + if kind in ("open", "filled"): + print( + f"[isolated/trades] {kind.upper()} id={trade.get('id')} " + f"price={trade.get('price')}" + ) + elif kind == "canceled": + print(f"[isolated/trades] CANCELED id={trade.get('id')}") + elif kind == "liquidation": + print( + f"[isolated/trades] LIQUIDATION id={trade.get('id')} " + f"exitPrice={trade.get('exitPrice')}" + ) + elif kind == "funding": + print( + f"[isolated/trades] FUNDING id={trade.get('id')} " + f"fundingFee={trade.get('fundingFee')}" + ) + else: + # closed | stoploss | takeprofit — same trade shape with pl + exitPrice + print( + f"[isolated/trades] {str(kind).upper()} id={trade.get('id')} " + f"pl={trade.get('pl')} exitPrice={trade.get('exitPrice')}" + ) + + client.on("futures/inverse/btc_usd/isolated/trades", handle_isolated_trades) + + client.on( + "wallet/deposit", + lambda deposit: print( + f"[wallet/deposit] id={deposit.get('id')} " + f"amount={deposit.get('amount')} status={deposit.get('status')} " + f"network={deposit.get('network')}" + ), + ) + + client.on( + "wallet/withdrawal", + lambda withdrawal: print( + f"[wallet/withdrawal] id={withdrawal.get('id')} " + f"amount={withdrawal.get('amount')} fee={withdrawal.get('fee')} " + f"status={withdrawal.get('status')}" + ), + ) + + client.on( + "futures/inverse/btc_usd/cross/orders", + lambda event: print( + f"[cross/orders] event={event.get('event')} " + f"id={event.get('order', {}).get('id')} " + f"side={event.get('order', {}).get('side')} " + f"price={event.get('order', {}).get('price')}" + ), + ) + + client.on( + "futures/inverse/btc_usd/cross/position", + lambda event: print( + f"[cross/position] event={event.get('event')} " + f"qty={event.get('position', {}).get('quantity')} " + f"margin={event.get('position', {}).get('margin')} " + f"pl={event.get('position', {}).get('totalPl')}" + ), + ) + + # --------------------------------------------------------------------- + # Connect + exercise every RPC method + # --------------------------------------------------------------------- + + # Server rate-limits to 10 messages/sec per socket. Pace RPCs with a small + # sleep so back-to-back calls stay comfortably under the ceiling. + rpc_pace_s = 0.15 + + await client.connect() + + hello = await client.public.hello( + HelloParams(client_name="sdk-python-example", client_version="0.0.0"), + ) + print(f"[rpc] hello -> version={hello.version}") + await asyncio.sleep(rpc_pace_s) + + ping = await client.public.ping() + print(f"[rpc] ping -> {ping}") + await asyncio.sleep(rpc_pace_s) + + time = await client.public.time() + print(f"[rpc] time -> {time.time}") + await asyncio.sleep(rpc_pace_s) + + public_topics: list[Topic] = [ + "announcements", + "futures/inverse/btc_usd/ticker", + "futures/inverse/btc_usd/lastPrice", + "futures/inverse/btc_usd/index", + "futures/inverse/btc_usd/buckets", + "futures/inverse/btc_usd/funding", + "futures/inverse/btc_usd/ohlc/1m", + "futures/inverse/btc_usd/ohlc/5m", + ] + + if want_auth: + key, secret, passphrase = resolve_creds(network) + auth = await client.auth.authenticate( + AuthenticateParams(key=key, secret=secret, passphrase=passphrase), + ) + print( + f"[rpc] authenticate -> authenticated={auth.authenticated} " + f"permissions={len(auth.permissions)}" + ) + await asyncio.sleep(rpc_pace_s) + + me = await client.auth.whoami() + print(f"[rpc] whoami -> apiKey={me.api_key} userId={me.user_id}") + await asyncio.sleep(rpc_pace_s) + + auth_topics: list[Topic] = [ + *public_topics, + "wallet/deposit", + "wallet/withdrawal", + "futures/inverse/btc_usd/isolated/trades", + "futures/inverse/btc_usd/cross/orders", + "futures/inverse/btc_usd/cross/position", + ] + subscribed = await client.subscription.subscribe( + SubscribeParams(topics=auth_topics), + ) + print(f"[rpc] subscribe -> {', '.join(subscribed.subscribed)}") + await asyncio.sleep(rpc_pace_s) + + # Demo single-topic unsubscribe — drop announcements only, rest keep streaming. + dropped = await client.subscription.unsubscribe( + UnsubscribeParams(topics=["announcements"]), + ) + else: + subscribed = await client.subscription.subscribe( + SubscribeParams(topics=public_topics), + ) + print(f"[rpc] subscribe -> {', '.join(subscribed.subscribed)}") + await asyncio.sleep(rpc_pace_s) + + # Demo single-topic unsubscribe on public stream too. + dropped = await client.subscription.unsubscribe( + UnsubscribeParams(topics=["futures/inverse/btc_usd/ohlc/5m"]), + ) + print(f"[rpc] unsubscribe -> {', '.join(dropped.unsubscribed)}") + + run_ms = int(os.environ.get("STREAM_V1_RUN_MS", "30000")) + print(f"[run] streaming for {run_ms}ms — Ctrl+C to stop early") + + stop = asyncio.Event() + + async def shutdown(reason: str) -> None: + print(f"[shutdown] {reason}") + try: + all_dropped = await client.subscription.unsubscribe_all() + print(f"[rpc] unsubscribeAll -> {len(all_dropped.unsubscribed)} topic(s)") + except Exception as error: # noqa: BLE001 + print(f"[shutdown] unsubscribeAll failed: {error}", file=sys.stderr) + await client.close() + stop.set() + + loop = asyncio.get_running_loop() + # Windows: signal handlers via add_signal_handler not supported. + with contextlib.suppress(NotImplementedError): + loop.add_signal_handler( + signal.SIGINT, + lambda: asyncio.create_task(shutdown("SIGINT")), + ) + + try: + await asyncio.wait_for(stop.wait(), timeout=run_ms / 1000) + except TimeoutError: + await shutdown("timeout") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index cee2c46..160ca39 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.0.18" +version = "0.1.0" description = "LN Markets API Python SDK" readme = "README.md" license = { text = "MIT" } @@ -33,7 +33,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries :: Python Modules", ] -dependencies = ["requests>=2.32.3", "pydantic>=2.12.2", "httpx>=0.28.1"] +dependencies = ["requests>=2.32.3", "pydantic>=2.12.2", "httpx>=0.28.1", "websockets>=12.0", "orjson>=3.10.0"] [dependency-groups] @@ -41,9 +41,9 @@ dev = [ { include-group = "lint" }, { include-group = "test" }, "python-dotenv>=1.0.1", - "playwright>=1.40.0", "faker>=37.12.0", "faker-crypto>=1.0.1", + "pyright>=1.1.407", ] lint = ["ruff>=0.12.0", "pyright>=1.1.390"] test = ["pytest", "pytest-asyncio", "pytest-httpx>=0.35.0"] @@ -56,7 +56,19 @@ packages = ["src/lnmarkets_sdk"] [tool.pyright] pythonVersion = "3.13" -typeCheckingMode = "basic" +typeCheckingMode = "strict" +include = ["src", "examples"] +exclude = [ + "**/__pycache__", + "**/.venv", + "**/tests/**", +] +reportMissingTypeStubs = false +# Pydantic dynamic attrs produce "unknown" types pervasively; downgrade to warning. +reportUnknownMemberType = "warning" +reportUnknownArgumentType = "warning" +reportUnknownVariableType = "warning" +reportUnknownLambdaType = "warning" [tool.ruff] line-length = 88 diff --git a/src/lnmarkets_sdk/rest/__init__.py b/src/lnmarkets_sdk/rest/__init__.py new file mode 100644 index 0000000..4c45290 --- /dev/null +++ b/src/lnmarkets_sdk/rest/__init__.py @@ -0,0 +1,4 @@ +"""LN Markets REST API namespace package. + +Versions live under `rest.`, e.g. `lnmarkets_sdk.rest.v3`. +""" diff --git a/src/lnmarkets_sdk/rest/v3/__init__.py b/src/lnmarkets_sdk/rest/v3/__init__.py new file mode 100644 index 0000000..12eaf86 --- /dev/null +++ b/src/lnmarkets_sdk/rest/v3/__init__.py @@ -0,0 +1,5 @@ +"""LN Markets REST API v3 client.""" + +from lnmarkets_sdk.rest.v3.http.client import APIAuthContext, APIClientConfig, LNMClient + +__all__ = ["APIAuthContext", "APIClientConfig", "LNMClient"] diff --git a/src/lnmarkets_sdk/v3/_internal/__init__.py b/src/lnmarkets_sdk/rest/v3/_internal/__init__.py similarity index 96% rename from src/lnmarkets_sdk/v3/_internal/__init__.py rename to src/lnmarkets_sdk/rest/v3/_internal/__init__.py index fc0e4c5..9f72ba0 100644 --- a/src/lnmarkets_sdk/v3/_internal/__init__.py +++ b/src/lnmarkets_sdk/rest/v3/_internal/__init__.py @@ -37,7 +37,7 @@ async def __aenter__(self) -> "BaseClient": ) return self - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + async def __aexit__(self, *_: object) -> None: """Exit async context manager.""" if self._client: await self._client.aclose() @@ -54,7 +54,7 @@ async def request( raise RuntimeError("Client must be used within async context manager") params_dict = prepare_params(params) - headers = {} + headers: dict[str, str] = {} if credentials: if not self.auth: diff --git a/src/lnmarkets_sdk/v3/_internal/models.py b/src/lnmarkets_sdk/rest/v3/_internal/models.py similarity index 100% rename from src/lnmarkets_sdk/v3/_internal/models.py rename to src/lnmarkets_sdk/rest/v3/_internal/models.py diff --git a/src/lnmarkets_sdk/v3/_internal/utils.py b/src/lnmarkets_sdk/rest/v3/_internal/utils.py similarity index 97% rename from src/lnmarkets_sdk/v3/_internal/utils.py rename to src/lnmarkets_sdk/rest/v3/_internal/utils.py index c901159..64d1d3e 100644 --- a/src/lnmarkets_sdk/v3/_internal/utils.py +++ b/src/lnmarkets_sdk/rest/v3/_internal/utils.py @@ -23,7 +23,7 @@ ) -def _float_to_int(value): +def _float_to_int(value: object) -> object: if isinstance(value, float) and value.is_integer(): return int(value) return value @@ -104,7 +104,7 @@ def parse_response[T]( return data try: - adapter = TypeAdapter(model) + adapter: TypeAdapter[T] = TypeAdapter(model) return adapter.validate_python(data) except ValidationError as e: raise APIValidationException( diff --git a/src/lnmarkets_sdk/v3/http/client/__init__.py b/src/lnmarkets_sdk/rest/v3/http/client/__init__.py similarity index 89% rename from src/lnmarkets_sdk/v3/http/client/__init__.py rename to src/lnmarkets_sdk/rest/v3/http/client/__init__.py index 6a47fcd..d7700d5 100644 --- a/src/lnmarkets_sdk/v3/http/client/__init__.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/__init__.py @@ -3,9 +3,13 @@ from pydantic import BaseModel -from lnmarkets_sdk.v3._internal import BaseClient -from lnmarkets_sdk.v3._internal.models import APIAuthContext, APIClientConfig, APIMethod -from lnmarkets_sdk.v3._internal.utils import get_hostname, parse_response +from lnmarkets_sdk.rest.v3._internal import BaseClient +from lnmarkets_sdk.rest.v3._internal.models import ( + APIAuthContext, + APIClientConfig, + APIMethod, +) +from lnmarkets_sdk.rest.v3._internal.utils import get_hostname, parse_response from .account import AccountClient from .futures import FuturesClient @@ -31,8 +35,8 @@ def __init__(self, config: APIClientConfig | None = None): Example: ```python - from lnmarkets_sdk.v3.http.client import LNMClient, APIClientConfig, APIAuthContext - from lnmarkets_sdk.v3.models.futures_isolated import FuturesOrder + from lnmarkets_sdk.rest.v3.http.client import LNMClient, APIClientConfig, APIAuthContext + from lnmarkets_sdk.rest.v3.models.futures_isolated import FuturesOrder config = APIClientConfig( authentication=APIAuthContext( @@ -86,9 +90,9 @@ async def __aenter__(self) -> "LNMClient": await self._base_client.__aenter__() return self - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + async def __aexit__(self, *args: object) -> None: """Exit async context manager.""" - await self._base_client.__aexit__(exc_type, exc_val, exc_tb) + await self._base_client.__aexit__(*args) async def request[T]( self, diff --git a/src/lnmarkets_sdk/v3/http/client/account.py b/src/lnmarkets_sdk/rest/v3/http/client/account.py similarity index 72% rename from src/lnmarkets_sdk/v3/http/client/account.py rename to src/lnmarkets_sdk/rest/v3/http/client/account.py index f2aebcf..5d47031 100644 --- a/src/lnmarkets_sdk/v3/http/client/account.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/account.py @@ -1,32 +1,26 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from lnmarkets_sdk.v3.http.client import LNMClient + from lnmarkets_sdk.rest.v3.http.client import LNMClient -from lnmarkets_sdk.v3._internal.models import PaginatedResponse -from lnmarkets_sdk.v3.models.account import ( +from lnmarkets_sdk.rest.v3._internal.models import PaginatedResponse +from lnmarkets_sdk.rest.v3.models.account import ( Account, AddBitcoinAddressParams, AddBitcoinAddressResponse, DepositLightningParams, DepositLightningResponse, GetBitcoinAddressResponse, - GetInternalDepositsParams, - GetInternalWithdrawalsParams, GetLightningDepositsParams, GetLightningWithdrawalsParams, GetNotificationsParams, GetOnChainDepositsParams, GetOnChainWithdrawalsParams, - InternalDeposit, - InternalWithdrawal, LightningDeposits, LightningWithdrawal, Notification, OnChainDeposit, OnChainWithdrawal, - WithdrawInternalParams, - WithdrawInternalResponse, WithdrawLightningParams, WithdrawLightningResponse, WithdrawOnChainParams, @@ -83,7 +77,7 @@ async def add_bitcoin_address(self, params: AddBitcoinAddressParams | None = Non Example: ```python - from lnmarkets_sdk.v3.models.account import AddBitcoinAddressParams + from lnmarkets_sdk.rest.v3.models.account import AddBitcoinAddressParams async with LNMClient(config) as client: params = AddBitcoinAddressParams(format="p2wpkh") @@ -105,7 +99,7 @@ async def deposit_lightning(self, params: DepositLightningParams): Example: ```python - from lnmarkets_sdk.v3.models.account import DepositLightningParams + from lnmarkets_sdk.rest.v3.models.account import DepositLightningParams async with LNMClient(config) as client: params = DepositLightningParams( @@ -130,7 +124,7 @@ async def withdraw_lightning(self, params: WithdrawLightningParams): Example: ```python - from lnmarkets_sdk.v3.models.account import WithdrawLightningParams + from lnmarkets_sdk.rest.v3.models.account import WithdrawLightningParams async with LNMClient(config) as client: params = WithdrawLightningParams(invoice="lnbc...") @@ -146,35 +140,13 @@ async def withdraw_lightning(self, params: WithdrawLightningParams): response_model=WithdrawLightningResponse, ) - async def withdraw_internal(self, params: WithdrawInternalParams): - """ - Withdraw to another LN Markets account. - - Example: - ```python - from lnmarkets_sdk.v3.models.account import WithdrawInternalParams - - async with LNMClient(config) as client: - params = WithdrawInternalParams(amount=100_000, to_username="user123") - withdrawal = await client.account.withdraw_internal(params) - print(f"Withdrawal ID: {withdrawal.id}") - ``` - """ - return await self._client.request( - "POST", - "/account/withdraw/internal", - params=params, - credentials=True, - response_model=WithdrawInternalResponse, - ) - async def withdraw_on_chain(self, params: WithdrawOnChainParams): """ Withdraw via on-chain Bitcoin transaction. Example: ```python - from lnmarkets_sdk.v3.models.account import WithdrawOnChainParams + from lnmarkets_sdk.rest.v3.models.account import WithdrawOnChainParams async with LNMClient(config) as client: params = WithdrawOnChainParams( @@ -201,7 +173,7 @@ async def get_lightning_deposits( Example: ```python - from lnmarkets_sdk.v3.models.account import GetLightningDepositsParams + from lnmarkets_sdk.rest.v3.models.account import GetLightningDepositsParams async with LNMClient(config) as client: params = GetLightningDepositsParams(limit=10, settled=True) @@ -228,7 +200,7 @@ async def get_lightning_withdrawals( Example: ```python - from lnmarkets_sdk.v3.models.account import GetLightningWithdrawalsParams + from lnmarkets_sdk.rest.v3.models.account import GetLightningWithdrawalsParams async with LNMClient(config) as client: params = GetLightningWithdrawalsParams( @@ -250,60 +222,6 @@ async def get_lightning_withdrawals( response_model=PaginatedResponse[LightningWithdrawal], ) - async def get_internal_deposits( - self, params: GetInternalDepositsParams | None = None - ): - """ - Get internal deposit history. - - Example: - ```python - from lnmarkets_sdk.v3.models.account import GetInternalDepositsParams - - async with LNMClient(config) as client: - params = GetInternalDepositsParams(limit=10) - response = await client.account.get_internal_deposits(params) - for deposit in response.data: - print(f"From: {deposit.from_username}, Amount: {deposit.amount}") - if response.next_cursor: - print(f"Next cursor: {response.next_cursor}") - ``` - """ - return await self._client.request( - "GET", - "/account/deposits/internal", - params=params, - credentials=True, - response_model=PaginatedResponse[InternalDeposit], - ) - - async def get_internal_withdrawals( - self, params: GetInternalWithdrawalsParams | None = None - ): - """ - Get internal withdrawal history. - - Example: - ```python - from lnmarkets_sdk.v3.models.account import GetInternalWithdrawalsParams - - async with LNMClient(config) as client: - params = GetInternalWithdrawalsParams(limit=10) - response = await client.account.get_internal_withdrawals(params) - for withdrawal in response.data: - print(f"To: {withdrawal.to_username}, Amount: {withdrawal.amount}") - if response.next_cursor: - print(f"Next cursor: {response.next_cursor}") - ``` - """ - return await self._client.request( - "GET", - "/account/withdrawals/internal", - params=params, - credentials=True, - response_model=PaginatedResponse[InternalWithdrawal], - ) - async def get_on_chain_deposits( self, params: GetOnChainDepositsParams | None = None ): @@ -312,7 +230,7 @@ async def get_on_chain_deposits( Example: ```python - from lnmarkets_sdk.v3.models.account import GetOnChainDepositsParams + from lnmarkets_sdk.rest.v3.models.account import GetOnChainDepositsParams async with LNMClient(config) as client: params = GetOnChainDepositsParams( @@ -342,7 +260,7 @@ async def get_on_chain_withdrawals( Example: ```python - from lnmarkets_sdk.v3.models.account import GetOnChainWithdrawalsParams + from lnmarkets_sdk.rest.v3.models.account import GetOnChainWithdrawalsParams async with LNMClient(config) as client: params = GetOnChainWithdrawalsParams( @@ -364,13 +282,29 @@ async def get_on_chain_withdrawals( response_model=PaginatedResponse[OnChainWithdrawal], ) + async def read_notifications(self) -> None: + """ + Mark all notifications as read. + + Example: + ```python + async with LNMClient(config) as client: + await client.account.read_notifications() + ``` + """ + await self._client.request_raw( + "PUT", + "/account/notifications", + credentials=True, + ) + async def get_notifications(self, params: GetNotificationsParams | None = None): """ Get account notifications. Example: ```python - from lnmarkets_sdk.v3.models.account import GetNotificationsParams + from lnmarkets_sdk.rest.v3.models.account import GetNotificationsParams async with LNMClient(config) as client: params = GetNotificationsParams(limit=10) diff --git a/src/lnmarkets_sdk/v3/http/client/futures/__init__.py b/src/lnmarkets_sdk/rest/v3/http/client/futures/__init__.py similarity index 89% rename from src/lnmarkets_sdk/v3/http/client/futures/__init__.py rename to src/lnmarkets_sdk/rest/v3/http/client/futures/__init__.py index b653e19..13cd712 100644 --- a/src/lnmarkets_sdk/v3/http/client/futures/__init__.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/futures/__init__.py @@ -1,11 +1,11 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from lnmarkets_sdk.v3.http.client import LNMClient + from lnmarkets_sdk.rest.v3.http.client import LNMClient -from lnmarkets_sdk.v3._internal.models import PaginatedResponse -from lnmarkets_sdk.v3.models.funding_fees import FundingSettlement -from lnmarkets_sdk.v3.models.futures_data import ( +from lnmarkets_sdk.rest.v3._internal.models import PaginatedResponse +from lnmarkets_sdk.rest.v3.models.funding_fees import FundingSettlement +from lnmarkets_sdk.rest.v3.models.futures_data import ( Candle, GetCandlesParams, GetFundingSettlementsParams, @@ -73,7 +73,7 @@ async def get_candles(self, params: GetCandlesParams): Example: ```python - from lnmarkets_sdk.v3.models.futures_data import GetCandlesParams + from lnmarkets_sdk.rest.v3.models.futures_data import GetCandlesParams async with LNMClient(config) as client: params = GetCandlesParams( @@ -105,7 +105,7 @@ async def get_funding_settlements( Example: ```python - from lnmarkets_sdk.v3.models.futures_data import GetFundingSettlementsParams + from lnmarkets_sdk.rest.v3.models.futures_data import GetFundingSettlementsParams async with LNMClient(config) as client: params = GetFundingSettlementsParams(limit=10) diff --git a/src/lnmarkets_sdk/v3/http/client/futures/cross.py b/src/lnmarkets_sdk/rest/v3/http/client/futures/cross.py similarity index 90% rename from src/lnmarkets_sdk/v3/http/client/futures/cross.py rename to src/lnmarkets_sdk/rest/v3/http/client/futures/cross.py index 2e9a7f5..f0453ef 100644 --- a/src/lnmarkets_sdk/v3/http/client/futures/cross.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/futures/cross.py @@ -1,11 +1,11 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from lnmarkets_sdk.v3.http.client import LNMClient + from lnmarkets_sdk.rest.v3.http.client import LNMClient -from lnmarkets_sdk.v3._internal.models import PaginatedResponse -from lnmarkets_sdk.v3.models.funding_fees import FundingFees -from lnmarkets_sdk.v3.models.futures_cross import ( +from lnmarkets_sdk.rest.v3._internal.models import PaginatedResponse +from lnmarkets_sdk.rest.v3.models.funding_fees import FundingFees +from lnmarkets_sdk.rest.v3.models.futures_cross import ( CancelOrderParams, DepositParams, FuturesCrossCanceledOrder, @@ -37,7 +37,7 @@ async def new_order( Example: ```python - from lnmarkets_sdk.v3.models.futures_cross import FuturesCrossOrderLimit + from lnmarkets_sdk.rest.v3.models.futures_cross import FuturesCrossOrderLimit async with LNMClient(config) as client: params = FuturesCrossOrderLimit( @@ -104,7 +104,7 @@ async def get_filled_orders(self, params: GetFilledOrdersParams | None = None): Example: ```python - from lnmarkets_sdk.v3.models.futures_cross import GetFilledOrdersParams + from lnmarkets_sdk.rest.v3.models.futures_cross import GetFilledOrdersParams async with LNMClient(config) as client: params = GetFilledOrdersParams(limit=10) @@ -151,7 +151,7 @@ async def cancel(self, params: CancelOrderParams): Example: ```python - from lnmarkets_sdk.v3.models.futures_cross import CancelOrderParams + from lnmarkets_sdk.rest.v3.models.futures_cross import CancelOrderParams async with LNMClient(config) as client: params = CancelOrderParams(id=order_id) @@ -191,7 +191,7 @@ async def deposit(self, params: DepositParams): Example: ```python - from lnmarkets_sdk.v3.models.futures_cross import DepositParams + from lnmarkets_sdk.rest.v3.models.futures_cross import DepositParams async with LNMClient(config) as client: params = DepositParams(amount=100_000) @@ -213,7 +213,7 @@ async def withdraw(self, params: WithdrawParams): Example: ```python - from lnmarkets_sdk.v3.models.futures_cross import WithdrawParams + from lnmarkets_sdk.rest.v3.models.futures_cross import WithdrawParams async with LNMClient(config) as client: params = WithdrawParams(amount=50_000) @@ -235,7 +235,7 @@ async def set_leverage(self, params: SetLeverageParams): Example: ```python - from lnmarkets_sdk.v3.models.futures_cross import SetLeverageParams + from lnmarkets_sdk.rest.v3.models.futures_cross import SetLeverageParams async with LNMClient(config) as client: params = SetLeverageParams(leverage=50) @@ -257,7 +257,7 @@ async def get_transfers(self, params: GetTransfersParams | None = None): Example: ```python - from lnmarkets_sdk.v3.models.futures_cross import GetTransfersParams + from lnmarkets_sdk.rest.v3.models.futures_cross import GetTransfersParams async with LNMClient(config) as client: params = GetTransfersParams(limit=10) @@ -282,7 +282,7 @@ async def get_funding_fees(self, params: GetCrossFundingFeesParams | None = None Example: ```python - from lnmarkets_sdk.v3.models.futures_cross import GetCrossFundingFeesParams + from lnmarkets_sdk.rest.v3.models.futures_cross import GetCrossFundingFeesParams async with LNMClient(config) as client: params = GetCrossFundingFeesParams(limit=10) diff --git a/src/lnmarkets_sdk/v3/http/client/futures/isolated.py b/src/lnmarkets_sdk/rest/v3/http/client/futures/isolated.py similarity index 88% rename from src/lnmarkets_sdk/v3/http/client/futures/isolated.py rename to src/lnmarkets_sdk/rest/v3/http/client/futures/isolated.py index b35abc4..4066eaa 100644 --- a/src/lnmarkets_sdk/v3/http/client/futures/isolated.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/futures/isolated.py @@ -1,11 +1,11 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from lnmarkets_sdk.v3.http.client import LNMClient + from lnmarkets_sdk.rest.v3.http.client import LNMClient -from lnmarkets_sdk.v3._internal.models import PaginatedResponse -from lnmarkets_sdk.v3.models.funding_fees import FundingFees -from lnmarkets_sdk.v3.models.futures_isolated import ( +from lnmarkets_sdk.rest.v3._internal.models import PaginatedResponse +from lnmarkets_sdk.rest.v3.models.funding_fees import FundingFees +from lnmarkets_sdk.rest.v3.models.futures_isolated import ( AddMarginParams, CancelTradeParams, CashInParams, @@ -28,13 +28,15 @@ class FuturesIsolatedClient: def __init__(self, client: "LNMClient"): self._client = client - async def new_trade(self, params: FuturesOrder): + async def new_trade( + self, params: FuturesOrder + ) -> FuturesRunningTrade | FuturesOpenTrade: """ Open a new isolated margin futures trade. Example: ```python - from lnmarkets_sdk.v3.models.futures_isolated import FuturesOrder + from lnmarkets_sdk.rest.v3.models.futures_isolated import FuturesOrder async with LNMClient(config) as client: params = FuturesOrder( @@ -102,7 +104,7 @@ async def get_closed_trades(self, params: GetClosedTradesParams | None = None): Example: ```python - from lnmarkets_sdk.v3.models.futures_isolated import GetClosedTradesParams + from lnmarkets_sdk.rest.v3.models.futures_isolated import GetClosedTradesParams async with LNMClient(config) as client: params = GetClosedTradesParams(limit=10) @@ -127,7 +129,7 @@ async def close(self, params: CloseTradeParams): Example: ```python - from lnmarkets_sdk.v3.models.futures_isolated import CloseTradeParams + from lnmarkets_sdk.rest.v3.models.futures_isolated import CloseTradeParams async with LNMClient(config) as client: params = CloseTradeParams(id=trade_id) @@ -149,7 +151,7 @@ async def cancel(self, params: CancelTradeParams): Example: ```python - from lnmarkets_sdk.v3.models.futures_isolated import CancelTradeParams + from lnmarkets_sdk.rest.v3.models.futures_isolated import CancelTradeParams async with LNMClient(config) as client: params = CancelTradeParams(id=trade_id) @@ -189,7 +191,7 @@ async def add_margin(self, params: AddMarginParams): Example: ```python - from lnmarkets_sdk.v3.models.futures_isolated import AddMarginParams + from lnmarkets_sdk.rest.v3.models.futures_isolated import AddMarginParams async with LNMClient(config) as client: params = AddMarginParams(id=trade_id, amount=10_000) @@ -211,7 +213,7 @@ async def cash_in(self, params: CashInParams): Example: ```python - from lnmarkets_sdk.v3.models.futures_isolated import CashInParams + from lnmarkets_sdk.rest.v3.models.futures_isolated import CashInParams async with LNMClient(config) as client: params = CashInParams(id=trade_id, amount=10_000) @@ -233,7 +235,7 @@ async def update_stoploss(self, params: UpdateStoplossParams): Example: ```python - from lnmarkets_sdk.v3.models.futures_isolated import UpdateStoplossParams + from lnmarkets_sdk.rest.v3.models.futures_isolated import UpdateStoplossParams async with LNMClient(config) as client: params = UpdateStoplossParams(id=trade_id, value=90000) @@ -255,7 +257,7 @@ async def update_takeprofit(self, params: UpdateTakeprofitParams): Example: ```python - from lnmarkets_sdk.v3.models.futures_isolated import UpdateTakeprofitParams + from lnmarkets_sdk.rest.v3.models.futures_isolated import UpdateTakeprofitParams async with LNMClient(config) as client: params = UpdateTakeprofitParams(id=trade_id, value=10000) @@ -280,7 +282,7 @@ async def get_funding_fees( Example: ```python - from lnmarkets_sdk.v3.models.futures_isolated import GetIsolatedFundingFeesParams + from lnmarkets_sdk.rest.v3.models.futures_isolated import GetIsolatedFundingFeesParams async with LNMClient(config) as client: params = GetIsolatedFundingFeesParams(limit=10, trade_id=trade_id) @@ -305,7 +307,7 @@ async def get_canceled_trades(self, params: GetClosedTradesParams | None = None) Example: ```python - from lnmarkets_sdk.v3.models.futures_isolated import GetClosedTradesParams + from lnmarkets_sdk.rest.v3.models.futures_isolated import GetClosedTradesParams async with LNMClient(config) as client: params = GetClosedTradesParams(limit=10) diff --git a/src/lnmarkets_sdk/v3/http/client/oracle.py b/src/lnmarkets_sdk/rest/v3/http/client/oracle.py similarity index 86% rename from src/lnmarkets_sdk/v3/http/client/oracle.py rename to src/lnmarkets_sdk/rest/v3/http/client/oracle.py index 57d517f..3100c22 100644 --- a/src/lnmarkets_sdk/v3/http/client/oracle.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/oracle.py @@ -1,9 +1,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from lnmarkets_sdk.v3.http.client import LNMClient + from lnmarkets_sdk.rest.v3.http.client import LNMClient -from lnmarkets_sdk.v3.models.oracle import ( +from lnmarkets_sdk.rest.v3.models.oracle import ( GetIndexParams, GetLastPriceParams, OracleIndex, @@ -23,7 +23,7 @@ async def get_index(self, params: GetIndexParams | None = None): Example: ```python - from lnmarkets_sdk.v3.models.oracle import GetIndexParams + from lnmarkets_sdk.rest.v3.models.oracle import GetIndexParams async with LNMClient(config) as client: params = GetIndexParams(limit=10, from_="2023-05-23T09:52:57.863Z") @@ -48,7 +48,7 @@ async def get_last_price( Example: ```python - from lnmarkets_sdk.v3.models.oracle import GetLastPriceParams + from lnmarkets_sdk.rest.v3.models.oracle import GetLastPriceParams async with LNMClient(config) as client: params = GetLastPriceParams(limit=10, from_="2023-05-23T09:52:57.863Z") diff --git a/src/lnmarkets_sdk/v3/http/client/synthetic_usd.py b/src/lnmarkets_sdk/rest/v3/http/client/synthetic_usd.py similarity index 86% rename from src/lnmarkets_sdk/v3/http/client/synthetic_usd.py rename to src/lnmarkets_sdk/rest/v3/http/client/synthetic_usd.py index 3017bfd..6c3e59e 100644 --- a/src/lnmarkets_sdk/v3/http/client/synthetic_usd.py +++ b/src/lnmarkets_sdk/rest/v3/http/client/synthetic_usd.py @@ -1,10 +1,10 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from lnmarkets_sdk.v3.http.client import LNMClient + from lnmarkets_sdk.rest.v3.http.client import LNMClient -from lnmarkets_sdk.v3._internal.models import PaginatedResponse -from lnmarkets_sdk.v3.models.synthetic_usd import ( +from lnmarkets_sdk.rest.v3._internal.models import PaginatedResponse +from lnmarkets_sdk.rest.v3.models.synthetic_usd import ( BestPriceResponse, CreateSwapOutput, GetSwapsParams, @@ -43,7 +43,7 @@ async def get_swaps(self, params: GetSwapsParams | None = None): Example: ```python - from lnmarkets_sdk.v3.models.synthetic_usd import GetSwapsParams + from lnmarkets_sdk.rest.v3.models.synthetic_usd import GetSwapsParams async with LNMClient(config) as client: params = GetSwapsParams(limit=10) @@ -68,7 +68,7 @@ async def new_swap(self, params: NewSwapParams): Example: ```python - from lnmarkets_sdk.v3.models.synthetic_usd import NewSwapParams + from lnmarkets_sdk.rest.v3.models.synthetic_usd import NewSwapParams async with LNMClient(config) as client: params = NewSwapParams( diff --git a/src/lnmarkets_sdk/v3/models/account.py b/src/lnmarkets_sdk/rest/v3/models/account.py similarity index 82% rename from src/lnmarkets_sdk/v3/models/account.py rename to src/lnmarkets_sdk/rest/v3/models/account.py index b1ef6d8..f5185f4 100644 --- a/src/lnmarkets_sdk/v3/models/account.py +++ b/src/lnmarkets_sdk/rest/v3/models/account.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field, SkipValidation -from lnmarkets_sdk.v3._internal.models import UUID, BaseConfig, FromToLimitParams +from lnmarkets_sdk.rest.v3._internal.models import UUID, BaseConfig, FromToLimitParams class Account(BaseModel, BaseConfig): @@ -49,40 +49,6 @@ class OnChainDeposit(BaseModel, BaseConfig): ) -class InternalDeposit(BaseModel, BaseConfig): - """Internal deposit item.""" - - amount: SkipValidation[float] = Field( - ..., description="Amount of the deposit (in satoshis)" - ) - created_at: SkipValidation[str] = Field( - ..., description="Timestamp when the deposit was created" - ) - from_username: SkipValidation[str] = Field( - ..., description="Username of the sender" - ) - id: SkipValidation[UUID] = Field( - ..., description="Unique identifier for this deposit" - ) - - -class InternalWithdrawal(BaseModel, BaseConfig): - """Internal withdrawal item.""" - - amount: SkipValidation[float] = Field( - ..., description="Amount of the transfer (in satoshis)" - ) - created_at: SkipValidation[str] = Field( - ..., description="Timestamp when the transfer was created" - ) - id: SkipValidation[UUID] = Field( - ..., description="Unique identifier for this transfer" - ) - to_username: SkipValidation[str] = Field( - ..., description="Username of the recipient" - ) - - class LightningDeposits(BaseModel, BaseConfig): amount: SkipValidation[float] | None = Field( None, description="Amount of the deposit (in satoshis)" @@ -156,14 +122,6 @@ class DepositLightningResponse(BaseModel, BaseConfig): ) -class WithdrawInternalResponse(BaseModel, BaseConfig): - id: SkipValidation[UUID] - created_at: SkipValidation[str] - from_uid: SkipValidation[UUID] - to_uid: SkipValidation[UUID] - amount: SkipValidation[float] - - class WithdrawOnChainResponse(BaseModel, BaseConfig): id: SkipValidation[UUID] uid: SkipValidation[UUID] @@ -226,11 +184,6 @@ class WithdrawLightningResponse(BaseModel, BaseConfig): ) -class WithdrawInternalParams(BaseModel, BaseConfig): - amount: float = Field(..., gt=0, description="Amount to withdraw (in satoshis)") - to_username: str = Field(..., description="Username of the recipient") - - class WithdrawOnChainParams(BaseModel, BaseConfig): address: str = Field(..., description="Bitcoin address to withdraw to") amount: float = Field(..., gt=0, description="Amount to withdraw (in satoshis)") @@ -246,12 +199,6 @@ class GetLightningWithdrawalsParams(FromToLimitParams): ) -class GetInternalDepositsParams(FromToLimitParams): ... - - -class GetInternalWithdrawalsParams(FromToLimitParams): ... - - class GetOnChainDepositsParams(FromToLimitParams): status: Literal["MEMPOOL", "CONFIRMED", "IRREVERSIBLE"] | None = Field( default=None, description="Filter by deposit status" diff --git a/src/lnmarkets_sdk/v3/models/funding_fees.py b/src/lnmarkets_sdk/rest/v3/models/funding_fees.py similarity index 93% rename from src/lnmarkets_sdk/v3/models/funding_fees.py rename to src/lnmarkets_sdk/rest/v3/models/funding_fees.py index 60d98c9..e56fb7c 100644 --- a/src/lnmarkets_sdk/v3/models/funding_fees.py +++ b/src/lnmarkets_sdk/rest/v3/models/funding_fees.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field, SkipValidation -from lnmarkets_sdk.v3._internal.models import UUID, BaseConfig +from lnmarkets_sdk.rest.v3._internal.models import UUID, BaseConfig class FundingFees(BaseModel, BaseConfig): diff --git a/src/lnmarkets_sdk/v3/models/futures_cross.py b/src/lnmarkets_sdk/rest/v3/models/futures_cross.py similarity index 98% rename from src/lnmarkets_sdk/v3/models/futures_cross.py rename to src/lnmarkets_sdk/rest/v3/models/futures_cross.py index 91161eb..002b02f 100644 --- a/src/lnmarkets_sdk/v3/models/futures_cross.py +++ b/src/lnmarkets_sdk/rest/v3/models/futures_cross.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field, SkipValidation -from lnmarkets_sdk.v3._internal.models import UUID, BaseConfig, FromToLimitParams +from lnmarkets_sdk.rest.v3._internal.models import UUID, BaseConfig, FromToLimitParams class FuturesCrossOrderSideQuantity(BaseModel, BaseConfig): diff --git a/src/lnmarkets_sdk/v3/models/futures_data.py b/src/lnmarkets_sdk/rest/v3/models/futures_data.py similarity index 97% rename from src/lnmarkets_sdk/v3/models/futures_data.py rename to src/lnmarkets_sdk/rest/v3/models/futures_data.py index 1fcd503..9b3b968 100644 --- a/src/lnmarkets_sdk/v3/models/futures_data.py +++ b/src/lnmarkets_sdk/rest/v3/models/futures_data.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field, SkipValidation -from lnmarkets_sdk.v3._internal.models import BaseConfig, FromToLimitParams +from lnmarkets_sdk.rest.v3._internal.models import BaseConfig, FromToLimitParams CandleResolution = Literal[ "1m", diff --git a/src/lnmarkets_sdk/v3/models/futures_isolated.py b/src/lnmarkets_sdk/rest/v3/models/futures_isolated.py similarity index 91% rename from src/lnmarkets_sdk/v3/models/futures_isolated.py rename to src/lnmarkets_sdk/rest/v3/models/futures_isolated.py index e99bf1d..8b9bc6d 100644 --- a/src/lnmarkets_sdk/v3/models/futures_isolated.py +++ b/src/lnmarkets_sdk/rest/v3/models/futures_isolated.py @@ -1,8 +1,14 @@ +# Pydantic discriminated-union pattern intentionally narrows base-class fields +# (e.g. `closed_at: str | None` -> `closed_at: None`) in concrete trade variants. +# Pyright flags this as an LSP violation, but pydantic supports + recommends it +# for discriminated unions. Disable the override check for this file only. +# pyright: reportIncompatibleVariableOverride=false + from typing import Literal from pydantic import BaseModel, Field, SkipValidation, model_validator -from lnmarkets_sdk.v3._internal.models import UUID, BaseConfig, FromToLimitParams +from lnmarkets_sdk.rest.v3._internal.models import UUID, BaseConfig, FromToLimitParams class FuturesOrder(BaseModel, BaseConfig): diff --git a/src/lnmarkets_sdk/v3/models/oracle.py b/src/lnmarkets_sdk/rest/v3/models/oracle.py similarity index 88% rename from src/lnmarkets_sdk/v3/models/oracle.py rename to src/lnmarkets_sdk/rest/v3/models/oracle.py index c0da775..99038d5 100644 --- a/src/lnmarkets_sdk/v3/models/oracle.py +++ b/src/lnmarkets_sdk/rest/v3/models/oracle.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field, SkipValidation -from lnmarkets_sdk.v3._internal.models import BaseConfig, FromToLimitParams +from lnmarkets_sdk.rest.v3._internal.models import BaseConfig, FromToLimitParams class OracleIndex(BaseModel, BaseConfig): diff --git a/src/lnmarkets_sdk/v3/models/synthetic_usd.py b/src/lnmarkets_sdk/rest/v3/models/synthetic_usd.py similarity index 94% rename from src/lnmarkets_sdk/v3/models/synthetic_usd.py rename to src/lnmarkets_sdk/rest/v3/models/synthetic_usd.py index bd8eb41..435c848 100644 --- a/src/lnmarkets_sdk/v3/models/synthetic_usd.py +++ b/src/lnmarkets_sdk/rest/v3/models/synthetic_usd.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field, SkipValidation -from lnmarkets_sdk.v3._internal.models import UUID, BaseConfig, FromToLimitParams +from lnmarkets_sdk.rest.v3._internal.models import UUID, BaseConfig, FromToLimitParams SwapAssets = Literal["BTC", "USD"] diff --git a/src/lnmarkets_sdk/v3/tests/test_integration.py b/src/lnmarkets_sdk/rest/v3/tests/test_integration.py similarity index 84% rename from src/lnmarkets_sdk/v3/tests/test_integration.py rename to src/lnmarkets_sdk/rest/v3/tests/test_integration.py index 45fb395..446b5eb 100644 --- a/src/lnmarkets_sdk/v3/tests/test_integration.py +++ b/src/lnmarkets_sdk/rest/v3/tests/test_integration.py @@ -6,21 +6,18 @@ import pytest from dotenv import load_dotenv -from lnmarkets_sdk.v3.http.client import APIAuthContext, APIClientConfig, LNMClient -from lnmarkets_sdk.v3.models.account import ( +from lnmarkets_sdk.rest.v3.http.client import APIAuthContext, APIClientConfig, LNMClient +from lnmarkets_sdk.rest.v3.models.account import ( AddBitcoinAddressParams, DepositLightningParams, - GetInternalDepositsParams, - GetInternalWithdrawalsParams, GetLightningDepositsParams, GetLightningWithdrawalsParams, GetOnChainDepositsParams, GetOnChainWithdrawalsParams, - WithdrawInternalParams, WithdrawLightningParams, WithdrawOnChainParams, ) -from lnmarkets_sdk.v3.models.futures_cross import ( +from lnmarkets_sdk.rest.v3.models.futures_cross import ( CancelOrderParams, DepositParams, FuturesCrossOrderLimit, @@ -30,11 +27,11 @@ SetLeverageParams, WithdrawParams, ) -from lnmarkets_sdk.v3.models.futures_data import ( +from lnmarkets_sdk.rest.v3.models.futures_data import ( GetCandlesParams, GetFundingSettlementsParams, ) -from lnmarkets_sdk.v3.models.futures_isolated import ( +from lnmarkets_sdk.rest.v3.models.futures_isolated import ( AddMarginParams, CancelTradeParams, CashInParams, @@ -45,8 +42,8 @@ UpdateStoplossParams, UpdateTakeprofitParams, ) -from lnmarkets_sdk.v3.models.oracle import GetIndexParams -from lnmarkets_sdk.v3.models.synthetic_usd import GetSwapsParams, NewSwapParams +from lnmarkets_sdk.rest.v3.models.oracle import GetIndexParams +from lnmarkets_sdk.rest.v3.models.synthetic_usd import GetSwapsParams, NewSwapParams load_dotenv() @@ -74,9 +71,9 @@ def create_auth_config() -> APIClientConfig: return APIClientConfig( network="testnet4", authentication=APIAuthContext( - key=os.environ.get("TEST_API_KEY", "test-key"), - secret=os.environ.get("TEST_API_SECRET", "test-secret"), - passphrase=os.environ.get("TEST_API_PASSPHRASE", "test-passphrase"), + key=os.environ.get("TESTNET4_API_KEY", "test-key"), + secret=os.environ.get("TESTNET4_API_KEY_SECRET", "test-secret"), + passphrase=os.environ.get("TESTNET4_API_KEY_PASSPHRASE", "test-passphrase"), ), ) @@ -104,8 +101,8 @@ class TestAccountIntegration: """Integration tests for account endpoints (require authentication).""" @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_account(self): async with LNMClient(create_auth_config()) as client: @@ -122,8 +119,8 @@ async def test_get_account(self): assert isinstance(account.linking_public_key, str) @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_bitcoin_address(self): async with LNMClient(create_auth_config()) as client: @@ -131,8 +128,8 @@ async def test_get_bitcoin_address(self): assert result.address is not None @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_add_bitcoin_address(self): async with LNMClient(create_auth_config()) as client: @@ -148,8 +145,8 @@ async def test_add_bitcoin_address(self): ) @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_deposit_lightning(self): async with LNMClient(create_auth_config()) as client: @@ -159,8 +156,8 @@ async def test_deposit_lightning(self): assert result.payment_request.startswith("ln") @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_withdraw_lightning(self): async with LNMClient(create_auth_config()) as client: @@ -175,25 +172,8 @@ async def test_withdraw_lightning(self): assert "Send a correct BOLT 11 invoice" in str(e) @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", - ) - async def test_withdraw_internal(self): - async with LNMClient(create_auth_config()) as client: - params = WithdrawInternalParams(amount=100_000, to_username="test_username") - try: - result = await client.account.withdraw_internal(params) - assert result.id is not None - assert result.amount is not None - assert result.created_at is not None - assert result.from_uid is not None - assert result.to_uid is not None - except Exception as e: - assert "User not found" in str(e) - - @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_withdraw_on_chain(self): async with LNMClient(create_auth_config()) as client: @@ -211,8 +191,8 @@ async def test_withdraw_on_chain(self): assert "Invalid address" in str(e) @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_lightning_deposits(self): async with LNMClient(create_auth_config()) as client: @@ -236,8 +216,8 @@ async def test_get_lightning_deposits(self): assert isinstance(data[0].settled_at, str) @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_lightning_withdrawals(self): async with LNMClient(create_auth_config()) as client: @@ -254,40 +234,8 @@ async def test_get_lightning_withdrawals(self): assert data[0].status in ["failed", "processed", "processing"] @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", - ) - async def test_get_internal_deposits(self): - async with LNMClient(create_auth_config()) as client: - params = GetInternalDepositsParams(limit=2) - result = await client.account.get_internal_deposits(params) - data = result.data - assert len(data) <= params.limit - if len(data) > 0: - assert data[0].id is not None - assert data[0].created_at is not None - assert data[0].amount is not None - assert data[0].from_username is not None - - @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", - ) - async def test_get_internal_withdrawals(self): - async with LNMClient(create_auth_config()) as client: - params = GetInternalWithdrawalsParams(limit=2) - result = await client.account.get_internal_withdrawals(params) - data = result.data - assert len(data) <= params.limit - if len(data) > 0: - assert data[0].id is not None - assert data[0].created_at is not None - assert data[0].amount is not None - assert data[0].to_username is not None - - @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_on_chain_deposits(self): async with LNMClient(create_auth_config()) as client: @@ -309,8 +257,8 @@ async def test_get_on_chain_deposits(self): assert "HTTP 404: Not found" in str(e) @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_on_chain_withdrawals(self): async with LNMClient(create_auth_config()) as client: @@ -414,8 +362,8 @@ class TestFuturesIsolatedIntegration: """Integration tests for isolated margin futures endpoints.""" @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_new_trade(self): async with LNMClient(create_auth_config()) as client: @@ -448,8 +396,8 @@ async def test_new_trade(self): pytest.skip("Could not create a new trade: " + str(e)) @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_open_trades(self): async with LNMClient(create_auth_config()) as client: @@ -468,8 +416,8 @@ async def test_get_open_trades(self): assert open_trade.leverage > 0 @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_running_trades(self): async with LNMClient(create_auth_config()) as client: @@ -485,8 +433,8 @@ async def test_get_running_trades(self): assert running_trade.pl is not None @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_closed_trades(self): async with LNMClient(create_auth_config()) as client: @@ -507,8 +455,8 @@ async def test_get_closed_trades(self): assert isinstance(closed_trade.closed_at, str) @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_cancel_trade(self): async with LNMClient(create_auth_config()) as client: @@ -533,8 +481,8 @@ async def test_cancel_trade(self): pytest.skip("No running trades to cancel") @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_cancel_all_trades(self): async with LNMClient(create_auth_config()) as client: @@ -546,8 +494,8 @@ async def test_cancel_all_trades(self): assert canceled.running is False @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_close_trade(self): async with LNMClient(create_auth_config()) as client: @@ -574,8 +522,8 @@ async def test_close_trade(self): assert len(str(e)) > 0 @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_add_margin(self): async with LNMClient(create_auth_config()) as client: @@ -593,8 +541,8 @@ async def test_add_margin(self): pytest.skip("No running trades to test add_margin") @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_cash_in(self): async with LNMClient(create_auth_config()) as client: @@ -611,8 +559,8 @@ async def test_cash_in(self): pytest.skip("No running trades to test cash_in") @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_update_stoploss(self): async with LNMClient(create_auth_config()) as client: @@ -630,8 +578,8 @@ async def test_update_stoploss(self): pytest.skip("No running trades to test update_stoploss") @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_update_takeprofit(self): async with LNMClient(create_auth_config()) as client: @@ -649,8 +597,8 @@ async def test_update_takeprofit(self): pytest.skip("No running trades to test update_takeprofit") @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_funding_fees_isolated(self): async with LNMClient(create_auth_config()) as client: @@ -675,8 +623,8 @@ class TestFuturesCrossIntegration: """Integration tests for cross margin futures.""" @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_position(self): async with LNMClient(create_auth_config()) as client: @@ -699,8 +647,8 @@ async def test_get_position(self): assert position.liquidation > 0 @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_new_order(self): async with LNMClient(create_auth_config()) as client: @@ -724,8 +672,8 @@ async def test_new_order(self): pytest.skip(f"Could not create order: {str(e)}") @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_open_orders(self): async with LNMClient(create_auth_config()) as client: @@ -745,8 +693,8 @@ async def test_get_open_orders(self): assert order.created_at is not None @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_filled_orders(self): async with LNMClient(create_auth_config()) as client: @@ -772,8 +720,8 @@ async def test_get_filled_orders(self): assert isinstance(order.filled_at, str) @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_cancel_order(self): async with LNMClient(create_auth_config()) as client: @@ -798,8 +746,8 @@ async def test_cancel_order(self): pytest.skip("No running orders to cancel") @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_cancel_all_orders(self): async with LNMClient(create_auth_config()) as client: @@ -811,8 +759,8 @@ async def test_cancel_all_orders(self): assert canceled.filled is False @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_close_position(self): async with LNMClient(create_auth_config()) as client: @@ -827,8 +775,8 @@ async def test_close_position(self): pytest.skip("No position to close") @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_deposit(self): async with LNMClient(create_auth_config()) as client: @@ -839,8 +787,8 @@ async def test_deposit(self): assert position.leverage > 0 @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_withdraw(self): async with LNMClient(create_auth_config()) as client: @@ -856,8 +804,8 @@ async def test_withdraw(self): pytest.skip("Insufficient margin to test withdraw") @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_set_leverage(self): async with LNMClient(create_auth_config()) as client: @@ -867,8 +815,8 @@ async def test_set_leverage(self): assert position.leverage == 50 @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_transfers(self): async with LNMClient(create_auth_config()) as client: @@ -885,8 +833,8 @@ async def test_get_transfers(self): assert transfers[0].time is not None @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_funding_fees_cross(self): async with LNMClient(create_auth_config()) as client: @@ -940,8 +888,8 @@ async def test_get_best_price(self): assert result.bid_price > 0 @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_get_swaps(self): async with LNMClient(create_auth_config()) as client: @@ -961,8 +909,8 @@ async def test_get_swaps(self): assert swaps[0].out_asset in ["BTC", "USD"] @pytest.mark.skipif( - not os.environ.get("TEST_API_KEY"), - reason="TEST_API_KEY not set in environment", + not os.environ.get("TESTNET4_API_KEY"), + reason="TESTNET4_API_KEY not set in environment", ) async def test_new_swap(self): async with LNMClient(create_auth_config()) as client: diff --git a/src/lnmarkets_sdk/stream/__init__.py b/src/lnmarkets_sdk/stream/__init__.py new file mode 100644 index 0000000..fea4dd8 --- /dev/null +++ b/src/lnmarkets_sdk/stream/__init__.py @@ -0,0 +1,4 @@ +"""LN Markets Stream API namespace package. + +Versions live under `stream.`, e.g. `lnmarkets_sdk.stream.v1`. +""" diff --git a/src/lnmarkets_sdk/stream/v1/__init__.py b/src/lnmarkets_sdk/stream/v1/__init__.py new file mode 100644 index 0000000..88468bf --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/__init__.py @@ -0,0 +1,75 @@ +"""LN Markets Stream V1 WebSocket client. + +Layout mirrors `rest_v3`: + +- `_internal/`: WebSocket transport, JSON-RPC framing, config, exceptions. +- `models/`: pydantic models per domain (futures, isolated, cross, ohlc, + wallet, announcements, lifecycle, auth, subscription, common). +- `client/`: `StreamClient` composing `public`, `auth`, `subscription` + domain clients. + +Example: +```python +import asyncio +from lnmarkets_sdk.stream.v1 import StreamClient, StreamClientConfig +from lnmarkets_sdk.stream.v1.models import ( + AuthenticateParams, + SubscribeParams, +) + +async def main(): + config = StreamClientConfig(network="testnet4") + async with StreamClient(config) as client: + await client.connect() + await client.auth.authenticate( + AuthenticateParams(key=k, secret=s, passphrase=p), + ) + await client.subscription.subscribe( + SubscribeParams(topics=["futures/inverse/btc_usd/ticker"]), + ) + client.on( + "futures/inverse/btc_usd/ticker", + lambda data: print(data), + ) + await asyncio.sleep(30) + +asyncio.run(main()) +``` +""" + +from lnmarkets_sdk.stream.v1._internal.models import ( + ConnectionState, + ReconnectFailedError, + StreamAuthContext, + StreamClientConfig, + StreamDisconnectedError, + StreamException, + StreamRequestTimeoutError, + StreamRpcError, + StreamValidationException, +) +from lnmarkets_sdk.stream.v1._internal.utils import ( + create_signature, + generate_auth_params, + get_hostname, + get_websocket_url, +) +from lnmarkets_sdk.stream.v1.client import StreamClient, create_stream_client + +__all__ = [ + "ConnectionState", + "ReconnectFailedError", + "StreamAuthContext", + "StreamClient", + "StreamClientConfig", + "StreamDisconnectedError", + "StreamException", + "StreamRequestTimeoutError", + "StreamRpcError", + "StreamValidationException", + "create_signature", + "create_stream_client", + "generate_auth_params", + "get_hostname", + "get_websocket_url", +] diff --git a/src/lnmarkets_sdk/stream/v1/_internal/__init__.py b/src/lnmarkets_sdk/stream/v1/_internal/__init__.py new file mode 100644 index 0000000..db574dd --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/_internal/__init__.py @@ -0,0 +1,308 @@ +"""Internal stream-v1 transport — not part of public API. + +Mirrors the role of `rest_v3._internal.BaseClient`: the low-level transport +that grouped client classes delegate to. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import inspect +import os +import secrets +from collections.abc import Awaitable, Callable +from types import UnionType +from typing import Any, cast + +import orjson +import websockets +from websockets.asyncio.client import ClientConnection + +from .models import ( + ConnectionState, + JsonRpcResponseFrame, + ReconnectFailedError, + StreamClientConfig, + StreamDisconnectedError, + StreamRequestTimeoutError, + StreamRpcError, +) +from .utils import get_websocket_url, parse_payload + +REQUEST_TIMEOUT_MS = 10_000 + +Listener = Callable[..., object | Awaitable[object]] + + +class StreamInstance: + """Low-level WebSocket transport for stream-v1. + + Responsibilities: + - Connection state machine (disconnected/connecting/connected/reconnecting) + - JSON-RPC 2.0 request/response correlation with per-request timeout + - Subscription event dispatch via `on(topic, listener)` + - Lifecycle events: `open`, `close`, `error`, `reconnected` + - Auto-reconnect with attempt counter and `ReconnectFailedError` + + Like `rest_v3._internal.BaseClient`, this is not a public class — use + `StreamClient` from `lnmarkets_sdk.stream.v1.client`. + """ + + def __init__(self, config: StreamClientConfig | None = None) -> None: + if config is None: + config = StreamClientConfig() + self._config = config + env_url = os.environ.get("STREAM_V1_API_URL") + self._url = env_url or get_websocket_url(config.network, config.hostname) + + self._ws: ClientConnection | None = None + self._state: ConnectionState = "disconnected" + self._reconnect_task: asyncio.Task[None] | None = None + self._receive_task: asyncio.Task[None] | None = None + self._reconnect_attempts = 0 + self._user_initiated_close = False + self._pending: dict[str, asyncio.Future[object]] = {} + self._listeners: dict[str, list[Listener]] = {} + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ + + @property + def state(self) -> ConnectionState: + return self._state + + async def __aenter__(self) -> StreamInstance: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + async def connect(self) -> None: + if self._state != "disconnected": + msg = f"Cannot connect(): state is '{self._state}'" + raise RuntimeError(msg) + self._user_initiated_close = False + await self._open_websocket() + + async def close(self) -> None: + self._user_initiated_close = True + if self._reconnect_task is not None and not self._reconnect_task.done(): + self._reconnect_task.cancel() + self._reconnect_task = None + if self._ws is not None and self._state != "disconnected": + with contextlib.suppress(Exception): + await self._ws.close(code=1000) + self._state = "disconnected" + + # ------------------------------------------------------------------ + # JSON-RPC request + # ------------------------------------------------------------------ + + async def request[T]( + self, + method: str, + params: Any | None = None, + response_model: type[T] | UnionType | None = None, + ) -> T: + """Send a JSON-RPC request, await its result, optionally validate.""" + if self._state != "connected" or self._ws is None: + raise StreamDisconnectedError("request") + + request_id = secrets.token_hex(8) + loop = asyncio.get_running_loop() + future: asyncio.Future[object] = loop.create_future() + self._pending[request_id] = future + + frame: dict[str, object] = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + } + if params is not None: + frame["params"] = params + + try: + await self._ws.send(orjson.dumps(frame).decode("utf-8")) + except Exception: + self._pending.pop(request_id, None) + raise + + try: + result = await asyncio.wait_for(future, timeout=REQUEST_TIMEOUT_MS / 1000) + except TimeoutError: + self._pending.pop(request_id, None) + raise StreamRequestTimeoutError(method) from None + + return parse_payload(result, response_model) + + # ------------------------------------------------------------------ + # Event emitter + # ------------------------------------------------------------------ + + def on(self, event: str, listener: Listener) -> None: + self._listeners.setdefault(event, []).append(listener) + + def off(self, event: str, listener: Listener) -> None: + listeners = self._listeners.get(event) + if listeners and listener in listeners: + listeners.remove(listener) + if not listeners: + del self._listeners[event] + + def remove_all_listeners(self, event: str | None = None) -> None: + if event is None: + self._listeners.clear() + else: + self._listeners.pop(event, None) + + async def _emit(self, event: str, *args: object) -> None: + for listener in list(self._listeners.get(event, [])): + try: + result = listener(*args) + if inspect.isawaitable(result): + await result + except Exception as error: + if event != "error": + await self._emit("error", error) + + # ------------------------------------------------------------------ + # Internal: WebSocket lifecycle + # ------------------------------------------------------------------ + + async def _open_websocket(self) -> None: + is_reconnect = self._reconnect_attempts > 0 + self._state = "reconnecting" if is_reconnect else "connecting" + try: + self._ws = await websockets.connect(self._url) + except Exception as error: + self._ws = None + self._state = "disconnected" + await self._emit("error", error) + # Only continue auto-reconnect chain on retry failures. + # Initial connect() failure must not leave an orphan reconnect task. + if is_reconnect and self._should_reconnect(): + self._schedule_reconnect() + raise + + attempts = self._reconnect_attempts + self._state = "connected" + self._reconnect_attempts = 0 + + self._receive_task = asyncio.create_task(self._receive_loop()) + await self._emit("open") + if is_reconnect: + await self._emit("reconnected", {"attempts": attempts}) + + async def _receive_loop(self) -> None: + ws = self._ws + if ws is None: + return + close_code = 1006 + close_reason = "" + try: + async for raw in ws: + # orjson.loads accepts both str and bytes — skip decode hop. + await self._on_message(raw) + except websockets.ConnectionClosed as closed: + close_code = closed.code or 1006 + close_reason = closed.reason or "" + except Exception as error: + await self._emit("error", error) + finally: + was_user_initiated = self._user_initiated_close + self._ws = None + self._state = "disconnected" + await self._emit("close", close_code, close_reason) + self._reject_pending() + if ( + not was_user_initiated + and close_code != 1000 + and self._should_reconnect() + ): + self._schedule_reconnect() + + async def _on_message(self, payload: str | bytes) -> None: + try: + parsed = orjson.loads(payload) + except orjson.JSONDecodeError as error: + await self._emit("error", ValueError(f"Malformed message: {error}")) + return + + if not isinstance(parsed, dict): + await self._emit("error", ValueError("Unknown JSON-RPC frame")) + return + frame = cast(dict[str, object], parsed) + + # Subscription frames are hot path — skip pydantic, use dict access. + # Frame shape: {"jsonrpc":"2.0","method":"subscription","params":{"topic":str,"data":...}} + if frame.get("method") == "subscription": + params = frame.get("params") + if not isinstance(params, dict): + await self._emit("error", ValueError("Malformed subscription frame")) + return + sub_params = cast(dict[str, object], params) + topic = sub_params.get("topic") + if not isinstance(topic, str): + await self._emit( + "error", ValueError("Subscription frame missing topic") + ) + return + await self._emit(topic, sub_params.get("data")) + return + + if isinstance(frame.get("id"), str): + try: + response = JsonRpcResponseFrame.model_validate(frame) + except Exception as error: + await self._emit("error", error) + return + future = self._pending.pop(response.id, None) + if future is None or future.done(): + return + if response.error is not None: + future.set_exception( + StreamRpcError( + response.error.code, + response.error.message, + response.error.data, + ) + ) + else: + future.set_result(response.result) + return + + await self._emit("error", ValueError("Unknown JSON-RPC frame")) + + def _should_reconnect(self) -> bool: + return self._config.reconnect_enabled and not self._user_initiated_close + + def _schedule_reconnect(self) -> None: + if self._reconnect_attempts >= self._config.max_reconnect_attempts: + self._state = "disconnected" + asyncio.create_task( + self._emit("error", ReconnectFailedError(self._reconnect_attempts)) + ) + return + self._state = "reconnecting" + self._reconnect_task = asyncio.create_task(self._reconnect_after_delay()) + + async def _reconnect_after_delay(self) -> None: + try: + await asyncio.sleep(self._config.reconnect_interval) + except asyncio.CancelledError: + return + self._reconnect_attempts += 1 + # error already emitted; reconnect chain managed inside _open_websocket + with contextlib.suppress(Exception): + await self._open_websocket() + + def _reject_pending(self) -> None: + for future in self._pending.values(): + if not future.done(): + future.set_exception(StreamDisconnectedError("pending request")) + self._pending.clear() + + +__all__ = ["StreamInstance"] diff --git a/src/lnmarkets_sdk/stream/v1/_internal/models.py b/src/lnmarkets_sdk/stream/v1/_internal/models.py new file mode 100644 index 0000000..24f4894 --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/_internal/models.py @@ -0,0 +1,156 @@ +"""Internal models, config, and exceptions for stream-v1.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic.alias_generators import to_camel + +type StreamNetwork = Literal["mainnet", "testnet4"] +type ConnectionState = Literal[ + "disconnected", "connecting", "connected", "reconnecting" +] + + +class BaseConfig: + """Base configuration for all pydantic models in stream-v1.""" + + model_config = ConfigDict( + extra="allow", + validate_assignment=True, + str_strip_whitespace=True, + use_enum_values=True, + alias_generator=to_camel, + validate_by_name=True, + ) + + +class StreamAuthContext(BaseModel, BaseConfig): + """Stream API authentication context.""" + + key: str = Field(..., min_length=1) + secret: str = Field(..., min_length=1) + passphrase: str = Field(..., min_length=1) + + +class StreamClientConfig(BaseModel): + """Stream client configuration.""" + + model_config = ConfigDict(extra="forbid") + + network: StreamNetwork = "mainnet" + hostname: str | None = None + reconnect_interval: float = Field( + default=5.0, gt=0, description="Seconds between reconnection attempts" + ) + reconnect_enabled: bool = True + max_reconnect_attempts: int = Field(default=5, ge=0) + + +# --------------------------------------------------------------------------- +# JSON-RPC framing +# --------------------------------------------------------------------------- + + +class JsonRpcRequestFrame(BaseModel): + model_config = ConfigDict(extra="forbid") + + jsonrpc: Literal["2.0"] = "2.0" + id: str + method: str + params: Any | None = None + + +class JsonRpcErrorBody(BaseModel, BaseConfig): + code: int + message: str + data: Any | None = None + + +class JsonRpcResponseFrame(BaseModel, BaseConfig): + jsonrpc: Literal["2.0"] = "2.0" + id: str + result: Any | None = None + error: JsonRpcErrorBody | None = None + + +class JsonRpcSubscriptionParams(BaseModel, BaseConfig): + topic: str + data: Any | None = None + + +class JsonRpcSubscriptionFrame(BaseModel, BaseConfig): + jsonrpc: Literal["2.0"] = "2.0" + method: Literal["subscription"] + params: JsonRpcSubscriptionParams + + +# --------------------------------------------------------------------------- +# Exceptions (mirror of rest_v3 APIException family) +# --------------------------------------------------------------------------- + + +class StreamException(Exception): + """Base exception for stream-v1.""" + + +class StreamDisconnectedError(StreamException): + """Raised when a method is called on a disconnected client.""" + + def __init__(self, method: str) -> None: + super().__init__(f"Cannot call {method}(): WebSocket is not connected") + self.method = method + + +class ReconnectFailedError(StreamException): + """Raised when reconnection attempts are exhausted.""" + + def __init__(self, attempts: int) -> None: + super().__init__(f"WebSocket reconnect failed after {attempts} attempt(s)") + self.attempts = attempts + + +class StreamRequestTimeoutError(StreamException): + """Raised when a JSON-RPC request times out.""" + + def __init__(self, method: str) -> None: + super().__init__(f"Request timed out after 10s (method: {method})") + self.method = method + + +class StreamRpcError(StreamException): + """Raised when the server returns a JSON-RPC error response.""" + + def __init__(self, code: int, message: str, data: object | None = None) -> None: + super().__init__(message) + self.code = code + self.data = data + + +class StreamValidationException(StreamException): + """Raised when a payload fails pydantic validation.""" + + def __init__(self, message: str, validation_error: ValidationError) -> None: + super().__init__(message) + self.validation_error = validation_error + + +__all__ = [ + "BaseConfig", + "ConnectionState", + "JsonRpcErrorBody", + "JsonRpcRequestFrame", + "JsonRpcResponseFrame", + "JsonRpcSubscriptionFrame", + "JsonRpcSubscriptionParams", + "ReconnectFailedError", + "StreamAuthContext", + "StreamClientConfig", + "StreamDisconnectedError", + "StreamException", + "StreamNetwork", + "StreamRequestTimeoutError", + "StreamRpcError", + "StreamValidationException", +] diff --git a/src/lnmarkets_sdk/stream/v1/_internal/utils.py b/src/lnmarkets_sdk/stream/v1/_internal/utils.py new file mode 100644 index 0000000..229e4e9 --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/_internal/utils.py @@ -0,0 +1,73 @@ +"""Internal utilities for stream-v1.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +from datetime import UTC, datetime +from types import UnionType +from typing import Any + +from pydantic import TypeAdapter, ValidationError + +from .models import StreamAuthContext, StreamNetwork, StreamValidationException + + +def get_hostname(network: StreamNetwork) -> str: + """Stream API hostname for the given network.""" + return ( + "stream.testnet4.lnmarkets.com" + if network == "testnet4" + else "stream.lnmarkets.com" + ) + + +def get_websocket_url(network: StreamNetwork, hostname: str | None = None) -> str: + """Full wss:// URL for the given network or explicit hostname.""" + host = hostname or get_hostname(network) + return f"wss://{host}/v1" + + +def create_signature(secret: str, timestamp: int, nonce: str) -> str: + """HMAC-SHA256 signature over `{timestamp}{nonce}`, base64-encoded.""" + payload = f"{timestamp}{nonce}".encode() + digest = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).digest() + return base64.b64encode(digest).decode("utf-8") + + +def generate_auth_params(auth: StreamAuthContext) -> dict[str, str | int]: + """Build the params object expected by the `authenticate` RPC method.""" + nonce = secrets.token_hex(8) + timestamp = int(datetime.now(tz=UTC).timestamp() * 1000) + signature = create_signature(auth.secret, timestamp, nonce) + return { + "key": auth.key, + "signature": signature, + "timestamp": timestamp, + "passphrase": auth.passphrase, + "nonce": nonce, + } + + +def parse_payload[T](data: Any, model: type[T] | UnionType | None) -> T | Any: + """Validate a payload against a pydantic model (or union).""" + if model is None: + return data + try: + adapter: TypeAdapter[T] = TypeAdapter(model) + return adapter.validate_python(data) + except ValidationError as error: + raise StreamValidationException( + f"Payload validation failed: {error}", validation_error=error + ) from error + + +__all__ = [ + "create_signature", + "generate_auth_params", + "get_hostname", + "get_websocket_url", + "parse_payload", +] diff --git a/src/lnmarkets_sdk/stream/v1/client/__init__.py b/src/lnmarkets_sdk/stream/v1/client/__init__.py new file mode 100644 index 0000000..83f3bb6 --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/client/__init__.py @@ -0,0 +1,165 @@ +"""Public stream-v1 client (mirror of rest_v3.http.client.LNMClient).""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from types import UnionType +from typing import Any, Literal, TypedDict, overload + +from pydantic import BaseModel + +from lnmarkets_sdk.stream.v1._internal import StreamInstance +from lnmarkets_sdk.stream.v1._internal.models import ( + ConnectionState, + StreamClientConfig, +) +from lnmarkets_sdk.stream.v1.models.common import Topic + +from .auth import AuthClient +from .public import PublicClient +from .subscription import SubscriptionClient + +Listener = Callable[..., object | Awaitable[object]] + + +# --------------------------------------------------------------------------- +# Typed listener signatures — used by overloads on `StreamClient.on(...)`. +# Mirrors the TS SDK's `EventEmitter` mapped types so callers +# get inferred lambda parameter types instead of `Unknown`. +# --------------------------------------------------------------------------- + + +class ReconnectedEventPayload(TypedDict): + """Payload dispatched on the `reconnected` lifecycle event.""" + + attempts: int + + +OpenListener = Callable[[], None | Awaitable[None]] +CloseListener = Callable[[int, str], None | Awaitable[None]] +ErrorListener = Callable[[BaseException], None | Awaitable[None]] +ReconnectedListener = Callable[[ReconnectedEventPayload], None | Awaitable[None]] +TopicListener = Callable[[dict[str, Any]], None | Awaitable[None]] + + +class StreamClient: + """Async WebSocket client for LN Markets Stream API v1. + + Composes domain clients (`public`, `auth`, `subscription`) over a shared + `StreamInstance` transport. Mirrors the layout of `rest_v3.LNMClient`. + + Example: + ```python + from lnmarkets_sdk.stream.v1 import StreamClient + from lnmarkets_sdk.stream.v1.models import ( + AuthenticateParams, + SubscribeParams, + ) + + async with StreamClient() as client: + await client.connect() + + await client.auth.authenticate( + AuthenticateParams(key=k, secret=s, passphrase=p), + ) + + await client.subscription.subscribe( + SubscribeParams(topics=["futures/inverse/btc_usd/ticker"]), + ) + client.on("futures/inverse/btc_usd/ticker", lambda data: print(data)) + + pong = await client.public.ping() + ``` + """ + + def __init__(self, config: StreamClientConfig | None = None) -> None: + self._instance = StreamInstance(config) + self.public = PublicClient(self) + self.auth = AuthClient(self) + self.subscription = SubscriptionClient(self) + + async def __aenter__(self) -> StreamClient: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + @property + def state(self) -> ConnectionState: + return self._instance.state + + async def connect(self) -> None: + await self._instance.connect() + + async def close(self) -> None: + await self._instance.close() + + @overload + def on(self, event: Literal["open"], listener: OpenListener) -> None: ... + @overload + def on(self, event: Literal["close"], listener: CloseListener) -> None: ... + @overload + def on(self, event: Literal["error"], listener: ErrorListener) -> None: ... + @overload + def on( + self, event: Literal["reconnected"], listener: ReconnectedListener + ) -> None: ... + @overload + def on(self, event: Topic, listener: TopicListener) -> None: ... + def on(self, event: str, listener: Listener) -> None: + """Register a listener for a topic or lifecycle event. + + Lifecycle events: `open`, `close`, `error`, `reconnected`. + Listener parameter types are inferred per event via overloads: + - `open` -> `()` + - `close` -> `(code: int, reason: str)` + - `error` -> `(err: BaseException)` + - `reconnected` -> `(event: ReconnectedEventPayload)` + - Topic events -> `(data: dict[str, Any])` + """ + self._instance.on(event, listener) + + def off(self, event: str, listener: Listener) -> None: + self._instance.off(event, listener) + + def remove_all_listeners(self, event: str | None = None) -> None: + self._instance.remove_all_listeners(event) + + async def request[T]( + self, + method: str, + params: BaseModel | dict[str, Any] | None = None, + response_model: type[T] | UnionType | None = None, + ) -> T: + """Send a JSON-RPC request through the underlying transport. + + Pydantic params are dumped via `model_dump(by_alias=True, exclude_none=True)` + so wire fields use camelCase. + """ + params_dict: Any | None + if isinstance(params, BaseModel): + params_dict = params.model_dump( + mode="json", exclude_none=True, by_alias=True + ) + else: + params_dict = params + return await self._instance.request( + method, params=params_dict, response_model=response_model + ) + + +def create_stream_client(config: StreamClientConfig | None = None) -> StreamClient: + """Factory mirroring `createStreamClient(options)` in the TS SDK.""" + return StreamClient(config) + + +__all__ = [ + "CloseListener", + "ErrorListener", + "OpenListener", + "ReconnectedEventPayload", + "ReconnectedListener", + "StreamClient", + "TopicListener", + "create_stream_client", +] diff --git a/src/lnmarkets_sdk/stream/v1/client/auth.py b/src/lnmarkets_sdk/stream/v1/client/auth.py new file mode 100644 index 0000000..4dc882a --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/client/auth.py @@ -0,0 +1,57 @@ +"""Authentication RPC methods (authenticate, whoami).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from lnmarkets_sdk.stream.v1._internal.models import StreamAuthContext +from lnmarkets_sdk.stream.v1._internal.utils import generate_auth_params +from lnmarkets_sdk.stream.v1.models.auth import ( + AuthenticateParams, + AuthenticateResponse, + WhoamiResponse, +) + +if TYPE_CHECKING: + from lnmarkets_sdk.stream.v1.client import StreamClient + + +class AuthClient: + """Client for authentication RPC methods.""" + + def __init__(self, client: StreamClient) -> None: + self._client = client + + async def authenticate(self, params: AuthenticateParams) -> AuthenticateResponse: + """Authenticate with the Stream API. + + Example: + ```python + from lnmarkets_sdk.stream.v1 import create_stream_client + from lnmarkets_sdk.stream.v1.models import AuthenticateParams + + async with create_stream_client() as client: + await client.connect() + await client.auth.authenticate( + AuthenticateParams(key=k, secret=s, passphrase=p), + ) + ``` + """ + auth = StreamAuthContext( + key=params.key, secret=params.secret, passphrase=params.passphrase + ) + return await self._client.request( + "authenticate", + params=generate_auth_params(auth), + response_model=AuthenticateResponse, + ) + + async def whoami(self) -> WhoamiResponse: + """Return information about the authenticated user.""" + return await self._client.request( + "whoami", + response_model=WhoamiResponse, + ) + + +__all__ = ["AuthClient"] diff --git a/src/lnmarkets_sdk/stream/v1/client/public.py b/src/lnmarkets_sdk/stream/v1/client/public.py new file mode 100644 index 0000000..23a2255 --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/client/public.py @@ -0,0 +1,56 @@ +"""Public RPC methods (hello, ping, time).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from lnmarkets_sdk.stream.v1.models.lifecycle import ( + HelloParams, + HelloResponse, + PingResponse, + TimeResponse, +) + +if TYPE_CHECKING: + from lnmarkets_sdk.stream.v1.client import StreamClient + + +class PublicClient: + """Client for unauthenticated RPC methods.""" + + def __init__(self, client: StreamClient) -> None: + self._client = client + + async def hello(self, params: HelloParams) -> HelloResponse: + """Send a `hello` handshake. + + Example: + ```python + from lnmarkets_sdk.stream.v1 import create_stream_client + from lnmarkets_sdk.stream.v1.models import HelloParams + + async with create_stream_client() as client: + await client.connect() + result = await client.public.hello( + HelloParams(client_name="my-bot", client_version="1.0.0"), + ) + print(result.version) + ``` + """ + return await self._client.request( + "hello", + params=params, + response_model=HelloResponse, + ) + + async def ping(self) -> PingResponse: + """Ping the server. Returns the literal string `'pong'`.""" + result: Any = await self._client.request("ping") + return cast(PingResponse, result) + + async def time(self) -> TimeResponse: + """Get the server time.""" + return await self._client.request("time", response_model=TimeResponse) + + +__all__ = ["PublicClient"] diff --git a/src/lnmarkets_sdk/stream/v1/client/subscription.py b/src/lnmarkets_sdk/stream/v1/client/subscription.py new file mode 100644 index 0000000..2ff4a93 --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/client/subscription.py @@ -0,0 +1,62 @@ +"""Subscription RPC methods (subscribe, unsubscribe, unsubscribe_all).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from lnmarkets_sdk.stream.v1.models.subscription import ( + SubscribeParams, + SubscribeResponse, + UnsubscribeAllResponse, + UnsubscribeParams, + UnsubscribeResponse, +) + +if TYPE_CHECKING: + from lnmarkets_sdk.stream.v1.client import StreamClient + + +class SubscriptionClient: + """Client for managing topic subscriptions.""" + + def __init__(self, client: StreamClient) -> None: + self._client = client + + async def subscribe(self, params: SubscribeParams) -> SubscribeResponse: + """Subscribe to one or more topics. + + Example: + ```python + from lnmarkets_sdk.stream.v1 import create_stream_client + from lnmarkets_sdk.stream.v1.models import SubscribeParams + + async with create_stream_client() as client: + await client.connect() + await client.subscription.subscribe( + SubscribeParams(topics=["futures/inverse/btc_usd/ticker"]), + ) + ``` + """ + return await self._client.request( + "subscribe", + params=params, + response_model=SubscribeResponse, + ) + + async def unsubscribe(self, params: UnsubscribeParams) -> UnsubscribeResponse: + """Unsubscribe from one or more topics.""" + return await self._client.request( + "unsubscribe", + params=params, + response_model=UnsubscribeResponse, + ) + + async def unsubscribe_all(self) -> UnsubscribeAllResponse: + """Unsubscribe from every active topic.""" + return await self._client.request( + "unsubscribeAll", + response_model=UnsubscribeAllResponse, + ) + + +__all__ = ["SubscriptionClient"] diff --git a/src/lnmarkets_sdk/stream/v1/models/__init__.py b/src/lnmarkets_sdk/stream/v1/models/__init__.py new file mode 100644 index 0000000..5307963 --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/models/__init__.py @@ -0,0 +1,143 @@ +"""Public stream-v1 models, grouped by domain (mirror of rest_v3.models).""" + +from lnmarkets_sdk.stream.v1.models.announcements import ( + AnnouncementAdd, + AnnouncementEvent, + AnnouncementRemove, +) +from lnmarkets_sdk.stream.v1.models.auth import ( + AuthenticateParams, + AuthenticateResponse, + WhoamiResponse, +) +from lnmarkets_sdk.stream.v1.models.common import ( + ExplicitTopic, + Instrument, + MarginMode, + OhlcResolution, + OhlcTopic, + Pair, + ReconnectedEvent, + Topic, + make_ohlc_topic, +) +from lnmarkets_sdk.stream.v1.models.cross import ( + CrossOrderCanceled, + CrossOrderCanceledPayload, + CrossOrderEvent, + CrossOrderFilledPayload, + CrossOrderLimit, + CrossOrderNew, + CrossOrderOpenPayload, + CrossPositionData, + CrossPositionPayload, +) +from lnmarkets_sdk.stream.v1.models.futures import ( + FundingTime, + FuturesBucket, + FuturesBucketData, + FuturesFundingData, + FuturesIndexData, + FuturesLastPriceData, + FuturesTickerData, +) +from lnmarkets_sdk.stream.v1.models.isolated import ( + IsolatedTradeCanceled, + IsolatedTradeCanceledPayload, + IsolatedTradeClosed, + IsolatedTradeClosedPayload, + IsolatedTradeFilled, + IsolatedTradeFilledPayload, + IsolatedTradeFunding, + IsolatedTradeFundingPayload, + IsolatedTradeLiquidation, + IsolatedTradeLiquidationPayload, + IsolatedTradeOpen, + IsolatedTradeOpenPayload, + IsolatedTradesEvent, + IsolatedTradeStoploss, + IsolatedTradeStoplossPayload, + IsolatedTradeTakeprofit, + IsolatedTradeTakeprofitPayload, +) +from lnmarkets_sdk.stream.v1.models.lifecycle import ( + HelloParams, + HelloResponse, + PingResponse, + TimeResponse, +) +from lnmarkets_sdk.stream.v1.models.ohlc import OhlcData +from lnmarkets_sdk.stream.v1.models.subscription import ( + SubscribeParams, + SubscribeResponse, + UnsubscribeAllResponse, + UnsubscribeParams, + UnsubscribeResponse, +) +from lnmarkets_sdk.stream.v1.models.wallet import ( + WalletDepositData, + WalletWithdrawData, +) + +__all__ = [ + "AnnouncementAdd", + "AnnouncementEvent", + "AnnouncementRemove", + "AuthenticateParams", + "AuthenticateResponse", + "CrossOrderCanceled", + "CrossOrderCanceledPayload", + "CrossOrderEvent", + "CrossOrderFilledPayload", + "CrossOrderLimit", + "CrossOrderNew", + "CrossOrderOpenPayload", + "CrossPositionData", + "CrossPositionPayload", + "ExplicitTopic", + "FundingTime", + "FuturesBucket", + "FuturesBucketData", + "FuturesFundingData", + "FuturesIndexData", + "FuturesLastPriceData", + "FuturesTickerData", + "HelloParams", + "HelloResponse", + "Instrument", + "IsolatedTradeCanceled", + "IsolatedTradeCanceledPayload", + "IsolatedTradeClosed", + "IsolatedTradeClosedPayload", + "IsolatedTradeFilled", + "IsolatedTradeFilledPayload", + "IsolatedTradeFunding", + "IsolatedTradeFundingPayload", + "IsolatedTradeLiquidation", + "IsolatedTradeLiquidationPayload", + "IsolatedTradeOpen", + "IsolatedTradeOpenPayload", + "IsolatedTradeStoploss", + "IsolatedTradeStoplossPayload", + "IsolatedTradeTakeprofit", + "IsolatedTradeTakeprofitPayload", + "IsolatedTradesEvent", + "MarginMode", + "OhlcData", + "OhlcResolution", + "OhlcTopic", + "Pair", + "PingResponse", + "ReconnectedEvent", + "SubscribeParams", + "SubscribeResponse", + "TimeResponse", + "Topic", + "UnsubscribeAllResponse", + "UnsubscribeParams", + "UnsubscribeResponse", + "WalletDepositData", + "WalletWithdrawData", + "WhoamiResponse", + "make_ohlc_topic", +] diff --git a/src/lnmarkets_sdk/stream/v1/models/announcements.py b/src/lnmarkets_sdk/stream/v1/models/announcements.py new file mode 100644 index 0000000..4be95ad --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/models/announcements.py @@ -0,0 +1,28 @@ +"""Announcement topic payloads.""" + +from __future__ import annotations + +from pydantic import BaseModel + +from lnmarkets_sdk.stream.v1._internal.models import BaseConfig + + +class AnnouncementAdd(BaseModel, BaseConfig): + """An announcement was added.""" + + id: str + title: str + message: str + link: str | None = None + + +class AnnouncementRemove(BaseModel, BaseConfig): + """An announcement was removed.""" + + id: str + + +type AnnouncementEvent = AnnouncementAdd | AnnouncementRemove + + +__all__ = ["AnnouncementAdd", "AnnouncementEvent", "AnnouncementRemove"] diff --git a/src/lnmarkets_sdk/stream/v1/models/auth.py b/src/lnmarkets_sdk/stream/v1/models/auth.py new file mode 100644 index 0000000..4970456 --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/models/auth.py @@ -0,0 +1,37 @@ +"""Models for the authenticate / whoami RPC methods.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from lnmarkets_sdk.stream.v1._internal.models import BaseConfig + + +class AuthenticateParams(BaseModel, BaseConfig): + """Input for `client.auth.authenticate(params)`.""" + + key: str = Field(..., min_length=1) + secret: str = Field(..., min_length=1) + passphrase: str = Field(..., min_length=1) + + +class AuthenticateResponse(BaseModel, BaseConfig): + """Result of the `authenticate` RPC method.""" + + authenticated: bool + permissions: list[str] + + +class WhoamiResponse(BaseModel, BaseConfig): + """Result of the `whoami` RPC method.""" + + api_key: str + user_id: str + permissions: list[str] + + +__all__ = [ + "AuthenticateParams", + "AuthenticateResponse", + "WhoamiResponse", +] diff --git a/src/lnmarkets_sdk/stream/v1/models/common.py b/src/lnmarkets_sdk/stream/v1/models/common.py new file mode 100644 index 0000000..5b7b7ff --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/models/common.py @@ -0,0 +1,95 @@ +"""Common stream-v1 primitives shared across domains.""" + +from __future__ import annotations + +from typing import Literal, cast + +from pydantic import BaseModel, ConfigDict + +type Pair = Literal["btc_usd"] +type Instrument = Literal["inverse"] +type MarginMode = Literal["isolated", "cross"] + +type OhlcResolution = Literal[ + "1m", + "3m", + "5m", + "10m", + "15m", + "30m", + "45m", + "1h", + "2h", + "3h", + "4h", + "1d", + "1w", + "1month", + "3months", +] + +type ExplicitTopic = Literal[ + "announcements", + "wallet/deposit", + "wallet/withdrawal", + "futures/inverse/btc_usd/ticker", + "futures/inverse/btc_usd/lastPrice", + "futures/inverse/btc_usd/index", + "futures/inverse/btc_usd/buckets", + "futures/inverse/btc_usd/funding", + "futures/inverse/btc_usd/isolated/trades", + "futures/inverse/btc_usd/cross/orders", + "futures/inverse/btc_usd/cross/position", +] + +# Full enumeration of OHLC topics — Instrument × Pair × OhlcResolution. +type OhlcTopic = Literal[ + "futures/inverse/btc_usd/ohlc/1m", + "futures/inverse/btc_usd/ohlc/3m", + "futures/inverse/btc_usd/ohlc/5m", + "futures/inverse/btc_usd/ohlc/10m", + "futures/inverse/btc_usd/ohlc/15m", + "futures/inverse/btc_usd/ohlc/30m", + "futures/inverse/btc_usd/ohlc/45m", + "futures/inverse/btc_usd/ohlc/1h", + "futures/inverse/btc_usd/ohlc/2h", + "futures/inverse/btc_usd/ohlc/3h", + "futures/inverse/btc_usd/ohlc/4h", + "futures/inverse/btc_usd/ohlc/1d", + "futures/inverse/btc_usd/ohlc/1w", + "futures/inverse/btc_usd/ohlc/1month", + "futures/inverse/btc_usd/ohlc/3months", +] + +type Topic = ExplicitTopic | OhlcTopic + + +def make_ohlc_topic(pair: Pair, resolution: OhlcResolution) -> OhlcTopic: + """Build an OHLC topic string for the given pair and resolution. + + Python lacks template-literal types, so the computed `f"..."` is cast to + `OhlcTopic`. Inputs are Literal-typed, so the resulting string is always + a valid member of `OhlcTopic` by construction. + """ + return cast("OhlcTopic", f"futures/inverse/{pair}/ohlc/{resolution}") + + +class ReconnectedEvent(BaseModel): + """Payload emitted by the `reconnected` event.""" + + model_config = ConfigDict(extra="forbid") + + attempts: int + + +__all__ = [ + "ExplicitTopic", + "Instrument", + "MarginMode", + "OhlcResolution", + "OhlcTopic", + "Pair", + "ReconnectedEvent", + "Topic", + "make_ohlc_topic", +] diff --git a/src/lnmarkets_sdk/stream/v1/models/cross.py b/src/lnmarkets_sdk/stream/v1/models/cross.py new file mode 100644 index 0000000..125cbfe --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/models/cross.py @@ -0,0 +1,111 @@ +"""Cross futures order and position events.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +from lnmarkets_sdk.stream.v1._internal.models import BaseConfig +from lnmarkets_sdk.stream.v1.models.common import Pair + + +class CrossOrderOpenPayload(BaseModel, BaseConfig): + id: str + side: Literal["buy", "sell"] + type: Literal["limit"] + quantity: int + price: float + trading_fee: int + client_id: str | None = None + created_at: int + + +class CrossOrderFilledPayload(BaseModel, BaseConfig): + id: str + side: Literal["buy", "sell"] + type: Literal["limit", "liquidation", "market"] + quantity: int + price: float + trading_fee: int + client_id: str | None = None + created_at: int + filled_at: int + + +class CrossOrderCanceledPayload(BaseModel, BaseConfig): + id: str + side: Literal["buy", "sell"] + type: Literal["limit"] + quantity: int + price: float + client_id: str | None = None + created_at: int + canceled_at: int + + +class CrossOrderNew(BaseModel, BaseConfig): + pair: Pair + event: Literal["new"] + order: CrossOrderOpenPayload | CrossOrderFilledPayload + + +class CrossOrderLimit(BaseModel, BaseConfig): + pair: Pair + event: Literal["limit"] + order: CrossOrderFilledPayload + + +class CrossOrderCanceled(BaseModel, BaseConfig): + pair: Pair + event: Literal["canceled"] + order: CrossOrderCanceledPayload + + +type CrossOrderEvent = CrossOrderNew | CrossOrderLimit | CrossOrderCanceled + + +class CrossPositionPayload(BaseModel, BaseConfig): + quantity: int + leverage: float + margin: int + entry_price: float | None = None + liquidation: float | None = None + total_pl: int + funding_fees: int + trading_fees: int + initial_margin: int + maintenance_margin: int + running_margin: int + delta_pl: int + updated_at: int + + +class CrossPositionData(BaseModel, BaseConfig): + """Payload for `futures/inverse/btc_usd/cross/position`.""" + + pair: Pair + event: Literal[ + "new", + "limit", + "cancel", + "leverage", + "deposit", + "withdraw", + "liquidation", + "funding", + ] + position: CrossPositionPayload + + +__all__ = [ + "CrossOrderCanceled", + "CrossOrderCanceledPayload", + "CrossOrderEvent", + "CrossOrderFilledPayload", + "CrossOrderLimit", + "CrossOrderNew", + "CrossOrderOpenPayload", + "CrossPositionData", + "CrossPositionPayload", +] diff --git a/src/lnmarkets_sdk/stream/v1/models/futures.py b/src/lnmarkets_sdk/stream/v1/models/futures.py new file mode 100644 index 0000000..fb2cb94 --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/models/futures.py @@ -0,0 +1,68 @@ +"""Futures topic payloads (ticker, lastPrice, index, buckets, funding).""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from lnmarkets_sdk.stream.v1._internal.models import BaseConfig +from lnmarkets_sdk.stream.v1.models.common import Pair + + +class FundingTime(BaseModel, BaseConfig): + rate: float | None = Field(default=None) + time: int | None = Field(default=None) + + +class FuturesTickerData(BaseModel, BaseConfig): + """Payload for `futures/inverse/btc_usd/ticker`.""" + + time: int + last_price: float | None = None + index: float | None = None + funding: FundingTime + + +class FuturesLastPriceData(BaseModel, BaseConfig): + """Payload for `futures/inverse/btc_usd/lastPrice`.""" + + time: int + last_price: float + + +class FuturesIndexData(BaseModel, BaseConfig): + """Payload for `futures/inverse/btc_usd/index`.""" + + time: int + index: float + + +class FuturesBucket(BaseModel, BaseConfig): + min_size: int + max_size: int + ask_price: float | None = None + bid_price: float | None = None + + +class FuturesBucketData(BaseModel, BaseConfig): + """Payload for `futures/inverse/btc_usd/buckets`.""" + + time: int + buckets: list[FuturesBucket] + + +class FuturesFundingData(BaseModel, BaseConfig): + """Payload for `futures/inverse/btc_usd/funding`.""" + + pair: Pair + current: FundingTime + + +__all__ = [ + "FundingTime", + "FuturesBucket", + "FuturesBucketData", + "FuturesFundingData", + "FuturesIndexData", + "FuturesLastPriceData", + "FuturesTickerData", +] diff --git a/src/lnmarkets_sdk/stream/v1/models/isolated.py b/src/lnmarkets_sdk/stream/v1/models/isolated.py new file mode 100644 index 0000000..57b1b47 --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/models/isolated.py @@ -0,0 +1,167 @@ +"""Isolated futures trade events (`futures/inverse/btc_usd/isolated/trades`).""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +from lnmarkets_sdk.stream.v1._internal.models import BaseConfig +from lnmarkets_sdk.stream.v1.models.common import Pair + + +class IsolatedTradeOpenPayload(BaseModel, BaseConfig): + id: str + side: Literal["buy", "sell"] + type: Literal["limit"] + quantity: int + margin: int + leverage: float + price: float + opening_fee: int + created_at: int + client_id: str | None = None + + +class IsolatedTradeOpen(BaseModel, BaseConfig): + pair: Pair + event: Literal["open"] + trade: IsolatedTradeOpenPayload + + +class IsolatedTradeFilledPayload(BaseModel, BaseConfig): + id: str + side: Literal["buy", "sell"] + type: Literal["limit", "market"] + quantity: int + margin: int + leverage: float + price: float + opening_fee: int + created_at: int + client_id: str | None = None + + +class IsolatedTradeFilled(BaseModel, BaseConfig): + pair: Pair + event: Literal["filled"] + trade: IsolatedTradeFilledPayload + + +class IsolatedTradeClosedPayload(BaseModel, BaseConfig): + id: str + closed_at: int + closing_fee: int + pl: int + exit_price: float + client_id: str | None = None + + +class IsolatedTradeClosed(BaseModel, BaseConfig): + pair: Pair + event: Literal["closed"] + trade: IsolatedTradeClosedPayload + + +class IsolatedTradeCanceledPayload(BaseModel, BaseConfig): + id: str + closed_at: int + client_id: str | None = None + + +class IsolatedTradeCanceled(BaseModel, BaseConfig): + pair: Pair + event: Literal["canceled"] + trade: IsolatedTradeCanceledPayload + + +class IsolatedTradeLiquidationPayload(BaseModel, BaseConfig): + id: str + closed_at: int + closing_fee: int + exit_price: float + client_id: str | None = None + + +class IsolatedTradeLiquidation(BaseModel, BaseConfig): + pair: Pair + event: Literal["liquidation"] + trade: IsolatedTradeLiquidationPayload + + +class IsolatedTradeStoplossPayload(BaseModel, BaseConfig): + id: str + closed_at: int + closing_fee: int + pl: int + exit_price: float + client_id: str | None = None + + +class IsolatedTradeStoploss(BaseModel, BaseConfig): + pair: Pair + event: Literal["stoploss"] + trade: IsolatedTradeStoplossPayload + + +class IsolatedTradeTakeprofitPayload(BaseModel, BaseConfig): + id: str + closed_at: int + closing_fee: int + pl: int + exit_price: float + client_id: str | None = None + + +class IsolatedTradeTakeprofit(BaseModel, BaseConfig): + pair: Pair + event: Literal["takeprofit"] + trade: IsolatedTradeTakeprofitPayload + + +class IsolatedTradeFundingPayload(BaseModel, BaseConfig): + id: str + margin: int + liquidation_price: float + funding_fee: int + funded_at: int + client_id: str | None = None + + +class IsolatedTradeFunding(BaseModel, BaseConfig): + pair: Pair + event: Literal["funding"] + trade: IsolatedTradeFundingPayload + + +type IsolatedTradesEvent = ( + IsolatedTradeOpen + | IsolatedTradeFilled + | IsolatedTradeClosed + | IsolatedTradeCanceled + | IsolatedTradeLiquidation + | IsolatedTradeStoploss + | IsolatedTradeTakeprofit + | IsolatedTradeFunding +) + + +__all__ = [ + "IsolatedTradeCanceled", + "IsolatedTradeCanceledPayload", + "IsolatedTradeClosed", + "IsolatedTradeClosedPayload", + "IsolatedTradeFilled", + "IsolatedTradeFilledPayload", + "IsolatedTradeFunding", + "IsolatedTradeFundingPayload", + "IsolatedTradeLiquidation", + "IsolatedTradeLiquidationPayload", + "IsolatedTradeOpen", + "IsolatedTradeOpenPayload", + "IsolatedTradeStoploss", + "IsolatedTradeStoplossPayload", + "IsolatedTradeTakeprofit", + "IsolatedTradeTakeprofitPayload", + "IsolatedTradesEvent", +] diff --git a/src/lnmarkets_sdk/stream/v1/models/lifecycle.py b/src/lnmarkets_sdk/stream/v1/models/lifecycle.py new file mode 100644 index 0000000..4106561 --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/models/lifecycle.py @@ -0,0 +1,39 @@ +"""Models for lifecycle RPC methods (hello / ping / time).""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +from lnmarkets_sdk.stream.v1._internal.models import BaseConfig + + +class HelloParams(BaseModel, BaseConfig): + """Input for `client.public.hello(params)`.""" + + client_name: str + client_version: str + + +class HelloResponse(BaseModel, BaseConfig): + """Result of the `hello` RPC method.""" + + version: Literal["1.0.0"] + + +PingResponse = Literal["pong"] + + +class TimeResponse(BaseModel, BaseConfig): + """Result of the `time` RPC method.""" + + time: int + + +__all__ = [ + "HelloParams", + "HelloResponse", + "PingResponse", + "TimeResponse", +] diff --git a/src/lnmarkets_sdk/stream/v1/models/ohlc.py b/src/lnmarkets_sdk/stream/v1/models/ohlc.py new file mode 100644 index 0000000..5b12b25 --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/models/ohlc.py @@ -0,0 +1,21 @@ +"""OHLC topic payload.""" + +from __future__ import annotations + +from pydantic import BaseModel + +from lnmarkets_sdk.stream.v1._internal.models import BaseConfig + + +class OhlcData(BaseModel, BaseConfig): + """Payload for `futures/inverse/{pair}/ohlc/{resolution}` topics.""" + + time: int + open: float + high: float + low: float + close: float + volume: float + + +__all__ = ["OhlcData"] diff --git a/src/lnmarkets_sdk/stream/v1/models/subscription.py b/src/lnmarkets_sdk/stream/v1/models/subscription.py new file mode 100644 index 0000000..333b89c --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/models/subscription.py @@ -0,0 +1,47 @@ +"""Models for the subscribe / unsubscribe / unsubscribeAll RPC methods.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from lnmarkets_sdk.stream.v1._internal.models import BaseConfig +from lnmarkets_sdk.stream.v1.models.common import Topic + + +class SubscribeParams(BaseModel, BaseConfig): + """Input for `client.subscription.subscribe(params)`.""" + + topics: list[Topic] = Field(..., min_length=1) + + +class SubscribeResponse(BaseModel, BaseConfig): + """Result of the `subscribe` RPC method.""" + + subscribed: list[Topic] + + +class UnsubscribeParams(BaseModel, BaseConfig): + """Input for `client.subscription.unsubscribe(params)`.""" + + topics: list[Topic] = Field(..., min_length=1) + + +class UnsubscribeResponse(BaseModel, BaseConfig): + """Result of the `unsubscribe` RPC method.""" + + unsubscribed: list[Topic] + + +class UnsubscribeAllResponse(BaseModel, BaseConfig): + """Result of the `unsubscribeAll` RPC method.""" + + unsubscribed: list[Topic] + + +__all__ = [ + "SubscribeParams", + "SubscribeResponse", + "UnsubscribeAllResponse", + "UnsubscribeParams", + "UnsubscribeResponse", +] diff --git a/src/lnmarkets_sdk/stream/v1/models/wallet.py b/src/lnmarkets_sdk/stream/v1/models/wallet.py new file mode 100644 index 0000000..3f5cf2d --- /dev/null +++ b/src/lnmarkets_sdk/stream/v1/models/wallet.py @@ -0,0 +1,37 @@ +"""Wallet topic payloads (deposit, withdrawal).""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +from lnmarkets_sdk.stream.v1._internal.models import BaseConfig + + +class WalletDepositData(BaseModel, BaseConfig): + """Payload for `wallet/deposit`.""" + + currency: Literal["btc"] + network: Literal["lightning", "bitcoin"] + id: str + amount: int + balance: int + status: str + tx_id: str + + +class WalletWithdrawData(BaseModel, BaseConfig): + """Payload for `wallet/withdrawal`.""" + + currency: Literal["btc"] + network: Literal["lightning", "bitcoin"] + id: str + amount: int + fee: int + balance: int + status: str + tx_id: str + + +__all__ = ["WalletDepositData", "WalletWithdrawData"] diff --git a/uv.lock b/uv.lock index 29489f7..e8459ad 100644 --- a/uv.lock +++ b/uv.lock @@ -124,45 +124,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/2a/04f9b13a25f7705e7900f18918258361fb29abf50fb29e3cdf76164927db/faker_crypto-1.0.1-py3-none-any.whl", hash = "sha256:96bd12a561c4c35070cdf8088a4099126d6752263fa4e4000051382197ed1058", size = 3635, upload-time = "2025-10-23T13:35:39.225Z" }, ] -[[package]] -name = "greenlet" -version = "3.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, - { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, - { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, - { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, - { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, - { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, - { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, - { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, - { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, - { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, - { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, - { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, - { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, - { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, - { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, - { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, - { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, - { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, - { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, - { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, - { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -220,19 +181,20 @@ wheels = [ [[package]] name = "lnmarkets-sdk" -version = "0.0.18" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "orjson" }, { name = "pydantic" }, { name = "requests" }, + { name = "websockets" }, ] [package.dev-dependencies] dev = [ { name = "faker" }, { name = "faker-crypto" }, - { name = "playwright" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -253,16 +215,18 @@ test = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, + { name = "orjson", specifier = ">=3.10.0" }, { name = "pydantic", specifier = ">=2.12.2" }, { name = "requests", specifier = ">=2.32.3" }, + { name = "websockets", specifier = ">=12.0" }, ] [package.metadata.requires-dev] dev = [ { name = "faker", specifier = ">=37.12.0" }, { name = "faker-crypto", specifier = ">=1.0.1" }, - { name = "playwright", specifier = ">=1.40.0" }, { name = "pyright", specifier = ">=1.1.390" }, + { name = "pyright", specifier = ">=1.1.407" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-httpx", specifier = ">=0.35.0" }, @@ -288,6 +252,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -297,25 +314,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "playwright" -version = "1.55.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet" }, - { name = "pyee" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/80/3a/c81ff76df266c62e24f19718df9c168f49af93cabdbc4608ae29656a9986/playwright-1.55.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034", size = 40428109, upload-time = "2025-08-28T15:46:20.357Z" }, - { url = "https://files.pythonhosted.org/packages/cf/f5/bdb61553b20e907196a38d864602a9b4a461660c3a111c67a35179b636fa/playwright-1.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", size = 38687254, upload-time = "2025-08-28T15:46:23.925Z" }, - { url = "https://files.pythonhosted.org/packages/4a/64/48b2837ef396487807e5ab53c76465747e34c7143fac4a084ef349c293a8/playwright-1.55.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", size = 40428108, upload-time = "2025-08-28T15:46:27.119Z" }, - { url = "https://files.pythonhosted.org/packages/08/33/858312628aa16a6de97839adc2ca28031ebc5391f96b6fb8fdf1fcb15d6c/playwright-1.55.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", size = 45905643, upload-time = "2025-08-28T15:46:30.312Z" }, - { url = "https://files.pythonhosted.org/packages/83/83/b8d06a5b5721931aa6d5916b83168e28bd891f38ff56fe92af7bdee9860f/playwright-1.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", size = 45296647, upload-time = "2025-08-28T15:46:33.221Z" }, - { url = "https://files.pythonhosted.org/packages/06/2e/9db64518aebcb3d6ef6cd6d4d01da741aff912c3f0314dadb61226c6a96a/playwright-1.55.0-py3-none-win32.whl", hash = "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", size = 35476046, upload-time = "2025-08-28T15:46:36.184Z" }, - { url = "https://files.pythonhosted.org/packages/46/4f/9ba607fa94bb9cee3d4beb1c7b32c16efbfc9d69d5037fa85d10cafc618b/playwright-1.55.0-py3-none-win_amd64.whl", hash = "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", size = 35476048, upload-time = "2025-08-28T15:46:38.867Z" }, - { url = "https://files.pythonhosted.org/packages/21/98/5ca173c8ec906abde26c28e1ecb34887343fd71cc4136261b90036841323/playwright-1.55.0-py3-none-win_arm64.whl", hash = "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", size = 31225543, upload-time = "2025-08-28T15:46:41.613Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -407,18 +405,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, ] -[[package]] -name = "pyee" -version = "13.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -430,15 +416,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.407" +version = "1.1.409" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, + { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, ] [[package]] @@ -580,3 +566,48 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599 wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +]