lpsignal 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.
- lpsignal-0.1.0/.gitignore +8 -0
- lpsignal-0.1.0/LICENSE +21 -0
- lpsignal-0.1.0/PKG-INFO +126 -0
- lpsignal-0.1.0/README.md +110 -0
- lpsignal-0.1.0/pyproject.toml +27 -0
- lpsignal-0.1.0/scripts/e2e.py +239 -0
- lpsignal-0.1.0/src/lpsignal/__init__.py +12 -0
- lpsignal-0.1.0/src/lpsignal/client.py +375 -0
- lpsignal-0.1.0/src/lpsignal/stream.py +305 -0
- lpsignal-0.1.0/src/lpsignal/types.py +154 -0
- lpsignal-0.1.0/src/lpsignal/webhook.py +51 -0
- lpsignal-0.1.0/tests/conftest.py +24 -0
- lpsignal-0.1.0/tests/test_client.py +131 -0
- lpsignal-0.1.0/tests/test_stream.py +402 -0
- lpsignal-0.1.0/tests/test_webhook.py +55 -0
lpsignal-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LPSignal
|
|
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.
|
lpsignal-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lpsignal
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official LPSignal SDK: net-of-IL APR signals for concentrated-liquidity pools, a WebSocket stream that never misses a signal, webhook verification and the REST API.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Ligengxin96/lpsignal-sdk
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: apr,concentrated-liquidity,defi,impermanent-loss,liquidity-provider,lpsignal,uniswap
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: websockets>=14
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
14
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# lpsignal (Python)
|
|
18
|
+
|
|
19
|
+
Official Python SDK for [LPSignal](https://lpsignal.app): net-of-IL APR signals for concentrated-liquidity pools.
|
|
20
|
+
Python ≥ 3.10, built on `httpx` and `websockets`.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install lpsignal
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## REST
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from lpsignal import LPSignal, LPSignalError
|
|
30
|
+
|
|
31
|
+
with LPSignal(api_key="lps_...") as lps: # key optional for public endpoints
|
|
32
|
+
page = lps.pools(chain="base", window=168, min_tvl_usd=1e6)
|
|
33
|
+
top = page["pools"][0]
|
|
34
|
+
detail = lps.pool("base", top["address"]) # every (window × range) metric
|
|
35
|
+
bt = lps.backtest("base", top["address"], range_pct=5, days=7)
|
|
36
|
+
try:
|
|
37
|
+
lps.wallet_positions("0x...")
|
|
38
|
+
except LPSignalError as e:
|
|
39
|
+
if e.code == "pro_required":
|
|
40
|
+
...
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`AsyncLPSignal` has the same methods as coroutines. Responses are the API's JSON (camelCase keys); `lpsignal.types`
|
|
44
|
+
has `TypedDict`s for the main shapes.
|
|
45
|
+
|
|
46
|
+
| Method | Endpoint | Key |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `health()` · `chains()` | `/v1/health` · `/v1/chains` | – |
|
|
49
|
+
| `pools(chain, pair_class, window, min_tvl_usd, limit, offset)` | `GET /v1/pools` | – |
|
|
50
|
+
| `pool(chain, address)` · `pool_hours(chain, address, hours)` | `GET /v1/pools/:chain/:address[/hours]` | – |
|
|
51
|
+
| `backtest(chain, address, range_pct, days)` | `GET …/backtest` | – |
|
|
52
|
+
| `signals(kind, limit, before)` · `signal(id)` | `GET /v1/signals[/:id]` | optional |
|
|
53
|
+
| `signal_stats(days)` | `GET /v1/signals/stats` — the public track record | – |
|
|
54
|
+
| `iter_signals(kind)` | every page, newest first | optional |
|
|
55
|
+
| `signals_after(id)` | everything newer than `id`, oldest first | optional |
|
|
56
|
+
| `smart_lps(window_days, chain, limit)` | `GET /v1/smart-lps` | optional |
|
|
57
|
+
| `wallet_positions(owner)` | `GET /v1/smart-lps/:owner/positions` | Pro |
|
|
58
|
+
| `follows()` · `follow(owner)` · `unfollow(owner)` | `/v1/me/follows` | Pro |
|
|
59
|
+
| `me()` · `set_webhook(url)` · `delete_webhook()` · `telegram_link()` | `/v1/me…` | yes |
|
|
60
|
+
| `create_api_key(replace)` | `POST /v1/me/api-key` — replaces the key (or `replace=False`: only if none), returned once | yes |
|
|
61
|
+
| `billing(refresh)` · `checkout(tier)` · `billing_portal()` | `/v1/billing…` | yes |
|
|
62
|
+
| `crypto_billing()` · `create_crypto_order(tier, months)` · `crypto_order(id)` · `cancel_crypto_order(id)` | `/v1/billing/crypto…` — prepaid USDT/USDC plans | yes |
|
|
63
|
+
|
|
64
|
+
Errors are `LPSignalError` with `status`, `code` (the API's `error` field), `body` and `request_id`. A `429` on a
|
|
65
|
+
GET, PUT or DELETE is retried after its `Retry-After` (`max_retries`, default 2).
|
|
66
|
+
|
|
67
|
+
## Stream
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import asyncio
|
|
71
|
+
from lpsignal import AsyncLPSignal, FileLastIdStore, SignalStream
|
|
72
|
+
|
|
73
|
+
async def on_signal(signal, source): # source: "rest" | "replay" | "live"
|
|
74
|
+
... # once per signal, in id order, never concurrently
|
|
75
|
+
|
|
76
|
+
async def main():
|
|
77
|
+
lps = AsyncLPSignal(api_key="lps_...") # Basic or Pro
|
|
78
|
+
stream = SignalStream(lps, on_signal, store=FileLastIdStore("lpsignal-state.json"),
|
|
79
|
+
on_event=lambda e: print(e) if e["type"] == "fatal" else None)
|
|
80
|
+
await stream.run() # or: await stream.start() … await stream.stop()
|
|
81
|
+
|
|
82
|
+
asyncio.run(main())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- **First start** with an empty store: begins after the newest signal that exists now (or after `since`).
|
|
86
|
+
- **Every (re)connect**: first fetches everything after the saved id over REST, repeating until a pass finds
|
|
87
|
+
nothing new, then connects with `?since=<id>`; anything at or below the saved id is dropped. No gap however long
|
|
88
|
+
you were away, and no duplicates while the process runs.
|
|
89
|
+
- **Your handler decides progress**: the id is saved only after `on_signal` returns. If it raises, the connection
|
|
90
|
+
is dropped and the signal is offered again. A crash after the handler but before the save also offers it again
|
|
91
|
+
after the restart: delivery is **at-least-once**, so make the handler idempotent on `signal["id"]`.
|
|
92
|
+
- **No repeats even across crashes**: keep the last id in the same database transaction as your side effects
|
|
93
|
+
(write it inside `on_signal`), and pass a `store` that reads and writes that same row; its `save()` must only
|
|
94
|
+
move forward (e.g. `UPDATE … SET last_id = GREATEST(last_id, %s)`), since the stream also saves the starting point.
|
|
95
|
+
- `stop()` waits for the signal being handled; nothing new is handed over after it is called. Cancelling `run()`
|
|
96
|
+
stops the stream. Don't await `stop()` from inside `on_signal` (it would wait for itself). After a `fatal` event,
|
|
97
|
+
call `stop()` before `start()` again.
|
|
98
|
+
- **Fatal** (the stream stops): invalid key (401), a plan without the stream (402), or the plan expiring (4402).
|
|
99
|
+
Everything else reconnects with backoff.
|
|
100
|
+
|
|
101
|
+
The state file has the same format as the Node SDK's, so you can switch languages without losing your place.
|
|
102
|
+
|
|
103
|
+
## Webhooks
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from fastapi import FastAPI, Request, Response
|
|
107
|
+
from lpsignal import WebhookVerificationError, verify_webhook
|
|
108
|
+
|
|
109
|
+
app = FastAPI()
|
|
110
|
+
|
|
111
|
+
@app.post("/lpsignal")
|
|
112
|
+
async def lpsignal(request: Request):
|
|
113
|
+
try:
|
|
114
|
+
event = verify_webhook(await request.body(), request.headers, SECRET)
|
|
115
|
+
except WebhookVerificationError as e:
|
|
116
|
+
return Response(e.reason, status_code=400)
|
|
117
|
+
# deliveries are at-least-once: skip event["deliveryId"] if already handled
|
|
118
|
+
return Response(status_code=200)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Pass the raw body bytes, never re-serialised JSON. Deliveries older than 5 minutes are rejected (`tolerance_sec`);
|
|
122
|
+
every retry is signed afresh.
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
lpsignal-0.1.0/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# lpsignal (Python)
|
|
2
|
+
|
|
3
|
+
Official Python SDK for [LPSignal](https://lpsignal.app): net-of-IL APR signals for concentrated-liquidity pools.
|
|
4
|
+
Python ≥ 3.10, built on `httpx` and `websockets`.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install lpsignal
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## REST
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from lpsignal import LPSignal, LPSignalError
|
|
14
|
+
|
|
15
|
+
with LPSignal(api_key="lps_...") as lps: # key optional for public endpoints
|
|
16
|
+
page = lps.pools(chain="base", window=168, min_tvl_usd=1e6)
|
|
17
|
+
top = page["pools"][0]
|
|
18
|
+
detail = lps.pool("base", top["address"]) # every (window × range) metric
|
|
19
|
+
bt = lps.backtest("base", top["address"], range_pct=5, days=7)
|
|
20
|
+
try:
|
|
21
|
+
lps.wallet_positions("0x...")
|
|
22
|
+
except LPSignalError as e:
|
|
23
|
+
if e.code == "pro_required":
|
|
24
|
+
...
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`AsyncLPSignal` has the same methods as coroutines. Responses are the API's JSON (camelCase keys); `lpsignal.types`
|
|
28
|
+
has `TypedDict`s for the main shapes.
|
|
29
|
+
|
|
30
|
+
| Method | Endpoint | Key |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `health()` · `chains()` | `/v1/health` · `/v1/chains` | – |
|
|
33
|
+
| `pools(chain, pair_class, window, min_tvl_usd, limit, offset)` | `GET /v1/pools` | – |
|
|
34
|
+
| `pool(chain, address)` · `pool_hours(chain, address, hours)` | `GET /v1/pools/:chain/:address[/hours]` | – |
|
|
35
|
+
| `backtest(chain, address, range_pct, days)` | `GET …/backtest` | – |
|
|
36
|
+
| `signals(kind, limit, before)` · `signal(id)` | `GET /v1/signals[/:id]` | optional |
|
|
37
|
+
| `signal_stats(days)` | `GET /v1/signals/stats` — the public track record | – |
|
|
38
|
+
| `iter_signals(kind)` | every page, newest first | optional |
|
|
39
|
+
| `signals_after(id)` | everything newer than `id`, oldest first | optional |
|
|
40
|
+
| `smart_lps(window_days, chain, limit)` | `GET /v1/smart-lps` | optional |
|
|
41
|
+
| `wallet_positions(owner)` | `GET /v1/smart-lps/:owner/positions` | Pro |
|
|
42
|
+
| `follows()` · `follow(owner)` · `unfollow(owner)` | `/v1/me/follows` | Pro |
|
|
43
|
+
| `me()` · `set_webhook(url)` · `delete_webhook()` · `telegram_link()` | `/v1/me…` | yes |
|
|
44
|
+
| `create_api_key(replace)` | `POST /v1/me/api-key` — replaces the key (or `replace=False`: only if none), returned once | yes |
|
|
45
|
+
| `billing(refresh)` · `checkout(tier)` · `billing_portal()` | `/v1/billing…` | yes |
|
|
46
|
+
| `crypto_billing()` · `create_crypto_order(tier, months)` · `crypto_order(id)` · `cancel_crypto_order(id)` | `/v1/billing/crypto…` — prepaid USDT/USDC plans | yes |
|
|
47
|
+
|
|
48
|
+
Errors are `LPSignalError` with `status`, `code` (the API's `error` field), `body` and `request_id`. A `429` on a
|
|
49
|
+
GET, PUT or DELETE is retried after its `Retry-After` (`max_retries`, default 2).
|
|
50
|
+
|
|
51
|
+
## Stream
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import asyncio
|
|
55
|
+
from lpsignal import AsyncLPSignal, FileLastIdStore, SignalStream
|
|
56
|
+
|
|
57
|
+
async def on_signal(signal, source): # source: "rest" | "replay" | "live"
|
|
58
|
+
... # once per signal, in id order, never concurrently
|
|
59
|
+
|
|
60
|
+
async def main():
|
|
61
|
+
lps = AsyncLPSignal(api_key="lps_...") # Basic or Pro
|
|
62
|
+
stream = SignalStream(lps, on_signal, store=FileLastIdStore("lpsignal-state.json"),
|
|
63
|
+
on_event=lambda e: print(e) if e["type"] == "fatal" else None)
|
|
64
|
+
await stream.run() # or: await stream.start() … await stream.stop()
|
|
65
|
+
|
|
66
|
+
asyncio.run(main())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- **First start** with an empty store: begins after the newest signal that exists now (or after `since`).
|
|
70
|
+
- **Every (re)connect**: first fetches everything after the saved id over REST, repeating until a pass finds
|
|
71
|
+
nothing new, then connects with `?since=<id>`; anything at or below the saved id is dropped. No gap however long
|
|
72
|
+
you were away, and no duplicates while the process runs.
|
|
73
|
+
- **Your handler decides progress**: the id is saved only after `on_signal` returns. If it raises, the connection
|
|
74
|
+
is dropped and the signal is offered again. A crash after the handler but before the save also offers it again
|
|
75
|
+
after the restart: delivery is **at-least-once**, so make the handler idempotent on `signal["id"]`.
|
|
76
|
+
- **No repeats even across crashes**: keep the last id in the same database transaction as your side effects
|
|
77
|
+
(write it inside `on_signal`), and pass a `store` that reads and writes that same row; its `save()` must only
|
|
78
|
+
move forward (e.g. `UPDATE … SET last_id = GREATEST(last_id, %s)`), since the stream also saves the starting point.
|
|
79
|
+
- `stop()` waits for the signal being handled; nothing new is handed over after it is called. Cancelling `run()`
|
|
80
|
+
stops the stream. Don't await `stop()` from inside `on_signal` (it would wait for itself). After a `fatal` event,
|
|
81
|
+
call `stop()` before `start()` again.
|
|
82
|
+
- **Fatal** (the stream stops): invalid key (401), a plan without the stream (402), or the plan expiring (4402).
|
|
83
|
+
Everything else reconnects with backoff.
|
|
84
|
+
|
|
85
|
+
The state file has the same format as the Node SDK's, so you can switch languages without losing your place.
|
|
86
|
+
|
|
87
|
+
## Webhooks
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from fastapi import FastAPI, Request, Response
|
|
91
|
+
from lpsignal import WebhookVerificationError, verify_webhook
|
|
92
|
+
|
|
93
|
+
app = FastAPI()
|
|
94
|
+
|
|
95
|
+
@app.post("/lpsignal")
|
|
96
|
+
async def lpsignal(request: Request):
|
|
97
|
+
try:
|
|
98
|
+
event = verify_webhook(await request.body(), request.headers, SECRET)
|
|
99
|
+
except WebhookVerificationError as e:
|
|
100
|
+
return Response(e.reason, status_code=400)
|
|
101
|
+
# deliveries are at-least-once: skip event["deliveryId"] if already handled
|
|
102
|
+
return Response(status_code=200)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Pass the raw body bytes, never re-serialised JSON. Deliveries older than 5 minutes are rejected (`tolerance_sec`);
|
|
106
|
+
every retry is signed afresh.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lpsignal"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official LPSignal SDK: net-of-IL APR signals for concentrated-liquidity pools, a WebSocket stream that never misses a signal, webhook verification and the REST API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = ["lpsignal", "defi", "uniswap", "concentrated-liquidity", "liquidity-provider", "impermanent-loss", "apr"]
|
|
13
|
+
dependencies = ["httpx>=0.27", "websockets>=14"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
test = ["pytest>=8", "pytest-asyncio>=0.24"]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/Ligengxin96/lpsignal-sdk"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/lpsignal"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
asyncio_mode = "auto"
|
|
26
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
27
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""End-to-end check of the Python SDK against a running LPSignal API. Read-only by default (safe against production):
|
|
3
|
+
|
|
4
|
+
LPSIGNAL_BASE_URL=https://api.lpsignal.app [LPSIGNAL_API_KEY=lps_...] python scripts/e2e.py
|
|
5
|
+
|
|
6
|
+
Optional:
|
|
7
|
+
E2E_WRITE=1 also exercise write endpoints (webhook, Telegram link, follows) — test/PPE accounts only
|
|
8
|
+
E2E_EXPECT_SIGNAL_SEC=60 wait this long for one live signal on the stream (something must fire meanwhile)
|
|
9
|
+
LPSIGNAL_FREE_API_KEY=... a free-plan key: the stream must refuse it
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from lpsignal import AsyncLPSignal, LPSignal, LPSignalError, SignalStream
|
|
20
|
+
|
|
21
|
+
BASE = os.environ.get("LPSIGNAL_BASE_URL")
|
|
22
|
+
if not BASE:
|
|
23
|
+
sys.exit("set LPSIGNAL_BASE_URL")
|
|
24
|
+
KEY = os.environ.get("LPSIGNAL_API_KEY") or None
|
|
25
|
+
FREE_KEY = os.environ.get("LPSIGNAL_FREE_API_KEY") or None
|
|
26
|
+
WRITE = os.environ.get("E2E_WRITE") == "1"
|
|
27
|
+
EXPECT_SIGNAL_SEC = float(os.environ.get("E2E_EXPECT_SIGNAL_SEC", "0"))
|
|
28
|
+
|
|
29
|
+
anon = LPSignal(base_url=BASE)
|
|
30
|
+
authed = LPSignal(base_url=BASE, api_key=KEY) if KEY else None
|
|
31
|
+
reader = authed or anon
|
|
32
|
+
stats = {"passed": 0, "failed": 0, "skipped": 0}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Skip(Exception):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check(name, fn):
|
|
40
|
+
try:
|
|
41
|
+
note = fn()
|
|
42
|
+
stats["passed"] += 1
|
|
43
|
+
print(f"ok {name}{f' — {note}' if note else ''}")
|
|
44
|
+
except Skip:
|
|
45
|
+
stats["skipped"] += 1
|
|
46
|
+
print(f"skip {name}")
|
|
47
|
+
except Exception as e: # noqa: BLE001 - report every failure and keep going
|
|
48
|
+
stats["failed"] += 1
|
|
49
|
+
print(f"FAIL {name}: {e!r}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def expect(cond, msg):
|
|
53
|
+
if not cond:
|
|
54
|
+
raise AssertionError(msg)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def expect_error(fn, status, code):
|
|
58
|
+
try:
|
|
59
|
+
fn()
|
|
60
|
+
except LPSignalError as e:
|
|
61
|
+
expect(e.status == status and e.code == code, f"got {e}")
|
|
62
|
+
return
|
|
63
|
+
raise AssertionError(f"expected {status} {code}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
print(f"SDK E2E (python) against {BASE} ({'with key' if KEY else 'anonymous'}{', writes on' if WRITE else ', read-only'})")
|
|
67
|
+
|
|
68
|
+
check("health", lambda: expect(anon.health()["ok"] is True, "not ok"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def chains():
|
|
72
|
+
cs = anon.chains()
|
|
73
|
+
for c in cs:
|
|
74
|
+
expect(isinstance(c["block"], str) and isinstance(c["lagSec"], int), f"bad row {c}")
|
|
75
|
+
return ", ".join(f"{c['chain']} lag {c['lagSec']}s" for c in cs) or "no chains yet"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
check("chains", chains)
|
|
79
|
+
|
|
80
|
+
top: dict = {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pools():
|
|
84
|
+
page = anon.pools(limit=5)
|
|
85
|
+
if not page["pools"]: # a young deployment has no 7-day metrics yet
|
|
86
|
+
page = anon.pools(limit=5, window=24)
|
|
87
|
+
nets = [p["best"]["netApr"] for p in page["pools"]]
|
|
88
|
+
expect(len(nets) <= 5 and nets == sorted(nets, reverse=True), "not ranked/bounded")
|
|
89
|
+
if page["pools"]:
|
|
90
|
+
top.update(page["pools"][0])
|
|
91
|
+
return f"{len(nets)} pools"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
check("pools: ranked by net APR, bounded", pools)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def detail():
|
|
98
|
+
if not top:
|
|
99
|
+
raise Skip
|
|
100
|
+
d = anon.pool(top["chain"], top["address"])
|
|
101
|
+
expect(d["pool"]["address"] == top["address"] and d["metrics"], "detail mismatch")
|
|
102
|
+
hours = anon.pool_hours(top["chain"], top["address"], hours=24)
|
|
103
|
+
expect([h["hour"] for h in hours] == sorted(h["hour"] for h in hours), "hours not ascending")
|
|
104
|
+
try:
|
|
105
|
+
bt = anon.backtest(top["chain"], top["address"], range_pct=5, days=1)
|
|
106
|
+
except LPSignalError as e:
|
|
107
|
+
if e.code in ("window_not_covered", "no_price_data"):
|
|
108
|
+
return f"backtest: {e.code}"
|
|
109
|
+
raise
|
|
110
|
+
expect(abs(bt["netApr"] - (bt["feeApr"] + bt["ilApr"])) < 1e-9 and bt["ilApr"] <= 1e-12, "APR identity")
|
|
111
|
+
return f"{len(d['metrics'])} metrics, {len(hours)} hours, backtest ok"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
check("pool detail, hours, backtest", detail)
|
|
115
|
+
check("unknown pool → 404 pool_not_found", lambda: expect_error(lambda: anon.pool("base", "0x" + "0" * 40), 404, "pool_not_found"))
|
|
116
|
+
check("invalid query → 400 invalid_request", lambda: expect_error(lambda: anon.pools(limit=1000), 400, "invalid_request"))
|
|
117
|
+
|
|
118
|
+
newest: list = []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def signals():
|
|
122
|
+
page = reader.signals(limit=3)
|
|
123
|
+
newest.extend(page["signals"])
|
|
124
|
+
if not newest:
|
|
125
|
+
return "no signals yet"
|
|
126
|
+
ids = [int(s["id"]) for s in newest]
|
|
127
|
+
expect(ids == sorted(ids, reverse=True), "not newest first")
|
|
128
|
+
expect(reader.signal(newest[0]["id"])["id"] == newest[0]["id"], "signal(id) mismatch")
|
|
129
|
+
seen = 0
|
|
130
|
+
for _ in reader.iter_signals(limit=2):
|
|
131
|
+
seen += 1
|
|
132
|
+
if seen >= 6:
|
|
133
|
+
break
|
|
134
|
+
if len(newest) >= 2:
|
|
135
|
+
after = reader.signals_after(newest[1]["id"])
|
|
136
|
+
expect(after and int(after[0]["id"]) > ids[1], "signals_after wrong")
|
|
137
|
+
return f"newest #{newest[0]['id']}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
check("signals: page, by id, paging, signals_after", signals)
|
|
141
|
+
def check_signal_stats():
|
|
142
|
+
st = anon.signal_stats(days=30)
|
|
143
|
+
expect(isinstance(st["scored"], int) and st["positive"] <= st["scored"] and isinstance(st["points"], list), "bad shape")
|
|
144
|
+
return f"{st['scored']} scored, {st['inexact']} inexact"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
check("signal stats (public track record)", check_signal_stats)
|
|
148
|
+
check("smart LP leaderboard", lambda: expect(isinstance(reader.smart_lps(window_days=30)["wallets"], list), "bad shape"))
|
|
149
|
+
|
|
150
|
+
me: dict = {}
|
|
151
|
+
if authed:
|
|
152
|
+
def me_billing():
|
|
153
|
+
me.update(authed.me())
|
|
154
|
+
expect(me["tier"] == authed.billing()["tier"], "tier mismatch")
|
|
155
|
+
return f"tier {me['tier']}, paid {me['paid']}"
|
|
156
|
+
|
|
157
|
+
check("me + billing", me_billing)
|
|
158
|
+
|
|
159
|
+
def pro_gate():
|
|
160
|
+
if me.get("tier") == "pro":
|
|
161
|
+
return f"{len(authed.follows())} follows"
|
|
162
|
+
expect_error(lambda: authed.wallet_positions("0x" + "1" * 40), 403, "pro_required")
|
|
163
|
+
|
|
164
|
+
check("Pro gate", pro_gate)
|
|
165
|
+
|
|
166
|
+
async def stream_check():
|
|
167
|
+
if not me.get("paid") or len(newest) < 3:
|
|
168
|
+
raise Skip
|
|
169
|
+
got, events = [], []
|
|
170
|
+
client = AsyncLPSignal(base_url=BASE, api_key=KEY)
|
|
171
|
+
stream = SignalStream(client, lambda s, src: got.append((s["id"], src)), on_event=events.append,
|
|
172
|
+
since=newest[2]["id"])
|
|
173
|
+
await stream.start()
|
|
174
|
+
t0 = time.monotonic()
|
|
175
|
+
while not any(e["type"] in ("live", "fatal") for e in events) and time.monotonic() - t0 < 15:
|
|
176
|
+
await asyncio.sleep(0.05)
|
|
177
|
+
live = any(e["type"] == "live" for e in events)
|
|
178
|
+
note = ""
|
|
179
|
+
if live and EXPECT_SIGNAL_SEC > 0:
|
|
180
|
+
before = len(got)
|
|
181
|
+
while not any(g[1] == "live" for g in got[before:]) and time.monotonic() - t0 < EXPECT_SIGNAL_SEC:
|
|
182
|
+
await asyncio.sleep(0.1)
|
|
183
|
+
lives = [g for g in got[before:] if g[1] == "live"]
|
|
184
|
+
expect(lives, f"no live signal within {EXPECT_SIGNAL_SEC}s")
|
|
185
|
+
note = f", live #{lives[0][0]}"
|
|
186
|
+
await stream.stop()
|
|
187
|
+
await client.aclose()
|
|
188
|
+
expect(live, f"never went live: {[e['type'] for e in events]}")
|
|
189
|
+
rest = [g[0] for g in got if g[1] == "rest"]
|
|
190
|
+
expect(newest[0]["id"] in rest and newest[1]["id"] in rest, f"catch-up missed signals: {got}")
|
|
191
|
+
ids = [int(g[0]) for g in got]
|
|
192
|
+
expect(ids == sorted(set(ids)), "not strictly ascending")
|
|
193
|
+
return f"{len(rest)} via REST{note}"
|
|
194
|
+
|
|
195
|
+
check("stream: catches up over REST from since, then goes live", lambda: asyncio.run(stream_check()))
|
|
196
|
+
|
|
197
|
+
if FREE_KEY:
|
|
198
|
+
async def free_refused():
|
|
199
|
+
events = []
|
|
200
|
+
client = AsyncLPSignal(base_url=BASE, api_key=FREE_KEY)
|
|
201
|
+
s = SignalStream(client, lambda *_: None, on_event=events.append, since="0")
|
|
202
|
+
await s.start()
|
|
203
|
+
t0 = time.monotonic()
|
|
204
|
+
while not any(e["type"] == "fatal" for e in events) and time.monotonic() - t0 < 10:
|
|
205
|
+
await asyncio.sleep(0.05)
|
|
206
|
+
await s.stop()
|
|
207
|
+
await client.aclose()
|
|
208
|
+
fatal = [e for e in events if e["type"] == "fatal"]
|
|
209
|
+
expect(fatal and "402" in str(fatal[0]["error"]), f"expected 402 fatal, got {[e['type'] for e in events]}")
|
|
210
|
+
return str(fatal[0]["error"])
|
|
211
|
+
|
|
212
|
+
check("stream refuses a free key", lambda: asyncio.run(free_refused()))
|
|
213
|
+
|
|
214
|
+
if authed and WRITE:
|
|
215
|
+
def webhook():
|
|
216
|
+
reg = authed.set_webhook("https://example.com/lpsignal-sdk-e2e")
|
|
217
|
+
expect(reg["secret"].startswith("whsec_"), "no secret")
|
|
218
|
+
expect(authed.me()["webhookUrl"] == reg["url"], "not stored")
|
|
219
|
+
authed.delete_webhook()
|
|
220
|
+
expect(authed.me()["webhookUrl"] is None, "not deleted")
|
|
221
|
+
|
|
222
|
+
check("write: webhook set / read back / delete", webhook)
|
|
223
|
+
check("write: webhook rejects a private address",
|
|
224
|
+
lambda: expect_error(lambda: authed.set_webhook("https://127.0.0.1/x"), 400, "invalid_webhook_url"))
|
|
225
|
+
check("write: telegram link code", lambda: expect(len(authed.telegram_link()["code"]) >= 12, "no code"))
|
|
226
|
+
|
|
227
|
+
def follows():
|
|
228
|
+
if authed.me()["tier"] != "pro":
|
|
229
|
+
raise Skip
|
|
230
|
+
owner = "0x" + "e2" * 20
|
|
231
|
+
expect(authed.follow(owner)["following"] is True, "follow")
|
|
232
|
+
expect(any(f["owner"] == owner for f in authed.follows()), "not listed")
|
|
233
|
+
authed.unfollow(owner)
|
|
234
|
+
expect(not any(f["owner"] == owner for f in authed.follows()), "still listed")
|
|
235
|
+
|
|
236
|
+
check("write: follow / unfollow (Pro)", follows)
|
|
237
|
+
|
|
238
|
+
print(f"\n{stats['passed']} passed, {stats['failed']} failed, {stats['skipped']} skipped")
|
|
239
|
+
sys.exit(1 if stats["failed"] else 0)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Official LPSignal SDK: net-of-IL APR signals for concentrated-liquidity pools."""
|
|
2
|
+
|
|
3
|
+
from .client import DEFAULT_BASE_URL, AsyncLPSignal, LPSignal, LPSignalError
|
|
4
|
+
from .stream import FileLastIdStore, LastIdStore, MemoryLastIdStore, SignalStream
|
|
5
|
+
from .webhook import WebhookVerificationError, verify_webhook
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DEFAULT_BASE_URL", "AsyncLPSignal", "LPSignal", "LPSignalError",
|
|
9
|
+
"FileLastIdStore", "LastIdStore", "MemoryLastIdStore", "SignalStream",
|
|
10
|
+
"WebhookVerificationError", "verify_webhook",
|
|
11
|
+
]
|
|
12
|
+
__version__ = "0.1.0"
|