├── tests
├── __init__.py
└── test_client.py
├── luno_python
├── __init__.py
├── error.py
├── base_client.py
└── client.py
├── setup.cfg
├── .gitignore
├── CLAUDE.md
├── renovate.json5
├── .github
└── workflows
│ └── test.yml
├── pyproject.toml
├── README.md
├── LICENSE.txt
├── setup.py
├── CONTRIBUTING.md
├── examples
└── readonly.py
├── .pre-commit-config.yaml
└── AGENTS.md
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | """Test package for luno-python."""
2 |
--------------------------------------------------------------------------------
/luno_python/__init__.py:
--------------------------------------------------------------------------------
1 | """Luno Python SDK."""
2 |
3 | VERSION = "0.0.10"
4 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [aliases]
2 | test=pytest
3 |
4 | [tool:pytest]
5 | addopts=tests/
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | venv/
2 | *.pyc
3 | requirements.txt
4 | __pycache__/
5 | .pytest_cache/
6 | build/
7 | dist/
8 | *.egg-info/
9 | .eggs/
10 | .coverage
11 | .idea/
12 | /env/
13 | /.DS_Store
14 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | For compatibility with multiple agents, the instructions are located at @AGENTS.md (see [AGENTS.md](./AGENTS.md).
6 |
--------------------------------------------------------------------------------
/renovate.json5:
--------------------------------------------------------------------------------
1 | {
2 | $schema: 'https://docs.renovatebot.com/renovate-schema.json',
3 | extends: [
4 | 'config:recommended',
5 | ],
6 | branchConcurrentLimit: 3,
7 | labels: [
8 | 'Bot::Renovate',
9 | ],
10 | semanticCommits: 'disabled',
11 | commitMessagePrefix: 'renovate:',
12 | reviewers: [
13 | 'echarrod',
14 | 'adamhicks',
15 | ],
16 | }
17 |
--------------------------------------------------------------------------------
/luno_python/error.py:
--------------------------------------------------------------------------------
1 | """Luno API error classes."""
2 |
3 |
4 | class APIError(Exception):
5 | """Exception raised for Luno API errors."""
6 |
7 | def __init__(self, code, message):
8 | """Initialise APIError with code and message.
9 |
10 | :param code: Error code from the API
11 | :type code: str
12 | :param message: Error message from the API
13 | :type message: str
14 | """
15 | self.code = code
16 | self.message = message
17 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run Tests
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | jobs:
10 | build:
11 |
12 | runs-on: ubuntu-latest
13 | strategy:
14 | fail-fast: false
15 | matrix:
16 | python-version: [ "3.12", "3.13" ]
17 |
18 | steps:
19 |
20 | - uses: actions/checkout@v5
21 |
22 | - name: Set up Python ${{ matrix.python-version }}
23 | uses: actions/setup-python@v6
24 | with:
25 | python-version: ${{ matrix.python-version }}
26 |
27 | - name: Install dependencies
28 | run: |
29 | python -m pip install --upgrade pip setuptools wheel
30 | pip install .[test]
31 |
32 | - name: Test with pytest
33 | run: |
34 | pytest
35 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.black]
2 | line-length = 120
3 | target-version = ['py37', 'py38', 'py39', 'py310', 'py311']
4 | include = '\.pyi?$'
5 | exclude = '''
6 | /(
7 | \.git
8 | | \.venv
9 | | env
10 | | build
11 | | dist
12 | | \.egg-info
13 | )/
14 | '''
15 |
16 | [tool.isort]
17 | profile = "black"
18 | line_length = 120
19 | skip_gitignore = true
20 |
21 | [tool.bandit]
22 | exclude_dirs = ["tests", "env", "build"]
23 | skips = [
24 | "B101", # Skip assert_used check (common in tests)
25 | "B107", # Skip hardcoded_password_default (empty string defaults are acceptable for optional credentials)
26 | ]
27 |
28 | [tool.pytest.ini_options]
29 | testpaths = ["tests"]
30 | python_files = "test_*.py"
31 | python_classes = "Test*"
32 | python_functions = "test_*"
33 | addopts = "-v --cov=luno_python --cov-report=term-missing"
34 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Luno API [](https://travis-ci.org/luno/luno-python)
4 |
5 | This Python package provides a wrapper for the [Luno API](https://www.luno.com/api).
6 |
7 | ### Installation
8 |
9 | ```
10 | pip install luno-python
11 | ```
12 |
13 | ### Authentication
14 |
15 | Please visit the [Settings](https://www.luno.com/wallet/settings/api_keys) page
16 | to generate an API key.
17 |
18 | ### Example usage
19 |
20 | ```python
21 | from luno_python.client import Client
22 |
23 | c = Client(api_key_id='key_id', api_key_secret='key_secret')
24 | try:
25 | res = c.get_ticker(pair='XBTZAR')
26 | print res
27 | except Exception as e:
28 | print e
29 | ```
30 |
31 | ### License
32 |
33 | [MIT](https://github.com/luno/luno-python/blob/master/LICENSE.txt)
34 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Luno
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | """Setup script for luno-python package."""
2 |
3 | from setuptools import find_packages, setup
4 |
5 | from luno_python import VERSION
6 |
7 | setup(
8 | name="luno-python",
9 | version=VERSION,
10 | packages=find_packages(exclude=["tests"]),
11 | description="A Luno API client for Python",
12 | author="Neil Garb",
13 | author_email="neil@luno.com",
14 | install_requires=["requests>=2.18.4", "six>=1.11.0"],
15 | license="MIT",
16 | url="https://github.com/luno/luno-python",
17 | download_url=f"https://github.com/luno/luno-python/tarball/{VERSION}",
18 | keywords="Luno API Bitcoin Ethereum",
19 | test_suite="tests",
20 | setup_requires=["pytest-runner"],
21 | extras_require={
22 | "test": ["pytest", "pytest-cov", "requests_mock"],
23 | "dev": [
24 | "pytest",
25 | "pytest-cov",
26 | "requests_mock",
27 | "pre-commit",
28 | "black",
29 | "isort",
30 | "flake8",
31 | "flake8-docstrings",
32 | "flake8-bugbear",
33 | "bandit[toml]",
34 | ],
35 | },
36 | )
37 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Clone
4 |
5 | ```bash
6 | git clone https://github.com/luno/luno-python.git
7 | ```
8 |
9 | ## Create Virtual env
10 |
11 | ```bash
12 | cd luno-python
13 | python -m venv env
14 | source env/bin/activate
15 | ```
16 |
17 | ## Install Dependencies
18 |
19 | ```bash
20 | python -m pip install --upgrade pip setuptools wheel
21 | pip install -e '.[dev]'
22 | ```
23 |
24 | This installs the package in editable mode with all development dependencies including testing tools and pre-commit hooks.
25 |
26 | ## Set Up Pre-commit Hooks
27 |
28 | This project uses [pre-commit](https://pre-commit.com/) to maintain code quality and consistency. The hooks run automatically before commits and pushes.
29 |
30 | ### Install the git hook scripts
31 |
32 | ```bash
33 | pre-commit install
34 | ```
35 |
36 | This will run code formatting, linting, security checks, and tests on every commit.
37 |
38 | ### Run hooks manually
39 |
40 | To run all hooks on all files manually:
41 |
42 | ```bash
43 | pre-commit run --all-files
44 | ```
45 |
46 | ### What the hooks do
47 |
48 | - **Code formatting**: Automatically formats code with `black` and sorts imports with `isort`
49 | - **Linting**: Checks code quality with `flake8`
50 | - **Security**: Scans for common security issues with `bandit`
51 | - **File checks**: Fixes trailing whitespace, ensures files end with newlines, validates YAML/JSON
52 | - **Tests**: Runs the full test suite (via `pytest`)
53 |
54 | ### Skip hooks (use sparingly)
55 |
56 | If you need to skip hooks for a specific commit:
57 |
58 | ```bash
59 | git commit --no-verify
60 | ```
61 |
62 | ## Run Tests
63 |
64 | ```bash
65 | pytest
66 | ```
67 |
--------------------------------------------------------------------------------
/examples/readonly.py:
--------------------------------------------------------------------------------
1 | """Example script demonstrating read-only API calls to Luno."""
2 |
3 | import os
4 | import time
5 |
6 | from luno_python.client import Client
7 |
8 | if __name__ == "__main__":
9 | c = Client(api_key_id=os.getenv("LUNO_API_KEY_ID"), api_key_secret=os.getenv("LUNO_API_KEY_SECRET"))
10 |
11 | res = c.get_tickers()
12 | print(res)
13 | time.sleep(0.5)
14 |
15 | res = c.get_ticker(pair="XBTZAR")
16 | print(res)
17 | time.sleep(0.5)
18 |
19 | res = c.get_order_book(pair="XBTZAR")
20 | print(res)
21 | time.sleep(0.5)
22 |
23 | since = int(time.time() * 1000) - 24 * 60 * 59 * 1000
24 | res = c.list_trades(pair="XBTZAR", since=since)
25 | print(res)
26 | time.sleep(0.5)
27 |
28 | res = c.get_candles(pair="XBTZAR", since=since, duration=300)
29 | print(res)
30 | time.sleep(0.5)
31 |
32 | res = c.get_balances()
33 | print(res)
34 | time.sleep(0.5)
35 |
36 | aid = ""
37 | if res["balance"]:
38 | aid = res["balance"][0]["account_id"]
39 |
40 | if aid:
41 | res = c.list_transactions(id=aid, min_row=1, max_row=10)
42 | print(res)
43 | time.sleep(0.5)
44 |
45 | if aid:
46 | res = c.list_pending_transactions(id=aid)
47 | print(res)
48 | time.sleep(0.5)
49 |
50 | res = c.list_orders()
51 | print(res)
52 | time.sleep(0.5)
53 |
54 | res = c.list_user_trades(pair="XBTZAR")
55 | print(res)
56 | time.sleep(0.5)
57 |
58 | res = c.get_fee_info(pair="XBTZAR")
59 | print(res)
60 | time.sleep(0.5)
61 |
62 | res = c.get_funding_address(asset="XBT")
63 | print(res)
64 | time.sleep(0.5)
65 |
66 | res = c.list_withdrawals()
67 | print(res)
68 | time.sleep(0.5)
69 |
70 | wid = ""
71 | if res["withdrawals"]:
72 | wid = res["withdrawals"][0]["id"]
73 |
74 | if wid:
75 | res = c.get_withdrawal(id=wid)
76 | print(res)
77 | time.sleep(0.5)
78 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | # Pre-commit hooks for luno-python
2 | # See https://pre-commit.com for more information
3 | repos:
4 | # General file checks
5 | - repo: https://github.com/pre-commit/pre-commit-hooks
6 | rev: v4.6.0
7 | hooks:
8 | - id: trailing-whitespace
9 | args: [--markdown-linebreak-ext=md]
10 | - id: end-of-file-fixer
11 | - id: check-yaml
12 | - id: check-json
13 | - id: check-added-large-files
14 | args: ['--maxkb=500']
15 | - id: check-merge-conflict
16 | - id: check-case-conflict
17 | - id: mixed-line-ending
18 | args: ['--fix=lf']
19 | - id: detect-private-key
20 |
21 | # Python code formatting
22 | - repo: https://github.com/psf/black
23 | rev: 24.10.0
24 | hooks:
25 | - id: black
26 | language_version: python3
27 | args: ['--line-length=120']
28 |
29 | # Import sorting
30 | - repo: https://github.com/pycqa/isort
31 | rev: 5.13.2
32 | hooks:
33 | - id: isort
34 | args: ['--profile=black', '--line-length=120']
35 |
36 | # Linting
37 | - repo: https://github.com/pycqa/flake8
38 | rev: 7.1.1
39 | hooks:
40 | - id: flake8
41 | args: ['--max-line-length=120', '--extend-ignore=E203,W503']
42 | additional_dependencies: [flake8-docstrings, flake8-bugbear]
43 |
44 | # Security checks
45 | - repo: https://github.com/PyCQA/bandit
46 | rev: 1.7.10
47 | hooks:
48 | - id: bandit
49 | args: ['-c', 'pyproject.toml']
50 | additional_dependencies: ['bandit[toml]']
51 |
52 | # Python upgrade syntax
53 | - repo: https://github.com/asottile/pyupgrade
54 | rev: v3.19.0
55 | hooks:
56 | - id: pyupgrade
57 | args: [--py37-plus]
58 |
59 | # Check for common Python bugs
60 | - repo: https://github.com/pre-commit/pygrep-hooks
61 | rev: v1.10.0
62 | hooks:
63 | - id: python-check-blanket-noqa
64 | - id: python-check-blanket-type-ignore
65 | - id: python-use-type-annotations
66 |
67 | # Tests
68 | - repo: local
69 | hooks:
70 | - id: pytest
71 | name: pytest
72 | entry: env/bin/pytest
73 | args: ['-v', '--override-ini=addopts=']
74 | language: system
75 | pass_filenames: false
76 | always_run: true
77 |
--------------------------------------------------------------------------------
/luno_python/base_client.py:
--------------------------------------------------------------------------------
1 | """Base HTTP client for Luno API."""
2 |
3 | import json
4 | import platform
5 |
6 | import requests
7 |
8 | try:
9 | from json.decoder import JSONDecodeError
10 | except ImportError:
11 | JSONDecodeError = ValueError
12 |
13 | from . import VERSION
14 | from .error import APIError
15 |
16 | DEFAULT_BASE_URL = "https://api.luno.com"
17 | DEFAULT_TIMEOUT = 10
18 | PYTHON_VERSION = platform.python_version()
19 | SYSTEM = platform.system()
20 | ARCH = platform.machine()
21 |
22 |
23 | class BaseClient:
24 | """Base HTTP client for making authenticated requests to the Luno API."""
25 |
26 | def __init__(self, base_url="", timeout=0, api_key_id="", api_key_secret=""):
27 | """Initialise the base client.
28 |
29 | :type base_url: str
30 | :type timeout: float
31 | :type api_key_id: str
32 | :type api_key_secret: str
33 | """
34 | self.set_auth(api_key_id, api_key_secret)
35 | self.set_base_url(base_url)
36 | self.set_timeout(timeout)
37 |
38 | self.session = requests.Session()
39 |
40 | def set_auth(self, api_key_id, api_key_secret):
41 | """Set the API key and secret for authentication.
42 |
43 | :type api_key_id: str
44 | :type api_key_secret: str
45 | """
46 | self.api_key_id = api_key_id
47 | self.api_key_secret = api_key_secret
48 |
49 | def set_base_url(self, base_url):
50 | """Set the base URL for API requests.
51 |
52 | :type base_url: str
53 | """
54 | if base_url == "":
55 | base_url = DEFAULT_BASE_URL
56 | self.base_url = base_url.rstrip("/")
57 |
58 | def set_timeout(self, timeout):
59 | """Set the timeout in seconds for API requests.
60 |
61 | :type timeout: float
62 | """
63 | if timeout == 0:
64 | timeout = DEFAULT_TIMEOUT
65 | self.timeout = timeout
66 |
67 | def do(self, method, path, req=None, auth=False):
68 | """Perform an API request and return the response.
69 |
70 | TODO: Handle 429s.
71 |
72 | :type method: str
73 | :type path: str
74 | :type req: object
75 | :type auth: bool
76 | """
77 | if req is None:
78 | params = None
79 | else:
80 | try:
81 | params = json.loads(json.dumps(req))
82 | except TypeError as e:
83 | msg = "luno: request parameters must be JSON-serializable: %s"
84 | raise TypeError(msg % str(e)) from e
85 | headers = {"User-Agent": self.make_user_agent()}
86 | args = dict(timeout=self.timeout, params=params, headers=headers)
87 | if auth:
88 | args["auth"] = (self.api_key_id, self.api_key_secret)
89 | url = self.make_url(path, params)
90 | res = self.session.request(method, url, **args)
91 | try:
92 | e = res.json()
93 | if "error" in e and "error_code" in e:
94 | raise APIError(e["error_code"], e["error"])
95 | return e
96 | except JSONDecodeError:
97 | raise Exception("luno: unknown API error (%s)" % res.status_code)
98 |
99 | def make_url(self, path, params):
100 | """Construct the full URL for an API request.
101 |
102 | :type path: str
103 | :rtype: str
104 | """
105 | if params:
106 | for k, v in params.items():
107 | path = path.replace("{" + k + "}", str(v))
108 | return self.base_url + "/" + path.lstrip("/")
109 |
110 | def make_user_agent(self):
111 | """Generate the User-Agent string for API requests.
112 |
113 | :rtype: str
114 | """
115 | return f"LunoPythonSDK/{VERSION} python/{PYTHON_VERSION} {SYSTEM} {ARCH}"
116 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # AGENTS.md
2 |
3 | This file provides guidance to agents (e.g. Claude Code / GitHub CoPilot) when working with code in this repository.
4 |
5 | ## Project Overview
6 |
7 | This is the **Luno Python SDK**, a wrapper for the Luno API (cryptocurrency exchange platform). The package provides a Python client for interacting with Luno's REST API, supporting operations like account management, trading, fund transfers, and market data queries.
8 |
9 | ## Architecture Overview
10 |
11 | ### Core Structure
12 |
13 | - **`luno_python/base_client.py`**: Base HTTP client class (`BaseClient`) that handles:
14 | - HTTP requests using the `requests` library
15 | - Basic auth with API key and secret
16 | - Error handling and JSON parsing
17 | - User-Agent generation
18 | - URL construction with parameter substitution
19 |
20 | - **`luno_python/client.py`**: Main `Client` class extending `BaseClient`. Contains ~100+ API methods auto-generated from the Luno API specification. Each method:
21 | - Wraps a specific API endpoint
22 | - Constructs the request object with parameters
23 | - Calls `do()` to make the HTTP request
24 | - Returns the API response as a dictionary
25 |
26 | - **`luno_python/error.py`**: Custom `APIError` exception class for handling API errors
27 |
28 | - **`luno_python/__init__.py`**: Exports package version
29 |
30 | ### Request/Response Flow
31 |
32 | 1. User calls a method on `Client` (e.g., `get_ticker(pair='XBTZAR')`)
33 | 2. Method builds a request dict with parameters
34 | 3. Method calls `self.do(method, path, req, auth)` from `BaseClient`
35 | 4. `do()` makes HTTP request via `requests.Session`
36 | 5. `do()` parses JSON response and checks for API error codes
37 | 6. If error found, raises `APIError`; otherwise returns response dict
38 |
39 | ### API Design Patterns
40 |
41 | - **Authentication**: HTTP Basic Auth with `api_key_id` and `api_key_secret`
42 | - **Path parameters**: Substituted using `{param_name}` syntax in paths
43 | - **Query parameters**: Passed via `params` in GET requests
44 | - **Error handling**: API returns `{"error_code": "...", "error": "..."}` on errors
45 |
46 | ## Development Commands
47 |
48 | ### Setup
49 |
50 | ```bash
51 | # Create and activate virtual environment
52 | python -m venv env
53 | source env/bin/activate # On Windows: env\Scripts\activate
54 |
55 | # Install package in development mode with test dependencies
56 | pip install -e '.[test]'
57 | ```
58 |
59 | ### Testing
60 |
61 | ```bash
62 | # Run all tests
63 | pytest
64 |
65 | # Run specific test file
66 | pytest tests/test_client.py
67 |
68 | # Run specific test
69 | pytest tests/test_client.py::test_client_do_basic
70 |
71 | # Run with verbose output
72 | pytest -v
73 |
74 | # Run with coverage
75 | pytest --cov=luno_python
76 | ```
77 |
78 | ### Dependencies
79 |
80 | - **Runtime**: `requests>=2.18.4`, `six>=1.11.0`
81 | - **Test**: `pytest`, `pytest-cov`, `requests_mock`
82 |
83 | ## Testing Approach
84 |
85 | Tests use `requests_mock` to mock HTTP responses. The pattern:
86 |
87 | 1. Create a `Client` instance
88 | 2. Mount mock adapter to session: `adapter.register_uri(method, url, ...)`
89 | 3. Call client method and assert response
90 |
91 | Recent additions test the `get_balances()` method with `account_id` parameter for filtering results by account.
92 |
93 | ## Git Workflow
94 |
95 | When making changes:
96 |
97 | 1. Run `git pull` before creating a new branch
98 | 2. Branch naming: `{username}-{issue-number}-{description}`
99 | 3. Commit messages: Present tense, describe *why* not *what*
100 | 4. Example: `"client: Add account_id parameter to get_balances method"`
101 | 5. Push and create PR when ready
102 |
103 | ## Notes on Code Generation
104 |
105 | The `client.py` file contains ~100+ methods that are auto-generated from Luno's API specification. When modifying or adding methods:
106 |
107 | - Follow existing docstring format (includes HTTP method, path, permissions, parameter descriptions)
108 | - Each method constructs a `req` dict with parameters and calls `self.do()`
109 | - Type hints in docstrings use `:type param: type_name` format for Python 2 compatibility
110 |
111 | ## File Editing Requirements
112 |
113 | Always ensure files end with a newline character. This maintains consistency with Git diffs and repository standards.
114 |
--------------------------------------------------------------------------------
/tests/test_client.py:
--------------------------------------------------------------------------------
1 | """Tests for the Luno Python client."""
2 |
3 | from decimal import Decimal
4 |
5 | import pytest
6 | import requests_mock
7 |
8 | try:
9 | from json.decoder import JSONDecodeError
10 | except ImportError:
11 | JSONDecodeError = ValueError
12 |
13 | from luno_python.client import Client
14 | from luno_python.error import APIError
15 |
16 | # Mock response fixtures
17 | MOCK_BALANCES_RESPONSE = {
18 | "balance": [
19 | {
20 | "account_id": "12345678910",
21 | "asset": "XBT",
22 | "balance": "0.00",
23 | "reserved": "0.00",
24 | "unconfirmed": "0.00",
25 | },
26 | {
27 | "account_id": "98765432100",
28 | "asset": "ETH",
29 | "balance": "1.50",
30 | "reserved": "0.10",
31 | "unconfirmed": "0.05",
32 | },
33 | {
34 | "account_id": "55555555555",
35 | "asset": "ZAR",
36 | "balance": "1000.00",
37 | "reserved": "0.00",
38 | "unconfirmed": "0.00",
39 | },
40 | ]
41 | }
42 |
43 | MOCK_BALANCES_RESPONSE_TWO_ACCOUNTS = {
44 | "balance": [
45 | {
46 | "account_id": "12345678910",
47 | "asset": "XBT",
48 | "balance": "0.00",
49 | "reserved": "0.00",
50 | "unconfirmed": "0.00",
51 | },
52 | {
53 | "account_id": "98765432100",
54 | "asset": "ETH",
55 | "balance": "1.50",
56 | "reserved": "0.10",
57 | "unconfirmed": "0.05",
58 | },
59 | ]
60 | }
61 |
62 | MOCK_BALANCES_RESPONSE_INTEGER_IDS = {
63 | "balance": [
64 | {
65 | "account_id": 12345678910,
66 | "asset": "XBT",
67 | "balance": "0.00",
68 | "reserved": "0.00",
69 | "unconfirmed": "0.00",
70 | },
71 | {
72 | "account_id": 98765432100,
73 | "asset": "ETH",
74 | "balance": "1.50",
75 | "reserved": "0.10",
76 | "unconfirmed": "0.05",
77 | },
78 | ]
79 | }
80 |
81 | MOCK_EMPTY_BALANCE_RESPONSE = {"balance": []}
82 |
83 | MOCK_MALFORMED_RESPONSE = {"some_other_key": "value"}
84 |
85 |
86 | def test_client():
87 | """Test client initialization and configuration."""
88 | c = Client()
89 | c.set_auth("api_key_id", "api_key_secret")
90 | c.set_base_url("base_url")
91 | c.set_timeout(10)
92 |
93 | assert c.api_key_id == "api_key_id"
94 | assert c.api_key_secret == "api_key_secret"
95 | assert c.base_url == "base_url"
96 | assert c.timeout == 10
97 |
98 |
99 | def test_client_do_basic():
100 | """Test basic client do method functionality."""
101 | c = Client()
102 | c.set_base_url("mock://test/")
103 |
104 | adapter = requests_mock.Adapter()
105 | c.session.mount("mock", adapter)
106 |
107 | adapter.register_uri("GET", "mock://test/", text="ok")
108 | with pytest.raises(Exception, match="unknown API error"):
109 | res = c.do("GET", "/")
110 |
111 | adapter.register_uri("GET", "mock://test/", text='{"key":"value"}')
112 | res = c.do("GET", "/")
113 | assert res["key"] == "value"
114 |
115 | adapter.register_uri("GET", "mock://test/", text="{}", status_code=400)
116 | res = c.do("GET", "/") # no exception, because no error present
117 |
118 | adapter.register_uri("GET", "mock://test/", text='{"error_code":"code","error":"message"}', status_code=400)
119 | with pytest.raises(APIError) as e:
120 | res = c.do("GET", "/")
121 | assert e.value.code == "code"
122 | assert e.value.message == "message"
123 |
124 |
125 | def test_get_balances_without_account_id():
126 | """Test get_balances without account_id parameter (backward compatibility)."""
127 | c = Client()
128 | c.set_base_url("mock://test/")
129 |
130 | adapter = requests_mock.Adapter()
131 | c.session.mount("mock", adapter)
132 |
133 | adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_BALANCES_RESPONSE)
134 |
135 | # Test without account_id - should return full response
136 | result = c.get_balances()
137 | assert result == MOCK_BALANCES_RESPONSE
138 | assert "balance" in result
139 | assert len(result["balance"]) == 3
140 |
141 |
142 | def test_get_balances_with_valid_account_id():
143 | """Test get_balances with valid account_id parameter."""
144 | c = Client()
145 | c.set_base_url("mock://test/")
146 |
147 | adapter = requests_mock.Adapter()
148 | c.session.mount("mock", adapter)
149 |
150 | adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_BALANCES_RESPONSE)
151 |
152 | # Test with valid account_id - should return single account
153 | result = c.get_balances(account_id="12345678910")
154 | expected = {
155 | "account_id": "12345678910",
156 | "asset": "XBT",
157 | "balance": "0.00",
158 | "reserved": "0.00",
159 | "unconfirmed": "0.00",
160 | }
161 | assert result == expected
162 |
163 | # Test with another valid account_id
164 | result = c.get_balances(account_id="98765432100")
165 | expected = {
166 | "account_id": "98765432100",
167 | "asset": "ETH",
168 | "balance": "1.50",
169 | "reserved": "0.10",
170 | "unconfirmed": "0.05",
171 | }
172 | assert result == expected
173 |
174 |
175 | def test_get_balances_with_invalid_account_id():
176 | """Test get_balances with invalid account_id parameter."""
177 | c = Client()
178 | c.set_base_url("mock://test/")
179 |
180 | adapter = requests_mock.Adapter()
181 | c.session.mount("mock", adapter)
182 |
183 | adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_BALANCES_RESPONSE_TWO_ACCOUNTS)
184 |
185 | # Test with invalid account_id - should return None
186 | result = c.get_balances(account_id="99999999999")
187 | assert result is None
188 |
189 |
190 | def test_get_balances_with_account_id_and_assets():
191 | """Test get_balances with both account_id and assets parameters."""
192 | c = Client()
193 | c.set_base_url("mock://test/")
194 |
195 | adapter = requests_mock.Adapter()
196 | c.session.mount("mock", adapter)
197 |
198 | adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_BALANCES_RESPONSE_TWO_ACCOUNTS)
199 |
200 | # Test with both parameters
201 | result = c.get_balances(assets=["XBT"], account_id="12345678910")
202 | expected = {
203 | "account_id": "12345678910",
204 | "asset": "XBT",
205 | "balance": "0.00",
206 | "reserved": "0.00",
207 | "unconfirmed": "0.00",
208 | }
209 | assert result == expected
210 |
211 |
212 | def test_get_balances_with_account_id_type_conversion():
213 | """Test get_balances with account_id type conversion (string vs int)."""
214 | c = Client()
215 | c.set_base_url("mock://test/")
216 |
217 | adapter = requests_mock.Adapter()
218 | c.session.mount("mock", adapter)
219 |
220 | adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_BALANCES_RESPONSE_INTEGER_IDS)
221 |
222 | # Test with string account_id when API returns integer - should work due to type conversion
223 | result = c.get_balances(account_id="12345678910")
224 | expected = {
225 | "account_id": 12345678910,
226 | "asset": "XBT",
227 | "balance": "0.00",
228 | "reserved": "0.00",
229 | "unconfirmed": "0.00",
230 | }
231 | assert result == expected
232 |
233 |
234 | def test_get_balances_with_empty_balance_response():
235 | """Test get_balances when API returns empty balance list."""
236 | c = Client()
237 | c.set_base_url("mock://test/")
238 |
239 | adapter = requests_mock.Adapter()
240 | c.session.mount("mock", adapter)
241 |
242 | adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_EMPTY_BALANCE_RESPONSE)
243 |
244 | # Test with account_id on empty response
245 | result = c.get_balances(account_id="12345678910")
246 | assert result is None
247 |
248 | # Test without account_id on empty response
249 | result = c.get_balances()
250 | assert result == MOCK_EMPTY_BALANCE_RESPONSE
251 |
252 |
253 | def test_get_balances_with_malformed_response():
254 | """Test get_balances when API returns malformed response."""
255 | c = Client()
256 | c.set_base_url("mock://test/")
257 |
258 | adapter = requests_mock.Adapter()
259 | c.session.mount("mock", adapter)
260 |
261 | adapter.register_uri("GET", "mock://test/api/1/balance", json=MOCK_MALFORMED_RESPONSE)
262 |
263 | # Test with account_id on malformed response
264 | result = c.get_balances(account_id="12345678910")
265 | assert result is None
266 |
267 | # Test without account_id on malformed response
268 | result = c.get_balances()
269 | assert result == MOCK_MALFORMED_RESPONSE
270 |
271 |
272 | def test_client_do_with_non_serializable_params():
273 | """Test that non-JSON-serializable parameters raise a clear error."""
274 | c = Client()
275 | c.set_base_url("mock://test/")
276 |
277 | # Test with Decimal (not JSON-serializable)
278 | with pytest.raises(TypeError) as exc_info:
279 | c.do("GET", "/", req={"amount": Decimal("10.5")})
280 | assert "JSON-serializable" in str(exc_info.value)
281 |
282 | # Test with custom object (not JSON-serializable)
283 | class CustomObject:
284 | pass
285 |
286 | with pytest.raises(TypeError) as exc_info:
287 | c.do("GET", "/", req={"obj": CustomObject()})
288 | assert "JSON-serializable" in str(exc_info.value)
289 |
290 | # Test with None request (should not raise)
291 | adapter = requests_mock.Adapter()
292 | c.session.mount("mock", adapter)
293 | adapter.register_uri("GET", "mock://test/", json={"result": "ok"})
294 | result = c.do("GET", "/", req=None)
295 | assert result["result"] == "ok"
296 |
--------------------------------------------------------------------------------
/luno_python/client.py:
--------------------------------------------------------------------------------
1 | """Luno API client implementation."""
2 |
3 | from .base_client import BaseClient
4 |
5 |
6 | class Client(BaseClient):
7 | """
8 | Python SDK for the Luno API.
9 |
10 | Example usage:
11 |
12 | from luno_python.client import Client
13 |
14 |
15 | c = Client(api_key_id='key_id', api_key_secret='key_secret')
16 | try:
17 | res = c.get_ticker(pair='XBTZAR')
18 | print res
19 | except Exception as e:
20 | print e
21 | """
22 |
23 | def cancel_withdrawal(self, id):
24 | """Make a call to DELETE /api/1/withdrawals/{id}.
25 |
26 | Cancel a withdrawal request.
27 | This can only be done if the request is still in state PENDING.
28 |
29 | Permissions required: Perm_W_Withdrawals
30 |
31 | :param id: ID of the withdrawal to cancel.
32 | :type id: int
33 | """
34 | req = {
35 | "id": id,
36 | }
37 | return self.do("DELETE", "/api/1/withdrawals/{id}", req=req, auth=True)
38 |
39 | def create_account(self, currency, name):
40 | """Make a call to POST /api/1/accounts.
41 |
42 | This request creates an Account for the specified currency. Please note that the balances for the Account will
43 | be displayed based on the asset value, which is the currency the Account is based on.
44 |
45 | Permissions required: Perm_W_Addresses
46 |
47 | :param currency: The currency code for the Account you want to create. Please see the Currency section for a
48 | detailed list of currencies supported by the Luno platform.
49 |
50 | Users must be verified to trade currency in order to be able to create an Account. For more
51 | information on the verification process, please see
52 | How do I verify my identity?.
53 |
54 | Users have a limit of 10 accounts per currency.
55 | :type currency: str
56 | :param name: The label to use for this account
57 | :type name: str
58 | """
59 | req = {
60 | "currency": currency,
61 | "name": name,
62 | }
63 | return self.do("POST", "/api/1/accounts", req=req, auth=True)
64 |
65 | def create_beneficiary(self, account_type, bank_account_number, bank_name, bank_recipient):
66 | """Make a call to POST /api/1/beneficiaries.
67 |
68 | Create a new beneficiary.
69 |
70 | Permissions required: Perm_W_Beneficiaries
71 |
72 | :param account_type: Bank account type
73 | :type account_type: str
74 | :param bank_account_number: Beneficiary bank account number
75 | :type bank_account_number: str
76 | :param bank_name: Bank SWIFT code
77 | :type bank_name: str
78 | :param bank_recipient: The owner of the recipient account
79 | :type bank_recipient: str
80 | """
81 | req = {
82 | "account_type": account_type,
83 | "bank_account_number": bank_account_number,
84 | "bank_name": bank_name,
85 | "bank_recipient": bank_recipient,
86 | }
87 | return self.do("POST", "/api/1/beneficiaries", req=req, auth=True)
88 |
89 | def create_funding_address(self, asset, account_id=None, name=None):
90 | """Make a call to POST /api/1/funding_address.
91 |
92 | Allocate a new receive address to your account. There is a rate limit of 1
93 | address per hour, but bursts of up to 10 addresses are allowed. Only 1
94 | Ethereum receive address can be created.
95 |
96 | Permissions required: Perm_W_Addresses
97 |
98 | :param asset: Currency code of the asset.
99 | :type asset: str
100 | :param account_id: An optional account_id to assign the new Receive Address too
101 | :type account_id: int
102 | :param name: An optional name for the new Receive Address
103 | :type name: str
104 | """
105 | req = {
106 | "asset": asset,
107 | "account_id": account_id,
108 | "name": name,
109 | }
110 | return self.do("POST", "/api/1/funding_address", req=req, auth=True)
111 |
112 | def create_withdrawal(self, amount, type, beneficiary_id=None, external_id=None, fast=None, reference=None):
113 | """Make a call to POST /api/1/withdrawals.
114 |
115 | Create a new withdrawal request to the specified beneficiary.
116 |
117 | Permissions required: Perm_W_Withdrawals
118 |
119 | :param amount: Amount to withdraw. The currency withdrawn depends on the type setting.
120 | :type amount: float
121 | :param type: Withdrawal method.
122 | :type type: str
123 | :param beneficiary_id: The beneficiary ID of the bank account the withdrawal will be paid out to.
124 | This parameter is required if the user has set up multiple beneficiaries.
125 | The beneficiary ID can be found by selecting on the beneficiary name on the user's
126 | Beneficiaries page.
127 | :type beneficiary_id: int
128 | :param external_id: Optional unique ID to associate with this withdrawal.
129 | Useful to prevent duplicate sends.
130 | This field supports all alphanumeric characters including "-" and "_".
131 | :type external_id: str
132 | :param fast: If true, it will be a fast withdrawal if possible. Fast withdrawals come with a fee.
133 | Currently fast withdrawals are only available for `type=ZAR_EFT`; for other types, an error is
134 | returned. Fast withdrawals are not possible for Bank of Baroda, Deutsche Bank, Merrill Lynch South
135 | Africa, UBS, Postbank and Tyme Bank.
136 | The fee to be charged is the same as when withdrawing from the UI.
137 | :type fast: bool
138 | :param reference: For internal use.
139 | Deprecated: We don't allow custom references and will remove this soon.
140 | :type reference: str
141 | """
142 | req = {
143 | "amount": amount,
144 | "type": type,
145 | "beneficiary_id": beneficiary_id,
146 | "external_id": external_id,
147 | "fast": fast,
148 | "reference": reference,
149 | }
150 | return self.do("POST", "/api/1/withdrawals", req=req, auth=True)
151 |
152 | def delete_beneficiary(self, id):
153 | """Make a call to DELETE /api/1/beneficiaries/{id}.
154 |
155 | Delete a beneficiary
156 |
157 | Permissions required: Perm_W_Beneficiaries
158 |
159 | :param id: ID of the Beneficiary to delete.
160 | :type id: int
161 | """
162 | req = {
163 | "id": id,
164 | }
165 | return self.do("DELETE", "/api/1/beneficiaries/{id}", req=req, auth=True)
166 |
167 | def get_balances(self, assets=None, account_id=None):
168 | """Make a call to GET /api/1/balance.
169 |
170 | The list of all Accounts and their respective balances for the requesting user.
171 |
172 | Permissions required: Perm_R_Balance
173 |
174 | :param assets: Only return balances for wallets with these currencies (if not provided,
175 | all balances will be returned). To request balances for multiple currencies,
176 | pass the parameter multiple times,
177 | e.g. `assets=XBT&assets=ETH`.
178 | :type assets: list
179 | :param account_id: Only return balance for the account with this ID. If provided,
180 | returns a single account object instead of the full response.
181 | :type account_id: str
182 | """
183 | req = {
184 | "assets": assets,
185 | }
186 | response = self.do("GET", "/api/1/balance", req=req, auth=True)
187 |
188 | # If account_id is specified, filter to return only that account
189 | if account_id is not None:
190 | if "balance" in response:
191 | for account in response["balance"]:
192 | if str(account.get("account_id")) == str(account_id):
193 | return account
194 | # If account_id not found, return None
195 | return None
196 |
197 | # Return full response if no account_id specified (backward compatibility)
198 | return response
199 |
200 | def get_candles(self, duration, pair, since):
201 | """Make a call to GET /api/exchange/1/candles.
202 |
203 | Get candlestick market data from the specified time until now, from the oldest to the most recent.
204 |
205 | Permissions required: MP_None
206 |
207 | :param duration: Candle duration in seconds.
208 | For example, 300 corresponds to 5m candles. Currently supported
209 | durations are: 60 (1m), 300 (5m), 900 (15m), 1800 (30m), 3600 (1h),
210 | 10800 (3h), 14400 (4h), 28800 (8h), 86400 (24h), 259200 (3d), 604800
211 | (7d).
212 | :type duration: int
213 | :param pair: Currency pair
214 | :type pair: str
215 | :param since: Filter to candles starting on or after this timestamp (Unix milliseconds).
216 | Only up to 1000 of the earliest candles are returned.
217 | :type since: int
218 | """
219 | req = {
220 | "duration": duration,
221 | "pair": pair,
222 | "since": since,
223 | }
224 | return self.do("GET", "/api/exchange/1/candles", req=req, auth=True)
225 |
226 | def get_fee_info(self, pair):
227 | """Make a call to GET /api/1/fee_info.
228 |
229 | Return the fees and 30 day trading volume (as of midnight) for a given currency pair. For complete details,
230 | please see Fees & Features.
231 |
232 | Permissions required: Perm_R_Orders
233 |
234 | :param pair: Get fee information about this pair.
235 | :type pair: str
236 | """
237 | req = {
238 | "pair": pair,
239 | }
240 | return self.do("GET", "/api/1/fee_info", req=req, auth=True)
241 |
242 | def get_funding_address(self, asset, address=None):
243 | """Make a call to GET /api/1/funding_address.
244 |
245 | Return the default receive address associated with your account and the
246 | amount received via the address. Users can specify an optional address parameter to return information for a
247 | non-default receive address.
248 | In the response, total_received is the total confirmed amount received excluding unconfirmed
249 | transactions. total_unconfirmed is the total sum of unconfirmed receive transactions.
250 |
251 | Permissions required: Perm_R_Addresses
252 |
253 | :param asset: Currency code of the asset.
254 | :type asset: str
255 | :param address: Specific cryptocurrency address to retrieve. If not provided, the
256 | default address will be used.
257 | :type address: str
258 | """
259 | req = {
260 | "asset": asset,
261 | "address": address,
262 | }
263 | return self.do("GET", "/api/1/funding_address", req=req, auth=True)
264 |
265 | def get_move(self, client_move_id=None, id=None):
266 | """Make a call to GET /api/exchange/1/move.
267 |
268 | Get a specific move funds instruction by either id or
269 | client_move_id. If both are provided an API error will be
270 | returned.
271 |
272 | Permissions required: MP_None
273 |
274 | :param client_move_id: Get by the user defined ID. This is mutually exclusive with id and is
275 | required if id is not provided.
276 | :type client_move_id: str
277 | :param id: Get by the system ID. This is mutually exclusive with client_move_id and is required if
278 | client_move_id is not provided.
279 | :type id: str
280 | """
281 | req = {
282 | "client_move_id": client_move_id,
283 | "id": id,
284 | }
285 | return self.do("GET", "/api/exchange/1/move", req=req, auth=True)
286 |
287 | def get_order(self, id):
288 | """Make a call to GET /api/1/orders/{id}.
289 |
290 | Get an Order's details by its ID.
291 |
292 | Permissions required: Perm_R_Orders
293 |
294 | :param id: Order reference
295 | :type id: str
296 | """
297 | req = {
298 | "id": id,
299 | }
300 | return self.do("GET", "/api/1/orders/{id}", req=req, auth=True)
301 |
302 | def get_order_book(self, pair):
303 | """Make a call to GET /api/1/orderbook_top.
304 |
305 | This request returns the best 100 `bids` and `asks`, for the currency pair specified, in the Order Book.
306 |
307 | `asks` are sorted by price ascending and `bids` are sorted by price descending.
308 |
309 | Multiple orders at the same price are aggregated.
310 |
311 | :param pair: Currency pair of the Orders to retrieve
312 | :type pair: str
313 | """
314 | req = {
315 | "pair": pair,
316 | }
317 | return self.do("GET", "/api/1/orderbook_top", req=req, auth=False)
318 |
319 | def get_order_book_full(self, pair):
320 | """Make a call to GET /api/1/orderbook.
321 |
322 | This request returns all `bids` and `asks`, for the currency pair specified, in the Order Book.
323 |
324 | `asks` are sorted by price ascending and `bids` are sorted by price descending.
325 |
326 | Multiple orders at the same price are not aggregated.
327 |
328 | WARNING: This may return a large amount of data.
329 | Users are recommended to use the top 100 bids and asks
330 | or the Streaming API.
331 |
332 | :param pair: Currency pair of the Orders to retrieve
333 | :type pair: str
334 | """
335 | req = {
336 | "pair": pair,
337 | }
338 | return self.do("GET", "/api/1/orderbook", req=req, auth=False)
339 |
340 | def get_order_v2(self, id):
341 | """Make a call to GET /api/exchange/2/orders/{id}.
342 |
343 | Get the details for an order.
344 |
345 | Permissions required: Perm_R_Orders
346 |
347 | :param id: Order reference
348 | :type id: str
349 | """
350 | req = {
351 | "id": id,
352 | }
353 | return self.do("GET", "/api/exchange/2/orders/{id}", req=req, auth=True)
354 |
355 | def get_order_v3(self, client_order_id=None, id=None):
356 | """Make a call to GET /api/exchange/3/order.
357 |
358 | Get the details for an order by order reference or client order ID.
359 | Exactly one of the two parameters must be provided, otherwise an error is returned.
360 | Permissions required: Perm_R_Orders
361 |
362 | :param client_order_id: Client Order ID has the value that was passed in when the Order was posted.
363 | :type client_order_id: str
364 | :param id: Order reference
365 | :type id: str
366 | """
367 | req = {
368 | "client_order_id": client_order_id,
369 | "id": id,
370 | }
371 | return self.do("GET", "/api/exchange/3/order", req=req, auth=True)
372 |
373 | def get_ticker(self, pair):
374 | """Make a call to GET /api/1/ticker.
375 |
376 | Return the latest ticker indicators for the specified currency pair.
377 |
378 | Please see the Currency list for the complete list of supported currency pairs.
379 |
380 | :param pair: Currency pair
381 | :type pair: str
382 | """
383 | req = {
384 | "pair": pair,
385 | }
386 | return self.do("GET", "/api/1/ticker", req=req, auth=False)
387 |
388 | def get_tickers(self, pair=None):
389 | """Make a call to GET /api/1/tickers.
390 |
391 | Return the latest ticker indicators from all active Luno exchanges.
392 |
393 | Please see the Currency list for the complete list of supported currency pairs.
394 |
395 | :param pair: Return tickers for multiple markets (if not provided, all tickers will be returned).
396 | To request tickers for multiple markets, pass the parameter multiple times,
397 | e.g. `pair=XBTZAR&pair=ETHZAR`.
398 | :type pair: list
399 | """
400 | req = {
401 | "pair": pair,
402 | }
403 | return self.do("GET", "/api/1/tickers", req=req, auth=False)
404 |
405 | def get_withdrawal(self, id):
406 | """Make a call to GET /api/1/withdrawals/{id}.
407 |
408 | Return the status of a particular withdrawal request.
409 |
410 | Permissions required: Perm_R_Withdrawals
411 |
412 | :param id: Withdrawal ID to retrieve.
413 | :type id: int
414 | """
415 | req = {
416 | "id": id,
417 | }
418 | return self.do("GET", "/api/1/withdrawals/{id}", req=req, auth=True)
419 |
420 | def list_beneficiaries(self, bank_recipient=None):
421 | """Make a call to GET /api/1/beneficiaries.
422 |
423 | Return a list of bank beneficiaries.
424 |
425 | Permissions required: Perm_R_Beneficiaries
426 |
427 | :param bank_recipient: :type bank_recipient: str
428 | """
429 | req = {
430 | "bank_recipient": bank_recipient,
431 | }
432 | return self.do("GET", "/api/1/beneficiaries", req=req, auth=True)
433 |
434 | def list_moves(self, before=None, limit=None):
435 | """Make a call to GET /api/exchange/1/move/list_moves.
436 |
437 | Return a list of the most recent moves ordered from newest to oldest.
438 | This endpoint will list up to 100 most recent moves by default.
439 |
440 | Permissions required: MP_None
441 |
442 | :param before: Filter to moves requested before this timestamp (Unix milliseconds)
443 | :type before: int
444 | :param limit: Limit to this many moves
445 | :type limit: int
446 | """
447 | req = {
448 | "before": before,
449 | "limit": limit,
450 | }
451 | return self.do("GET", "/api/exchange/1/move/list_moves", req=req, auth=True)
452 |
453 | def list_orders(self, created_before=None, limit=None, pair=None, state=None):
454 | """Make a call to GET /api/1/listorders.
455 |
456 | Return a list of the most recently placed Orders.
457 | Users can specify an optional state=PENDING parameter to restrict the results to only open Orders.
458 | Users can also specify the market by using the optional currency pair parameter.
459 |
460 | Permissions required: Perm_R_Orders
461 |
462 | :param created_before: Filter to orders created before this timestamp (Unix milliseconds)
463 | :type created_before: int
464 | :param limit: Limit to this many orders
465 | :type limit: int
466 | :param pair: Filter to only orders of this currency pair
467 | :type pair: str
468 | :param state: Filter to only orders of this state
469 | :type state: str
470 | """
471 | req = {
472 | "created_before": created_before,
473 | "limit": limit,
474 | "pair": pair,
475 | "state": state,
476 | }
477 | return self.do("GET", "/api/1/listorders", req=req, auth=True)
478 |
479 | def list_orders_v2(self, closed=None, created_before=None, limit=None, pair=None):
480 | """Make a call to GET /api/exchange/2/listorders.
481 |
482 | Return a list of the most recently placed orders ordered from newest to
483 | oldest. This endpoint will list up to 100 most recent open orders by
484 | default.
485 |
486 | Please note: This data is archived 100 days after an exchange order is completed.
487 |
488 | Permissions required: Perm_R_Orders
489 |
490 | :param closed: If true, will return closed orders instead of open orders.
491 | :type closed: bool
492 | :param created_before: Filter to orders created before this timestamp (Unix milliseconds)
493 | :type created_before: int
494 | :param limit: Limit to this many orders
495 | :type limit: int
496 | :param pair: Filter to only orders of this currency pair.
497 | :type pair: str
498 | """
499 | req = {
500 | "closed": closed,
501 | "created_before": created_before,
502 | "limit": limit,
503 | "pair": pair,
504 | }
505 | return self.do("GET", "/api/exchange/2/listorders", req=req, auth=True)
506 |
507 | def list_pending_transactions(self, id):
508 | """Make a call to GET /api/1/accounts/{id}/pending.
509 |
510 | Return a list of all transactions that have not completed for the Account.
511 |
512 | Pending transactions are not numbered, and may be reordered, deleted or updated at any time.
513 |
514 | Permissions required: Perm_R_Transactions
515 |
516 | :param id: Account ID
517 | :type id: int
518 | """
519 | req = {
520 | "id": id,
521 | }
522 | return self.do("GET", "/api/1/accounts/{id}/pending", req=req, auth=True)
523 |
524 | def list_trades(self, pair, since=None):
525 | """Make a call to GET /api/1/trades.
526 |
527 | Return a list of recent trades for the specified currency pair. At most
528 | 100 trades are returned per call and never trades older than 24h. The
529 | trades are sorted from newest to oldest.
530 |
531 | Please see the Currency list for the complete list of supported currency pairs.
532 |
533 | :param pair: Currency pair of the market to list the trades from
534 | :type pair: str
535 | :param since: Fetch trades executed after this time, specified as a Unix timestamp in
536 | milliseconds. An error will be returned if this is before 24h ago. Use
537 | this parameter to either restrict to a shorter window or to iterate over
538 | the trades in case you need more than the 100 most recent trades.
539 | :type since: int
540 | """
541 | req = {
542 | "pair": pair,
543 | "since": since,
544 | }
545 | return self.do("GET", "/api/1/trades", req=req, auth=False)
546 |
547 | def list_transactions(self, id, max_row, min_row):
548 | """Make a call to GET /api/1/accounts/{id}/transactions.
549 |
550 | Return a list of transaction entries from an account.
551 |
552 | Transaction entry rows are numbered sequentially starting from 1, where 1 is
553 | the oldest entry. The range of rows to return are specified with the
554 | min_row (inclusive) and max_row (exclusive)
555 | parameters. At most 1000 rows can be requested per call.
556 |
557 | If min_row or max_row is non-positive, the range
558 | wraps around the most recent row. For example, to fetch the 100 most recent
559 | rows, use min_row=-100 and max_row=0.
560 |
561 | Permissions required: Perm_R_Transactions
562 |
563 | :param id: Account ID - the unique identifier for the specific Account.
564 | :type id: int
565 | :param max_row: Maximum of the row range to return (exclusive)
566 | :type max_row: int
567 | :param min_row: Minimum of the row range to return (inclusive)
568 | :type min_row: int
569 | """
570 | req = {
571 | "id": id,
572 | "max_row": max_row,
573 | "min_row": min_row,
574 | }
575 | return self.do("GET", "/api/1/accounts/{id}/transactions", req=req, auth=True)
576 |
577 | def list_transfers(self, account_id, before=None, limit=None):
578 | """Make a call to GET /api/exchange/1/transfers.
579 |
580 | Return a list of the most recent confirmed transfers ordered from newest to
581 | oldest.
582 | This includes bank transfers, card payments, or on-chain transactions that
583 | have been reflected on your account available balance.
584 |
585 | Note that the Transfer `amount` is always a positive value and you should
586 | use the `inbound` flag to determine the direction of the transfer.
587 |
588 | If you need to paginate the results you can set the `before` parameter to
589 | the last returned transfer `created_at` field value and repeat the request
590 | until you have all the transfers you need.
591 | This endpoint will list up to 100 transfers at a time by default.
592 |
593 | Permissions required: Perm_R_Transfers
594 |
595 | :param account_id: Unique identifier of the account to list the transfers from.
596 | :type account_id: int
597 | :param before: Filter to transfers created before this timestamp (Unix milliseconds).
598 | The default value (0) will return the latest transfers on the account.
599 | :type before: int
600 | :param limit: Limit to this many transfers.
601 | :type limit: int
602 | """
603 | req = {
604 | "account_id": account_id,
605 | "before": before,
606 | "limit": limit,
607 | }
608 | return self.do("GET", "/api/exchange/1/transfers", req=req, auth=True)
609 |
610 | def list_user_trades(
611 | self,
612 | pair,
613 | after_seq=None,
614 | before=None,
615 | before_seq=None,
616 | limit=None,
617 | since=None,
618 | sort_desc=None,
619 | ):
620 | """Make a call to GET /api/1/listtrades.
621 |
622 | Return a list of the recent Trades for a given currency pair for this user, sorted by oldest first.
623 | If before is specified, then Trades are returned sorted by most-recent first.
624 |
625 | type in the response indicates the type of Order that was placed to participate in the trade.
626 | Possible types: BID, ASK.
627 |
628 | If is_buy in the response is true, then the Order which completed the trade (market taker) was a
629 | Bid Order.
630 |
631 | Results of this query may lag behind the latest data.
632 |
633 | Permissions required: Perm_R_Orders
634 |
635 | :param pair: Filter to trades of this currency pair.
636 | :type pair: str
637 | :param after_seq: Filter to trades from (including) this sequence number.
638 | Default behaviour is not to include this filter.
639 | :type after_seq: int
640 | :param before: Filter to trades before this timestamp (Unix milliseconds).
641 | :type before: int
642 | :param before_seq: Filter to trades before (excluding) this sequence number.
643 | Default behaviour is not to include this filter.
644 | :type before_seq: int
645 | :param limit: Limit to this number of trades (default 100).
646 | :type limit: int
647 | :param since: Filter to trades on or after this timestamp (Unix milliseconds).
648 | :type since: int
649 | :param sort_desc: If set to true, sorts trades in descending order, otherwise ascending
650 | order will be assumed.
651 | :type sort_desc: bool
652 | """
653 | req = {
654 | "pair": pair,
655 | "after_seq": after_seq,
656 | "before": before,
657 | "before_seq": before_seq,
658 | "limit": limit,
659 | "since": since,
660 | "sort_desc": sort_desc,
661 | }
662 | return self.do("GET", "/api/1/listtrades", req=req, auth=True)
663 |
664 | def list_withdrawals(self, before_id=None, limit=None):
665 | """Make a call to GET /api/1/withdrawals.
666 |
667 | Return a list of withdrawal requests.
668 |
669 | Permissions required: Perm_R_Withdrawals
670 |
671 | :param before_id: Filter to withdrawals requested on or before the withdrawal with this ID.
672 | Can be used for pagination.
673 | :type before_id: int
674 | :param limit: Limit to this many withdrawals
675 | :type limit: int
676 | """
677 | req = {
678 | "before_id": before_id,
679 | "limit": limit,
680 | }
681 | return self.do("GET", "/api/1/withdrawals", req=req, auth=True)
682 |
683 | def markets(self, pair=None):
684 | """Make a call to GET /api/exchange/1/markets.
685 |
686 | List all supported markets parameter information like price scale, min and
687 | max order volumes and market ID.
688 |
689 | :param pair: List of market pairs to return. Requesting only the required pairs will improve response times.
690 | :type pair: list
691 | """
692 | req = {
693 | "pair": pair,
694 | }
695 | return self.do("GET", "/api/exchange/1/markets", req=req, auth=False)
696 |
697 | def move(self, amount, credit_account_id, debit_account_id, client_move_id=None):
698 | """Make a call to POST /api/exchange/1/move.
699 |
700 | Move funds between two of your transactional accounts with the same currency
701 | The funds may not be moved by the time the request returns. The GET method
702 | can be used to poll for the move's status.
703 |
704 | Note: moves will show as transactions, but not as transfers.
705 |
706 | Permissions required: MP_None_Write
707 |
708 | :param amount: Amount to transfer. Must be positive.
709 | :type amount: float
710 | :param credit_account_id: The account to credit the funds to.
711 | :type credit_account_id: int
712 | :param debit_account_id: The account to debit the funds from.
713 | :type debit_account_id: int
714 | :param client_move_id: Client move ID.
715 | May only contain alphanumeric (0-9, a-z, or A-Z) and special characters (_ ; , . -).
716 | Maximum length: 255. It will be available in read endpoints, so you can use it to avoid
717 | duplicate moves between the same accounts. Values must be unique across all your
718 | successful calls of this endpoint; trying to create a move request with the same
719 | `client_move_id` as one of your past move requests will result in a HTTP 409 Conflict
720 | response.
721 | :type client_move_id: str
722 | """
723 | req = {
724 | "amount": amount,
725 | "credit_account_id": credit_account_id,
726 | "debit_account_id": debit_account_id,
727 | "client_move_id": client_move_id,
728 | }
729 | return self.do("POST", "/api/exchange/1/move", req=req, auth=True)
730 |
731 | def post_limit_order(
732 | self,
733 | pair,
734 | price,
735 | type,
736 | volume,
737 | base_account_id=None,
738 | client_order_id=None,
739 | counter_account_id=None,
740 | post_only=None,
741 | stop_direction=None,
742 | stop_price=None,
743 | time_in_force=None,
744 | timestamp=None,
745 | ttl=None,
746 | ):
747 | """Make a call to POST /api/1/postorder.
748 |
749 | Warning! Orders cannot be reversed once they have executed.
750 | Please ensure your program has been thoroughly tested before submitting Orders.
751 |
752 | If no base_account_id or counter_account_id are specified,
753 | your default base currency or counter currency account will be used.
754 | You can find your Account IDs by calling the Balances API.
755 |
756 | Permissions required: Perm_W_Orders
757 |
758 | :param pair: The currency pair to trade.
759 | :type pair: str
760 | :param price: Limit price as a decimal string in units of ZAR/BTC.
761 | :type price: float
762 | :param type: BID for a bid (buy) limit order
763 | ASK for an ask (sell) limit order
764 | :type type: str
765 | :param volume: Amount of cryptocurrency to buy or sell as a decimal string in units of the currency.
766 | :type volume: float
767 | :param base_account_id: The base currency Account to use in the trade.
768 | :type base_account_id: int
769 | :param client_order_id: Client order ID.
770 | May only contain alphanumeric (0-9, a-z, or A-Z) and special characters (_ ; , . -).
771 | Maximum length: 255. It will be available in read endpoints, so you can use it to
772 | reconcile Luno with your internal system. Values must be unique across all your
773 | successful order creation endpoint calls; trying to create an order with the same
774 | `client_order_id` as one of your past orders will result in a HTTP 409 Conflict
775 | response.
776 | :type client_order_id: str
777 | :param counter_account_id: The counter currency Account to use in the trade.
778 | :type counter_account_id: int
779 | :param post_only: Post-only Orders will be cancelled if they would otherwise have traded immediately.
780 | For example, if there's a bid at ZAR 100,000 and you place a post-only ask at ZAR 100,000,
781 | your order will be cancelled instead of trading.
782 | If the best bid is ZAR 100,000 and you place a post-only ask at ZAR 101,000,
783 | your order won't trade but will go into the order book.
784 | :type post_only: bool
785 | :param stop_direction: Side of the trigger price to activate the order. This should be set if `stop_price` is
786 | also set.
787 |
788 | `RELATIVE_LAST_TRADE` will automatically infer the direction based on the last trade
789 | price and the stop price. If last trade price is less than stop price then stop direction
790 | is ABOVE otherwise is BELOW.
791 | :type stop_direction: str
792 | :param stop_price: Trigger trade price to activate this order as a decimal string. If this
793 | is set then this is treated as a Stop Limit Order and `stop_direction`
794 | is expected to be set too.
795 | :type stop_price: float
796 | :param time_in_force: GTC Good 'Til Cancelled. The order remains open until it is filled or
797 | cancelled by the user.
798 | IOC Immediate Or Cancel. The part of the order that cannot be filled
799 | immediately will be cancelled. Cannot be post-only.
800 | FOK Fill Or Kill. If the order cannot be filled immediately and completely it
801 | will be cancelled before any trade. Cannot be post-only.
802 | :type time_in_force: str
803 | :param timestamp: Unix timestamp in milliseconds of when the request was created and sent.
804 | :type timestamp: int
805 | :param ttl: Specifies the number of milliseconds after timestamp the request is valid for.
806 | If `timestamp` is not specified, `ttl` will not be used.
807 | :type ttl: int
808 | """
809 | req = {
810 | "pair": pair,
811 | "price": price,
812 | "type": type,
813 | "volume": volume,
814 | "base_account_id": base_account_id,
815 | "client_order_id": client_order_id,
816 | "counter_account_id": counter_account_id,
817 | "post_only": post_only,
818 | "stop_direction": stop_direction,
819 | "stop_price": stop_price,
820 | "time_in_force": time_in_force,
821 | "timestamp": timestamp,
822 | "ttl": ttl,
823 | }
824 | return self.do("POST", "/api/1/postorder", req=req, auth=True)
825 |
826 | def post_market_order(
827 | self,
828 | pair,
829 | type,
830 | base_account_id=None,
831 | base_volume=None,
832 | client_order_id=None,
833 | counter_account_id=None,
834 | counter_volume=None,
835 | timestamp=None,
836 | ttl=None,
837 | ):
838 | """Make a call to POST /api/1/marketorder.
839 |
840 | A Market Order executes immediately, and either buys as much of the asset that can be bought for a set amount of
841 | fiat currency, or sells a set amount of the asset for as much as possible.
842 |
843 | Warning! Orders cannot be reversed once they have executed.
844 | Please ensure your program has been thoroughly tested before submitting Orders.
845 |
846 | If no base_account_id or counter_account_id are specified, the default base
847 | currency or counter currency account will be used. Users can find their account IDs by calling the
848 | Balances request.
849 |
850 | Permissions required: Perm_W_Orders
851 |
852 | :param pair: The currency pair to trade.
853 | :type pair: str
854 | :param type: BUY to buy an asset
855 | SELL to sell an asset
856 | :type type: str
857 | :param base_account_id: The base currency account to use in the trade.
858 | :type base_account_id: int
859 | :param base_volume: For a SELL order: amount of the base currency to use (e.g. how much BTC to sell
860 | for EUR in the BTC/EUR market)
861 | :type base_volume: float
862 | :param client_order_id: Client order ID.
863 | May only contain alphanumeric (0-9, a-z, or A-Z) and special characters (_ ; , . -).
864 | Maximum length: 255. It will be available in read endpoints, so you can use it to
865 | reconcile Luno with your internal system. Values must be unique across all your
866 | successful order creation endpoint calls; trying to create an order with the same
867 | `client_order_id` as one of your past orders will result in a HTTP 409 Conflict
868 | response.
869 | :type client_order_id: str
870 | :param counter_account_id: The counter currency account to use in the trade.
871 | :type counter_account_id: int
872 | :param counter_volume: For a BUY order: amount of the counter currency to use (e.g. how much EUR to
873 | use to buy BTC in the BTC/EUR market)
874 | :type counter_volume: float
875 | :param timestamp: Unix timestamp in milliseconds of when the request was created and sent.
876 | :type timestamp: int
877 | :param ttl: Specifies the number of milliseconds after timestamp the request is valid for.
878 | If `timestamp` is not specified, `ttl` will not be used.
879 | :type ttl: int
880 | """
881 | req = {
882 | "pair": pair,
883 | "type": type,
884 | "base_account_id": base_account_id,
885 | "base_volume": base_volume,
886 | "client_order_id": client_order_id,
887 | "counter_account_id": counter_account_id,
888 | "counter_volume": counter_volume,
889 | "timestamp": timestamp,
890 | "ttl": ttl,
891 | }
892 | return self.do("POST", "/api/1/marketorder", req=req, auth=True)
893 |
894 | def send(
895 | self,
896 | address,
897 | amount,
898 | currency,
899 | account_id=None,
900 | description=None,
901 | destination_tag=None,
902 | external_id=None,
903 | forex_notice_self_declaration=None,
904 | has_destination_tag=None,
905 | is_drb=None,
906 | is_forex_send=None,
907 | memo=None,
908 | message=None,
909 | ):
910 | """Make a call to POST /api/1/send.
911 |
912 | Send assets from an Account. Please note that the asset type sent must match the receive address of the same
913 | cryptocurrency of the same type - Bitcoin to Bitcoin, Ethereum to Ethereum, etc.
914 |
915 | Sends can be made to cryptocurrency receive addresses.
916 |
917 | Note: This is currently unavailable to users who are verified in countries with money travel rules.
918 |
919 | Permissions required: Perm_W_Send
920 |
921 | :param address: Destination address or email address.
922 |
923 | Note:
924 |
MP_None
993 |
994 | :param address: Destination address or email address.
995 |
996 | Note:
997 | Perm_W_Orders
1025 |
1026 | :param order_id: The Order identifier as a string.
1027 | :type order_id: str
1028 | """
1029 | req = {
1030 | "order_id": order_id,
1031 | }
1032 | return self.do("POST", "/api/1/stoporder", req=req, auth=True)
1033 |
1034 | def update_account_name(self, id, name):
1035 | """Make a call to PUT /api/1/accounts/{id}/name.
1036 |
1037 | Update the name of an account with a given ID.
1038 |
1039 | Permissions required: Perm_W_Addresses
1040 |
1041 | :param id: Account ID - the unique identifier for the specific Account.
1042 | :type id: int
1043 | :param name: The label to use for this account
1044 | :type name: str
1045 | """
1046 | req = {
1047 | "id": id,
1048 | "name": name,
1049 | }
1050 | return self.do("PUT", "/api/1/accounts/{id}/name", req=req, auth=True)
1051 |
1052 | def validate(
1053 | self,
1054 | address,
1055 | currency,
1056 | address_name=None,
1057 | beneficiary_name=None,
1058 | country=None,
1059 | date_of_birth=None,
1060 | destination_tag=None,
1061 | has_destination_tag=None,
1062 | institution_name=None,
1063 | is_legal_entity=None,
1064 | is_private_wallet=None,
1065 | is_self_send=None,
1066 | memo=None,
1067 | nationality=None,
1068 | physical_address=None,
1069 | wallet_name=None,
1070 | ):
1071 | """Make a call to POST /api/1/address/validate.
1072 |
1073 | Validate receive addresses, to which a customer wishes to make cryptocurrency sends, are verified under covering
1074 | regulatory requirements for the customer such as travel rules.
1075 |
1076 | Permissions required: Perm_W_Send
1077 |
1078 | :param address: Destination address or email address.
1079 |
1080 | Note:
1081 |