lpsignal 0.1.0__py3-none-any.whl
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/__init__.py +12 -0
- lpsignal/client.py +375 -0
- lpsignal/stream.py +305 -0
- lpsignal/types.py +154 -0
- lpsignal/webhook.py +51 -0
- lpsignal-0.1.0.dist-info/METADATA +126 -0
- lpsignal-0.1.0.dist-info/RECORD +9 -0
- lpsignal-0.1.0.dist-info/WHEEL +4 -0
- lpsignal-0.1.0.dist-info/licenses/LICENSE +21 -0
lpsignal/__init__.py
ADDED
|
@@ -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"
|
lpsignal/client.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, AsyncIterator, Iterator, Literal, Optional
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .types import (
|
|
12
|
+
Backtest, BillingStatus, ChainStatus, Follow, Leaderboard, Me, PoolDetail, PoolHour, PoolsPage, Signal,
|
|
13
|
+
SignalsPage, TelegramLink, WalletPositions, WebhookRegistration,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.lpsignal.app"
|
|
17
|
+
_IDEMPOTENT = {"GET", "PUT", "DELETE"}
|
|
18
|
+
_MAX_RETRY_WAIT = 60.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LPSignalError(Exception):
|
|
22
|
+
"""A non-2xx answer from the API. `code` is the API's stable `error` field (e.g. `rate_limited`)."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, status: int, body: Any, request_id: Optional[str] = None):
|
|
25
|
+
self.status = status
|
|
26
|
+
self.body = body
|
|
27
|
+
self.request_id = request_id
|
|
28
|
+
self.code: Optional[str] = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), str) else None
|
|
29
|
+
detail = body if isinstance(body, str) else json.dumps(body)
|
|
30
|
+
super().__init__(f"LPSignal API {status}{' ' + self.code if self.code else ''}"
|
|
31
|
+
f"{f' (request {request_id})' if request_id else ''}: {detail}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _q(v: str) -> str:
|
|
35
|
+
return quote(str(v), safe="")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Base:
|
|
39
|
+
def __init__(self, api_key: Optional[str], base_url: str, timeout: float, max_retries: int):
|
|
40
|
+
if api_key is not None and not api_key.startswith("lps_"):
|
|
41
|
+
raise ValueError('api_key must start with "lps_"')
|
|
42
|
+
self.api_key = api_key
|
|
43
|
+
self.base_url = base_url.rstrip("/")
|
|
44
|
+
self.timeout = timeout
|
|
45
|
+
self.max_retries = max_retries
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def stream_url(self) -> str:
|
|
49
|
+
"""The stream URL derived from the base URL (https → wss)."""
|
|
50
|
+
return ("ws" + self.base_url[4:] if self.base_url.startswith("http") else self.base_url) + "/v1/stream"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def _headers(self) -> dict[str, str]:
|
|
54
|
+
h = {"accept": "application/json"}
|
|
55
|
+
if self.api_key:
|
|
56
|
+
h["authorization"] = f"Bearer {self.api_key}"
|
|
57
|
+
return h
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _params(query: Optional[dict[str, Any]]) -> dict[str, Any]:
|
|
61
|
+
return {k: v for k, v in (query or {}).items() if v is not None}
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _body(res: httpx.Response) -> Any:
|
|
65
|
+
text = res.text
|
|
66
|
+
if not text:
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
return json.loads(text)
|
|
70
|
+
except ValueError:
|
|
71
|
+
return text
|
|
72
|
+
|
|
73
|
+
def _retry_wait(self, method: str, res: httpx.Response, body: Any, attempt: int) -> Optional[float]:
|
|
74
|
+
"""Seconds to wait before retrying, or None to raise."""
|
|
75
|
+
if res.status_code != 429 or method not in _IDEMPOTENT or attempt >= self.max_retries:
|
|
76
|
+
return None
|
|
77
|
+
raw = res.headers.get("retry-after") or (body.get("retryAfterSec") if isinstance(body, dict) else None)
|
|
78
|
+
try:
|
|
79
|
+
after = float(raw) if raw is not None else 1.0
|
|
80
|
+
except (TypeError, ValueError):
|
|
81
|
+
after = 1.0
|
|
82
|
+
return min(_MAX_RETRY_WAIT, after if after > 0 else 1.0)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _pools_query(chain, pair_class, window, min_tvl_usd, limit, offset) -> dict[str, Any]:
|
|
86
|
+
return {"chain": chain, "class": pair_class, "window": window, "minTvlUsd": min_tvl_usd, "limit": limit, "offset": offset}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class LPSignal(_Base):
|
|
90
|
+
"""Synchronous client for the LPSignal API. `api_key` is optional: public endpoints work without one
|
|
91
|
+
(opportunity signals then arrive 24h late)."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, api_key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL, timeout: float = 15.0,
|
|
94
|
+
max_retries: int = 2, http: Optional[httpx.Client] = None):
|
|
95
|
+
super().__init__(api_key, base_url, timeout, max_retries)
|
|
96
|
+
self._http = http or httpx.Client(timeout=timeout)
|
|
97
|
+
|
|
98
|
+
def close(self) -> None:
|
|
99
|
+
self._http.close()
|
|
100
|
+
|
|
101
|
+
def __enter__(self) -> "LPSignal":
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def __exit__(self, *exc: Any) -> None:
|
|
105
|
+
self.close()
|
|
106
|
+
|
|
107
|
+
def request(self, method: str, path: str, query: Optional[dict[str, Any]] = None, body: Any = None) -> Any:
|
|
108
|
+
attempt = 0
|
|
109
|
+
while True:
|
|
110
|
+
res = self._http.request(method, self.base_url + path, params=self._params(query), headers=self._headers,
|
|
111
|
+
json=body)
|
|
112
|
+
parsed = self._body(res)
|
|
113
|
+
if res.is_success:
|
|
114
|
+
return parsed
|
|
115
|
+
wait = self._retry_wait(method, res, parsed, attempt)
|
|
116
|
+
if wait is None:
|
|
117
|
+
raise LPSignalError(res.status_code, parsed, res.headers.get("x-request-id"))
|
|
118
|
+
time.sleep(wait)
|
|
119
|
+
attempt += 1
|
|
120
|
+
|
|
121
|
+
# ── status
|
|
122
|
+
def health(self) -> dict[str, Any]:
|
|
123
|
+
return self.request("GET", "/v1/health")
|
|
124
|
+
|
|
125
|
+
def chains(self) -> list[ChainStatus]:
|
|
126
|
+
"""Scan progress per chain."""
|
|
127
|
+
return self.request("GET", "/v1/chains")["chains"]
|
|
128
|
+
|
|
129
|
+
# ── pools
|
|
130
|
+
def pools(self, chain: Optional[str] = None, pair_class: Optional[str] = None, window: Optional[int] = None,
|
|
131
|
+
min_tvl_usd: Optional[float] = None, limit: Optional[int] = None, offset: Optional[int] = None) -> PoolsPage:
|
|
132
|
+
"""Active pools ranked by their best range's net APR over `window` hours (24, 168 or 720)."""
|
|
133
|
+
return self.request("GET", "/v1/pools", self._pools_query(chain, pair_class, window, min_tvl_usd, limit, offset))
|
|
134
|
+
|
|
135
|
+
def pool(self, chain: str, address: str) -> PoolDetail:
|
|
136
|
+
"""One pool with every (window, range) metric. `address` is the pool id for Uniswap v4."""
|
|
137
|
+
return self.request("GET", f"/v1/pools/{_q(chain)}/{_q(address)}")
|
|
138
|
+
|
|
139
|
+
def pool_hours(self, chain: str, address: str, hours: Optional[int] = None) -> list[PoolHour]:
|
|
140
|
+
"""Hourly aggregates, oldest first (at most 720 hours)."""
|
|
141
|
+
return self.request("GET", f"/v1/pools/{_q(chain)}/{_q(address)}/hours", {"hours": hours})["hours"]
|
|
142
|
+
|
|
143
|
+
def backtest(self, chain: str, address: str, range_pct: float, days: Optional[int] = None) -> Backtest:
|
|
144
|
+
"""Backtest a symmetric ±`range_pct`% range (0 = full range) over the last `days` (1..30, default 7)."""
|
|
145
|
+
return self.request("GET", f"/v1/pools/{_q(chain)}/{_q(address)}/backtest", {"rangePct": range_pct, "days": days})
|
|
146
|
+
|
|
147
|
+
# ── signals
|
|
148
|
+
def signals(self, kind: Optional[str] = None, limit: Optional[int] = None, before: Optional[str] = None) -> SignalsPage:
|
|
149
|
+
"""One page of signals, newest first."""
|
|
150
|
+
return self.request("GET", "/v1/signals", {"kind": kind, "limit": limit, "before": before})
|
|
151
|
+
|
|
152
|
+
def signal_stats(self, days: Optional[int] = None) -> dict[str, Any]:
|
|
153
|
+
"""The public track record over the last `days` (7..365, default 30)."""
|
|
154
|
+
return self.request("GET", "/v1/signals/stats", {"days": days})
|
|
155
|
+
|
|
156
|
+
def signal(self, signal_id: str) -> Signal:
|
|
157
|
+
return self.request("GET", f"/v1/signals/{_q(signal_id)}")
|
|
158
|
+
|
|
159
|
+
def iter_signals(self, kind: Optional[str] = None, limit: int = 100) -> Iterator[Signal]:
|
|
160
|
+
"""Every signal matching `kind`, newest first, following the `next` cursor page by page."""
|
|
161
|
+
before: Optional[str] = None
|
|
162
|
+
while True:
|
|
163
|
+
page = self.signals(kind=kind, limit=limit, before=before)
|
|
164
|
+
yield from page["signals"]
|
|
165
|
+
if not page["next"]:
|
|
166
|
+
return
|
|
167
|
+
before = page["next"]
|
|
168
|
+
|
|
169
|
+
def signals_after(self, after_id: str) -> list[Signal]:
|
|
170
|
+
"""Every signal visible to this key with an id greater than `after_id`, oldest first."""
|
|
171
|
+
newer: list[Signal] = []
|
|
172
|
+
for s in self.iter_signals():
|
|
173
|
+
if int(s["id"]) <= int(after_id):
|
|
174
|
+
break
|
|
175
|
+
newer.append(s)
|
|
176
|
+
newer.reverse()
|
|
177
|
+
return newer
|
|
178
|
+
|
|
179
|
+
# ── smart LPs
|
|
180
|
+
def smart_lps(self, window_days: Optional[int] = None, chain: Optional[str] = None, limit: Optional[int] = None) -> Leaderboard:
|
|
181
|
+
"""Wallets ranked by LP pnl versus holding. Without Pro: the top 10 with masked addresses."""
|
|
182
|
+
return self.request("GET", "/v1/smart-lps", {"windowDays": window_days, "chain": chain, "limit": limit})
|
|
183
|
+
|
|
184
|
+
def wallet_positions(self, owner: str, limit: Optional[int] = None) -> WalletPositions:
|
|
185
|
+
"""A wallet's open and closed positions (Pro)."""
|
|
186
|
+
return self.request("GET", f"/v1/smart-lps/{_q(owner)}/positions", {"limit": limit})
|
|
187
|
+
|
|
188
|
+
def follows(self) -> list[Follow]:
|
|
189
|
+
"""Wallets you follow (Pro)."""
|
|
190
|
+
return self.request("GET", "/v1/me/follows")["follows"]
|
|
191
|
+
|
|
192
|
+
def follow(self, owner: str) -> dict[str, Any]:
|
|
193
|
+
"""Follow a wallet: its new positions of $10k+ become smart_lp signals for you (Pro, up to 50)."""
|
|
194
|
+
return self.request("PUT", f"/v1/me/follows/{_q(owner)}")
|
|
195
|
+
|
|
196
|
+
def unfollow(self, owner: str) -> None:
|
|
197
|
+
self.request("DELETE", f"/v1/me/follows/{_q(owner)}")
|
|
198
|
+
|
|
199
|
+
# ── account
|
|
200
|
+
def me(self) -> Me:
|
|
201
|
+
return self.request("GET", "/v1/me")
|
|
202
|
+
|
|
203
|
+
def set_webhook(self, url: str) -> WebhookRegistration:
|
|
204
|
+
"""Set or replace the webhook. The signing secret is returned only here."""
|
|
205
|
+
return self.request("PUT", "/v1/me/webhook", body={"url": url})
|
|
206
|
+
|
|
207
|
+
def delete_webhook(self) -> None:
|
|
208
|
+
self.request("DELETE", "/v1/me/webhook")
|
|
209
|
+
|
|
210
|
+
def create_api_key(self, replace: Optional[bool] = None) -> dict[str, str]:
|
|
211
|
+
"""Create a new API key and return it (shown only here). By default it replaces the current key, which stops
|
|
212
|
+
working at once; `replace=False` only creates one when the account has none (else 409 `api_key_exists`)."""
|
|
213
|
+
return self.request("POST", "/v1/me/api-key", body=None if replace is None else {"replace": replace})
|
|
214
|
+
|
|
215
|
+
def telegram_link(self) -> TelegramLink:
|
|
216
|
+
"""A one-time code: send `/start <code>` to the LPSignal Telegram bot."""
|
|
217
|
+
return self.request("POST", "/v1/me/telegram-link")
|
|
218
|
+
|
|
219
|
+
# ── billing
|
|
220
|
+
def billing(self, refresh: bool = False) -> BillingStatus:
|
|
221
|
+
"""Current plan. `refresh=True` re-reads Stripe first (use it right after a checkout)."""
|
|
222
|
+
return self.request("GET", "/v1/billing", {"refresh": 1 if refresh else None})
|
|
223
|
+
|
|
224
|
+
def checkout(self, tier: Literal["basic", "pro"]) -> dict[str, str]:
|
|
225
|
+
"""A Stripe Checkout URL for a monthly plan."""
|
|
226
|
+
return self.request("POST", "/v1/billing/checkout", body={"tier": tier})
|
|
227
|
+
|
|
228
|
+
def billing_portal(self) -> dict[str, str]:
|
|
229
|
+
"""A Stripe Customer Portal URL (change plan, cancel, invoices)."""
|
|
230
|
+
return self.request("POST", "/v1/billing/portal")
|
|
231
|
+
|
|
232
|
+
def crypto_billing(self) -> dict[str, Any]:
|
|
233
|
+
"""Prepaid USDT/USDC plans: prices for this account (upgrade credit applied), networks, the open order."""
|
|
234
|
+
return self.request("GET", "/v1/billing/crypto")
|
|
235
|
+
|
|
236
|
+
def create_crypto_order(self, tier: Literal["basic", "pro"], months: Literal[1, 3, 12]) -> dict[str, Any]:
|
|
237
|
+
"""Start (or get back the open) crypto order; send exactly `amount` before `expiresAt`."""
|
|
238
|
+
return self.request("POST", "/v1/billing/crypto/orders", body={"tier": tier, "months": months})
|
|
239
|
+
|
|
240
|
+
def crypto_order(self, order_id: str) -> dict[str, Any]:
|
|
241
|
+
return self.request("GET", f"/v1/billing/crypto/orders/{_q(order_id)}")
|
|
242
|
+
|
|
243
|
+
def cancel_crypto_order(self, order_id: str) -> dict[str, Any]:
|
|
244
|
+
"""Withdraw an open order (a payment already sent before its deadline still pays it)."""
|
|
245
|
+
return self.request("POST", f"/v1/billing/crypto/orders/{_q(order_id)}/cancel")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class AsyncLPSignal(_Base):
|
|
249
|
+
"""Asynchronous client; same methods as `LPSignal`, awaitable. Needed by `SignalStream`."""
|
|
250
|
+
|
|
251
|
+
def __init__(self, api_key: Optional[str] = None, base_url: str = DEFAULT_BASE_URL, timeout: float = 15.0,
|
|
252
|
+
max_retries: int = 2, http: Optional[httpx.AsyncClient] = None):
|
|
253
|
+
super().__init__(api_key, base_url, timeout, max_retries)
|
|
254
|
+
self._http = http or httpx.AsyncClient(timeout=timeout)
|
|
255
|
+
|
|
256
|
+
async def aclose(self) -> None:
|
|
257
|
+
await self._http.aclose()
|
|
258
|
+
|
|
259
|
+
async def __aenter__(self) -> "AsyncLPSignal":
|
|
260
|
+
return self
|
|
261
|
+
|
|
262
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
263
|
+
await self.aclose()
|
|
264
|
+
|
|
265
|
+
async def request(self, method: str, path: str, query: Optional[dict[str, Any]] = None, body: Any = None) -> Any:
|
|
266
|
+
attempt = 0
|
|
267
|
+
while True:
|
|
268
|
+
res = await self._http.request(method, self.base_url + path, params=self._params(query), headers=self._headers,
|
|
269
|
+
json=body)
|
|
270
|
+
parsed = self._body(res)
|
|
271
|
+
if res.is_success:
|
|
272
|
+
return parsed
|
|
273
|
+
wait = self._retry_wait(method, res, parsed, attempt)
|
|
274
|
+
if wait is None:
|
|
275
|
+
raise LPSignalError(res.status_code, parsed, res.headers.get("x-request-id"))
|
|
276
|
+
await asyncio.sleep(wait)
|
|
277
|
+
attempt += 1
|
|
278
|
+
|
|
279
|
+
async def health(self) -> dict[str, Any]:
|
|
280
|
+
return await self.request("GET", "/v1/health")
|
|
281
|
+
|
|
282
|
+
async def chains(self) -> list[ChainStatus]:
|
|
283
|
+
return (await self.request("GET", "/v1/chains"))["chains"]
|
|
284
|
+
|
|
285
|
+
async def pools(self, chain: Optional[str] = None, pair_class: Optional[str] = None, window: Optional[int] = None,
|
|
286
|
+
min_tvl_usd: Optional[float] = None, limit: Optional[int] = None, offset: Optional[int] = None) -> PoolsPage:
|
|
287
|
+
return await self.request("GET", "/v1/pools", self._pools_query(chain, pair_class, window, min_tvl_usd, limit, offset))
|
|
288
|
+
|
|
289
|
+
async def pool(self, chain: str, address: str) -> PoolDetail:
|
|
290
|
+
return await self.request("GET", f"/v1/pools/{_q(chain)}/{_q(address)}")
|
|
291
|
+
|
|
292
|
+
async def pool_hours(self, chain: str, address: str, hours: Optional[int] = None) -> list[PoolHour]:
|
|
293
|
+
return (await self.request("GET", f"/v1/pools/{_q(chain)}/{_q(address)}/hours", {"hours": hours}))["hours"]
|
|
294
|
+
|
|
295
|
+
async def backtest(self, chain: str, address: str, range_pct: float, days: Optional[int] = None) -> Backtest:
|
|
296
|
+
return await self.request("GET", f"/v1/pools/{_q(chain)}/{_q(address)}/backtest", {"rangePct": range_pct, "days": days})
|
|
297
|
+
|
|
298
|
+
async def signals(self, kind: Optional[str] = None, limit: Optional[int] = None, before: Optional[str] = None) -> SignalsPage:
|
|
299
|
+
return await self.request("GET", "/v1/signals", {"kind": kind, "limit": limit, "before": before})
|
|
300
|
+
|
|
301
|
+
async def signal_stats(self, days: Optional[int] = None) -> dict[str, Any]:
|
|
302
|
+
return await self.request("GET", "/v1/signals/stats", {"days": days})
|
|
303
|
+
|
|
304
|
+
async def signal(self, signal_id: str) -> Signal:
|
|
305
|
+
return await self.request("GET", f"/v1/signals/{_q(signal_id)}")
|
|
306
|
+
|
|
307
|
+
async def iter_signals(self, kind: Optional[str] = None, limit: int = 100) -> AsyncIterator[Signal]:
|
|
308
|
+
before: Optional[str] = None
|
|
309
|
+
while True:
|
|
310
|
+
page = await self.signals(kind=kind, limit=limit, before=before)
|
|
311
|
+
for s in page["signals"]:
|
|
312
|
+
yield s
|
|
313
|
+
if not page["next"]:
|
|
314
|
+
return
|
|
315
|
+
before = page["next"]
|
|
316
|
+
|
|
317
|
+
async def signals_after(self, after_id: str) -> list[Signal]:
|
|
318
|
+
newer: list[Signal] = []
|
|
319
|
+
async for s in self.iter_signals():
|
|
320
|
+
if int(s["id"]) <= int(after_id):
|
|
321
|
+
break
|
|
322
|
+
newer.append(s)
|
|
323
|
+
newer.reverse()
|
|
324
|
+
return newer
|
|
325
|
+
|
|
326
|
+
async def smart_lps(self, window_days: Optional[int] = None, chain: Optional[str] = None, limit: Optional[int] = None) -> Leaderboard:
|
|
327
|
+
return await self.request("GET", "/v1/smart-lps", {"windowDays": window_days, "chain": chain, "limit": limit})
|
|
328
|
+
|
|
329
|
+
async def wallet_positions(self, owner: str, limit: Optional[int] = None) -> WalletPositions:
|
|
330
|
+
return await self.request("GET", f"/v1/smart-lps/{_q(owner)}/positions", {"limit": limit})
|
|
331
|
+
|
|
332
|
+
async def follows(self) -> list[Follow]:
|
|
333
|
+
return (await self.request("GET", "/v1/me/follows"))["follows"]
|
|
334
|
+
|
|
335
|
+
async def follow(self, owner: str) -> dict[str, Any]:
|
|
336
|
+
return await self.request("PUT", f"/v1/me/follows/{_q(owner)}")
|
|
337
|
+
|
|
338
|
+
async def unfollow(self, owner: str) -> None:
|
|
339
|
+
await self.request("DELETE", f"/v1/me/follows/{_q(owner)}")
|
|
340
|
+
|
|
341
|
+
async def me(self) -> Me:
|
|
342
|
+
return await self.request("GET", "/v1/me")
|
|
343
|
+
|
|
344
|
+
async def set_webhook(self, url: str) -> WebhookRegistration:
|
|
345
|
+
return await self.request("PUT", "/v1/me/webhook", body={"url": url})
|
|
346
|
+
|
|
347
|
+
async def delete_webhook(self) -> None:
|
|
348
|
+
await self.request("DELETE", "/v1/me/webhook")
|
|
349
|
+
|
|
350
|
+
async def create_api_key(self, replace: Optional[bool] = None) -> dict[str, str]:
|
|
351
|
+
return await self.request("POST", "/v1/me/api-key", body=None if replace is None else {"replace": replace})
|
|
352
|
+
|
|
353
|
+
async def telegram_link(self) -> TelegramLink:
|
|
354
|
+
return await self.request("POST", "/v1/me/telegram-link")
|
|
355
|
+
|
|
356
|
+
async def billing(self, refresh: bool = False) -> BillingStatus:
|
|
357
|
+
return await self.request("GET", "/v1/billing", {"refresh": 1 if refresh else None})
|
|
358
|
+
|
|
359
|
+
async def checkout(self, tier: Literal["basic", "pro"]) -> dict[str, str]:
|
|
360
|
+
return await self.request("POST", "/v1/billing/checkout", body={"tier": tier})
|
|
361
|
+
|
|
362
|
+
async def billing_portal(self) -> dict[str, str]:
|
|
363
|
+
return await self.request("POST", "/v1/billing/portal")
|
|
364
|
+
|
|
365
|
+
async def crypto_billing(self) -> dict[str, Any]:
|
|
366
|
+
return await self.request("GET", "/v1/billing/crypto")
|
|
367
|
+
|
|
368
|
+
async def create_crypto_order(self, tier: Literal["basic", "pro"], months: Literal[1, 3, 12]) -> dict[str, Any]:
|
|
369
|
+
return await self.request("POST", "/v1/billing/crypto/orders", body={"tier": tier, "months": months})
|
|
370
|
+
|
|
371
|
+
async def crypto_order(self, order_id: str) -> dict[str, Any]:
|
|
372
|
+
return await self.request("GET", f"/v1/billing/crypto/orders/{_q(order_id)}")
|
|
373
|
+
|
|
374
|
+
async def cancel_crypto_order(self, order_id: str) -> dict[str, Any]:
|
|
375
|
+
return await self.request("POST", f"/v1/billing/crypto/orders/{_q(order_id)}/cancel")
|
lpsignal/stream.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Awaitable, Callable, Literal, Optional, Protocol, Union
|
|
10
|
+
|
|
11
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
12
|
+
from websockets.exceptions import ConnectionClosed, InvalidStatus
|
|
13
|
+
|
|
14
|
+
from .client import AsyncLPSignal, LPSignalError
|
|
15
|
+
from .types import Signal
|
|
16
|
+
|
|
17
|
+
Source = Literal["rest", "replay", "live"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LastIdStore(Protocol):
|
|
21
|
+
"""Where the stream is: the id of the last signal handled. Persist it to resume after a restart.
|
|
22
|
+
|
|
23
|
+
To make a crash unable to repeat a signal's side effects, keep the id in the same database transaction as those
|
|
24
|
+
effects (write it inside on_signal) and pass a store over that same row. Its save() must only move forward
|
|
25
|
+
(e.g. `SET last_id = GREATEST(last_id, %s)`): the stream also saves the starting point with it.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def load(self) -> Optional[str]: ...
|
|
29
|
+
def save(self, last_id: str) -> None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MemoryLastIdStore:
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._id: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
def load(self) -> Optional[str]:
|
|
37
|
+
return self._id
|
|
38
|
+
|
|
39
|
+
def save(self, last_id: str) -> None:
|
|
40
|
+
self._id = last_id
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FileLastIdStore:
|
|
44
|
+
"""A small JSON file, replaced atomically (temp file + os.replace). Same format as the Node SDK's."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, path: Union[str, Path]) -> None:
|
|
47
|
+
self.path = Path(path)
|
|
48
|
+
|
|
49
|
+
def load(self) -> Optional[str]:
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(self.path.read_text("utf8"))
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
return None
|
|
54
|
+
last = data.get("lastId") if isinstance(data, dict) else None
|
|
55
|
+
if not isinstance(last, str) or not last.isdigit():
|
|
56
|
+
raise ValueError(f"{self.path}: no valid lastId")
|
|
57
|
+
return last
|
|
58
|
+
|
|
59
|
+
def save(self, last_id: str) -> None:
|
|
60
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
tmp = self.path.with_name(self.path.name + ".tmp")
|
|
62
|
+
tmp.write_text(json.dumps({"lastId": last_id}), "utf8")
|
|
63
|
+
os.replace(tmp, self.path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
OnSignal = Callable[[Signal, Source], Union[None, Awaitable[None]]]
|
|
67
|
+
OnEvent = Callable[[dict[str, Any]], None]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SignalStream:
|
|
71
|
+
"""Live signals in id order, with no gaps across disconnects, outages and restarts.
|
|
72
|
+
|
|
73
|
+
The server replays the last 24 hours on `?since=<id>`. Before every connection this stream first fetches
|
|
74
|
+
everything newer than its position over REST, repeating until a pass finds nothing new, so an outage of any length
|
|
75
|
+
is filled and the socket opens seconds after the last fetch. Anything at or below the position is dropped, so
|
|
76
|
+
within one running process each signal reaches the handler once; across a crash the last one may be offered again.
|
|
77
|
+
|
|
78
|
+
`on_signal(signal, source)` is called in ascending id order, never concurrently; `source` is "rest", "replay" or
|
|
79
|
+
"live". The position is saved only after the handler returns: if it raises, the connection is dropped and the
|
|
80
|
+
signal is offered again. Delivery is at-least-once across crashes, so make the handler idempotent on
|
|
81
|
+
`signal["id"]` (or keep the position in your own database, see LastIdStore).
|
|
82
|
+
|
|
83
|
+
`on_event(event)` receives dicts with a `type`: anchored, caught_up, connecting, connected, live, disconnected,
|
|
84
|
+
error, fatal (the stream has stopped: bad key, or the plan does not include the stream).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, client: AsyncLPSignal, on_signal: OnSignal, on_event: Optional[OnEvent] = None,
|
|
88
|
+
store: Optional[LastIdStore] = None, since: Optional[str] = None, ping_interval: float = 30.0,
|
|
89
|
+
min_backoff: float = 1.0, max_backoff: float = 30.0):
|
|
90
|
+
if not client.api_key:
|
|
91
|
+
raise ValueError("SignalStream: the client needs an API key (the stream is for paid plans)")
|
|
92
|
+
if since is not None and not str(since).isdigit():
|
|
93
|
+
raise ValueError("SignalStream: since must be a signal id")
|
|
94
|
+
self._client = client
|
|
95
|
+
self._on_signal = on_signal
|
|
96
|
+
self._on_event = on_event
|
|
97
|
+
self._store = store or MemoryLastIdStore()
|
|
98
|
+
self._since = since
|
|
99
|
+
self._ping = ping_interval
|
|
100
|
+
self._min_backoff = min_backoff
|
|
101
|
+
self._max_backoff = max_backoff
|
|
102
|
+
self._last_id: Optional[str] = None
|
|
103
|
+
self._running = False
|
|
104
|
+
self._task: Optional[asyncio.Task[None]] = None
|
|
105
|
+
self._ws: Optional[ClientConnection] = None
|
|
106
|
+
self._wake = asyncio.Event()
|
|
107
|
+
self._idle = asyncio.Event() # set while no handler is running
|
|
108
|
+
self._idle.set()
|
|
109
|
+
self._done = asyncio.Event()
|
|
110
|
+
self._stopping: Optional[asyncio.Task[None]] = None
|
|
111
|
+
# the first-run starting point, chosen once and kept until it is saved (a retry must not pick a newer one)
|
|
112
|
+
self._anchor: Optional[str] = None
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def position(self) -> Optional[str]:
|
|
116
|
+
"""Id of the last signal handed to on_signal (None before the first connection)."""
|
|
117
|
+
return self._last_id
|
|
118
|
+
|
|
119
|
+
async def start(self) -> None:
|
|
120
|
+
"""Load the saved position and start in the background. Call stop() to end.
|
|
121
|
+
|
|
122
|
+
A no-op while running; during a stop() it starts once the stop has finished. After a `fatal` event, call
|
|
123
|
+
stop() before starting again.
|
|
124
|
+
"""
|
|
125
|
+
while self._stopping is not None:
|
|
126
|
+
await asyncio.shield(self._stopping)
|
|
127
|
+
if self._task is not None:
|
|
128
|
+
return
|
|
129
|
+
self._last_id = self._store.load() or self._since
|
|
130
|
+
self._running = True
|
|
131
|
+
self._wake.clear()
|
|
132
|
+
self._done = asyncio.Event() # each run has its own: run() must not return when an older run ends
|
|
133
|
+
self._task = asyncio.create_task(self._loop(self._done))
|
|
134
|
+
|
|
135
|
+
async def run(self) -> None:
|
|
136
|
+
"""Start and run until stop() or a fatal error. Cancelling run() stops the stream."""
|
|
137
|
+
await self.start()
|
|
138
|
+
done = self._done
|
|
139
|
+
try:
|
|
140
|
+
await done.wait()
|
|
141
|
+
except asyncio.CancelledError:
|
|
142
|
+
await self.stop()
|
|
143
|
+
raise
|
|
144
|
+
|
|
145
|
+
async def stop(self) -> None:
|
|
146
|
+
"""Wait for the signal being handled (if any), then close the connection. The stream can be started again.
|
|
147
|
+
|
|
148
|
+
Don't await it from inside on_signal: it would wait for itself.
|
|
149
|
+
"""
|
|
150
|
+
if self._stopping is None:
|
|
151
|
+
if self._task is None:
|
|
152
|
+
return
|
|
153
|
+
self._stopping = asyncio.create_task(self._shutdown())
|
|
154
|
+
# every caller waits for the same, complete shutdown; a cancelled caller does not cancel it
|
|
155
|
+
await asyncio.shield(self._stopping)
|
|
156
|
+
|
|
157
|
+
async def _shutdown(self) -> None:
|
|
158
|
+
try:
|
|
159
|
+
self._running = False
|
|
160
|
+
self._wake.set()
|
|
161
|
+
task = self._task
|
|
162
|
+
assert task is not None
|
|
163
|
+
# never interrupt a handler; anywhere else (REST, handshake, waiting for frames) the loop can be cancelled
|
|
164
|
+
await self._idle.wait()
|
|
165
|
+
if self._ws is not None:
|
|
166
|
+
with contextlib.suppress(Exception):
|
|
167
|
+
await self._ws.close()
|
|
168
|
+
task.cancel()
|
|
169
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
170
|
+
await task
|
|
171
|
+
finally:
|
|
172
|
+
self._task = None
|
|
173
|
+
self._stopping = None
|
|
174
|
+
|
|
175
|
+
def _emit(self, event: dict[str, Any]) -> None:
|
|
176
|
+
if self._on_event:
|
|
177
|
+
with contextlib.suppress(Exception): # a listener must not break the stream
|
|
178
|
+
self._on_event(event)
|
|
179
|
+
|
|
180
|
+
def _fatal(self, error: Exception) -> None:
|
|
181
|
+
self._running = False
|
|
182
|
+
self._emit({"type": "fatal", "error": error})
|
|
183
|
+
|
|
184
|
+
async def _loop(self, done: asyncio.Event) -> None:
|
|
185
|
+
try:
|
|
186
|
+
await self._loop_body()
|
|
187
|
+
finally:
|
|
188
|
+
done.set()
|
|
189
|
+
|
|
190
|
+
async def _loop_body(self) -> None:
|
|
191
|
+
backoff = self._min_backoff
|
|
192
|
+
while self._running:
|
|
193
|
+
healthy = False
|
|
194
|
+
try:
|
|
195
|
+
await self._catch_up()
|
|
196
|
+
if self._running:
|
|
197
|
+
healthy = await self._connect_once()
|
|
198
|
+
except asyncio.CancelledError:
|
|
199
|
+
raise
|
|
200
|
+
except LPSignalError as e:
|
|
201
|
+
if e.status == 401:
|
|
202
|
+
self._fatal(e)
|
|
203
|
+
else:
|
|
204
|
+
self._emit({"type": "error", "error": e})
|
|
205
|
+
except Exception as e: # network errors, timeouts: retry
|
|
206
|
+
self._emit({"type": "error", "error": e})
|
|
207
|
+
if not self._running:
|
|
208
|
+
break
|
|
209
|
+
backoff = self._min_backoff if healthy else min(backoff * 2, self._max_backoff)
|
|
210
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
211
|
+
await asyncio.wait_for(self._wake.wait(), timeout=backoff)
|
|
212
|
+
|
|
213
|
+
async def _catch_up(self) -> None:
|
|
214
|
+
"""Establish a position (first run) or fetch everything after it over REST."""
|
|
215
|
+
if self._last_id is None:
|
|
216
|
+
# nothing is consumed until the starting point is saved: a restart must resume from this same point
|
|
217
|
+
if self._anchor is None:
|
|
218
|
+
page = await self._client.signals(limit=1)
|
|
219
|
+
self._anchor = page["signals"][0]["id"] if page["signals"] else "0"
|
|
220
|
+
self._store.save(self._anchor)
|
|
221
|
+
self._last_id = self._anchor
|
|
222
|
+
self._emit({"type": "anchored", "last_id": self._last_id})
|
|
223
|
+
# repeat until a pass finds nothing: a long backlog can take longer than the server's 24h replay window, and
|
|
224
|
+
# whatever was created meanwhile must come from REST too, or the socket would skip it
|
|
225
|
+
delivered = 0
|
|
226
|
+
while self._running:
|
|
227
|
+
batch = await self._client.signals_after(self._last_id)
|
|
228
|
+
if not batch:
|
|
229
|
+
break
|
|
230
|
+
for s in batch:
|
|
231
|
+
if not self._running:
|
|
232
|
+
return
|
|
233
|
+
if await self._deliver(s, "rest"):
|
|
234
|
+
delivered += 1
|
|
235
|
+
if delivered:
|
|
236
|
+
self._emit({"type": "caught_up", "delivered": delivered})
|
|
237
|
+
|
|
238
|
+
async def _connect_once(self) -> bool:
|
|
239
|
+
"""One connection's life. Returns True when it went live."""
|
|
240
|
+
url = f"{self._client.stream_url}?since={self._last_id}"
|
|
241
|
+
self._emit({"type": "connecting", "url": url})
|
|
242
|
+
live = failed = False
|
|
243
|
+
try:
|
|
244
|
+
async with connect(url, additional_headers={"authorization": f"Bearer {self._client.api_key}"},
|
|
245
|
+
ping_interval=self._ping, ping_timeout=self._ping, open_timeout=15) as ws:
|
|
246
|
+
self._ws = ws
|
|
247
|
+
if not self._running:
|
|
248
|
+
return False
|
|
249
|
+
self._emit({"type": "connected"})
|
|
250
|
+
try:
|
|
251
|
+
async for raw in ws:
|
|
252
|
+
try:
|
|
253
|
+
frame = json.loads(raw)
|
|
254
|
+
except ValueError:
|
|
255
|
+
continue
|
|
256
|
+
if not isinstance(frame, dict):
|
|
257
|
+
continue
|
|
258
|
+
if frame.get("type") == "signal" and isinstance(frame.get("signal"), dict):
|
|
259
|
+
try:
|
|
260
|
+
await self._deliver(frame["signal"], "replay" if frame.get("replay") else "live")
|
|
261
|
+
except Exception as e: # keep the position: the signal is offered again
|
|
262
|
+
self._emit({"type": "error", "error": e})
|
|
263
|
+
failed = True
|
|
264
|
+
break
|
|
265
|
+
elif frame.get("type") == "ready":
|
|
266
|
+
live = True
|
|
267
|
+
self._emit({"type": "live"})
|
|
268
|
+
# replay_truncated / replay_failed: the server closes with 4409; we resume from the position
|
|
269
|
+
except ConnectionClosed:
|
|
270
|
+
pass
|
|
271
|
+
code, reason = ws.close_code or 1006, ws.close_reason or ""
|
|
272
|
+
except InvalidStatus as e:
|
|
273
|
+
status = e.response.status_code
|
|
274
|
+
if status == 401:
|
|
275
|
+
self._fatal(Exception("stream refused (401): the API key is invalid or was replaced"))
|
|
276
|
+
elif status == 402:
|
|
277
|
+
self._fatal(Exception("stream refused (402): the plan does not include the stream"))
|
|
278
|
+
else:
|
|
279
|
+
self._emit({"type": "error", "error": Exception(
|
|
280
|
+
f"stream refused ({status}){': too many connections for this key' if status == 429 else ''}")})
|
|
281
|
+
return False
|
|
282
|
+
finally:
|
|
283
|
+
self._ws = None
|
|
284
|
+
self._emit({"type": "disconnected", "code": code, "reason": reason})
|
|
285
|
+
if code == 4402:
|
|
286
|
+
self._fatal(Exception("stream closed (4402): the plan no longer includes the stream"))
|
|
287
|
+
return live and not failed
|
|
288
|
+
|
|
289
|
+
async def _deliver(self, s: Signal, source: Source) -> bool:
|
|
290
|
+
"""True when the signal was new and handed to on_signal."""
|
|
291
|
+
if not self._running: # after stop(), nothing new reaches the handler
|
|
292
|
+
return False
|
|
293
|
+
if self._last_id is not None and int(s["id"]) <= int(self._last_id):
|
|
294
|
+
return False
|
|
295
|
+
self._idle.clear()
|
|
296
|
+
try:
|
|
297
|
+
r = self._on_signal(s, source)
|
|
298
|
+
if inspect.isawaitable(r):
|
|
299
|
+
await r
|
|
300
|
+
finally:
|
|
301
|
+
self._idle.set()
|
|
302
|
+
# the in-memory position follows the saved one: a failed save offers the signal again
|
|
303
|
+
self._store.save(str(s["id"]))
|
|
304
|
+
self._last_id = str(s["id"])
|
|
305
|
+
return True
|
lpsignal/types.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Response shapes of the LPSignal API, as returned (camelCase keys, JSON values).
|
|
2
|
+
|
|
3
|
+
- APRs and ratios are fractions: 0.345 = 34.5%.
|
|
4
|
+
- `ilApr` / `il7d` are the loss versus holding, so they are <= 0, and `netApr = feeApr + ilApr`.
|
|
5
|
+
- `fee` is in hundredths of a basis point: 500 = the 0.05% fee tier.
|
|
6
|
+
- `rangeBp` is the range half-width in basis points of price: 500 = ±5%, 0 = full range.
|
|
7
|
+
- Ids and 256-bit values are strings; timestamps are ISO 8601 strings in UTC.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Literal, Optional, TypedDict, Union
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChainStatus(TypedDict):
|
|
16
|
+
chain: str
|
|
17
|
+
block: str
|
|
18
|
+
blockTs: str
|
|
19
|
+
firstBlockTs: str
|
|
20
|
+
lagSec: int
|
|
21
|
+
activePools: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BestRange(TypedDict):
|
|
25
|
+
windowHours: int
|
|
26
|
+
rangeBp: int
|
|
27
|
+
tickLower: int
|
|
28
|
+
tickUpper: int
|
|
29
|
+
exact: bool
|
|
30
|
+
feeApr: float
|
|
31
|
+
ilApr: float
|
|
32
|
+
netApr: float
|
|
33
|
+
inRangeRatio: float
|
|
34
|
+
stakedEmissionApr: float
|
|
35
|
+
asOf: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RankedPool(TypedDict):
|
|
39
|
+
chain: str
|
|
40
|
+
address: str
|
|
41
|
+
dex: str
|
|
42
|
+
pair: str
|
|
43
|
+
fee: int
|
|
44
|
+
pairClass: str
|
|
45
|
+
tvlUsd: float
|
|
46
|
+
tvlAt: Optional[str]
|
|
47
|
+
best: BestRange
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PoolsPage(TypedDict):
|
|
51
|
+
pools: list[RankedPool]
|
|
52
|
+
limit: int
|
|
53
|
+
offset: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PoolDetail(TypedDict):
|
|
57
|
+
pool: dict[str, Any]
|
|
58
|
+
metrics: list[dict[str, Any]]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PoolHour(TypedDict):
|
|
62
|
+
hour: str
|
|
63
|
+
swaps: int
|
|
64
|
+
volume0: float
|
|
65
|
+
volume1: float
|
|
66
|
+
fee0PerL: float
|
|
67
|
+
fee1PerL: float
|
|
68
|
+
openTick: int
|
|
69
|
+
closeTick: int
|
|
70
|
+
minTick: int
|
|
71
|
+
maxTick: int
|
|
72
|
+
closeLiquidity: str
|
|
73
|
+
feeExact: bool
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Backtest(TypedDict):
|
|
77
|
+
chain: str
|
|
78
|
+
address: str
|
|
79
|
+
rangeBp: int
|
|
80
|
+
days: int
|
|
81
|
+
asOf: str
|
|
82
|
+
feeApr: float
|
|
83
|
+
ilApr: float
|
|
84
|
+
netApr: float
|
|
85
|
+
inRangeRatio: float
|
|
86
|
+
uncertainFeeApr: float
|
|
87
|
+
startTick: int
|
|
88
|
+
endTick: int
|
|
89
|
+
tickLower: int
|
|
90
|
+
tickUpper: int
|
|
91
|
+
feesExact: bool
|
|
92
|
+
exact: bool
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SignalOutcome(TypedDict):
|
|
96
|
+
status: Literal["done", "inexact"]
|
|
97
|
+
netApr: Optional[float]
|
|
98
|
+
feeApr: Optional[float]
|
|
99
|
+
ilApr: Optional[float]
|
|
100
|
+
evaluatedAt: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# A signal carries the common fields below plus its kind's own fields (see the README):
|
|
104
|
+
# net_apr: tvlUsd, net24h, net7d, net30d, fee7d, il7d, inRange7d, stakedEmissionApr?
|
|
105
|
+
# tvl_outflow: tvlBeforeUsd, tvlNowUsd, drop, windowHours
|
|
106
|
+
# depeg: deviation, severe, medianTick, tick
|
|
107
|
+
# smart_lp: owner, tokenId, entryUsd, top, rank, wallet30d
|
|
108
|
+
Signal = dict[str, Any]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SignalsPage(TypedDict):
|
|
112
|
+
signals: list[Signal]
|
|
113
|
+
next: Optional[str]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Leaderboard(TypedDict):
|
|
117
|
+
windowDays: int
|
|
118
|
+
full: bool
|
|
119
|
+
wallets: list[dict[str, Any]]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class WalletPositions(TypedDict):
|
|
123
|
+
owner: str
|
|
124
|
+
open: list[dict[str, Any]]
|
|
125
|
+
closed: list[dict[str, Any]]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Follow(TypedDict):
|
|
129
|
+
owner: str
|
|
130
|
+
since: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Me(TypedDict):
|
|
134
|
+
id: str
|
|
135
|
+
tier: Literal["free", "basic", "pro"]
|
|
136
|
+
paid: bool
|
|
137
|
+
telegramLinked: bool
|
|
138
|
+
webhookUrl: Optional[str]
|
|
139
|
+
walletAddress: Optional[str]
|
|
140
|
+
hasApiKey: bool
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class WebhookRegistration(TypedDict):
|
|
144
|
+
url: str
|
|
145
|
+
secret: str
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TelegramLink(TypedDict):
|
|
149
|
+
code: str
|
|
150
|
+
instruction: str
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
BillingStatus = dict[str, Any]
|
|
154
|
+
Json = Union[dict[str, Any], list[Any]]
|
lpsignal/webhook.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Mapping, Optional, Union
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WebhookVerificationError(Exception):
|
|
12
|
+
"""`reason` is one of: missing_headers, bad_timestamp, expired, bad_signature, bad_body."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, reason: str):
|
|
15
|
+
self.reason = reason
|
|
16
|
+
super().__init__(f"LPSignal webhook rejected: {reason}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _header(headers: Mapping[str, Any], name: str) -> Optional[str]:
|
|
20
|
+
for k, v in headers.items():
|
|
21
|
+
if k.lower() == name:
|
|
22
|
+
return v[0] if isinstance(v, (list, tuple)) else v
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def verify_webhook(raw_body: Union[bytes, str], headers: Mapping[str, Any], secret: str,
|
|
27
|
+
tolerance_sec: int = 300, now: Optional[float] = None) -> dict[str, Any]:
|
|
28
|
+
"""Verify a webhook delivery and return its parsed body.
|
|
29
|
+
|
|
30
|
+
`x-lpsignal-signature` is `sha256=` + hex HMAC-SHA256 of `"<x-lpsignal-timestamp>.<raw body>"`, keyed with the
|
|
31
|
+
secret returned when the webhook was set. Pass the raw request bytes, before any JSON parsing. Deliveries older
|
|
32
|
+
than `tolerance_sec` are rejected as replays; each retry is signed afresh.
|
|
33
|
+
|
|
34
|
+
Deliveries are at-least-once: skip `deliveryId`s you have already handled.
|
|
35
|
+
"""
|
|
36
|
+
ts = _header(headers, "x-lpsignal-timestamp")
|
|
37
|
+
sig = _header(headers, "x-lpsignal-signature")
|
|
38
|
+
if not ts or not sig or not secret:
|
|
39
|
+
raise WebhookVerificationError("missing_headers")
|
|
40
|
+
if not re.fullmatch(r"\d{1,12}", ts):
|
|
41
|
+
raise WebhookVerificationError("bad_timestamp")
|
|
42
|
+
if abs((time.time() if now is None else now) - int(ts)) > tolerance_sec:
|
|
43
|
+
raise WebhookVerificationError("expired")
|
|
44
|
+
body = raw_body.encode("utf8") if isinstance(raw_body, str) else raw_body
|
|
45
|
+
want = "sha256=" + hmac.new(secret.encode("utf8"), ts.encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
|
|
46
|
+
if not hmac.compare_digest(sig.encode("utf8"), want.encode("ascii")):
|
|
47
|
+
raise WebhookVerificationError("bad_signature")
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(body)
|
|
50
|
+
except ValueError:
|
|
51
|
+
raise WebhookVerificationError("bad_body") from None
|
|
@@ -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,9 @@
|
|
|
1
|
+
lpsignal/__init__.py,sha256=D1kr2MBH6hxHoe-8G_No8AVOuF6ALPVJvBSAOpObsxc,539
|
|
2
|
+
lpsignal/client.py,sha256=7p6Z4jvI8jPT4o_oOXUqvUQ7NXuGAV5cd3ASoizB2qA,17751
|
|
3
|
+
lpsignal/stream.py,sha256=DJaV7wBo1PFKsr5Mc186PNxHR_rZmIrhKFZR-T6PSE0,13397
|
|
4
|
+
lpsignal/types.py,sha256=GOvxRA60HIFdKKFCswM4738SWkvTrXMyWqLcmqtONJE,3205
|
|
5
|
+
lpsignal/webhook.py,sha256=CR7e9e24M9gDNlk8gsgGF0KaB6gV2yyrl1qAx9acFGY,2150
|
|
6
|
+
lpsignal-0.1.0.dist-info/METADATA,sha256=GvkuMDe1p152BHbDZd7X7lmLim3g9doaRx_bcIskqCM,6117
|
|
7
|
+
lpsignal-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
lpsignal-0.1.0.dist-info/licenses/LICENSE,sha256=v5TTMRPLe5FH9vCDpsxD2mFSBIHWPBX9ZVpliPA24BU,1065
|
|
9
|
+
lpsignal-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|