klinelens 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.
- klinelens-0.1.0/LICENSE +21 -0
- klinelens-0.1.0/PKG-INFO +100 -0
- klinelens-0.1.0/README.md +77 -0
- klinelens-0.1.0/pyproject.toml +36 -0
- klinelens-0.1.0/setup.cfg +4 -0
- klinelens-0.1.0/src/klinelens/__init__.py +27 -0
- klinelens-0.1.0/src/klinelens/_client.py +157 -0
- klinelens-0.1.0/src/klinelens/_errors.py +41 -0
- klinelens-0.1.0/src/klinelens/_result.py +64 -0
- klinelens-0.1.0/src/klinelens/py.typed +0 -0
- klinelens-0.1.0/src/klinelens.egg-info/PKG-INFO +100 -0
- klinelens-0.1.0/src/klinelens.egg-info/SOURCES.txt +14 -0
- klinelens-0.1.0/src/klinelens.egg-info/dependency_links.txt +1 -0
- klinelens-0.1.0/src/klinelens.egg-info/requires.txt +3 -0
- klinelens-0.1.0/src/klinelens.egg-info/top_level.txt +1 -0
- klinelens-0.1.0/tests/test_client.py +172 -0
klinelens-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 K-Line Lens
|
|
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.
|
klinelens-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: klinelens
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the K-Line Lens API: Bitcoin order flow, liquidations, large trades, the order book and derivatives.
|
|
5
|
+
Author-email: K-Line Lens <support@klinelens.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://klinelens.com
|
|
8
|
+
Project-URL: Documentation, https://klinelens.com/api
|
|
9
|
+
Keywords: bitcoin,order flow,liquidations,cvd,crypto,market data,api
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
15
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: pandas
|
|
21
|
+
Requires-Dist: pandas>=1.5; extra == "pandas"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# klinelens
|
|
25
|
+
|
|
26
|
+
The Python client for the [K-Line Lens API](https://klinelens.com/api): Bitcoin taker buying and selling, liquidations, large trades, the order book, funding and open interest, in a few lines.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install klinelens # no dependencies
|
|
30
|
+
pip install "klinelens[pandas]" # adds .to_pandas()
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Create a key at [klinelens.com/api](https://klinelens.com/api). The free plan needs no card.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from klinelens import Client
|
|
37
|
+
|
|
38
|
+
kl = Client() # reads KLINELENS_API_KEY; or Client("kll_live_...")
|
|
39
|
+
|
|
40
|
+
snap = kl.snapshot()
|
|
41
|
+
print(snap["lastHour"]["liquidations"]) # {'longUsd': ..., 'shortUsd': ..., 'count': ...}
|
|
42
|
+
|
|
43
|
+
flow = kl.flow(window="24h", interval="1h").to_pandas()
|
|
44
|
+
print(flow[["at", "deltaUsd", "cumulativeUsd"]].tail())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Methods
|
|
48
|
+
|
|
49
|
+
| Method | Endpoint | What it returns |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `read()` | `/v1/read` | The read right now: the lean, how its readings vote, each one's side |
|
|
52
|
+
| `forecast(tf="15m")` | `/v1/forecast` | The forecast model's latest call and its path |
|
|
53
|
+
| `snapshot()` | `/v1/snapshot` | The last hour's flow, liquidations and large trades, the book and derivatives now |
|
|
54
|
+
| `flow(window, interval)` | `/v1/flow` | Taker buys and sells per interval, the delta and its running total (CVD) |
|
|
55
|
+
| `liquidations(window, interval, limit)` | `/v1/liquidations` | Longs and shorts forced closed per interval, and the latest ones |
|
|
56
|
+
| `whales(window, interval, min_usd, limit)` | `/v1/whales` | Trades of $50,000 and up per interval, and the latest ones |
|
|
57
|
+
| `book(levels, window, interval)` | `/v1/book` | The order book now, and its history with `window` |
|
|
58
|
+
| `derivatives(window, interval)` | `/v1/derivatives` | Funding, open interest, mark and index, and their history with `window` |
|
|
59
|
+
| `setup()` | `/v1/setup` | The ledger's open trade, or `None` |
|
|
60
|
+
| `ledger(limit)` | `/v1/ledger` | The ledger's account, totals and latest trades |
|
|
61
|
+
|
|
62
|
+
`window` is a number and `m`, `h` or `d` (`90m`, `24h`, `7d`). `interval` is one of `1m`, `5m`, `15m`, `1h`, `4h`, `1d`. How far back you can read, the smallest interval and how many events are listed depend on your plan; `result.meta["plan"]` says what yours allows.
|
|
63
|
+
|
|
64
|
+
## What you get back
|
|
65
|
+
|
|
66
|
+
Every method returns a `Result`:
|
|
67
|
+
|
|
68
|
+
- `result.data`: the answer. `result["totals"]` reads from it.
|
|
69
|
+
- `result.meta`: when it was made, the window, any gaps in the record, your plan's limits.
|
|
70
|
+
- `result.quota`: `{"limit": ..., "remaining": ...}` requests for today (UTC).
|
|
71
|
+
- `result.to_pandas()`: the answer's list (points, events, trades, history or path) as a DataFrame, timestamps in UTC. Name one with `to_pandas("events")`.
|
|
72
|
+
|
|
73
|
+
## Errors
|
|
74
|
+
|
|
75
|
+
Failures raise a subclass of `klinelens.KlineLensError`, with `.status` and `.message`:
|
|
76
|
+
|
|
77
|
+
| Error | When |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `BadRequestError` | 400: a parameter is wrong; the message says which |
|
|
80
|
+
| `AuthError` | 401: no key, or not a valid one |
|
|
81
|
+
| `PlanError` | 403: outside your plan (more history, a smaller interval) |
|
|
82
|
+
| `RateLimitError` | 429: today's requests are used up; they reset at 00:00 UTC |
|
|
83
|
+
| `ServiceError` | 5xx or no connection, after two retries |
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from klinelens import Client, PlanError
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
Client().flow(window="30d", interval="1m")
|
|
90
|
+
except PlanError as e:
|
|
91
|
+
print(e.message) # "Your plan reads 1 day of history. ..."
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Good to know
|
|
95
|
+
|
|
96
|
+
- Order flow, liquidations, large trades and the book come from one major exchange's Bitcoin perpetual market. Totals across every exchange are larger.
|
|
97
|
+
- Readings describe the market; they are not financial advice. The ledger places no real orders.
|
|
98
|
+
- The full API reference, and the OpenAPI description at `https://klinelens.com/v1/openapi.json`, are linked from [klinelens.com/api](https://klinelens.com/api).
|
|
99
|
+
|
|
100
|
+
MIT licence.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# klinelens
|
|
2
|
+
|
|
3
|
+
The Python client for the [K-Line Lens API](https://klinelens.com/api): Bitcoin taker buying and selling, liquidations, large trades, the order book, funding and open interest, in a few lines.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install klinelens # no dependencies
|
|
7
|
+
pip install "klinelens[pandas]" # adds .to_pandas()
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Create a key at [klinelens.com/api](https://klinelens.com/api). The free plan needs no card.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from klinelens import Client
|
|
14
|
+
|
|
15
|
+
kl = Client() # reads KLINELENS_API_KEY; or Client("kll_live_...")
|
|
16
|
+
|
|
17
|
+
snap = kl.snapshot()
|
|
18
|
+
print(snap["lastHour"]["liquidations"]) # {'longUsd': ..., 'shortUsd': ..., 'count': ...}
|
|
19
|
+
|
|
20
|
+
flow = kl.flow(window="24h", interval="1h").to_pandas()
|
|
21
|
+
print(flow[["at", "deltaUsd", "cumulativeUsd"]].tail())
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Methods
|
|
25
|
+
|
|
26
|
+
| Method | Endpoint | What it returns |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `read()` | `/v1/read` | The read right now: the lean, how its readings vote, each one's side |
|
|
29
|
+
| `forecast(tf="15m")` | `/v1/forecast` | The forecast model's latest call and its path |
|
|
30
|
+
| `snapshot()` | `/v1/snapshot` | The last hour's flow, liquidations and large trades, the book and derivatives now |
|
|
31
|
+
| `flow(window, interval)` | `/v1/flow` | Taker buys and sells per interval, the delta and its running total (CVD) |
|
|
32
|
+
| `liquidations(window, interval, limit)` | `/v1/liquidations` | Longs and shorts forced closed per interval, and the latest ones |
|
|
33
|
+
| `whales(window, interval, min_usd, limit)` | `/v1/whales` | Trades of $50,000 and up per interval, and the latest ones |
|
|
34
|
+
| `book(levels, window, interval)` | `/v1/book` | The order book now, and its history with `window` |
|
|
35
|
+
| `derivatives(window, interval)` | `/v1/derivatives` | Funding, open interest, mark and index, and their history with `window` |
|
|
36
|
+
| `setup()` | `/v1/setup` | The ledger's open trade, or `None` |
|
|
37
|
+
| `ledger(limit)` | `/v1/ledger` | The ledger's account, totals and latest trades |
|
|
38
|
+
|
|
39
|
+
`window` is a number and `m`, `h` or `d` (`90m`, `24h`, `7d`). `interval` is one of `1m`, `5m`, `15m`, `1h`, `4h`, `1d`. How far back you can read, the smallest interval and how many events are listed depend on your plan; `result.meta["plan"]` says what yours allows.
|
|
40
|
+
|
|
41
|
+
## What you get back
|
|
42
|
+
|
|
43
|
+
Every method returns a `Result`:
|
|
44
|
+
|
|
45
|
+
- `result.data`: the answer. `result["totals"]` reads from it.
|
|
46
|
+
- `result.meta`: when it was made, the window, any gaps in the record, your plan's limits.
|
|
47
|
+
- `result.quota`: `{"limit": ..., "remaining": ...}` requests for today (UTC).
|
|
48
|
+
- `result.to_pandas()`: the answer's list (points, events, trades, history or path) as a DataFrame, timestamps in UTC. Name one with `to_pandas("events")`.
|
|
49
|
+
|
|
50
|
+
## Errors
|
|
51
|
+
|
|
52
|
+
Failures raise a subclass of `klinelens.KlineLensError`, with `.status` and `.message`:
|
|
53
|
+
|
|
54
|
+
| Error | When |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `BadRequestError` | 400: a parameter is wrong; the message says which |
|
|
57
|
+
| `AuthError` | 401: no key, or not a valid one |
|
|
58
|
+
| `PlanError` | 403: outside your plan (more history, a smaller interval) |
|
|
59
|
+
| `RateLimitError` | 429: today's requests are used up; they reset at 00:00 UTC |
|
|
60
|
+
| `ServiceError` | 5xx or no connection, after two retries |
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from klinelens import Client, PlanError
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
Client().flow(window="30d", interval="1m")
|
|
67
|
+
except PlanError as e:
|
|
68
|
+
print(e.message) # "Your plan reads 1 day of history. ..."
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Good to know
|
|
72
|
+
|
|
73
|
+
- Order flow, liquidations, large trades and the book come from one major exchange's Bitcoin perpetual market. Totals across every exchange are larger.
|
|
74
|
+
- Readings describe the market; they are not financial advice. The ledger places no real orders.
|
|
75
|
+
- The full API reference, and the OpenAPI description at `https://klinelens.com/v1/openapi.json`, are linked from [klinelens.com/api](https://klinelens.com/api).
|
|
76
|
+
|
|
77
|
+
MIT licence.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "klinelens"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the K-Line Lens API: Bitcoin order flow, liquidations, large trades, the order book and derivatives."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "K-Line Lens", email = "support@klinelens.com" }]
|
|
13
|
+
keywords = ["bitcoin", "order flow", "liquidations", "cvd", "crypto", "market data", "api"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
20
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
pandas = ["pandas>=1.5"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://klinelens.com"
|
|
30
|
+
Documentation = "https://klinelens.com/api"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.package-data]
|
|
36
|
+
klinelens = ["py.typed"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""K-Line Lens: Bitcoin order flow, liquidations, large trades, the book and
|
|
2
|
+
derivatives, from https://klinelens.com/api."""
|
|
3
|
+
from ._client import DEFAULT_BASE_URL, Client, __version__
|
|
4
|
+
from ._errors import (
|
|
5
|
+
AuthError,
|
|
6
|
+
BadRequestError,
|
|
7
|
+
KlineLensError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PlanError,
|
|
10
|
+
RateLimitError,
|
|
11
|
+
ServiceError,
|
|
12
|
+
)
|
|
13
|
+
from ._result import Result
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Client",
|
|
17
|
+
"Result",
|
|
18
|
+
"KlineLensError",
|
|
19
|
+
"BadRequestError",
|
|
20
|
+
"AuthError",
|
|
21
|
+
"PlanError",
|
|
22
|
+
"NotFoundError",
|
|
23
|
+
"RateLimitError",
|
|
24
|
+
"ServiceError",
|
|
25
|
+
"DEFAULT_BASE_URL",
|
|
26
|
+
"__version__",
|
|
27
|
+
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""The K-Line Lens API client: one method per /v1 endpoint."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
from ._errors import (
|
|
14
|
+
AuthError,
|
|
15
|
+
BadRequestError,
|
|
16
|
+
KlineLensError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
PlanError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
ServiceError,
|
|
21
|
+
)
|
|
22
|
+
from ._result import Result
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://klinelens.com"
|
|
27
|
+
_RETRY_STATUS = {502, 503, 504}
|
|
28
|
+
_ERRORS = {400: BadRequestError, 401: AuthError, 403: PlanError, 404: NotFoundError, 429: RateLimitError}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Client:
|
|
32
|
+
"""A client for the K-Line Lens API.
|
|
33
|
+
|
|
34
|
+
>>> from klinelens import Client
|
|
35
|
+
>>> kl = Client() # reads KLINELENS_API_KEY
|
|
36
|
+
>>> kl.liquidations(window="1h").data["totals"]
|
|
37
|
+
|
|
38
|
+
Every method returns a :class:`Result`: ``.data``, ``.meta``, ``.quota``,
|
|
39
|
+
and ``.to_pandas()`` when pandas is installed.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
api_key: Optional[str] = None,
|
|
45
|
+
*,
|
|
46
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
47
|
+
timeout: float = 15.0,
|
|
48
|
+
retries: int = 2,
|
|
49
|
+
) -> None:
|
|
50
|
+
key = api_key if api_key is not None else os.environ.get("KLINELENS_API_KEY")
|
|
51
|
+
if not key:
|
|
52
|
+
raise AuthError(401, "No API key. Pass api_key=... or set KLINELENS_API_KEY. Create one at https://klinelens.com/api.")
|
|
53
|
+
self._key = key
|
|
54
|
+
self.base_url = base_url.rstrip("/")
|
|
55
|
+
self.timeout = timeout
|
|
56
|
+
self.retries = max(0, int(retries))
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
return f"Client(base_url={self.base_url!r})"
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------ the read
|
|
62
|
+
|
|
63
|
+
def read(self) -> Result:
|
|
64
|
+
"""The read right now: the lean, how its readings vote, each one's side."""
|
|
65
|
+
return self._get("/v1/read")
|
|
66
|
+
|
|
67
|
+
def forecast(self, tf: str = "15m") -> Result:
|
|
68
|
+
"""The forecast model's latest call for ``tf`` (5m, 15m or 1h; Free reads 15m)."""
|
|
69
|
+
return self._get("/v1/forecast", {"tf": tf})
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------ market data
|
|
72
|
+
|
|
73
|
+
def snapshot(self) -> Result:
|
|
74
|
+
"""The last hour's flow, liquidations and large trades, the book and derivatives now."""
|
|
75
|
+
return self._get("/v1/snapshot")
|
|
76
|
+
|
|
77
|
+
def flow(self, window: str = "24h", interval: str = "1h") -> Result:
|
|
78
|
+
"""Taker buying and selling per interval, with the running delta (CVD)."""
|
|
79
|
+
return self._get("/v1/flow", {"window": window, "interval": interval})
|
|
80
|
+
|
|
81
|
+
def liquidations(self, window: str = "24h", interval: str = "4h", limit: Optional[int] = None) -> Result:
|
|
82
|
+
"""Longs and shorts forced closed per interval, and the latest single ones."""
|
|
83
|
+
return self._get("/v1/liquidations", {"window": window, "interval": interval, "limit": limit})
|
|
84
|
+
|
|
85
|
+
def whales(
|
|
86
|
+
self,
|
|
87
|
+
window: str = "24h",
|
|
88
|
+
interval: str = "4h",
|
|
89
|
+
min_usd: Optional[float] = None,
|
|
90
|
+
limit: Optional[int] = None,
|
|
91
|
+
) -> Result:
|
|
92
|
+
"""Large single trades ($50,000 and up, or ``min_usd``) per interval, and the latest ones."""
|
|
93
|
+
return self._get("/v1/whales", {"window": window, "interval": interval, "min": min_usd, "limit": limit})
|
|
94
|
+
|
|
95
|
+
def book(self, levels: int = 20, window: Optional[str] = None, interval: Optional[str] = None) -> Result:
|
|
96
|
+
"""The order book as of the last minute; with ``window``, its history too."""
|
|
97
|
+
return self._get("/v1/book", {"levels": levels, "window": window, "interval": interval})
|
|
98
|
+
|
|
99
|
+
def derivatives(self, window: Optional[str] = None, interval: Optional[str] = None) -> Result:
|
|
100
|
+
"""Funding, open interest, mark and index now; with ``window``, their history too."""
|
|
101
|
+
return self._get("/v1/derivatives", {"window": window, "interval": interval})
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------ the ledger
|
|
104
|
+
|
|
105
|
+
def setup(self) -> Result:
|
|
106
|
+
"""The ledger's open trade, or ``data`` None when none is open (``meta['reason']``)."""
|
|
107
|
+
return self._get("/v1/setup")
|
|
108
|
+
|
|
109
|
+
def ledger(self, limit: Optional[int] = None) -> Result:
|
|
110
|
+
"""The ledger's account, totals and latest trades, newest first."""
|
|
111
|
+
return self._get("/v1/ledger", {"limit": limit})
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------ transport
|
|
114
|
+
|
|
115
|
+
def _get(self, path: str, params: Optional[Mapping[str, Any]] = None) -> Result:
|
|
116
|
+
query = {k: v for k, v in (params or {}).items() if v is not None}
|
|
117
|
+
url = self.base_url + path + ("?" + urllib.parse.urlencode(query) if query else "")
|
|
118
|
+
headers = {
|
|
119
|
+
"Authorization": f"Bearer {self._key}",
|
|
120
|
+
"Accept": "application/json",
|
|
121
|
+
"User-Agent": f"klinelens-python/{__version__}",
|
|
122
|
+
}
|
|
123
|
+
attempt = 0
|
|
124
|
+
while True:
|
|
125
|
+
try:
|
|
126
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
127
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
128
|
+
return Result(_decode(resp.read()), resp.headers)
|
|
129
|
+
except urllib.error.HTTPError as err:
|
|
130
|
+
body = _decode(err.read(), strict=False)
|
|
131
|
+
message = body.get("error") if isinstance(body, dict) else None
|
|
132
|
+
if err.code in _RETRY_STATUS and attempt < self.retries:
|
|
133
|
+
attempt += 1
|
|
134
|
+
_pause(attempt)
|
|
135
|
+
continue
|
|
136
|
+
cls = _ERRORS.get(err.code, ServiceError if err.code >= 500 else KlineLensError)
|
|
137
|
+
raise cls(err.code, message or f"HTTP {err.code}", err.headers) from None
|
|
138
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as err:
|
|
139
|
+
if attempt < self.retries:
|
|
140
|
+
attempt += 1
|
|
141
|
+
_pause(attempt)
|
|
142
|
+
continue
|
|
143
|
+
reason = getattr(err, "reason", err)
|
|
144
|
+
raise ServiceError(None, f"Could not reach {self.base_url}: {reason}") from None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _decode(raw: bytes, strict: bool = True) -> Any:
|
|
148
|
+
try:
|
|
149
|
+
return json.loads(raw.decode("utf-8"))
|
|
150
|
+
except (ValueError, UnicodeDecodeError):
|
|
151
|
+
if strict:
|
|
152
|
+
raise ServiceError(None, "The API answered with something that is not JSON.") from None
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _pause(attempt: int) -> None:
|
|
157
|
+
time.sleep(min(8.0, 0.5 * 2 ** (attempt - 1)) + random.random() * 0.25)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Errors the API answers with, one class per kind."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class KlineLensError(Exception):
|
|
8
|
+
"""An answer that is not data. ``status`` is the HTTP status (None if the API was not reached)."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, status: Optional[int], message: str, headers: Any = None) -> None:
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.status = status
|
|
13
|
+
self.message = message
|
|
14
|
+
self.headers = headers
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
return f"{self.status}: {self.message}" if self.status else self.message
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BadRequestError(KlineLensError):
|
|
21
|
+
"""400: a parameter is wrong; the message says which."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthError(KlineLensError):
|
|
25
|
+
"""401: no key, or not a valid one."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PlanError(KlineLensError):
|
|
29
|
+
"""403: outside your plan (more history, a smaller interval or another timeframe)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotFoundError(KlineLensError):
|
|
33
|
+
"""404: no such endpoint."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RateLimitError(KlineLensError):
|
|
37
|
+
"""429: today's requests are used up. It resets at 00:00 UTC."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ServiceError(KlineLensError):
|
|
41
|
+
"""5xx, or the API could not be reached. Retried before it is raised."""
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""What every call returns: the data, its meta, today's quota, and a table."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Mapping, Optional
|
|
5
|
+
|
|
6
|
+
# The list in each answer that makes the natural table, in order of preference.
|
|
7
|
+
_TABLES = ("points", "events", "trades", "history", "path")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Result:
|
|
11
|
+
"""An API answer. ``data`` and ``meta`` are the envelope's two halves;
|
|
12
|
+
``result["totals"]`` reads from ``data``."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, body: Mapping[str, Any], headers: Any = None) -> None:
|
|
15
|
+
self.data: Any = body.get("data")
|
|
16
|
+
self.meta: dict = dict(body.get("meta") or {})
|
|
17
|
+
limit = _int(headers.get("X-Quota-Limit")) if headers is not None else None
|
|
18
|
+
remaining = _int(headers.get("X-Quota-Remaining")) if headers is not None else None
|
|
19
|
+
self.quota = {"limit": limit, "remaining": remaining}
|
|
20
|
+
|
|
21
|
+
def __getitem__(self, key: str) -> Any:
|
|
22
|
+
if not isinstance(self.data, Mapping):
|
|
23
|
+
raise KeyError(key)
|
|
24
|
+
return self.data[key]
|
|
25
|
+
|
|
26
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
27
|
+
return self.data.get(key, default) if isinstance(self.data, Mapping) else default
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
keys = list(self.data) if isinstance(self.data, Mapping) else type(self.data).__name__
|
|
31
|
+
return f"Result(data={keys}, quota={self.quota})"
|
|
32
|
+
|
|
33
|
+
def to_pandas(self, table: Optional[str] = None):
|
|
34
|
+
"""The answer's list as a pandas DataFrame: points, events, trades,
|
|
35
|
+
history or path, whichever it has, or the one named in ``table``.
|
|
36
|
+
Timestamps become UTC datetimes. Needs ``pip install klinelens[pandas]``."""
|
|
37
|
+
try:
|
|
38
|
+
import pandas as pd
|
|
39
|
+
except ImportError:
|
|
40
|
+
raise ImportError("to_pandas() needs pandas: pip install klinelens[pandas]") from None
|
|
41
|
+
data = self.data if isinstance(self.data, Mapping) else {}
|
|
42
|
+
name = table or next((k for k in _TABLES if isinstance(data.get(k), list)), None)
|
|
43
|
+
if name is None:
|
|
44
|
+
# The book's two sides, as price and size.
|
|
45
|
+
if isinstance(data.get("bids"), list) or isinstance((data.get("now") or {}).get("bids"), list):
|
|
46
|
+
src = data if "bids" in data else data["now"]
|
|
47
|
+
rows = [{"side": side, "price": p, "qty": q} for side in ("bids", "asks") for p, q in src.get(side) or []]
|
|
48
|
+
return pd.DataFrame(rows, columns=["side", "price", "qty"])
|
|
49
|
+
raise ValueError(f"No table in this answer. Choose one of: {', '.join(k for k, v in data.items() if isinstance(v, list)) or 'none'}")
|
|
50
|
+
rows = data.get(name)
|
|
51
|
+
if not isinstance(rows, list):
|
|
52
|
+
raise ValueError(f"{name!r} is not a list in this answer.")
|
|
53
|
+
frame = pd.DataFrame(rows)
|
|
54
|
+
for col in ("at", "openedAt", "closedAt"):
|
|
55
|
+
if col in frame.columns:
|
|
56
|
+
frame[col] = pd.to_datetime(frame[col], utc=True)
|
|
57
|
+
return frame
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _int(value: Any) -> Optional[int]:
|
|
61
|
+
try:
|
|
62
|
+
return int(value)
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: klinelens
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the K-Line Lens API: Bitcoin order flow, liquidations, large trades, the order book and derivatives.
|
|
5
|
+
Author-email: K-Line Lens <support@klinelens.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://klinelens.com
|
|
8
|
+
Project-URL: Documentation, https://klinelens.com/api
|
|
9
|
+
Keywords: bitcoin,order flow,liquidations,cvd,crypto,market data,api
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
15
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: pandas
|
|
21
|
+
Requires-Dist: pandas>=1.5; extra == "pandas"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# klinelens
|
|
25
|
+
|
|
26
|
+
The Python client for the [K-Line Lens API](https://klinelens.com/api): Bitcoin taker buying and selling, liquidations, large trades, the order book, funding and open interest, in a few lines.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install klinelens # no dependencies
|
|
30
|
+
pip install "klinelens[pandas]" # adds .to_pandas()
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Create a key at [klinelens.com/api](https://klinelens.com/api). The free plan needs no card.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from klinelens import Client
|
|
37
|
+
|
|
38
|
+
kl = Client() # reads KLINELENS_API_KEY; or Client("kll_live_...")
|
|
39
|
+
|
|
40
|
+
snap = kl.snapshot()
|
|
41
|
+
print(snap["lastHour"]["liquidations"]) # {'longUsd': ..., 'shortUsd': ..., 'count': ...}
|
|
42
|
+
|
|
43
|
+
flow = kl.flow(window="24h", interval="1h").to_pandas()
|
|
44
|
+
print(flow[["at", "deltaUsd", "cumulativeUsd"]].tail())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Methods
|
|
48
|
+
|
|
49
|
+
| Method | Endpoint | What it returns |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `read()` | `/v1/read` | The read right now: the lean, how its readings vote, each one's side |
|
|
52
|
+
| `forecast(tf="15m")` | `/v1/forecast` | The forecast model's latest call and its path |
|
|
53
|
+
| `snapshot()` | `/v1/snapshot` | The last hour's flow, liquidations and large trades, the book and derivatives now |
|
|
54
|
+
| `flow(window, interval)` | `/v1/flow` | Taker buys and sells per interval, the delta and its running total (CVD) |
|
|
55
|
+
| `liquidations(window, interval, limit)` | `/v1/liquidations` | Longs and shorts forced closed per interval, and the latest ones |
|
|
56
|
+
| `whales(window, interval, min_usd, limit)` | `/v1/whales` | Trades of $50,000 and up per interval, and the latest ones |
|
|
57
|
+
| `book(levels, window, interval)` | `/v1/book` | The order book now, and its history with `window` |
|
|
58
|
+
| `derivatives(window, interval)` | `/v1/derivatives` | Funding, open interest, mark and index, and their history with `window` |
|
|
59
|
+
| `setup()` | `/v1/setup` | The ledger's open trade, or `None` |
|
|
60
|
+
| `ledger(limit)` | `/v1/ledger` | The ledger's account, totals and latest trades |
|
|
61
|
+
|
|
62
|
+
`window` is a number and `m`, `h` or `d` (`90m`, `24h`, `7d`). `interval` is one of `1m`, `5m`, `15m`, `1h`, `4h`, `1d`. How far back you can read, the smallest interval and how many events are listed depend on your plan; `result.meta["plan"]` says what yours allows.
|
|
63
|
+
|
|
64
|
+
## What you get back
|
|
65
|
+
|
|
66
|
+
Every method returns a `Result`:
|
|
67
|
+
|
|
68
|
+
- `result.data`: the answer. `result["totals"]` reads from it.
|
|
69
|
+
- `result.meta`: when it was made, the window, any gaps in the record, your plan's limits.
|
|
70
|
+
- `result.quota`: `{"limit": ..., "remaining": ...}` requests for today (UTC).
|
|
71
|
+
- `result.to_pandas()`: the answer's list (points, events, trades, history or path) as a DataFrame, timestamps in UTC. Name one with `to_pandas("events")`.
|
|
72
|
+
|
|
73
|
+
## Errors
|
|
74
|
+
|
|
75
|
+
Failures raise a subclass of `klinelens.KlineLensError`, with `.status` and `.message`:
|
|
76
|
+
|
|
77
|
+
| Error | When |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `BadRequestError` | 400: a parameter is wrong; the message says which |
|
|
80
|
+
| `AuthError` | 401: no key, or not a valid one |
|
|
81
|
+
| `PlanError` | 403: outside your plan (more history, a smaller interval) |
|
|
82
|
+
| `RateLimitError` | 429: today's requests are used up; they reset at 00:00 UTC |
|
|
83
|
+
| `ServiceError` | 5xx or no connection, after two retries |
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from klinelens import Client, PlanError
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
Client().flow(window="30d", interval="1m")
|
|
90
|
+
except PlanError as e:
|
|
91
|
+
print(e.message) # "Your plan reads 1 day of history. ..."
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Good to know
|
|
95
|
+
|
|
96
|
+
- Order flow, liquidations, large trades and the book come from one major exchange's Bitcoin perpetual market. Totals across every exchange are larger.
|
|
97
|
+
- Readings describe the market; they are not financial advice. The ledger places no real orders.
|
|
98
|
+
- The full API reference, and the OpenAPI description at `https://klinelens.com/v1/openapi.json`, are linked from [klinelens.com/api](https://klinelens.com/api).
|
|
99
|
+
|
|
100
|
+
MIT licence.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/klinelens/__init__.py
|
|
5
|
+
src/klinelens/_client.py
|
|
6
|
+
src/klinelens/_errors.py
|
|
7
|
+
src/klinelens/_result.py
|
|
8
|
+
src/klinelens/py.typed
|
|
9
|
+
src/klinelens.egg-info/PKG-INFO
|
|
10
|
+
src/klinelens.egg-info/SOURCES.txt
|
|
11
|
+
src/klinelens.egg-info/dependency_links.txt
|
|
12
|
+
src/klinelens.egg-info/requires.txt
|
|
13
|
+
src/klinelens.egg-info/top_level.txt
|
|
14
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
klinelens
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""The client against a local server that answers like the API."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import types
|
|
7
|
+
import unittest
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
+
from urllib.parse import parse_qs, urlparse
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
12
|
+
import klinelens # noqa: E402
|
|
13
|
+
from klinelens import AuthError, BadRequestError, Client, PlanError, RateLimitError, ServiceError # noqa: E402
|
|
14
|
+
|
|
15
|
+
SEEN = [] # (path, query, headers) of every request
|
|
16
|
+
SCRIPT = {} # path -> list of (status, body) answered in turn; the last repeats
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Handler(BaseHTTPRequestHandler):
|
|
20
|
+
def do_GET(self):
|
|
21
|
+
u = urlparse(self.path)
|
|
22
|
+
SEEN.append((u.path, parse_qs(u.query), dict(self.headers)))
|
|
23
|
+
queue = SCRIPT.get(u.path) or [(200, {"data": {"path": u.path}, "meta": {"version": "v1"}})]
|
|
24
|
+
status, body = queue.pop(0) if len(queue) > 1 else queue[0]
|
|
25
|
+
raw = body if isinstance(body, bytes) else json.dumps(body).encode()
|
|
26
|
+
self.send_response(status)
|
|
27
|
+
self.send_header("Content-Type", "application/json")
|
|
28
|
+
self.send_header("X-Quota-Limit", "100")
|
|
29
|
+
self.send_header("X-Quota-Remaining", "97")
|
|
30
|
+
self.end_headers()
|
|
31
|
+
self.wfile.write(raw)
|
|
32
|
+
|
|
33
|
+
def log_message(self, *args):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ClientTest(unittest.TestCase):
|
|
38
|
+
@classmethod
|
|
39
|
+
def setUpClass(cls):
|
|
40
|
+
cls.server = HTTPServer(("127.0.0.1", 0), Handler)
|
|
41
|
+
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
|
42
|
+
cls.base = f"http://127.0.0.1:{cls.server.server_port}"
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def tearDownClass(cls):
|
|
46
|
+
cls.server.shutdown()
|
|
47
|
+
|
|
48
|
+
def setUp(self):
|
|
49
|
+
SEEN.clear()
|
|
50
|
+
SCRIPT.clear()
|
|
51
|
+
klinelens._client._pause = lambda attempt: None # no waiting between retries here
|
|
52
|
+
self.kl = Client("kll_live_test", base_url=self.base)
|
|
53
|
+
|
|
54
|
+
def test_key_in_the_header_never_the_url(self):
|
|
55
|
+
self.kl.read()
|
|
56
|
+
path, query, headers = SEEN[0]
|
|
57
|
+
self.assertEqual(path, "/v1/read")
|
|
58
|
+
self.assertEqual(headers["Authorization"], "Bearer kll_live_test")
|
|
59
|
+
self.assertNotIn("key", query)
|
|
60
|
+
self.assertTrue(headers["User-Agent"].startswith("klinelens-python/"))
|
|
61
|
+
|
|
62
|
+
def test_key_from_the_environment_or_a_clear_error(self):
|
|
63
|
+
os.environ["KLINELENS_API_KEY"] = "kll_live_env"
|
|
64
|
+
try:
|
|
65
|
+
Client(base_url=self.base).snapshot()
|
|
66
|
+
self.assertEqual(SEEN[0][2]["Authorization"], "Bearer kll_live_env")
|
|
67
|
+
finally:
|
|
68
|
+
del os.environ["KLINELENS_API_KEY"]
|
|
69
|
+
with self.assertRaises(AuthError):
|
|
70
|
+
Client(base_url=self.base)
|
|
71
|
+
|
|
72
|
+
def test_each_method_asks_its_endpoint_with_its_parameters(self):
|
|
73
|
+
calls = [
|
|
74
|
+
(lambda: self.kl.forecast("1h"), "/v1/forecast", {"tf": ["1h"]}),
|
|
75
|
+
(lambda: self.kl.flow("7d", "4h"), "/v1/flow", {"window": ["7d"], "interval": ["4h"]}),
|
|
76
|
+
(lambda: self.kl.liquidations("1h", "15m", 10), "/v1/liquidations", {"window": ["1h"], "interval": ["15m"], "limit": ["10"]}),
|
|
77
|
+
(lambda: self.kl.whales(min_usd=250000), "/v1/whales", {"window": ["24h"], "interval": ["4h"], "min": ["250000"]}),
|
|
78
|
+
(lambda: self.kl.book(), "/v1/book", {"levels": ["20"]}),
|
|
79
|
+
(lambda: self.kl.book(5, "2h", "1h"), "/v1/book", {"levels": ["5"], "window": ["2h"], "interval": ["1h"]}),
|
|
80
|
+
(lambda: self.kl.derivatives(), "/v1/derivatives", {}),
|
|
81
|
+
(lambda: self.kl.setup(), "/v1/setup", {}),
|
|
82
|
+
(lambda: self.kl.ledger(5), "/v1/ledger", {"limit": ["5"]}),
|
|
83
|
+
]
|
|
84
|
+
for call, path, query in calls:
|
|
85
|
+
SEEN.clear()
|
|
86
|
+
call()
|
|
87
|
+
self.assertEqual((SEEN[0][0], SEEN[0][1]), (path, query), path)
|
|
88
|
+
|
|
89
|
+
def test_result_data_meta_and_quota(self):
|
|
90
|
+
SCRIPT["/v1/liquidations"] = [(200, {"data": {"totals": {"count": 3}}, "meta": {"interval": "4h"}})]
|
|
91
|
+
r = self.kl.liquidations()
|
|
92
|
+
self.assertEqual(r["totals"]["count"], 3)
|
|
93
|
+
self.assertEqual(r.meta["interval"], "4h")
|
|
94
|
+
self.assertEqual(r.quota, {"limit": 100, "remaining": 97})
|
|
95
|
+
|
|
96
|
+
def test_null_data_is_kept(self):
|
|
97
|
+
SCRIPT["/v1/setup"] = [(200, {"data": None, "meta": {"reason": "No trade is open right now."}})]
|
|
98
|
+
r = self.kl.setup()
|
|
99
|
+
self.assertIsNone(r.data)
|
|
100
|
+
self.assertEqual(r.meta["reason"], "No trade is open right now.")
|
|
101
|
+
self.assertIsNone(r.get("entry"))
|
|
102
|
+
|
|
103
|
+
def test_errors_by_status(self):
|
|
104
|
+
for status, cls in [(400, BadRequestError), (401, AuthError), (403, PlanError), (429, RateLimitError)]:
|
|
105
|
+
SCRIPT["/v1/flow"] = [(status, {"error": f"said {status}"})]
|
|
106
|
+
with self.assertRaises(cls) as ctx:
|
|
107
|
+
self.kl.flow()
|
|
108
|
+
self.assertEqual((ctx.exception.status, ctx.exception.message), (status, f"said {status}"))
|
|
109
|
+
|
|
110
|
+
def test_a_restart_is_retried_then_answers(self):
|
|
111
|
+
SCRIPT["/v1/read"] = [(503, {"error": "restarting"}), (503, {"error": "restarting"}), (200, {"data": {"ok": 1}, "meta": {}})]
|
|
112
|
+
self.assertEqual(self.kl.read()["ok"], 1)
|
|
113
|
+
self.assertEqual(len(SEEN), 3)
|
|
114
|
+
|
|
115
|
+
def test_a_quota_refusal_is_not_retried(self):
|
|
116
|
+
SCRIPT["/v1/read"] = [(429, {"error": "used up"})]
|
|
117
|
+
with self.assertRaises(RateLimitError):
|
|
118
|
+
self.kl.read()
|
|
119
|
+
self.assertEqual(len(SEEN), 1)
|
|
120
|
+
|
|
121
|
+
def test_retries_run_out(self):
|
|
122
|
+
SCRIPT["/v1/read"] = [(503, {"error": "down"})]
|
|
123
|
+
with self.assertRaises(ServiceError):
|
|
124
|
+
self.kl.read()
|
|
125
|
+
self.assertEqual(len(SEEN), 3)
|
|
126
|
+
|
|
127
|
+
def test_no_server(self):
|
|
128
|
+
kl = Client("kll_live_test", base_url="http://127.0.0.1:9", retries=0, timeout=2)
|
|
129
|
+
with self.assertRaises(ServiceError):
|
|
130
|
+
kl.read()
|
|
131
|
+
|
|
132
|
+
def test_to_pandas(self):
|
|
133
|
+
frames = []
|
|
134
|
+
|
|
135
|
+
class FakeFrame:
|
|
136
|
+
def __init__(self, rows, columns=None):
|
|
137
|
+
self.rows, self.columns = rows, columns or (list(rows[0]) if rows else [])
|
|
138
|
+
frames.append(self)
|
|
139
|
+
|
|
140
|
+
def __setitem__(self, k, v):
|
|
141
|
+
self.converted = k
|
|
142
|
+
|
|
143
|
+
def __getitem__(self, k):
|
|
144
|
+
return k
|
|
145
|
+
|
|
146
|
+
fake = types.ModuleType("pandas")
|
|
147
|
+
fake.DataFrame = FakeFrame
|
|
148
|
+
fake.to_datetime = lambda col, utc: ("utc", col)
|
|
149
|
+
sys.modules["pandas"] = fake
|
|
150
|
+
try:
|
|
151
|
+
SCRIPT["/v1/flow"] = [(200, {"data": {"points": [{"at": "2026-11-12T12:00:00Z", "deltaUsd": 1}], "totals": {}}, "meta": {}})]
|
|
152
|
+
f = self.kl.flow().to_pandas()
|
|
153
|
+
self.assertEqual(f.rows[0]["deltaUsd"], 1)
|
|
154
|
+
self.assertEqual(f.converted, "at")
|
|
155
|
+
SCRIPT["/v1/book"] = [(200, {"data": {"now": {"bids": [[1, 2]], "asks": [[3, 4]]}, "history": None}, "meta": {}})]
|
|
156
|
+
b = self.kl.book().to_pandas()
|
|
157
|
+
self.assertEqual(b.rows, [{"side": "bids", "price": 1, "qty": 2}, {"side": "asks", "price": 3, "qty": 4}])
|
|
158
|
+
SCRIPT["/v1/read"] = [(200, {"data": {"direction": "long"}, "meta": {}})]
|
|
159
|
+
with self.assertRaises(ValueError):
|
|
160
|
+
self.kl.read().to_pandas()
|
|
161
|
+
finally:
|
|
162
|
+
del sys.modules["pandas"]
|
|
163
|
+
try:
|
|
164
|
+
import pandas # noqa: F401
|
|
165
|
+
except ImportError:
|
|
166
|
+
SCRIPT["/v1/flow"] = [(200, {"data": {"points": []}, "meta": {}})]
|
|
167
|
+
with self.assertRaises(ImportError):
|
|
168
|
+
self.kl.flow().to_pandas()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
unittest.main()
|