dexscreen 0.0.1__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.
- dexscreen/__init__.py +31 -0
- dexscreen/api/__init__.py +3 -0
- dexscreen/api/client.py +672 -0
- dexscreen/config/__init__.py +0 -0
- dexscreen/core/__init__.py +27 -0
- dexscreen/core/http.py +460 -0
- dexscreen/core/models.py +106 -0
- dexscreen/stream/__init__.py +3 -0
- dexscreen/stream/polling.py +462 -0
- dexscreen/utils/__init__.py +4 -0
- dexscreen/utils/browser_selector.py +57 -0
- dexscreen/utils/filters.py +226 -0
- dexscreen/utils/ratelimit.py +65 -0
- dexscreen-0.0.1.dist-info/METADATA +278 -0
- dexscreen-0.0.1.dist-info/RECORD +17 -0
- dexscreen-0.0.1.dist-info/WHEEL +4 -0
- dexscreen-0.0.1.dist-info/licenses/LICENSE +21 -0
dexscreen/api/client.py
ADDED
@@ -0,0 +1,672 @@
|
|
1
|
+
"""
|
2
|
+
Simplified Dexscreener client with clean API
|
3
|
+
"""
|
4
|
+
|
5
|
+
import asyncio
|
6
|
+
from typing import Any, Callable, Optional, Union
|
7
|
+
|
8
|
+
from ..core.http import HttpClientCffi
|
9
|
+
from ..core.models import OrderInfo, TokenInfo, TokenPair
|
10
|
+
from ..stream.polling import PollingStream
|
11
|
+
from ..utils.filters import FilterConfig, TokenPairFilter
|
12
|
+
|
13
|
+
|
14
|
+
class DexscreenerClient:
|
15
|
+
"""
|
16
|
+
Simplified Dexscreener client with clean unified API
|
17
|
+
"""
|
18
|
+
|
19
|
+
# API limits
|
20
|
+
MAX_PAIRS_PER_REQUEST = 30
|
21
|
+
MAX_TOKENS_PER_REQUEST = 30
|
22
|
+
|
23
|
+
def __init__(self, impersonate: Optional[str] = None, client_kwargs: Optional[dict[str, Any]] = None):
|
24
|
+
"""
|
25
|
+
Initialize Dexscreener client.
|
26
|
+
|
27
|
+
Args:
|
28
|
+
impersonate: Browser to impersonate (default: None, uses random market-share based selection)
|
29
|
+
client_kwargs: Optional kwargs to pass to curl_cffi clients.
|
30
|
+
"""
|
31
|
+
# Setup client kwargs
|
32
|
+
self.client_kwargs = client_kwargs or {}
|
33
|
+
# Use provided impersonate or our custom realworld browser selection
|
34
|
+
if impersonate:
|
35
|
+
self.client_kwargs["impersonate"] = impersonate
|
36
|
+
else:
|
37
|
+
# Don't set it here, let HttpClientCffi handle it
|
38
|
+
pass
|
39
|
+
|
40
|
+
# HTTP clients for different rate limits
|
41
|
+
self._client_60rpm = HttpClientCffi(
|
42
|
+
60, 60, base_url="https://api.dexscreener.com", client_kwargs=self.client_kwargs
|
43
|
+
)
|
44
|
+
self._client_300rpm = HttpClientCffi(
|
45
|
+
300, 60, base_url="https://api.dexscreener.com", client_kwargs=self.client_kwargs
|
46
|
+
)
|
47
|
+
|
48
|
+
# Single streaming client for all subscriptions
|
49
|
+
self._http_stream: Optional[PollingStream] = None
|
50
|
+
|
51
|
+
# Active subscriptions
|
52
|
+
self._active_subscriptions: dict[str, dict] = {}
|
53
|
+
|
54
|
+
# Filters for each subscription
|
55
|
+
self._filters: dict[str, TokenPairFilter] = {}
|
56
|
+
|
57
|
+
# ========== Single Query Methods ==========
|
58
|
+
|
59
|
+
def get_pair(self, address: str) -> Optional[TokenPair]:
|
60
|
+
"""Get a single token pair by address using search"""
|
61
|
+
# Since the API requires chain ID, we use search as a workaround
|
62
|
+
resp = self._client_300rpm.request("GET", f"latest/dex/search?q={address}")
|
63
|
+
if resp is not None and isinstance(resp, dict) and "pairs" in resp and len(resp["pairs"]) > 0:
|
64
|
+
# Return the first matching pair
|
65
|
+
for pair in resp["pairs"]:
|
66
|
+
if pair.get("pairAddress", "").lower() == address.lower():
|
67
|
+
return TokenPair(**pair)
|
68
|
+
# If no exact match, return the first result
|
69
|
+
return TokenPair(**resp["pairs"][0])
|
70
|
+
return None
|
71
|
+
|
72
|
+
async def get_pair_async(self, address: str) -> Optional[TokenPair]:
|
73
|
+
"""Async version of get_pair"""
|
74
|
+
# Since the API requires chain ID, we use search as a workaround
|
75
|
+
resp = await self._client_300rpm.request_async("GET", f"latest/dex/search?q={address}")
|
76
|
+
if resp is not None and isinstance(resp, dict) and "pairs" in resp and len(resp["pairs"]) > 0:
|
77
|
+
# Return the first matching pair
|
78
|
+
for pair in resp["pairs"]:
|
79
|
+
if pair.get("pairAddress", "").lower() == address.lower():
|
80
|
+
return TokenPair(**pair)
|
81
|
+
# If no exact match, return the first result
|
82
|
+
return TokenPair(**resp["pairs"][0])
|
83
|
+
return None
|
84
|
+
|
85
|
+
def get_pairs_by_pairs_addresses(self, chain_id: str, pair_addresses: list[str]) -> list[TokenPair]:
|
86
|
+
"""
|
87
|
+
Get multiple pairs from a specific chain.
|
88
|
+
|
89
|
+
NOTE: API limit is 30 pairs per request. Requests with more than 30 pair addresses will raise an error.
|
90
|
+
|
91
|
+
Args:
|
92
|
+
chain_id: The blockchain identifier (e.g., "solana", "ethereum")
|
93
|
+
pair_addresses: List of pair addresses (max 30)
|
94
|
+
|
95
|
+
Returns:
|
96
|
+
List of TokenPair objects
|
97
|
+
|
98
|
+
Raises:
|
99
|
+
ValueError: If more than 30 pair addresses are provided
|
100
|
+
"""
|
101
|
+
if not pair_addresses:
|
102
|
+
return []
|
103
|
+
|
104
|
+
if len(pair_addresses) > self.MAX_PAIRS_PER_REQUEST:
|
105
|
+
raise ValueError(f"Maximum {self.MAX_PAIRS_PER_REQUEST} pair addresses allowed, got {len(pair_addresses)}")
|
106
|
+
|
107
|
+
addresses_str = ",".join(pair_addresses)
|
108
|
+
resp = self._client_300rpm.request("GET", f"latest/dex/pairs/{chain_id}/{addresses_str}")
|
109
|
+
if resp is None:
|
110
|
+
return []
|
111
|
+
if isinstance(resp, dict) and "pairs" in resp and resp["pairs"] is not None:
|
112
|
+
return [TokenPair(**pair) for pair in resp["pairs"]]
|
113
|
+
return []
|
114
|
+
|
115
|
+
async def get_pairs_by_pairs_addresses_async(self, chain_id: str, pair_addresses: list[str]) -> list[TokenPair]:
|
116
|
+
"""
|
117
|
+
Async version of get_pairs_by_pairs_addresses.
|
118
|
+
|
119
|
+
NOTE: API limit is 30 pairs per request. Requests with more than 30 pair addresses will raise an error.
|
120
|
+
|
121
|
+
Args:
|
122
|
+
chain_id: The blockchain identifier (e.g., "solana", "ethereum")
|
123
|
+
pair_addresses: List of pair addresses (max 30)
|
124
|
+
|
125
|
+
Returns:
|
126
|
+
List of TokenPair objects
|
127
|
+
|
128
|
+
Raises:
|
129
|
+
ValueError: If more than 30 pair addresses are provided
|
130
|
+
"""
|
131
|
+
if not pair_addresses:
|
132
|
+
return []
|
133
|
+
|
134
|
+
if len(pair_addresses) > self.MAX_PAIRS_PER_REQUEST:
|
135
|
+
raise ValueError(f"Maximum {self.MAX_PAIRS_PER_REQUEST} pair addresses allowed, got {len(pair_addresses)}")
|
136
|
+
|
137
|
+
addresses_str = ",".join(pair_addresses)
|
138
|
+
resp = await self._client_300rpm.request_async("GET", f"latest/dex/pairs/{chain_id}/{addresses_str}")
|
139
|
+
if resp is None:
|
140
|
+
return []
|
141
|
+
if isinstance(resp, dict) and "pairs" in resp and resp["pairs"] is not None:
|
142
|
+
return [TokenPair(**pair) for pair in resp["pairs"]]
|
143
|
+
return []
|
144
|
+
|
145
|
+
def get_pair_by_pair_address(self, chain_id: str, pair_address: str) -> Optional[TokenPair]:
|
146
|
+
"""Get a single token pair by chain and pair address"""
|
147
|
+
pairs = self.get_pairs_by_pairs_addresses(chain_id, [pair_address])
|
148
|
+
return pairs[0] if pairs else None
|
149
|
+
|
150
|
+
async def get_pair_by_pair_address_async(self, chain_id: str, pair_address: str) -> Optional[TokenPair]:
|
151
|
+
"""Async version of get_pair_by_pair_address"""
|
152
|
+
pairs = await self.get_pairs_by_pairs_addresses_async(chain_id, [pair_address])
|
153
|
+
return pairs[0] if pairs else None
|
154
|
+
|
155
|
+
def search_pairs(self, query: str) -> list[TokenPair]:
|
156
|
+
"""Search for pairs by query"""
|
157
|
+
resp = self._client_300rpm.request("GET", f"latest/dex/search?q={query}")
|
158
|
+
if resp is not None and isinstance(resp, dict):
|
159
|
+
return [TokenPair(**pair) for pair in resp.get("pairs", [])]
|
160
|
+
return []
|
161
|
+
|
162
|
+
async def search_pairs_async(self, query: str) -> list[TokenPair]:
|
163
|
+
"""Async version of search_pairs"""
|
164
|
+
resp = await self._client_300rpm.request_async("GET", f"latest/dex/search?q={query}")
|
165
|
+
if resp is not None and isinstance(resp, dict):
|
166
|
+
return [TokenPair(**pair) for pair in resp.get("pairs", [])]
|
167
|
+
return []
|
168
|
+
|
169
|
+
def get_latest_token_profiles(self) -> list[TokenInfo]:
|
170
|
+
"""Get latest token profiles"""
|
171
|
+
resp = self._client_60rpm.request("GET", "token-profiles/latest/v1")
|
172
|
+
if resp is not None:
|
173
|
+
return [TokenInfo(**token) for token in resp]
|
174
|
+
return []
|
175
|
+
|
176
|
+
async def get_latest_token_profiles_async(self) -> list[TokenInfo]:
|
177
|
+
"""Async version of get_latest_token_profiles"""
|
178
|
+
resp = await self._client_60rpm.request_async("GET", "token-profiles/latest/v1")
|
179
|
+
if resp is not None:
|
180
|
+
return [TokenInfo(**token) for token in resp]
|
181
|
+
return []
|
182
|
+
|
183
|
+
def get_latest_boosted_tokens(self) -> list[TokenInfo]:
|
184
|
+
"""Get latest boosted tokens"""
|
185
|
+
resp = self._client_60rpm.request("GET", "token-boosts/latest/v1")
|
186
|
+
if resp is not None:
|
187
|
+
return [TokenInfo(**token) for token in resp]
|
188
|
+
return []
|
189
|
+
|
190
|
+
async def get_latest_boosted_tokens_async(self) -> list[TokenInfo]:
|
191
|
+
"""Async version of get_latest_boosted_tokens"""
|
192
|
+
resp = await self._client_60rpm.request_async("GET", "token-boosts/latest/v1")
|
193
|
+
if resp is not None:
|
194
|
+
return [TokenInfo(**token) for token in resp]
|
195
|
+
return []
|
196
|
+
|
197
|
+
def get_tokens_most_active(self) -> list[TokenInfo]:
|
198
|
+
"""Get tokens with most active boosts"""
|
199
|
+
resp = self._client_60rpm.request("GET", "token-boosts/top/v1")
|
200
|
+
if resp is not None:
|
201
|
+
return [TokenInfo(**token) for token in resp]
|
202
|
+
return []
|
203
|
+
|
204
|
+
async def get_tokens_most_active_async(self) -> list[TokenInfo]:
|
205
|
+
"""Async version of get_tokens_most_active"""
|
206
|
+
resp = await self._client_60rpm.request_async("GET", "token-boosts/top/v1")
|
207
|
+
if resp is not None:
|
208
|
+
return [TokenInfo(**token) for token in resp]
|
209
|
+
return []
|
210
|
+
|
211
|
+
def get_orders_paid_of_token(self, chain_id: str, token_address: str) -> list[OrderInfo]:
|
212
|
+
"""Get orders for a token"""
|
213
|
+
resp = self._client_60rpm.request("GET", f"orders/v1/{chain_id}/{token_address}")
|
214
|
+
if resp is not None:
|
215
|
+
return [OrderInfo(**order) for order in resp]
|
216
|
+
return []
|
217
|
+
|
218
|
+
async def get_orders_paid_of_token_async(self, chain_id: str, token_address: str) -> list[OrderInfo]:
|
219
|
+
"""Async version of get_orders_paid_of_token"""
|
220
|
+
resp = await self._client_60rpm.request_async("GET", f"orders/v1/{chain_id}/{token_address}")
|
221
|
+
if resp is not None:
|
222
|
+
return [OrderInfo(**order) for order in resp]
|
223
|
+
return []
|
224
|
+
|
225
|
+
def get_pairs_by_token_address(self, chain_id: str, token_address: str) -> list[TokenPair]:
|
226
|
+
"""Get all pairs for a single token address on a specific chain"""
|
227
|
+
# Use the correct endpoint format: /tokens/v1/{chain}/{address}
|
228
|
+
resp = self._client_300rpm.request("GET", f"tokens/v1/{chain_id}/{token_address}")
|
229
|
+
if resp is None:
|
230
|
+
return []
|
231
|
+
|
232
|
+
# The response is a direct array of pairs
|
233
|
+
if isinstance(resp, list):
|
234
|
+
return [TokenPair(**pair) for pair in resp]
|
235
|
+
return []
|
236
|
+
|
237
|
+
async def get_pairs_by_token_address_async(self, chain_id: str, token_address: str) -> list[TokenPair]:
|
238
|
+
"""Async version of get_pairs_by_token_address"""
|
239
|
+
# Use the correct endpoint format: /tokens/v1/{chain}/{address}
|
240
|
+
resp = await self._client_300rpm.request_async("GET", f"tokens/v1/{chain_id}/{token_address}")
|
241
|
+
if resp is None:
|
242
|
+
return []
|
243
|
+
|
244
|
+
# The response is a direct array of pairs
|
245
|
+
if isinstance(resp, list):
|
246
|
+
return [TokenPair(**pair) for pair in resp]
|
247
|
+
return []
|
248
|
+
|
249
|
+
def get_pairs_by_token_addresses(self, chain_id: str, token_addresses: list[str]) -> list[TokenPair]:
|
250
|
+
"""
|
251
|
+
Get all pairs for given token addresses on a specific chain.
|
252
|
+
|
253
|
+
NOTE: To simplify the API, we now limit this to 30 token addresses per request.
|
254
|
+
The API will return a MAXIMUM of 30 pairs regardless.
|
255
|
+
|
256
|
+
Args:
|
257
|
+
chain_id: The blockchain identifier (e.g., "solana", "ethereum")
|
258
|
+
token_addresses: List of token addresses (max 30)
|
259
|
+
|
260
|
+
Returns:
|
261
|
+
List of TokenPair objects (maximum 30 pairs)
|
262
|
+
|
263
|
+
Raises:
|
264
|
+
ValueError: If more than 30 token addresses are provided
|
265
|
+
"""
|
266
|
+
if not token_addresses:
|
267
|
+
return []
|
268
|
+
|
269
|
+
if len(token_addresses) > self.MAX_TOKENS_PER_REQUEST:
|
270
|
+
raise ValueError(
|
271
|
+
f"Maximum {self.MAX_TOKENS_PER_REQUEST} token addresses allowed, got {len(token_addresses)}"
|
272
|
+
)
|
273
|
+
|
274
|
+
if len(token_addresses) == 1:
|
275
|
+
# For single token, use the single token method
|
276
|
+
return self.get_pairs_by_token_address(chain_id, token_addresses[0])
|
277
|
+
|
278
|
+
# The API supports comma-separated addresses
|
279
|
+
addresses_str = ",".join(token_addresses)
|
280
|
+
resp = self._client_300rpm.request("GET", f"tokens/v1/{chain_id}/{addresses_str}")
|
281
|
+
if resp is None:
|
282
|
+
return []
|
283
|
+
|
284
|
+
# Response is a direct array of pairs
|
285
|
+
if isinstance(resp, list):
|
286
|
+
# Return unique pairs (avoid duplicates if a pair contains multiple requested tokens)
|
287
|
+
seen_pairs = set()
|
288
|
+
unique_pairs = []
|
289
|
+
for pair_data in resp:
|
290
|
+
pair = TokenPair(**pair_data)
|
291
|
+
pair_key = f"{pair.chain_id}:{pair.pair_address}"
|
292
|
+
if pair_key not in seen_pairs:
|
293
|
+
seen_pairs.add(pair_key)
|
294
|
+
unique_pairs.append(pair)
|
295
|
+
return unique_pairs
|
296
|
+
return []
|
297
|
+
|
298
|
+
def get_pools_by_token_address(self, chain_id: str, token_address: str) -> list[TokenPair]:
|
299
|
+
"""Get pools info using token-pairs/v1 endpoint (Pool endpoint)"""
|
300
|
+
# Use the token-pairs/v1 endpoint
|
301
|
+
resp = self._client_300rpm.request("GET", f"token-pairs/v1/{chain_id}/{token_address}")
|
302
|
+
if resp is None:
|
303
|
+
return []
|
304
|
+
|
305
|
+
# The response is a direct array of pairs
|
306
|
+
if isinstance(resp, list):
|
307
|
+
return [TokenPair(**pair) for pair in resp]
|
308
|
+
return []
|
309
|
+
|
310
|
+
async def get_pools_by_token_address_async(self, chain_id: str, token_address: str) -> list[TokenPair]:
|
311
|
+
"""Async version of get_pools_by_token_address"""
|
312
|
+
# Use the token-pairs/v1 endpoint
|
313
|
+
resp = await self._client_300rpm.request_async("GET", f"token-pairs/v1/{chain_id}/{token_address}")
|
314
|
+
if resp is None:
|
315
|
+
return []
|
316
|
+
|
317
|
+
# The response is a direct array of pairs
|
318
|
+
if isinstance(resp, list):
|
319
|
+
return [TokenPair(**pair) for pair in resp]
|
320
|
+
return []
|
321
|
+
|
322
|
+
async def get_pairs_by_token_addresses_async(self, chain_id: str, token_addresses: list[str]) -> list[TokenPair]:
|
323
|
+
"""
|
324
|
+
Async version of get_pairs_by_token_addresses.
|
325
|
+
|
326
|
+
NOTE: To simplify the API, we now limit this to 30 token addresses per request.
|
327
|
+
The API will return a MAXIMUM of 30 pairs regardless.
|
328
|
+
|
329
|
+
Args:
|
330
|
+
chain_id: The blockchain identifier (e.g., "solana", "ethereum")
|
331
|
+
token_addresses: List of token addresses (max 30)
|
332
|
+
|
333
|
+
Returns:
|
334
|
+
List of TokenPair objects (maximum 30 pairs)
|
335
|
+
|
336
|
+
Raises:
|
337
|
+
ValueError: If more than 30 token addresses are provided
|
338
|
+
"""
|
339
|
+
if not token_addresses:
|
340
|
+
return []
|
341
|
+
|
342
|
+
if len(token_addresses) > self.MAX_TOKENS_PER_REQUEST:
|
343
|
+
raise ValueError(
|
344
|
+
f"Maximum {self.MAX_TOKENS_PER_REQUEST} token addresses allowed, got {len(token_addresses)}"
|
345
|
+
)
|
346
|
+
|
347
|
+
if len(token_addresses) == 1:
|
348
|
+
# For single token, use the single token method
|
349
|
+
return await self.get_pairs_by_token_address_async(chain_id, token_addresses[0])
|
350
|
+
|
351
|
+
# The API supports comma-separated addresses
|
352
|
+
addresses_str = ",".join(token_addresses)
|
353
|
+
resp = await self._client_300rpm.request_async("GET", f"tokens/v1/{chain_id}/{addresses_str}")
|
354
|
+
if resp is None:
|
355
|
+
return []
|
356
|
+
|
357
|
+
# Response is a direct array of pairs
|
358
|
+
if isinstance(resp, list):
|
359
|
+
# Return unique pairs (avoid duplicates if a pair contains multiple requested tokens)
|
360
|
+
seen_pairs = set()
|
361
|
+
unique_pairs = []
|
362
|
+
for pair_data in resp:
|
363
|
+
pair = TokenPair(**pair_data)
|
364
|
+
pair_key = f"{pair.chain_id}:{pair.pair_address}"
|
365
|
+
if pair_key not in seen_pairs:
|
366
|
+
seen_pairs.add(pair_key)
|
367
|
+
unique_pairs.append(pair)
|
368
|
+
return unique_pairs
|
369
|
+
return []
|
370
|
+
|
371
|
+
# ========== Streaming Methods ==========
|
372
|
+
|
373
|
+
async def subscribe_pairs(
|
374
|
+
self,
|
375
|
+
chain_id: str,
|
376
|
+
pair_addresses: list[str],
|
377
|
+
callback: Callable[[TokenPair], None],
|
378
|
+
*, # Force keyword arguments
|
379
|
+
filter: Union[bool, FilterConfig] = True,
|
380
|
+
interval: float = 0.2,
|
381
|
+
) -> None:
|
382
|
+
"""
|
383
|
+
Subscribe to pair updates.
|
384
|
+
|
385
|
+
Args:
|
386
|
+
chain_id: Blockchain identifier (e.g., "ethereum", "solana")
|
387
|
+
pair_addresses: List of pair contract addresses
|
388
|
+
callback: Function to call on updates
|
389
|
+
filter: Filtering configuration:
|
390
|
+
- False: No filtering, receive all updates
|
391
|
+
- True: Default filtering (changes only)
|
392
|
+
- FilterConfig: Custom filter configuration
|
393
|
+
interval: Polling interval for HTTP mode (seconds, default 0.2s = 300/min)
|
394
|
+
|
395
|
+
Examples:
|
396
|
+
# Simple change detection (default)
|
397
|
+
await client.subscribe_pairs("ethereum", ["0x..."], callback)
|
398
|
+
|
399
|
+
# No filtering
|
400
|
+
await client.subscribe_pairs("ethereum", ["0x..."], callback, filter=False)
|
401
|
+
|
402
|
+
# Only significant price changes (1%)
|
403
|
+
from dexscreen.utils import FilterPresets
|
404
|
+
await client.subscribe_pairs(
|
405
|
+
"ethereum", ["0x..."], callback,
|
406
|
+
filter=FilterPresets.significant_price_changes(0.01)
|
407
|
+
)
|
408
|
+
|
409
|
+
# Custom filter config
|
410
|
+
from dexscreen.utils import FilterConfig
|
411
|
+
config = FilterConfig(price_change_threshold=0.02)
|
412
|
+
await client.subscribe_pairs("ethereum", ["0x..."], callback, filter=config)
|
413
|
+
"""
|
414
|
+
# Handle single pair address for backward compatibility
|
415
|
+
for pair_address in pair_addresses:
|
416
|
+
subscription_key = f"{chain_id}:{pair_address}"
|
417
|
+
|
418
|
+
# Setup filter based on parameter type
|
419
|
+
if filter is False:
|
420
|
+
# No filtering
|
421
|
+
actual_callback = callback
|
422
|
+
filter_config_used = None
|
423
|
+
elif filter is True:
|
424
|
+
# Use default filter configuration
|
425
|
+
filter_config_used = FilterConfig()
|
426
|
+
filter_instance = TokenPairFilter(filter_config_used)
|
427
|
+
self._filters[subscription_key] = filter_instance
|
428
|
+
|
429
|
+
# Create filtered callback
|
430
|
+
async def filtered_callback(
|
431
|
+
pair: TokenPair, filter_instance=filter_instance, subscription_key=subscription_key
|
432
|
+
):
|
433
|
+
if filter_instance.should_emit(subscription_key, pair):
|
434
|
+
if asyncio.iscoroutinefunction(callback):
|
435
|
+
await callback(pair)
|
436
|
+
else:
|
437
|
+
callback(pair)
|
438
|
+
|
439
|
+
actual_callback = filtered_callback
|
440
|
+
elif isinstance(filter, FilterConfig):
|
441
|
+
# Use custom filter configuration
|
442
|
+
filter_config_used = filter
|
443
|
+
filter_instance = TokenPairFilter(filter_config_used)
|
444
|
+
self._filters[subscription_key] = filter_instance
|
445
|
+
|
446
|
+
# Create filtered callback
|
447
|
+
async def filtered_callback(
|
448
|
+
pair: TokenPair, filter_instance=filter_instance, subscription_key=subscription_key
|
449
|
+
):
|
450
|
+
if filter_instance.should_emit(subscription_key, pair):
|
451
|
+
if asyncio.iscoroutinefunction(callback):
|
452
|
+
await callback(pair)
|
453
|
+
else:
|
454
|
+
callback(pair)
|
455
|
+
|
456
|
+
actual_callback = filtered_callback
|
457
|
+
else:
|
458
|
+
raise ValueError(f"Invalid filter type: {type(filter)}. Must be bool or FilterConfig")
|
459
|
+
|
460
|
+
# Store subscription info
|
461
|
+
self._active_subscriptions[subscription_key] = {
|
462
|
+
"chain": chain_id,
|
463
|
+
"pair_address": pair_address,
|
464
|
+
"callback": callback,
|
465
|
+
"filter": filter,
|
466
|
+
"filter_config": filter_config_used,
|
467
|
+
"interval": interval,
|
468
|
+
}
|
469
|
+
|
470
|
+
# Subscribe to updates
|
471
|
+
# Always pass filter_changes=False to PollingStream since filtering is handled here
|
472
|
+
await self._subscribe_http(chain_id, pair_address, actual_callback, interval)
|
473
|
+
|
474
|
+
async def subscribe_tokens(
|
475
|
+
self,
|
476
|
+
chain_id: str,
|
477
|
+
token_addresses: list[str],
|
478
|
+
callback: Callable[[list[TokenPair]], None],
|
479
|
+
*, # Force keyword arguments
|
480
|
+
filter: Union[bool, FilterConfig] = True,
|
481
|
+
interval: float = 0.2,
|
482
|
+
) -> None:
|
483
|
+
"""
|
484
|
+
Subscribe to all pairs of tokens.
|
485
|
+
|
486
|
+
Args:
|
487
|
+
chain_id: Blockchain identifier (e.g., "ethereum", "solana")
|
488
|
+
token_addresses: List of token contract addresses
|
489
|
+
callback: Function to call on updates (receives list of TokenPair)
|
490
|
+
filter: Filtering configuration (same as subscribe_pairs)
|
491
|
+
interval: Polling interval (seconds, default 0.2s = 300/min)
|
492
|
+
|
493
|
+
Example:
|
494
|
+
# Subscribe to all pairs of USDC token on Solana
|
495
|
+
await client.subscribe_tokens(
|
496
|
+
"solana",
|
497
|
+
["EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"],
|
498
|
+
callback=handle_usdc_pairs
|
499
|
+
)
|
500
|
+
"""
|
501
|
+
# Handle multiple token addresses
|
502
|
+
for token_address in token_addresses:
|
503
|
+
subscription_key = f"token:{chain_id}:{token_address}"
|
504
|
+
|
505
|
+
# Setup filter based on parameter type
|
506
|
+
if filter is False:
|
507
|
+
# No filtering
|
508
|
+
actual_callback = callback
|
509
|
+
filter_config_used = None
|
510
|
+
elif filter is True:
|
511
|
+
# Use default filter configuration
|
512
|
+
filter_config_used = FilterConfig()
|
513
|
+
filter_instance = TokenPairFilter(filter_config_used)
|
514
|
+
self._filters[subscription_key] = filter_instance
|
515
|
+
|
516
|
+
# For token subscriptions, we need to track pairs individually
|
517
|
+
async def filtered_callback(pairs: list[TokenPair], filter_instance=filter_instance):
|
518
|
+
filtered_pairs = []
|
519
|
+
for pair in pairs:
|
520
|
+
pair_key = f"{pair.chain_id}:{pair.pair_address}"
|
521
|
+
if filter_instance.should_emit(pair_key, pair):
|
522
|
+
filtered_pairs.append(pair)
|
523
|
+
|
524
|
+
if filtered_pairs:
|
525
|
+
if asyncio.iscoroutinefunction(callback):
|
526
|
+
await callback(filtered_pairs)
|
527
|
+
else:
|
528
|
+
callback(filtered_pairs)
|
529
|
+
|
530
|
+
actual_callback = filtered_callback
|
531
|
+
elif isinstance(filter, FilterConfig):
|
532
|
+
# Use custom filter configuration
|
533
|
+
filter_config_used = filter
|
534
|
+
filter_instance = TokenPairFilter(filter_config_used)
|
535
|
+
self._filters[subscription_key] = filter_instance
|
536
|
+
|
537
|
+
# For token subscriptions, we need to track pairs individually
|
538
|
+
async def filtered_callback(pairs: list[TokenPair], filter_instance=filter_instance):
|
539
|
+
filtered_pairs = []
|
540
|
+
for pair in pairs:
|
541
|
+
pair_key = f"{pair.chain_id}:{pair.pair_address}"
|
542
|
+
if filter_instance.should_emit(pair_key, pair):
|
543
|
+
filtered_pairs.append(pair)
|
544
|
+
|
545
|
+
if filtered_pairs:
|
546
|
+
if asyncio.iscoroutinefunction(callback):
|
547
|
+
await callback(filtered_pairs)
|
548
|
+
else:
|
549
|
+
callback(filtered_pairs)
|
550
|
+
|
551
|
+
actual_callback = filtered_callback
|
552
|
+
else:
|
553
|
+
raise ValueError(f"Invalid filter type: {type(filter)}. Must be bool or FilterConfig")
|
554
|
+
|
555
|
+
# Store subscription info
|
556
|
+
self._active_subscriptions[subscription_key] = {
|
557
|
+
"type": "token",
|
558
|
+
"chain": chain_id,
|
559
|
+
"token_address": token_address,
|
560
|
+
"callback": callback,
|
561
|
+
"filter": filter,
|
562
|
+
"filter_config": filter_config_used,
|
563
|
+
"interval": interval,
|
564
|
+
}
|
565
|
+
|
566
|
+
# Subscribe to updates
|
567
|
+
await self._subscribe_token_http(chain_id, token_address, actual_callback, interval)
|
568
|
+
|
569
|
+
async def _subscribe_http(self, chain_id: str, pair_address: str, callback: Callable, interval: float):
|
570
|
+
"""Subscribe to updates"""
|
571
|
+
# Create single HTTP stream client if needed
|
572
|
+
if self._http_stream is None:
|
573
|
+
# Always pass filter_changes=False since filtering is handled in subscribe method
|
574
|
+
self._http_stream = PollingStream(
|
575
|
+
self,
|
576
|
+
interval=1.0, # Default interval, will be overridden per subscription
|
577
|
+
filter_changes=False,
|
578
|
+
)
|
579
|
+
await self._http_stream.connect()
|
580
|
+
|
581
|
+
# Subscribe with specific interval
|
582
|
+
await self._http_stream.subscribe(chain_id, pair_address, callback, interval)
|
583
|
+
|
584
|
+
async def _subscribe_token_http(self, chain_id: str, token_address: str, callback: Callable, interval: float):
|
585
|
+
"""Subscribe to token pair updates"""
|
586
|
+
# Create single HTTP stream client if needed
|
587
|
+
if self._http_stream is None:
|
588
|
+
# Always pass filter_changes=False since filtering is handled in subscribe method
|
589
|
+
self._http_stream = PollingStream(
|
590
|
+
self,
|
591
|
+
interval=1.0, # Default interval, will be overridden per subscription
|
592
|
+
filter_changes=False,
|
593
|
+
)
|
594
|
+
await self._http_stream.connect()
|
595
|
+
|
596
|
+
# Subscribe with specific interval
|
597
|
+
await self._http_stream.subscribe_token(chain_id, token_address, callback, interval)
|
598
|
+
|
599
|
+
async def unsubscribe_pairs(self, chain_id: str, pair_addresses: list[str]) -> None:
|
600
|
+
"""Unsubscribe from pair updates"""
|
601
|
+
for pair_address in pair_addresses:
|
602
|
+
subscription_key = f"{chain_id}:{pair_address}"
|
603
|
+
|
604
|
+
if subscription_key not in self._active_subscriptions:
|
605
|
+
continue
|
606
|
+
|
607
|
+
# Unsubscribe from the single HTTP client
|
608
|
+
if self._http_stream and self._http_stream.has_subscription(chain_id, pair_address):
|
609
|
+
await self._http_stream.unsubscribe(chain_id, pair_address)
|
610
|
+
|
611
|
+
# Clean up
|
612
|
+
del self._active_subscriptions[subscription_key]
|
613
|
+
if subscription_key in self._filters:
|
614
|
+
self._filters[subscription_key].reset(subscription_key)
|
615
|
+
del self._filters[subscription_key]
|
616
|
+
|
617
|
+
async def unsubscribe_tokens(self, chain_id: str, token_addresses: list[str]) -> None:
|
618
|
+
"""Unsubscribe from token pair updates"""
|
619
|
+
for token_address in token_addresses:
|
620
|
+
subscription_key = f"token:{chain_id}:{token_address}"
|
621
|
+
|
622
|
+
if subscription_key not in self._active_subscriptions:
|
623
|
+
continue
|
624
|
+
|
625
|
+
# Unsubscribe from the single HTTP client
|
626
|
+
if self._http_stream and self._http_stream.has_token_subscription(chain_id, token_address):
|
627
|
+
await self._http_stream.unsubscribe_token(chain_id, token_address)
|
628
|
+
|
629
|
+
# Clean up
|
630
|
+
del self._active_subscriptions[subscription_key]
|
631
|
+
if subscription_key in self._filters:
|
632
|
+
self._filters[subscription_key].reset()
|
633
|
+
del self._filters[subscription_key]
|
634
|
+
|
635
|
+
async def close_streams(self) -> None:
|
636
|
+
"""Close all streaming connections"""
|
637
|
+
# Close HTTP stream
|
638
|
+
if self._http_stream:
|
639
|
+
await self._http_stream.close()
|
640
|
+
self._http_stream = None
|
641
|
+
|
642
|
+
# Clear subscriptions and filters
|
643
|
+
self._active_subscriptions.clear()
|
644
|
+
for filter_instance in self._filters.values():
|
645
|
+
filter_instance.reset()
|
646
|
+
self._filters.clear()
|
647
|
+
|
648
|
+
def get_active_subscriptions(self) -> list[dict]:
|
649
|
+
"""Get list of active subscriptions"""
|
650
|
+
subscriptions = []
|
651
|
+
for _key, info in self._active_subscriptions.items():
|
652
|
+
if info.get("type") == "token":
|
653
|
+
subscriptions.append(
|
654
|
+
{
|
655
|
+
"type": "token",
|
656
|
+
"chain": info["chain"],
|
657
|
+
"token_address": info["token_address"],
|
658
|
+
"filter": info["filter"],
|
659
|
+
"interval": info.get("interval", 0.2),
|
660
|
+
}
|
661
|
+
)
|
662
|
+
else:
|
663
|
+
subscriptions.append(
|
664
|
+
{
|
665
|
+
"type": "pair",
|
666
|
+
"chain": info["chain"],
|
667
|
+
"pair_address": info["pair_address"],
|
668
|
+
"filter": info["filter"],
|
669
|
+
"interval": info.get("interval", 0.2),
|
670
|
+
}
|
671
|
+
)
|
672
|
+
return subscriptions
|
File without changes
|