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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.