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.
@@ -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