kraken-py-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kraken_py_sdk-0.1.0/.env.example +6 -0
- kraken_py_sdk-0.1.0/.gitignore +24 -0
- kraken_py_sdk-0.1.0/.python-version +1 -0
- kraken_py_sdk-0.1.0/AGENTS.md +101 -0
- kraken_py_sdk-0.1.0/JUSTFILE +31 -0
- kraken_py_sdk-0.1.0/PKG-INFO +29 -0
- kraken_py_sdk-0.1.0/README.md +3 -0
- kraken_py_sdk-0.1.0/pyproject.toml +74 -0
- kraken_py_sdk-0.1.0/src/kraken_py/__init__.py +19 -0
- kraken_py_sdk-0.1.0/src/kraken_py/errors.py +11 -0
- kraken_py_sdk-0.1.0/src/kraken_py/instruments.py +101 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/__init__.py +9 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/spot/__init__.py +39 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/spot/auth.py +166 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/spot/client.py +239 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/spot/endpoints/__init__.py +11 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/spot/endpoints/account.py +1044 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/spot/endpoints/earn.py +258 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/spot/endpoints/funding.py +451 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/spot/endpoints/market.py +670 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/spot/endpoints/subaccounts.py +89 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/spot/endpoints/trading.py +402 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/spot/endpoints/transparency.py +122 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/spot/errors.py +294 -0
- kraken_py_sdk-0.1.0/src/kraken_py/rest/spot/response.py +29 -0
- kraken_py_sdk-0.1.0/src/kraken_py/ws/__init__.py +21 -0
- kraken_py_sdk-0.1.0/src/kraken_py/ws/spot/__init__.py +19 -0
- kraken_py_sdk-0.1.0/src/kraken_py/ws/spot/api/__init__.py +8 -0
- kraken_py_sdk-0.1.0/src/kraken_py/ws/spot/api/account.py +242 -0
- kraken_py_sdk-0.1.0/src/kraken_py/ws/spot/api/public.py +457 -0
- kraken_py_sdk-0.1.0/src/kraken_py/ws/spot/api/system.py +41 -0
- kraken_py_sdk-0.1.0/src/kraken_py/ws/spot/api/trading.py +352 -0
- kraken_py_sdk-0.1.0/src/kraken_py/ws/spot/auth.py +79 -0
- kraken_py_sdk-0.1.0/src/kraken_py/ws/spot/client.py +347 -0
- kraken_py_sdk-0.1.0/src/kraken_py/ws/spot/models.py +98 -0
- kraken_py_sdk-0.1.0/tests/integration/conftest.py +122 -0
- kraken_py_sdk-0.1.0/tests/integration/rest/spot/test_account.py +111 -0
- kraken_py_sdk-0.1.0/tests/integration/rest/spot/test_auth.py +10 -0
- kraken_py_sdk-0.1.0/tests/integration/rest/spot/test_client.py +12 -0
- kraken_py_sdk-0.1.0/tests/integration/rest/spot/test_earn.py +41 -0
- kraken_py_sdk-0.1.0/tests/integration/rest/spot/test_funding.py +70 -0
- kraken_py_sdk-0.1.0/tests/integration/rest/spot/test_market.py +55 -0
- kraken_py_sdk-0.1.0/tests/integration/rest/spot/test_subaccounts.py +31 -0
- kraken_py_sdk-0.1.0/tests/integration/rest/spot/test_trading.py +74 -0
- kraken_py_sdk-0.1.0/tests/integration/rest/spot/test_transparency.py +21 -0
- kraken_py_sdk-0.1.0/tests/integration/ws/spot/test_account.py +32 -0
- kraken_py_sdk-0.1.0/tests/integration/ws/spot/test_auth.py +21 -0
- kraken_py_sdk-0.1.0/tests/integration/ws/spot/test_client.py +12 -0
- kraken_py_sdk-0.1.0/tests/integration/ws/spot/test_public.py +68 -0
- kraken_py_sdk-0.1.0/tests/integration/ws/spot/test_trading.py +50 -0
- kraken_py_sdk-0.1.0/tests/unit/conftest.py +12 -0
- kraken_py_sdk-0.1.0/tests/unit/test_auth.py +122 -0
- kraken_py_sdk-0.1.0/tests/unit/test_earn_flows.py +61 -0
- kraken_py_sdk-0.1.0/tests/unit/test_errors.py +107 -0
- kraken_py_sdk-0.1.0/tests/unit/test_funding_flows.py +150 -0
- kraken_py_sdk-0.1.0/tests/unit/test_instruments.py +46 -0
- kraken_py_sdk-0.1.0/tests/unit/test_models.py +117 -0
- kraken_py_sdk-0.1.0/tests/unit/test_order_flows.py +170 -0
- kraken_py_sdk-0.1.0/tests/unit/test_response_protocol.py +78 -0
- kraken_py_sdk-0.1.0/tests/unit/test_subaccount_flows.py +83 -0
- kraken_py_sdk-0.1.0/tests/unit/test_transport.py +180 -0
- kraken_py_sdk-0.1.0/tests/unit/test_ws_order_flows.py +90 -0
- kraken_py_sdk-0.1.0/tests/unit/test_ws_transport.py +126 -0
- kraken_py_sdk-0.1.0/uv.lock +478 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
py.typed
|
|
9
|
+
|
|
10
|
+
# Virtual environments
|
|
11
|
+
.venv
|
|
12
|
+
|
|
13
|
+
# IDE files
|
|
14
|
+
.idea/
|
|
15
|
+
|
|
16
|
+
# Environment variables
|
|
17
|
+
.env
|
|
18
|
+
*.env
|
|
19
|
+
|
|
20
|
+
# testing
|
|
21
|
+
.coverage
|
|
22
|
+
|
|
23
|
+
.pi/
|
|
24
|
+
.pi-lens/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Instructions For Coding Agents
|
|
2
|
+
|
|
3
|
+
**Note:** This is a living document. If patterns emerge that should be codified here, suggest additions.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Project Overview
|
|
8
|
+
|
|
9
|
+
Typed, production-grade Python SDK for the Kraken crypto exchange API
|
|
10
|
+
|
|
11
|
+
For complete domain knowledge, refer to the project README.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Meta Instructions
|
|
16
|
+
|
|
17
|
+
### Session Start Checklist
|
|
18
|
+
|
|
19
|
+
Every new session, in order:
|
|
20
|
+
|
|
21
|
+
1. ✓ Read this file (AGENTS.md) -> Your primary instruction set
|
|
22
|
+
2. ✓ Read README.md -> Important Domain knowledge regarding the project
|
|
23
|
+
|
|
24
|
+
**ALWAYS** await further instructions before taking any action!
|
|
25
|
+
|
|
26
|
+
### Just as a command runner
|
|
27
|
+
|
|
28
|
+
- This project uses `just` for task management: [Just Github](https://github.com/casey/just)
|
|
29
|
+
- You can safely assume the user has it installed and available in the PATH
|
|
30
|
+
- Use `just` with no arguments to get a list of available recipes
|
|
31
|
+
- If you need a new recipe, propose it to me first and if I concur we can add it to `./JUSTFILE`
|
|
32
|
+
|
|
33
|
+
**ALWAYS** prefer to use existing recipes when available rather than running commands directly!
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Karpathy's Instructions
|
|
38
|
+
|
|
39
|
+
*Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.*
|
|
40
|
+
|
|
41
|
+
> Tradeoff:
|
|
42
|
+
>
|
|
43
|
+
> These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
|
44
|
+
|
|
45
|
+
### 1. Think Before Coding
|
|
46
|
+
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
|
47
|
+
|
|
48
|
+
Before implementing:
|
|
49
|
+
|
|
50
|
+
State your assumptions explicitly. If uncertain, ask.
|
|
51
|
+
- If multiple interpretations exist, present them - don't pick silently.
|
|
52
|
+
- If a simpler approach exists, say so. Push back when warranted.
|
|
53
|
+
- If something is unclear, stop. Name what's confusing. Ask.
|
|
54
|
+
|
|
55
|
+
### 2. Simplicity First
|
|
56
|
+
**Minimum code that solves the problem. Nothing speculative.**
|
|
57
|
+
|
|
58
|
+
- No features beyond what was asked.
|
|
59
|
+
- No abstractions for single-use code.
|
|
60
|
+
- No "flexibility" or "configurability" that wasn't requested.
|
|
61
|
+
- No error handling for impossible scenarios.
|
|
62
|
+
- If you write 200 lines and it could be 50, rewrite it.
|
|
63
|
+
|
|
64
|
+
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
|
65
|
+
|
|
66
|
+
### 3. Surgical Changes
|
|
67
|
+
**Touch only what you must. Clean up only your own mess.**
|
|
68
|
+
|
|
69
|
+
When editing existing code:
|
|
70
|
+
|
|
71
|
+
- Don't "improve" adjacent code, comments, or formatting.
|
|
72
|
+
- Don't refactor things that aren't broken.
|
|
73
|
+
- Match existing style, even if you'd do it differently.
|
|
74
|
+
- If you notice unrelated dead code, mention it - don't delete it.
|
|
75
|
+
|
|
76
|
+
When your changes create orphans:
|
|
77
|
+
|
|
78
|
+
- Remove imports/variables/functions that YOUR changes made unused.
|
|
79
|
+
- Don't remove pre-existing dead code unless asked.
|
|
80
|
+
|
|
81
|
+
The test: Every changed line should trace directly to the user's request.
|
|
82
|
+
|
|
83
|
+
### 4. Goal-Driven Execution
|
|
84
|
+
**Define success criteria. Loop until verified.**
|
|
85
|
+
|
|
86
|
+
Transform tasks into verifiable goals:
|
|
87
|
+
|
|
88
|
+
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
|
89
|
+
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
|
90
|
+
- "Refactor X" → "Ensure tests pass before and after"
|
|
91
|
+
|
|
92
|
+
For multi-step tasks, state a brief plan:
|
|
93
|
+
|
|
94
|
+
1. [Step] → verify: [check]
|
|
95
|
+
2. [Step] → verify: [check]
|
|
96
|
+
3. [Step] → verify: [check]
|
|
97
|
+
|
|
98
|
+
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
|
99
|
+
|
|
100
|
+
These guidelines are working if: fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and
|
|
101
|
+
clarifying questions come before implementation rather than after mistakes.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
set dotenv-load := true
|
|
2
|
+
|
|
3
|
+
# Default recipe - list all available recipes
|
|
4
|
+
list:
|
|
5
|
+
@just --list
|
|
6
|
+
|
|
7
|
+
# Run tests with an optional pytest marker expression. State-changing tests are opt-in.
|
|
8
|
+
test marker="":
|
|
9
|
+
#!/usr/bin/env bash
|
|
10
|
+
selected_marker="{{marker}}"
|
|
11
|
+
selected_marker="${selected_marker#marker=}"
|
|
12
|
+
if [ -n "$selected_marker" ]; then
|
|
13
|
+
uv run pytest -v -m "$selected_marker"
|
|
14
|
+
else
|
|
15
|
+
uv run pytest -v -m "not orders and not mutating"
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
# Analyze the codebase using cloc, excluding certain directories
|
|
19
|
+
cloc:
|
|
20
|
+
@cloc . --vcs=git --exclude-dir=products,tests
|
|
21
|
+
|
|
22
|
+
# Build the package distribution
|
|
23
|
+
build:
|
|
24
|
+
@echo "Building package..."
|
|
25
|
+
rm -rf dist
|
|
26
|
+
uv build
|
|
27
|
+
|
|
28
|
+
# Publish the package to PyPI using UV, with the token provided in the environment variable
|
|
29
|
+
publish:
|
|
30
|
+
@echo "Publishing package to PyPI..."
|
|
31
|
+
uv publish --token $UV_PUBLISH_TOKEN
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kraken-py-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed, production-grade Python SDK for the Kraken crypto exchange API
|
|
5
|
+
Project-URL: Homepage, https://github.com/cstone-io/kraken-py
|
|
6
|
+
Project-URL: Repository, https://github.com/cstone-io/kraken-py
|
|
7
|
+
Project-URL: Issues, https://github.com/cstone-io/kraken-py/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/cstone-io/kraken-py/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Connor Stone
|
|
10
|
+
License: MIT
|
|
11
|
+
Keywords: api,crypto,exchange,kraken,sdk,trading
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Requires-Dist: httpx>=0.28.1
|
|
23
|
+
Requires-Dist: pydantic>=2.12.5
|
|
24
|
+
Requires-Dist: websockets>=15.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# kraken-py
|
|
28
|
+
|
|
29
|
+
Typed, production-grade Python SDK for the [Kraken](https://kraken.com) crypto exchange API.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "kraken-py-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Typed, production-grade Python SDK for the Kraken crypto exchange API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [{ name = "Connor Stone" }]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
keywords = ["kraken", "crypto", "exchange", "api", "sdk", "trading"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Topic :: Office/Business :: Financial",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["httpx>=0.28.1", "pydantic>=2.12.5", "websockets>=15.0"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/cstone-io/kraken-py"
|
|
25
|
+
Repository = "https://github.com/cstone-io/kraken-py"
|
|
26
|
+
Issues = "https://github.com/cstone-io/kraken-py/issues"
|
|
27
|
+
Changelog = "https://github.com/cstone-io/kraken-py/blob/main/CHANGELOG.md"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/kraken_py"]
|
|
35
|
+
|
|
36
|
+
[tool.uv]
|
|
37
|
+
package = true
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
pythonpath = ["src"]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
addopts = ["--import-mode=importlib"]
|
|
43
|
+
markers = [
|
|
44
|
+
"unit: deterministic tests that do not require external services",
|
|
45
|
+
"integration: tests that exercise the SDK against external APIs",
|
|
46
|
+
"rest: REST API tests",
|
|
47
|
+
"ws: WebSocket API tests",
|
|
48
|
+
"spot: Spot API tests",
|
|
49
|
+
"orders: tests that may create, amend, or cancel orders",
|
|
50
|
+
"mutating: tests that may change account or exchange state",
|
|
51
|
+
]
|
|
52
|
+
asyncio_mode = "strict"
|
|
53
|
+
|
|
54
|
+
[dependency-groups]
|
|
55
|
+
dev = [
|
|
56
|
+
"pytest>=9.0.2",
|
|
57
|
+
"pytest-asyncio>=0.24.0",
|
|
58
|
+
"pytest-cov>=7.0.0",
|
|
59
|
+
"python-dotenv>=1.2.1",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[tool.ty.environment]
|
|
63
|
+
python-version = "3.12"
|
|
64
|
+
root = ["./src", "./tests"]
|
|
65
|
+
|
|
66
|
+
[tool.ty.src]
|
|
67
|
+
include = ["src", "tests"]
|
|
68
|
+
|
|
69
|
+
[tool.ty.rules]
|
|
70
|
+
possibly-unresolved-reference = "warn"
|
|
71
|
+
division-by-zero = "ignore"
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint]
|
|
74
|
+
ignore = ["F403", "F405"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Typed Python SDK for the Kraken API."""
|
|
2
|
+
|
|
3
|
+
from kraken_py.instruments import (
|
|
4
|
+
DerivativesInstrument,
|
|
5
|
+
SpotPair,
|
|
6
|
+
SymbolProtocol,
|
|
7
|
+
format_symbol,
|
|
8
|
+
)
|
|
9
|
+
from kraken_py.rest import ApiCredentials, Authenticator, RestClient
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ApiCredentials",
|
|
13
|
+
"Authenticator",
|
|
14
|
+
"DerivativesInstrument",
|
|
15
|
+
"RestClient",
|
|
16
|
+
"SpotPair",
|
|
17
|
+
"SymbolProtocol",
|
|
18
|
+
"format_symbol",
|
|
19
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Protocol-neutral exceptions raised by kraken-py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class KrakenError(Exception):
|
|
7
|
+
"""Base exception for all SDK failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class KrakenProtocolError(KrakenError):
|
|
11
|
+
"""A Kraken response does not match its documented wire schema."""
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Protocol-aware Kraken trading instruments and symbol formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, Final
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_serializer, model_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SymbolProtocol(str, Enum):
|
|
12
|
+
"""Protocol whose symbol format should be produced."""
|
|
13
|
+
|
|
14
|
+
REST = "rest"
|
|
15
|
+
WEBSOCKET = "websocket"
|
|
16
|
+
FIX = "fix"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SpotPair(BaseModel):
|
|
20
|
+
"""A human-readable Spot market pair."""
|
|
21
|
+
|
|
22
|
+
model_config = ConfigDict(frozen=True)
|
|
23
|
+
|
|
24
|
+
base: str = Field(min_length=1)
|
|
25
|
+
quote: str = Field(min_length=1)
|
|
26
|
+
|
|
27
|
+
@model_validator(mode="before")
|
|
28
|
+
@classmethod
|
|
29
|
+
def parse_symbol(cls, value: Any) -> Any:
|
|
30
|
+
"""Accept a readable ``BASE/QUOTE`` symbol at model boundaries."""
|
|
31
|
+
if isinstance(value, str):
|
|
32
|
+
try:
|
|
33
|
+
base, quote = value.split("/", maxsplit=1)
|
|
34
|
+
except ValueError as error:
|
|
35
|
+
raise ValueError("Spot symbols must use the BASE/QUOTE format") from error
|
|
36
|
+
return {"base": base, "quote": quote}
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
@field_validator("base", "quote", mode="before")
|
|
40
|
+
@classmethod
|
|
41
|
+
def normalize_asset(cls, value: Any) -> str:
|
|
42
|
+
"""Normalize asset symbols while preserving Kraken's asset spelling."""
|
|
43
|
+
if not isinstance(value, str):
|
|
44
|
+
raise ValueError("asset symbols must be strings")
|
|
45
|
+
return value.strip().upper()
|
|
46
|
+
|
|
47
|
+
def symbol(self, protocol: SymbolProtocol = SymbolProtocol.REST) -> str:
|
|
48
|
+
"""Render this pair using the requested protocol's symbol format."""
|
|
49
|
+
if protocol is SymbolProtocol.FIX:
|
|
50
|
+
base = _FIX_ASSET_ALIASES.get(self.base, self.base)
|
|
51
|
+
return f"{base}/{self.quote}"
|
|
52
|
+
return f"{self.base}/{self.quote}"
|
|
53
|
+
|
|
54
|
+
@model_serializer(mode="plain")
|
|
55
|
+
def serialize(self) -> str:
|
|
56
|
+
"""Serialize nested pairs as the readable REST/Spot WebSocket symbol."""
|
|
57
|
+
return self.symbol()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DerivativesInstrument(BaseModel):
|
|
61
|
+
"""A Kraken Derivatives instrument identified by its exchange symbol."""
|
|
62
|
+
|
|
63
|
+
model_config = ConfigDict(frozen=True)
|
|
64
|
+
|
|
65
|
+
symbol: str = Field(min_length=1)
|
|
66
|
+
|
|
67
|
+
@field_validator("symbol", mode="before")
|
|
68
|
+
@classmethod
|
|
69
|
+
def normalize_symbol(cls, value: Any) -> str:
|
|
70
|
+
"""Normalize a Derivatives symbol without changing its structure."""
|
|
71
|
+
if not isinstance(value, str):
|
|
72
|
+
raise ValueError("instrument symbols must be strings")
|
|
73
|
+
return value.strip().upper()
|
|
74
|
+
|
|
75
|
+
def formatted_symbol(self, protocol: SymbolProtocol = SymbolProtocol.REST) -> str:
|
|
76
|
+
"""Return this instrument's symbol for the requested protocol."""
|
|
77
|
+
_ = protocol
|
|
78
|
+
return self.symbol
|
|
79
|
+
|
|
80
|
+
@model_serializer(mode="plain")
|
|
81
|
+
def serialize(self) -> str:
|
|
82
|
+
"""Serialize nested instruments as their exchange symbol."""
|
|
83
|
+
return self.symbol
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
Instrument = SpotPair | DerivativesInstrument
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_FIX_ASSET_ALIASES: Final[dict[str, str]] = {
|
|
90
|
+
"BTC": "XBT",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def format_symbol(
|
|
95
|
+
instrument: Instrument,
|
|
96
|
+
protocol: SymbolProtocol = SymbolProtocol.REST,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Render an instrument using the requested protocol's symbol format."""
|
|
99
|
+
if isinstance(instrument, SpotPair):
|
|
100
|
+
return instrument.symbol(protocol)
|
|
101
|
+
return instrument.formatted_symbol(protocol)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Kraken Spot REST API support."""
|
|
2
|
+
|
|
3
|
+
from kraken_py.rest.spot.auth import (
|
|
4
|
+
ApiCredentials,
|
|
5
|
+
Authenticator,
|
|
6
|
+
MonotonicNonceProvider,
|
|
7
|
+
NonceProvider,
|
|
8
|
+
SignedRequest,
|
|
9
|
+
)
|
|
10
|
+
from kraken_py.rest.spot.client import RestClient
|
|
11
|
+
from kraken_py.rest.spot.errors import (
|
|
12
|
+
KrakenRestApiError,
|
|
13
|
+
KrakenRestMessage,
|
|
14
|
+
KrakenRestRequestContext,
|
|
15
|
+
KrakenRestTransportError,
|
|
16
|
+
RestErrorCode,
|
|
17
|
+
RestMessageCategory,
|
|
18
|
+
RestMessageSeverity,
|
|
19
|
+
RestRetryAdvice,
|
|
20
|
+
)
|
|
21
|
+
from kraken_py.rest.spot.response import KrakenRestResponse
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ApiCredentials",
|
|
25
|
+
"Authenticator",
|
|
26
|
+
"KrakenRestApiError",
|
|
27
|
+
"KrakenRestMessage",
|
|
28
|
+
"KrakenRestRequestContext",
|
|
29
|
+
"KrakenRestResponse",
|
|
30
|
+
"KrakenRestTransportError",
|
|
31
|
+
"MonotonicNonceProvider",
|
|
32
|
+
"NonceProvider",
|
|
33
|
+
"RestClient",
|
|
34
|
+
"RestErrorCode",
|
|
35
|
+
"RestMessageCategory",
|
|
36
|
+
"RestMessageSeverity",
|
|
37
|
+
"RestRetryAdvice",
|
|
38
|
+
"SignedRequest",
|
|
39
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Authentication primitives for Kraken Spot private REST requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
from abc import abstractmethod
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Protocol
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
|
|
18
|
+
|
|
19
|
+
_MAX_NONCE = 2**64 - 1
|
|
20
|
+
_PRIVATE_PREFIX = "/0/private/"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NonceProvider(Protocol):
|
|
24
|
+
"""Source of strictly increasing unsigned 64-bit nonces."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def next_nonce(self) -> int:
|
|
28
|
+
"""Return the next nonce for an API key."""
|
|
29
|
+
return 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MonotonicNonceProvider:
|
|
33
|
+
"""Thread-safe millisecond nonce provider.
|
|
34
|
+
|
|
35
|
+
This guarantees monotonicity within one Python process. Applications that
|
|
36
|
+
share an API key between processes must provide a shared NonceProvider.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
self._last_nonce = 0
|
|
42
|
+
|
|
43
|
+
def next_nonce(self) -> int:
|
|
44
|
+
now = time.time_ns() // 1_000_000
|
|
45
|
+
with self._lock:
|
|
46
|
+
nonce = max(now, self._last_nonce + 1)
|
|
47
|
+
if nonce > _MAX_NONCE:
|
|
48
|
+
raise OverflowError("Kraken nonce exceeds unsigned 64-bit range")
|
|
49
|
+
self._last_nonce = nonce
|
|
50
|
+
return nonce
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ApiCredentials(BaseModel):
|
|
54
|
+
"""Credentials used to authenticate private Spot REST requests."""
|
|
55
|
+
|
|
56
|
+
model_config = ConfigDict(frozen=True)
|
|
57
|
+
|
|
58
|
+
api_key: str = Field(min_length=1)
|
|
59
|
+
api_secret: SecretStr
|
|
60
|
+
otp: str | None = Field(default=None, min_length=1)
|
|
61
|
+
|
|
62
|
+
@field_validator("api_secret")
|
|
63
|
+
@classmethod
|
|
64
|
+
def validate_api_secret(cls, value: SecretStr) -> SecretStr:
|
|
65
|
+
try:
|
|
66
|
+
decoded = base64.b64decode(value.get_secret_value(), validate=True)
|
|
67
|
+
if not decoded:
|
|
68
|
+
raise ValueError("api_secret must not be empty")
|
|
69
|
+
except (ValueError, binascii.Error) as error:
|
|
70
|
+
raise ValueError("api_secret must be valid Base64") from error
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
@field_validator("api_key")
|
|
74
|
+
@classmethod
|
|
75
|
+
def strip_api_key(cls, value: str) -> str:
|
|
76
|
+
value = value.strip()
|
|
77
|
+
if not value:
|
|
78
|
+
raise ValueError("api_key must not be blank")
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def secret_bytes(self) -> bytes:
|
|
83
|
+
"""Return the decoded secret for HMAC use."""
|
|
84
|
+
return base64.b64decode(self.api_secret.get_secret_value(), validate=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class SignedRequest:
|
|
89
|
+
"""The exact JSON body and headers for an authenticated request."""
|
|
90
|
+
|
|
91
|
+
body: bytes
|
|
92
|
+
headers: Mapping[str, str]
|
|
93
|
+
nonce: int
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _payload_mapping(payload: BaseModel | Mapping[str, Any]) -> dict[str, Any]:
|
|
97
|
+
if isinstance(payload, BaseModel):
|
|
98
|
+
return payload.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
99
|
+
return dict(payload)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def encode_json_payload(payload: Mapping[str, Any]) -> bytes:
|
|
103
|
+
"""Encode a Kraken request payload exactly as it is sent on the wire."""
|
|
104
|
+
return json.dumps(
|
|
105
|
+
payload,
|
|
106
|
+
ensure_ascii=False,
|
|
107
|
+
separators=(",", ":"),
|
|
108
|
+
).encode("utf-8")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def generate_signature(uri_path: str, encoded_payload: bytes, nonce: int, secret: bytes) -> str:
|
|
112
|
+
"""Generate an API-Sign value from an already encoded request payload."""
|
|
113
|
+
if not uri_path.startswith(_PRIVATE_PREFIX):
|
|
114
|
+
raise ValueError("uri_path must begin with /0/private/")
|
|
115
|
+
if not 0 <= nonce <= _MAX_NONCE:
|
|
116
|
+
raise ValueError("nonce must be an unsigned 64-bit integer")
|
|
117
|
+
|
|
118
|
+
message = uri_path.encode("utf-8") + hashlib.sha256(
|
|
119
|
+
str(nonce).encode("ascii") + encoded_payload
|
|
120
|
+
).digest()
|
|
121
|
+
digest = hmac.new(secret, message, hashlib.sha512).digest()
|
|
122
|
+
return base64.b64encode(digest).decode("ascii")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Authenticator:
|
|
126
|
+
"""Create signed Kraken private REST requests."""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
credentials: ApiCredentials,
|
|
131
|
+
nonce_provider: NonceProvider | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
self.credentials = credentials
|
|
134
|
+
self.nonce_provider = nonce_provider or MonotonicNonceProvider()
|
|
135
|
+
|
|
136
|
+
def sign(
|
|
137
|
+
self,
|
|
138
|
+
uri_path: str,
|
|
139
|
+
payload: BaseModel | Mapping[str, Any] | None = None,
|
|
140
|
+
*,
|
|
141
|
+
otp: str | None = None,
|
|
142
|
+
) -> SignedRequest:
|
|
143
|
+
values = _payload_mapping(payload or {})
|
|
144
|
+
if "nonce" in values:
|
|
145
|
+
raise ValueError("nonce is managed by Authenticator and must not be supplied")
|
|
146
|
+
|
|
147
|
+
nonce = self.nonce_provider.next_nonce()
|
|
148
|
+
values = {"nonce": nonce, **values}
|
|
149
|
+
selected_otp = otp if otp is not None else self.credentials.otp
|
|
150
|
+
if selected_otp is not None:
|
|
151
|
+
values["otp"] = selected_otp
|
|
152
|
+
|
|
153
|
+
body = encode_json_payload(values)
|
|
154
|
+
signature = generate_signature(uri_path, body, nonce, self.credentials.secret_bytes)
|
|
155
|
+
return SignedRequest(
|
|
156
|
+
body=body,
|
|
157
|
+
headers={
|
|
158
|
+
"API-Key": self.credentials.api_key,
|
|
159
|
+
"API-Sign": signature,
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
},
|
|
162
|
+
nonce=nonce,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def __repr__(self) -> str:
|
|
166
|
+
return f"Authenticator(api_key={self.credentials.api_key!r})"
|