diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b064a37..a5307b6 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -36,3 +36,14 @@ jobs: - name: Run type checker (pyright) run: uv run pyright + + - name: Run tests + run: | + uv run --isolated --python=3.12 pytest + uv run --isolated --python=3.13 pytest + uv run --isolated --python=3.14 pytest + + env: + V3_API_KEY: ${{ secrets.V3_API_KEY }} + V3_API_KEY_SECRET: ${{ secrets.V3_API_KEY_SECRET }} + V3_API_KEY_PASSPHRASE: ${{ secrets.V3_API_KEY_PASSPHRASE }} diff --git a/README.md b/README.md index 51aa483..ffb0015 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This is the Python version of the LN Markets API SDK. It provides a client-based For public endpoints, you can just do this: ```python -from lnmarkets_sdk.client import LNMClient +from lnmarkets_sdk.http.client import LNMClient import asyncio async with LNMClient() as client: @@ -21,7 +21,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.client import APIAuthContext, APIClientConfig, LNMClient +from lnmarkets_sdk.http.client import APIAuthContext, APIClientConfig, LNMClient config = APIClientConfig( authentication=APIAuthContext( @@ -41,7 +41,7 @@ For endpoints that requires input parameters, you can find the corresponding mod ```python -from lnmarkets_sdk.client import APIAuthContext, APIClientConfig, LNMClient +from lnmarkets_sdk.http.client import APIAuthContext, APIClientConfig, LNMClient from lnmarkets_sdk.models.account import GetLightningDepositsParams config = APIClientConfig( diff --git a/examples/basic.py b/examples/basic.py new file mode 100644 index 0000000..364252a --- /dev/null +++ b/examples/basic.py @@ -0,0 +1,162 @@ +""" +Example usage of the v3 client-based API. +""" + +import asyncio +import os +from pprint import pprint + +from dotenv import load_dotenv + +from lnmarkets_sdk.http.client import APIAuthContext, APIClientConfig, LNMClient +from lnmarkets_sdk.models.account import GetLightningDepositsParams +from lnmarkets_sdk.models.futures_cross import ( + FuturesCrossOrderLimit, +) + +load_dotenv() + + +async def example_public_endpoints(): + """Example: Make public requests without authentication.""" + print("\n" + "=" * 80) + print("PUBLIC ENDPOINTS EXAMPLE") + print("=" * 80) + + # Create client without authentication for public endpoints + # The httpx.AsyncClient is created once and reuses connections + async with LNMClient(APIClientConfig(network="testnet4")) as client: + # All these requests share the same connection pool + print("\n🔄 Making multiple requests with connection reuse...") + + # Get futures ticker + ticker = await client.futures.get_ticker() + print("\n--- Futures Ticker ---") + pprint(ticker.model_dump(), indent=2, width=100) + + # Get leaderboard + await asyncio.sleep(1) + leaderboard = await client.futures.get_leaderboard() + print("\n--- Leaderboard (Top 3 Daily) ---") + pprint(leaderboard.daily[:3], indent=2, width=100) + + # Get oracle data + await asyncio.sleep(1) + oracle_index = await client.oracle.get_index() + print("\n--- Oracle Index (Latest) ---") + pprint(oracle_index[0].model_dump() if oracle_index else "No data", indent=2) + + # Get synthetic USD best price + await asyncio.sleep(1) + best_price = await client.synthetic_usd.get_best_price() + print("\n--- Synthetic USD Best Price ---") + pprint(best_price.model_dump(), indent=2) + + # Ping the API + await asyncio.sleep(1) + ping_response = await client.ping() + print("\n--- Ping ---") + print(f"Response: {ping_response}") + + +async def example_authenticated_endpoints(): + """Example: Use authenticated endpoints with credentials.""" + print("\n" + "=" * 80) + print("AUTHENTICATED ENDPOINTS EXAMPLE") + 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}") + + 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") + return + + # Create config with authentication and custom timeout + config = APIClientConfig( + authentication=APIAuthContext( + key=key, + secret=secret, + passphrase=passphrase, + ), + network="testnet4", + timeout=60.0, # 60 second timeout (default is 30s) + ) + + async with LNMClient(config) as client: + print("\n🔐 Using authenticated endpoints with connection pooling...") + + # Get account info + account = await client.account.get_account() + print("\n--- Account Info ---") + print(f"Username: {account.username}") + print(f"Balance: {account.balance} sats") + print(f"Synthetic USD Balance: {account.synthetic_usd_balance}") + + # Get Bitcoin address + btc_address = await client.account.get_bitcoin_address() + print("\n--- Bitcoin Deposit Address ---") + print(f"Address: {btc_address.address}") + + # Get lightning deposits (last 5) + deposits = await client.account.get_lightning_deposits( + GetLightningDepositsParams(from_="2022-01-01", limit=5) + ) + print(f"\n--- Recent Lightning Deposits (Last {len(deposits)}) ---") + for deposit in deposits: + print(f"Deposits {deposit.amount} sats at {deposit.created_at}") + + # Get running trades + running_trades = await client.futures.isolated.get_running_trades() + print("\n--- Running Isolated Trades ---") + print(f"Count: {len(running_trades)}") + for trade in running_trades[:3]: # Show first 3 + side = "LONG" if trade.side == "b" else "SHORT" + print( + f" {side} - Margin: {trade.margin} sats, Leverage: {trade.leverage}x, PL: {trade.pl} sats" + ) + + # Get cross margin position + try: + position = await client.futures.cross.get_position() + print("\n--- Cross Margin Position ---") + print(f"Quantity: {position.quantity}") + print(f"Margin: {position.margin} sats") + print(f"Leverage: {position.leverage}x") + print(f"Total PL: {position.total_pl} sats") + except Exception as e: + print(f"Error: {e}") + + # Open a new cross order + try: + print("\n--- Try to open a new cross order ---") + order_params = FuturesCrossOrderLimit( + type="limit", price=1.5, quantity=1, side="b" + ) + new_order = await client.futures.cross.new_order(order_params) + print(f"New order: {new_order}") + except Exception as e: + print(f"Error: {e}") + + +async def main(): + """Run all examples.""" + print("\n" + "=" * 80) + print("LN MARKETS V3 CLIENT EXAMPLES") + print("=" * 80) + + await example_public_endpoints() + await example_authenticated_endpoints() + + print("\n" + "=" * 80) + print("EXAMPLES COMPLETE") + print("=" * 80 + "\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 2cef7bf..0a8aea1 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.3" +version = "0.0.7" description = "LN Markets API Python SDK" readme = "README.md" license = { text = "MIT" } @@ -44,7 +44,7 @@ dev = [ "playwright>=1.40.0", ] lint = ["ruff>=0.12.0", "pyright>=1.1.390"] -test = ["pytest", "pytest-asyncio"] +test = ["pytest", "pytest-asyncio", "pytest-httpx>=0.35.0"] [tool.hatch.build.metadata] include = ["src/**", "README.md", "LICENSE"] @@ -78,3 +78,20 @@ ignore = [ [tool.ruff.format] quote-style = "double" indent-style = "space" + +[tool.pytest.ini_options] +# testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +addopts = [ + "--import-mode=importlib", +] +markers = [ + "asyncio: mark test as an async test", +] +filterwarnings = [ + "ignore::DeprecationWarning", +] diff --git a/src/lnmarkets_sdk/_internal/__init__.py b/src/lnmarkets_sdk/_internal/__init__.py index 785b709..461ca9b 100644 --- a/src/lnmarkets_sdk/_internal/__init__.py +++ b/src/lnmarkets_sdk/_internal/__init__.py @@ -52,32 +52,30 @@ async def request( if not self._client: raise RuntimeError("Client must be used within async context manager") - data = prepare_params(params) + params_dict = prepare_params(params) headers = {} if credentials: if not self.auth: raise ValueError("Authentication required but no credentials provided") - body_str = "" - if data: + data = "" + if params_dict: if method == "GET": - path = f"{path}?{urlencode({k: str(v) for k, v in data.items()})}" + data = f"?{urlencode({k: str(v) for k, v in params_dict.items()})}" else: - body_str = json.dumps(data, separators=(",", ":")) + data = json.dumps(params_dict, separators=(",", ":")) headers.update({"Content-Type": "application/json"}) - auth_headers = create_auth_headers( - self.auth, method, f"/v3{path}", body_str - ) + auth_headers = create_auth_headers(self.auth, method, f"/v3{path}", data) headers.update(auth_headers) # Use httpx native parameter handling if method == "GET": return await self._client.request( - method, path, params=data, headers=headers if headers else None + method, path, params=params_dict, headers=headers if headers else None ) else: return await self._client.request( - method, path, json=data, headers=headers if headers else None + method, path, json=params_dict, headers=headers if headers else None ) diff --git a/src/lnmarkets_sdk/_internal/models.py b/src/lnmarkets_sdk/_internal/models.py index 8f4353c..455159c 100644 --- a/src/lnmarkets_sdk/_internal/models.py +++ b/src/lnmarkets_sdk/_internal/models.py @@ -5,7 +5,7 @@ from pydantic.alias_generators import to_camel from pydantic.types import UUID4 -type APINetwork = Literal["mainnet", "testnet"] +type APINetwork = Literal["mainnet", "testnet4"] type APIMethod = Literal["GET", "POST", "PUT"] type UUID = UUID4 @@ -19,7 +19,7 @@ class BaseConfig: str_strip_whitespace=True, use_enum_values=True, alias_generator=to_camel, - populate_by_name=True, # to make `from_` field becomes `from` + validate_by_name=True, # to make `from_` field becomes `from` ) @@ -79,7 +79,8 @@ def __init__(self, message: str, status_code: int, response: httpx.Response): class FromToLimitParams(BaseModel, BaseConfig): from_: str | None = Field( default=None, - alias="from", + serialization_alias="from", + validation_alias="from", description="Start date as a string value in ISO format", ) limit: int = Field( diff --git a/src/lnmarkets_sdk/_internal/utils.py b/src/lnmarkets_sdk/_internal/utils.py index e3794f8..c901159 100644 --- a/src/lnmarkets_sdk/_internal/utils.py +++ b/src/lnmarkets_sdk/_internal/utils.py @@ -3,7 +3,6 @@ import hashlib import hmac import json -import os from base64 import b64encode from collections.abc import Mapping from datetime import datetime @@ -71,10 +70,9 @@ def create_auth_headers( def get_hostname(network: APINetwork) -> str: """Get API hostname based on network.""" - hostname = os.getenv("LNM_API_HOSTNAME") - if hostname: - return hostname - return "api.testnet.lnmarkets.com" if network == "testnet" else "api.lnmarkets.com" + return ( + "api.testnet4.lnmarkets.com" if network == "testnet4" else "api.lnmarkets.com" + ) def parse_response[T]( diff --git a/src/lnmarkets_sdk/http/__init__.py b/src/lnmarkets_sdk/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/lnmarkets_sdk/client/__init__.py b/src/lnmarkets_sdk/http/client/__init__.py similarity index 100% rename from src/lnmarkets_sdk/client/__init__.py rename to src/lnmarkets_sdk/http/client/__init__.py diff --git a/src/lnmarkets_sdk/client/account.py b/src/lnmarkets_sdk/http/client/account.py similarity index 85% rename from src/lnmarkets_sdk/client/account.py rename to src/lnmarkets_sdk/http/client/account.py index 64db870..e1b5d98 100644 --- a/src/lnmarkets_sdk/client/account.py +++ b/src/lnmarkets_sdk/http/client/account.py @@ -1,27 +1,27 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from lnmarkets_sdk.client import LNMClient + from lnmarkets_sdk.http.client import LNMClient from lnmarkets_sdk.models.account import ( Account, AddBitcoinAddressParams, - BitcoinAddressResponse, - BitcoinAddressWithTimestampResponse, - BitcoinDepositCondensed, + AddBitcoinAddressResponse, DepositLightningParams, DepositLightningResponse, + GetBitcoinAddressResponse, GetInternalDepositsParams, + GetInternalDepositsResponse, GetInternalWithdrawalsParams, + GetInternalWithdrawalsResponse, GetLightningDepositsParams, + GetLightningDepositsResponse, GetLightningWithdrawalsParams, + GetLightningWithdrawalsResponse, GetOnChainDepositsParams, + GetOnChainDepositsResponse, GetOnChainWithdrawalsParams, - InternalDepositCondensed, - InternalWithdrawalCondensed, - LightningDepositCondensed, - LightningWithdrawalCondensed, - OnChainWithdrawalCondensed, + GetOnChainWithdrawalsResponse, WithdrawInternalParams, WithdrawInternalResponse, WithdrawLightningParams, @@ -52,7 +52,7 @@ async def get_bitcoin_address(self): "GET", "/account/address/bitcoin", credentials=True, - response_model=BitcoinAddressResponse, + response_model=GetBitcoinAddressResponse, ) async def add_bitcoin_address(self, params: AddBitcoinAddressParams | None = None): @@ -62,7 +62,7 @@ async def add_bitcoin_address(self, params: AddBitcoinAddressParams | None = Non "/account/address/bitcoin", params=params, credentials=True, - response_model=BitcoinAddressWithTimestampResponse, + response_model=AddBitcoinAddressResponse, ) async def deposit_lightning(self, params: DepositLightningParams): @@ -114,7 +114,7 @@ async def get_lightning_deposits( "/account/deposits/lightning", params=params, credentials=True, - response_model=list[LightningDepositCondensed], + response_model=list[GetLightningDepositsResponse], ) async def get_lightning_withdrawals( @@ -126,7 +126,7 @@ async def get_lightning_withdrawals( "/account/withdrawals/lightning", params=params, credentials=True, - response_model=list[LightningWithdrawalCondensed], + response_model=list[GetLightningWithdrawalsResponse], ) async def get_internal_deposits( @@ -138,7 +138,7 @@ async def get_internal_deposits( "/account/deposits/internal", params=params, credentials=True, - response_model=list[InternalDepositCondensed], + response_model=list[GetInternalDepositsResponse], ) async def get_internal_withdrawals( @@ -150,7 +150,7 @@ async def get_internal_withdrawals( "/account/withdrawals/internal", params=params, credentials=True, - response_model=list[InternalWithdrawalCondensed], + response_model=list[GetInternalWithdrawalsResponse], ) async def get_on_chain_deposits( @@ -162,7 +162,7 @@ async def get_on_chain_deposits( "/account/deposits/bitcoin", params=params, credentials=True, - response_model=list[BitcoinDepositCondensed], + response_model=list[GetOnChainDepositsResponse], ) async def get_on_chain_withdrawals( @@ -174,5 +174,5 @@ async def get_on_chain_withdrawals( "/account/withdrawals/bitcoin", params=params, credentials=True, - response_model=list[OnChainWithdrawalCondensed], + response_model=list[GetOnChainWithdrawalsResponse], ) diff --git a/src/lnmarkets_sdk/client/futures/__init__.py b/src/lnmarkets_sdk/http/client/futures/__init__.py similarity index 90% rename from src/lnmarkets_sdk/client/futures/__init__.py rename to src/lnmarkets_sdk/http/client/futures/__init__.py index 3d075f2..105737d 100644 --- a/src/lnmarkets_sdk/client/futures/__init__.py +++ b/src/lnmarkets_sdk/http/client/futures/__init__.py @@ -1,9 +1,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from lnmarkets_sdk.client import LNMClient + from lnmarkets_sdk.http.client import LNMClient -from lnmarkets_sdk.models.funding_fees import FundingSettlementResponse +from lnmarkets_sdk.models.funding_fees import GetFundingSettlementsResponse from lnmarkets_sdk.models.futures_data import ( Candle, GetCandlesParams, @@ -61,5 +61,5 @@ async def get_funding_settlements( "/futures/funding-settlements", params=params, credentials=False, - response_model=FundingSettlementResponse, + response_model=GetFundingSettlementsResponse, ) diff --git a/src/lnmarkets_sdk/client/futures/cross.py b/src/lnmarkets_sdk/http/client/futures/cross.py similarity index 98% rename from src/lnmarkets_sdk/client/futures/cross.py rename to src/lnmarkets_sdk/http/client/futures/cross.py index 9301ed9..cec7f11 100644 --- a/src/lnmarkets_sdk/client/futures/cross.py +++ b/src/lnmarkets_sdk/http/client/futures/cross.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from lnmarkets_sdk.client import LNMClient + from lnmarkets_sdk.http.client import LNMClient from lnmarkets_sdk.models.funding_fees import FundingFees from lnmarkets_sdk.models.futures_cross import ( diff --git a/src/lnmarkets_sdk/client/futures/isolated.py b/src/lnmarkets_sdk/http/client/futures/isolated.py similarity index 98% rename from src/lnmarkets_sdk/client/futures/isolated.py rename to src/lnmarkets_sdk/http/client/futures/isolated.py index a58fbb8..a7f8edc 100644 --- a/src/lnmarkets_sdk/client/futures/isolated.py +++ b/src/lnmarkets_sdk/http/client/futures/isolated.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from lnmarkets_sdk.client import LNMClient + from lnmarkets_sdk.http.client import LNMClient from lnmarkets_sdk.models.funding_fees import FundingFees from lnmarkets_sdk.models.futures_isolated import ( diff --git a/src/lnmarkets_sdk/client/oracle.py b/src/lnmarkets_sdk/http/client/oracle.py similarity index 95% rename from src/lnmarkets_sdk/client/oracle.py rename to src/lnmarkets_sdk/http/client/oracle.py index 8f5ad9c..deca195 100644 --- a/src/lnmarkets_sdk/client/oracle.py +++ b/src/lnmarkets_sdk/http/client/oracle.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from lnmarkets_sdk.client import LNMClient + from lnmarkets_sdk.http.client import LNMClient from lnmarkets_sdk.models.oracle import ( GetIndexParams, diff --git a/src/lnmarkets_sdk/client/synthetic_usd.py b/src/lnmarkets_sdk/http/client/synthetic_usd.py similarity index 95% rename from src/lnmarkets_sdk/client/synthetic_usd.py rename to src/lnmarkets_sdk/http/client/synthetic_usd.py index 8a263f5..d5105ff 100644 --- a/src/lnmarkets_sdk/client/synthetic_usd.py +++ b/src/lnmarkets_sdk/http/client/synthetic_usd.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from lnmarkets_sdk.client import LNMClient + from lnmarkets_sdk.http.client import LNMClient from lnmarkets_sdk.models.synthetic_usd import ( BestPriceResponse, diff --git a/src/lnmarkets_sdk/models/account.py b/src/lnmarkets_sdk/models/account.py index 8c88def..bafe49a 100644 --- a/src/lnmarkets_sdk/models/account.py +++ b/src/lnmarkets_sdk/models/account.py @@ -12,15 +12,17 @@ class Account(BaseModel, BaseConfig): ) balance: float = Field(..., description="Balance of the user (in satoshis)") fee_tier: int = Field(..., description="Fee tier of the user") - email: str | None = Field(None, description="Email of the user") + email: str | None = Field(default=None, description="Email of the user") id: UUID = Field(..., description="Unique identifier for this account") - linking_public_key: str | None = Field(None, description="Public key of the user") + linking_public_key: str | None = Field( + default=None, description="Public key of the user" + ) -class BitcoinDepositCondensed(BaseModel, BaseConfig): +class GetOnChainDepositsResponse(BaseModel, BaseConfig): amount: float = Field(..., description="The amount of the deposit") block_height: int | None = Field( - None, description="The block height of the deposit" + default=None, description="The block height of the deposit" ) confirmations: int = Field( ..., description="The number of confirmations of the deposit" @@ -33,34 +35,36 @@ class BitcoinDepositCondensed(BaseModel, BaseConfig): tx_id: str = Field(..., description="The transaction ID of the deposit") -class InternalDepositCondensed(BaseModel, BaseConfig): +class GetInternalDepositsResponse(BaseModel, BaseConfig): amount: float = Field(..., description="Amount of the deposit (in satoshis)") created_at: str = Field(..., description="Timestamp when the deposit was created") from_username: str = Field(..., description="Username of the sender") id: UUID = Field(..., description="Unique identifier for this deposit") -class InternalWithdrawalCondensed(BaseModel, BaseConfig): +class GetInternalWithdrawalsResponse(BaseModel, BaseConfig): amount: float = Field(..., description="Amount of the transfer (in satoshis)") created_at: str = Field(..., description="Timestamp when the transfer was created") id: UUID = Field(..., description="Unique identifier for this transfer") to_username: str = Field(..., description="Username of the recipient") -class LightningDepositCondensed(BaseModel, BaseConfig): +class GetLightningDepositsResponse(BaseModel, BaseConfig): amount: float | None = Field( None, description="Amount of the deposit (in satoshis)" ) - comment: str | None = Field(None, description="Comment of the deposit") + comment: str | None = Field(default=None, description="Comment of the deposit") created_at: str = Field(..., description="Timestamp when the deposit was created") id: UUID = Field(..., description="Unique identifier for this deposit") - payment_hash: str | None = Field(None, description="Payment hash of the deposit") + payment_hash: str | None = Field( + default=None, description="Payment hash of the deposit" + ) settled_at: str | None = Field( - None, description="Timestamp when the deposit was settled" + default=None, description="Timestamp when the deposit was settled" ) -class LightningWithdrawalCondensed(BaseModel, BaseConfig): +class GetLightningWithdrawalsResponse(BaseModel, BaseConfig): amount: float = Field(..., description="Amount of the withdrawal (in satoshis)") created_at: str = Field( ..., description="Timestamp when the withdrawal was created" @@ -73,18 +77,22 @@ class LightningWithdrawalCondensed(BaseModel, BaseConfig): ) -class OnChainWithdrawalCondensed(BaseModel, BaseConfig): +class GetOnChainWithdrawalsResponse(BaseModel, BaseConfig): address: str = Field(..., description="Address to withdraw to") amount: float = Field(..., description="Amount to withdraw") created_at: str = Field( ..., description="Timestamp when the withdrawal was created" ) - fee: float | None = Field(None, description="Fee of the withdrawal (in satoshis)") + fee: float | None = Field( + default=None, description="Fee of the withdrawal (in satoshis)" + ) id: UUID = Field(..., description="Unique identifier for the withdrawal") status: Literal["canceled", "pending", "processed", "processing", "rejected"] = ( Field(..., description="Status of the withdrawal") ) - tx_id: str | None = Field(None, description="Transaction ID of the withdrawal") + tx_id: str | None = Field( + default=None, description="Transaction ID of the withdrawal" + ) class InternalTransfer(BaseModel, BaseConfig): @@ -137,11 +145,11 @@ class WithdrawOnChainResponse(BaseModel, BaseConfig): tx_id: None = None -class BitcoinAddressResponse(BaseModel, BaseConfig): +class GetBitcoinAddressResponse(BaseModel, BaseConfig): address: str = Field(..., description="Bitcoin address") -class BitcoinAddressWithTimestampResponse(BaseModel, BaseConfig): +class AddBitcoinAddressResponse(BaseModel, BaseConfig): address: str = Field(..., description="The generated Bitcoin address") created_at: str = Field(..., description="The creation time of the address") @@ -154,7 +162,7 @@ class AddBitcoinAddressParams(BaseModel, BaseConfig): class DepositLightningParams(BaseModel, BaseConfig): amount: int = Field(..., gt=0, description="Amount to deposit (in satoshis)") - comment: str | None = Field(None, description="Comment for the deposit") + comment: str | None = Field(default=None, description="Comment for the deposit") description_hash: str | None = Field( default=None, pattern=r"^[a-f0-9]{64}$", diff --git a/src/lnmarkets_sdk/models/funding_fees.py b/src/lnmarkets_sdk/models/funding_fees.py index 3f86e66..fd740bc 100644 --- a/src/lnmarkets_sdk/models/funding_fees.py +++ b/src/lnmarkets_sdk/models/funding_fees.py @@ -9,7 +9,7 @@ class FundingFees(BaseModel, BaseConfig): fee: float = Field(..., description="Funding fee amount") settlement_id: UUID = Field(..., description="Funding settlement ID") time: str = Field(..., description="Timestamp in ISO format") - trade_id: UUID | None = Field(None, description="Associated trade ID") + trade_id: UUID | None = Field(default=None, description="Associated trade ID") class FundingSettlement(BaseModel, BaseConfig): @@ -21,7 +21,7 @@ class FundingSettlement(BaseModel, BaseConfig): time: str = Field(..., description="Funding settlement time") -class FundingSettlementResponse(BaseModel, BaseConfig): +class GetFundingSettlementsResponse(BaseModel, BaseConfig): """Funding settlement response.""" data: list[FundingSettlement] = Field( diff --git a/src/lnmarkets_sdk/models/futures_cross.py b/src/lnmarkets_sdk/models/futures_cross.py index fd3946f..35b8ed0 100644 --- a/src/lnmarkets_sdk/models/futures_cross.py +++ b/src/lnmarkets_sdk/models/futures_cross.py @@ -71,12 +71,12 @@ class FuturesCrossCanceledOrder(BaseModel, BaseConfig): class FuturesCrossPosition(BaseModel, BaseConfig): delta_pl: float = Field(..., description="Delta P&L") - entry_price: float | None = Field(None, description="Entry price") + entry_price: float | None = Field(default=None, description="Entry price") funding_fees: float = Field(..., description="Funding fees") id: UUID = Field(..., description="Position ID") initial_margin: float = Field(..., description="Initial margin") leverage: int = Field(..., gt=0, description="Leverage") - liquidation: float | None = Field(None, description="Liquidation price") + liquidation: float | None = Field(default=None, description="Liquidation price") maintenance_margin: float = Field(..., description="Maintenance margin") margin: float = Field(..., description="Current margin") quantity: float = Field(..., description="Position quantity") diff --git a/src/lnmarkets_sdk/models/futures_data.py b/src/lnmarkets_sdk/models/futures_data.py index 2df6aae..71230a7 100644 --- a/src/lnmarkets_sdk/models/futures_data.py +++ b/src/lnmarkets_sdk/models/futures_data.py @@ -27,10 +27,10 @@ class PriceBucket(BaseModel, BaseConfig): """Price bucket for ticker.""" ask_price: float | None = Field( - None, description="Current best ask/sell price available (in USD)" + default=None, description="Current best ask/sell price available (in USD)" ) bid_price: float | None = Field( - None, description="Current best bid price available (in USD)" + default=None, description="Current best bid price available (in USD)" ) max_size: int = Field(..., description="Maximum order size (in BTC)") min_size: int = Field(..., description="Minimum order size (in BTC)") @@ -78,7 +78,9 @@ class UserInfo(BaseModel, BaseConfig): class Leaderboard(BaseModel, BaseConfig): """Futures leaderboard data.""" - all_time: list[UserInfo] = Field(alias="all-time") + all_time: list[UserInfo] = Field( + validation_alias="all-time", serialization_alias="all-time" + ) daily: list[UserInfo] monthly: list[UserInfo] weekly: list[UserInfo] @@ -86,7 +88,10 @@ class Leaderboard(BaseModel, BaseConfig): class GetCandlesParams(BaseModel, BaseConfig): from_: str = Field( - ..., alias="from", description="Start date as a string value in ISO format" + ..., + validation_alias="from", + serialization_alias="from", + description="Start date as a string value in ISO format", ) range: CandleResolution = Field( default="1m", description="Resolution of the OHLC candle" @@ -94,7 +99,9 @@ class GetCandlesParams(BaseModel, BaseConfig): limit: int = Field( default=100, ge=1, le=1000, description="Number of entries to return" ) - to: str | None = Field(None, description="End date as a string value in ISO format") + to: str | None = Field( + default=None, description="End date as a string value in ISO format" + ) class GetFundingSettlementsParams(FromToLimitParams): ... diff --git a/src/lnmarkets_sdk/models/futures_isolated.py b/src/lnmarkets_sdk/models/futures_isolated.py index f86a3f7..87291c1 100644 --- a/src/lnmarkets_sdk/models/futures_isolated.py +++ b/src/lnmarkets_sdk/models/futures_isolated.py @@ -22,8 +22,12 @@ class FuturesOrder(BaseModel, BaseConfig): multiple_of=0.5, description="Take profit price level (0 if not set)", ) - margin: int | None = Field(None, description="Margin of the position (in satoshis)") - quantity: int | None = Field(None, description="Quantity of the position (in USD)") + margin: int | None = Field( + default=None, description="Margin of the position (in satoshis)" + ) + quantity: int | None = Field( + default=None, description="Quantity of the position (in USD)" + ) price: float | None = Field( default=None, gt=0, multiple_of=0.5, description="Price of the limit order" ) diff --git a/src/lnmarkets_sdk/tests/test_integration.py b/src/lnmarkets_sdk/tests/test_integration.py new file mode 100644 index 0000000..d7477af --- /dev/null +++ b/src/lnmarkets_sdk/tests/test_integration.py @@ -0,0 +1,399 @@ +"""Integration tests for LNMarkets SDK v3""" + +import asyncio +import os + +import pytest +from dotenv import load_dotenv + +from lnmarkets_sdk.http.client import APIAuthContext, APIClientConfig, LNMClient +from lnmarkets_sdk.models.account import ( + AddBitcoinAddressParams, + DepositLightningParams, + GetInternalDepositsParams, + GetInternalWithdrawalsParams, + GetLightningDepositsParams, + GetLightningWithdrawalsParams, + GetOnChainDepositsParams, + WithdrawInternalParams, + WithdrawLightningParams, + WithdrawOnChainParams, +) +from lnmarkets_sdk.models.futures_isolated import FuturesOrder + +load_dotenv() + + +# Add delay between tests to avoid rate limiting +@pytest.fixture +async def public_rate_limit_delay(): + """Add delay between tests for public endpoints to avoid rate limiting.""" + await asyncio.sleep(0.5) # 0.5s delay between tests (2 requests per second) + + +@pytest.fixture +async def auth_rate_limit_delay(): + """Add delay between tests for authentication endpoints to avoid rate limiting.""" + await asyncio.sleep(0.1) # 0.1s delay between tests (10 requests per second) + + +def create_public_config() -> APIClientConfig: + """Create config for testnet4.""" + return APIClientConfig(network="testnet4") + + +def create_auth_config() -> APIClientConfig: + """Create authenticated config for testnet4.""" + return APIClientConfig( + network="testnet4", + authentication=APIAuthContext( + key=os.environ.get("V3_API_KEY", "test-key"), + secret=os.environ.get("V3_API_KEY_SECRET", "test-secret"), + passphrase=os.environ.get("V3_API_KEY_PASSPHRASE", "test-passphrase"), + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("public_rate_limit_delay") +class TestBasicsIntegration: + """Integration tests for basic API endpoints.""" + + async def test_ping(self): + async with LNMClient(create_public_config()) as client: + result = await client.ping() + assert "pong" in result + + async def test_time(self): + async with LNMClient(create_public_config()) as client: + result = await client.request("GET", "/time") + assert "time" in result + assert isinstance(result["time"], str) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("auth_rate_limit_delay") +class TestAccountIntegration: + """Integration tests for account endpoints (require authentication).""" + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_API_KEY not set in environment", + ) + async def test_get_account(self): + async with LNMClient(create_auth_config()) as client: + account = await client.account.get_account() + assert account.balance >= 0 + assert isinstance(account.email, str) + assert isinstance(account.username, str) + assert account.fee_tier >= 0 + assert account.id is not None + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_API_KEY not set in environment", + ) + async def test_get_bitcoin_address(self): + async with LNMClient(create_auth_config()) as client: + result = await client.account.get_bitcoin_address() + assert result.address is not None + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_API_KEY not set in environment", + ) + async def test_add_bitcoin_address(self): + async with LNMClient(create_auth_config()) as client: + params = AddBitcoinAddressParams(format="p2wpkh") + try: + result = await client.account.add_bitcoin_address(params) + assert result.address is not None + assert result.created_at is not None + except Exception as e: + assert ( + "You have too many unused addresses. Please use one of them." + in str(e) + ) + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_API_KEY not set in environment", + ) + async def test_deposit_lightning(self): + async with LNMClient(create_auth_config()) as client: + params = DepositLightningParams(amount=100_000) + result = await client.account.deposit_lightning(params) + assert result.deposit_id is not None + assert result.payment_request.startswith("ln") + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_API_KEY not set in environment", + ) + async def test_withdraw_lightning(self): + async with LNMClient(create_auth_config()) as client: + params = WithdrawLightningParams(invoice="test_invoice") + try: + result = await client.account.withdraw_lightning(params) + assert result.id is not None + assert result.amount is not None + assert result.max_fees is not None + except Exception as e: + assert "Send a correct BOLT 11 invoice" in str(e) + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_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("V3_API_KEY"), + reason="V3_API_KEY not set in environment", + ) + async def test_withdraw_on_chain(self): + async with LNMClient(create_auth_config()) as client: + params = WithdrawOnChainParams(amount=100_000, address="test_address") + try: + result = await client.account.withdraw_on_chain(params) + assert result.id is not None + assert result.amount is not None + assert result.created_at is not None + except Exception as e: + assert "Invalid address" in str(e) + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_API_KEY not set in environment", + ) + async def test_get_lightning_deposits(self): + async with LNMClient(create_auth_config()) as client: + params = GetLightningDepositsParams(limit=2) + result = await client.account.get_lightning_deposits(params) + assert len(result) <= params.limit + if len(result) > 0: + assert result[0].id is not None + assert result[0].created_at is not None + assert result[0].amount is not None + assert result[0].comment is None + assert result[0].settled_at is None + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_API_KEY not set in environment", + ) + async def test_get_lightning_withdrawals(self): + async with LNMClient(create_auth_config()) as client: + params = GetLightningWithdrawalsParams(limit=2) + result = await client.account.get_lightning_withdrawals(params) + assert len(result) <= params.limit + if len(result) > 0: + assert result[0].id is not None + assert result[0].created_at is not None + assert result[0].amount is not None + assert result[0].fee is not None + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_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) + assert len(result) <= params.limit + if len(result) > 0: + assert result[0].id is not None + assert result[0].created_at is not None + assert result[0].amount is not None + assert result[0].from_username is not None + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_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) + assert len(result) <= params.limit + if len(result) > 0: + assert result[0].id is not None + assert result[0].created_at is not None + assert result[0].amount is not None + assert result[0].to_username is not None + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_API_KEY not set in environment", + ) + async def test_get_on_chain_deposits(self): + async with LNMClient(create_auth_config()) as client: + params = GetOnChainDepositsParams(limit=2) + try: + result = await client.account.get_on_chain_deposits(params) + assert len(result) <= params.limit + if len(result) > 0: + assert result[0].id is not None + assert result[0].created_at is not None + assert result[0].amount is not None + assert result[0].block_height is not None + except Exception as e: + assert "HTTP 404: Not found" in str(e) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("public_rate_limit_delay") +class TestFuturesIntegration: + """Integration tests for futures data endpoints.""" + + async def test_get_ticker(self): + async with LNMClient(create_public_config()) as client: + ticker = await client.futures.get_ticker() + assert ticker.index > 0 + assert ticker.last_price > 0 + + async def test_get_leaderboard(self): + async with LNMClient(create_public_config()) as client: + leaderboard = await client.futures.get_leaderboard() + assert isinstance(leaderboard.daily, list) + + async def test_get_candles(self): + from lnmarkets_sdk.models.futures_data import GetCandlesParams + + async with LNMClient(create_public_config()) as client: + params = GetCandlesParams( + from_="2023-05-23T09:52:57.863Z", range="1m", limit=1 + ) + candles = await client.futures.get_candles(params) + assert isinstance(candles, list) + assert len(candles) > 0 + assert candles[0].open > 0 + assert candles[0].high > 0 + assert candles[0].low > 0 + assert candles[0].close > 0 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("auth_rate_limit_delay") +class TestFuturesIsolatedIntegration: + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_API_KEY not set in environment", + ) + async def test_futures_isolated(self): + async with LNMClient(create_auth_config()) as client: + # Create a new trade + params = FuturesOrder( + type="l", # limit order + side="b", # buy + price=100_000, + quantity=1, + leverage=100, + ) + trade = await client.futures.isolated.new_trade(params) + assert trade.id is not None + assert trade.side == "b" + assert trade.type == "l" + assert trade.leverage == 100 + + # Get open trades + open_trades = await client.futures.isolated.get_open_trades() + assert isinstance(open_trades, list) + # Our trade should be in the list + trade_ids = [t.id for t in open_trades] + assert trade.id in trade_ids + + # Cancel the trade + from lnmarkets_sdk.models.futures_isolated import CancelTradeParams + + cancel_params = CancelTradeParams(id=trade.id) + canceled = await client.futures.isolated.cancel(cancel_params) + assert canceled.id == trade.id + assert canceled.canceled is True + + +@pytest.mark.asyncio +class TestFuturesCrossIntegration: + """Integration tests for cross margin futures.""" + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_API_KEY not set in environment", + ) + async def test_get_position(self): + async with LNMClient(create_auth_config()) as client: + position = await client.futures.cross.get_position() + assert position.margin >= 0 + assert position.leverage > 0 + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_API_KEY not set in environment", + ) + async def test_cross_orders(self): + async with LNMClient(create_auth_config()) as client: + # Get open orders + open_orders = await client.futures.cross.get_open_orders() + assert isinstance(open_orders, list) + + # Get filled orders + from lnmarkets_sdk.models.futures_cross import GetFilledOrdersParams + + params = GetFilledOrdersParams(limit=5) + filled_orders = await client.futures.cross.get_filled_orders(params) + assert isinstance(filled_orders, list) + + +@pytest.mark.asyncio +class TestOracleIntegration: + """Integration tests for oracle endpoints.""" + + async def test_get_last_price(self): + async with LNMClient(create_public_config()) as client: + result = await client.oracle.get_last_price() + assert result[0].last_price > 0 + assert result[0].time is not None + + async def test_get_index(self): + from lnmarkets_sdk.models.oracle import GetIndexParams + + async with LNMClient(create_public_config()) as client: + params = GetIndexParams(limit=5) + result = await client.oracle.get_index(params) + assert isinstance(result, list) + assert len(result) > 0 + assert result[0].index > 0 + + +@pytest.mark.asyncio +class TestSyntheticUSDIntegration: + """Integration tests for synthetic USD endpoints.""" + + async def test_get_best_price(self): + async with LNMClient(create_public_config()) as client: + result = await client.synthetic_usd.get_best_price() + assert result.ask_price + + @pytest.mark.skipif( + not os.environ.get("V3_API_KEY"), + reason="V3_API_KEY not set in environment", + ) + async def test_get_swaps(self): + from lnmarkets_sdk.models.synthetic_usd import GetSwapsParams + + async with LNMClient(create_auth_config()) as client: + params = GetSwapsParams(limit=5) + result = await client.synthetic_usd.get_swaps(params) + assert isinstance(result, list) diff --git a/uv.lock b/uv.lock index ead6a73..d79998a 100644 --- a/uv.lock +++ b/uv.lock @@ -196,7 +196,7 @@ wheels = [ [[package]] name = "lnmarkets-sdk" -version = "0.0.3" +version = "0.0.7" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -210,6 +210,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-httpx" }, { name = "python-dotenv" }, { name = "ruff" }, ] @@ -220,6 +221,7 @@ lint = [ test = [ { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-httpx" }, ] [package.metadata] @@ -235,6 +237,7 @@ dev = [ { name = "pyright", specifier = ">=1.1.390" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-httpx", specifier = ">=0.35.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "ruff", specifier = ">=0.12.0" }, ] @@ -245,6 +248,7 @@ lint = [ test = [ { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-httpx", specifier = ">=0.35.0" }, ] [[package]] @@ -438,6 +442,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, ] +[[package]] +name = "pytest-httpx" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/89/5b12b7b29e3d0af3a4b9c071ee92fa25a9017453731a38f08ba01c280f4c/pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f", size = 54146, upload-time = "2024-11-28T19:16:54.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/ed/026d467c1853dd83102411a78126b4842618e86c895f93528b0528c7a620/pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744", size = 19442, upload-time = "2024-11-28T19:16:52.787Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1"