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.
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+
2
+ [pandas]
3
+ pandas>=1.5
@@ -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()