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.
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ dist/
3
+ __pycache__/
4
+ *.pyc
5
+ .venv/
6
+ .pytest_cache/
7
+ *.egg-info/
8
+ build/
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.
@@ -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
@@ -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"