dexscreener-python 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.
- dexscreener/__init__.py +11 -0
- dexscreener/client.py +458 -0
- dexscreener/models.py +92 -0
- dexscreener/py.typed +0 -0
- dexscreener_python-0.1.0.dist-info/METADATA +208 -0
- dexscreener_python-0.1.0.dist-info/RECORD +8 -0
- dexscreener_python-0.1.0.dist-info/WHEEL +4 -0
- dexscreener_python-0.1.0.dist-info/licenses/LICENSE +21 -0
dexscreener/__init__.py
ADDED
dexscreener/client.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async DexScreener API client.
|
|
3
|
+
|
|
4
|
+
Free public API — no authentication required.
|
|
5
|
+
Rate limit: ~300 req/min (~5/s).
|
|
6
|
+
API docs: https://docs.dexscreener.com/api/reference
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from .models import DexPairData
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_BASE_URL = "https://api.dexscreener.com"
|
|
24
|
+
|
|
25
|
+
# 429 retry settings
|
|
26
|
+
_MAX_429_RETRIES = 3
|
|
27
|
+
_429_BASE_BACKOFF = 2.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _retry_after_seconds(resp: httpx.Response) -> float | None:
|
|
31
|
+
header = resp.headers.get("Retry-After")
|
|
32
|
+
if not header:
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
return max(0.0, float(header))
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _safe_decimal(val: Any) -> Decimal:
|
|
41
|
+
if val is None:
|
|
42
|
+
return Decimal("0")
|
|
43
|
+
try:
|
|
44
|
+
return Decimal(str(val))
|
|
45
|
+
except Exception:
|
|
46
|
+
return Decimal("0")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _safe_int(val: Any) -> int:
|
|
50
|
+
try:
|
|
51
|
+
return int(val or 0)
|
|
52
|
+
except (ValueError, TypeError):
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DexScreenerClient:
|
|
57
|
+
"""Async DexScreener API client with rate limiting and 429 handling.
|
|
58
|
+
|
|
59
|
+
Works on any chain DexScreener supports: solana, ethereum, base, bsc,
|
|
60
|
+
arbitrum, polygon, avalanche, etc.
|
|
61
|
+
|
|
62
|
+
Example::
|
|
63
|
+
|
|
64
|
+
async with DexScreenerClient() as client:
|
|
65
|
+
pairs = await client.get_token_pairs("solana", token_address)
|
|
66
|
+
print(pairs[0].price_usd)
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
__slots__ = (
|
|
70
|
+
"_http",
|
|
71
|
+
"_cache",
|
|
72
|
+
"_cache_ttl",
|
|
73
|
+
"_429_cooldown_until",
|
|
74
|
+
"_429_consecutive",
|
|
75
|
+
"_rate_limit",
|
|
76
|
+
"_rate_tokens",
|
|
77
|
+
"_rate_last_refill",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
rate_limit: float = 5.0,
|
|
84
|
+
cache_ttl: float = 8.0,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Args:
|
|
88
|
+
rate_limit: Max requests per second (default 5.0).
|
|
89
|
+
cache_ttl: Default cache TTL in seconds.
|
|
90
|
+
"""
|
|
91
|
+
self._http: httpx.AsyncClient | None = None
|
|
92
|
+
self._cache: dict[str, tuple[Any, float]] = {} # key → (value, expires_at)
|
|
93
|
+
self._cache_ttl = cache_ttl
|
|
94
|
+
self._429_cooldown_until: float = 0.0
|
|
95
|
+
self._429_consecutive: int = 0
|
|
96
|
+
self._rate_limit = max(rate_limit, 0.1)
|
|
97
|
+
self._rate_tokens = rate_limit
|
|
98
|
+
self._rate_last_refill = time.monotonic()
|
|
99
|
+
|
|
100
|
+
async def __aenter__(self) -> DexScreenerClient:
|
|
101
|
+
await self.startup()
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
105
|
+
await self.shutdown()
|
|
106
|
+
|
|
107
|
+
async def startup(self) -> None:
|
|
108
|
+
self._http = httpx.AsyncClient(
|
|
109
|
+
timeout=httpx.Timeout(10.0),
|
|
110
|
+
headers={"Accept": "application/json"},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def shutdown(self) -> None:
|
|
114
|
+
if self._http:
|
|
115
|
+
await self._http.aclose()
|
|
116
|
+
self._http = None
|
|
117
|
+
|
|
118
|
+
# -- Public API ----------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
async def get_token_pairs(
|
|
121
|
+
self,
|
|
122
|
+
chain: str,
|
|
123
|
+
token_address: str,
|
|
124
|
+
) -> list[DexPairData]:
|
|
125
|
+
"""Get all trading pairs for a token, sorted by liquidity (highest first).
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
chain: Chain identifier (e.g., "solana", "ethereum", "base").
|
|
129
|
+
token_address: Token contract/mint address.
|
|
130
|
+
"""
|
|
131
|
+
cache_key = f"pairs:{chain}:{token_address}"
|
|
132
|
+
cached = self._cache_get(cache_key)
|
|
133
|
+
if cached is not None:
|
|
134
|
+
return cached
|
|
135
|
+
|
|
136
|
+
data = await self._get_with_retry(
|
|
137
|
+
f"{_BASE_URL}/tokens/v1/{chain}/{token_address}",
|
|
138
|
+
"token_pairs",
|
|
139
|
+
)
|
|
140
|
+
raw_pairs = data if isinstance(data, list) else data.get("pairs", []) if data else []
|
|
141
|
+
pairs = self._parse_pairs(raw_pairs)
|
|
142
|
+
pairs.sort(key=lambda p: p.liquidity_usd, reverse=True)
|
|
143
|
+
self._cache_set(cache_key, pairs)
|
|
144
|
+
return pairs
|
|
145
|
+
|
|
146
|
+
async def get_pair_by_address(
|
|
147
|
+
self,
|
|
148
|
+
chain: str,
|
|
149
|
+
pair_address: str,
|
|
150
|
+
) -> DexPairData | None:
|
|
151
|
+
"""Get data for a specific pair address.
|
|
152
|
+
|
|
153
|
+
Returns None if the pair is not found.
|
|
154
|
+
"""
|
|
155
|
+
cache_key = f"pair:{chain}:{pair_address}"
|
|
156
|
+
cached = self._cache_get(cache_key)
|
|
157
|
+
if cached is not None:
|
|
158
|
+
return cached
|
|
159
|
+
|
|
160
|
+
data = await self._get_with_retry(
|
|
161
|
+
f"{_BASE_URL}/pairs/v1/{chain}/{pair_address}",
|
|
162
|
+
"pair",
|
|
163
|
+
)
|
|
164
|
+
raw_pair = (
|
|
165
|
+
data[0]
|
|
166
|
+
if isinstance(data, list) and data
|
|
167
|
+
else data.get("pair", data) if data else None
|
|
168
|
+
)
|
|
169
|
+
if not raw_pair:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
pairs = self._parse_pairs([raw_pair])
|
|
173
|
+
result = pairs[0] if pairs else None
|
|
174
|
+
if result:
|
|
175
|
+
self._cache_set(cache_key, result)
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
async def get_token_price(
|
|
179
|
+
self,
|
|
180
|
+
chain: str,
|
|
181
|
+
token_address: str,
|
|
182
|
+
) -> Decimal | None:
|
|
183
|
+
"""Get the best available USD price for a token.
|
|
184
|
+
|
|
185
|
+
Uses the highest-liquidity pair. Returns None if no price available.
|
|
186
|
+
"""
|
|
187
|
+
pairs = await self.get_token_pairs(chain, token_address)
|
|
188
|
+
if not pairs:
|
|
189
|
+
return None
|
|
190
|
+
best = pairs[0]
|
|
191
|
+
return best.price_usd if best.price_usd > 0 else None
|
|
192
|
+
|
|
193
|
+
async def get_token_liquidity(
|
|
194
|
+
self,
|
|
195
|
+
chain: str,
|
|
196
|
+
token_address: str,
|
|
197
|
+
) -> Decimal:
|
|
198
|
+
"""Get total USD liquidity across all pairs for a token."""
|
|
199
|
+
pairs = await self.get_token_pairs(chain, token_address)
|
|
200
|
+
return sum((p.liquidity_usd for p in pairs), Decimal("0"))
|
|
201
|
+
|
|
202
|
+
async def get_tokens_batch(
|
|
203
|
+
self,
|
|
204
|
+
chain: str,
|
|
205
|
+
token_addresses: list[str],
|
|
206
|
+
) -> dict[str, DexPairData]:
|
|
207
|
+
"""Fetch market data for up to 30 tokens in a single request.
|
|
208
|
+
|
|
209
|
+
Returns a dict keyed by token address → best DexPairData (highest liquidity).
|
|
210
|
+
Tokens with no data are absent from the result.
|
|
211
|
+
"""
|
|
212
|
+
if not token_addresses:
|
|
213
|
+
return {}
|
|
214
|
+
|
|
215
|
+
BATCH_SIZE = 30
|
|
216
|
+
result: dict[str, DexPairData] = {}
|
|
217
|
+
|
|
218
|
+
for i in range(0, len(token_addresses), BATCH_SIZE):
|
|
219
|
+
batch = token_addresses[i : i + BATCH_SIZE]
|
|
220
|
+
comma_addrs = ",".join(batch)
|
|
221
|
+
|
|
222
|
+
data = await self._get_with_retry(
|
|
223
|
+
f"{_BASE_URL}/tokens/v1/{chain}/{comma_addrs}",
|
|
224
|
+
"batch_tokens",
|
|
225
|
+
)
|
|
226
|
+
raw_pairs = data if isinstance(data, list) else data.get("pairs", []) if data else []
|
|
227
|
+
pairs = self._parse_pairs(raw_pairs)
|
|
228
|
+
|
|
229
|
+
for pair in pairs:
|
|
230
|
+
addr = pair.base_token_address
|
|
231
|
+
if addr and (
|
|
232
|
+
addr not in result
|
|
233
|
+
or pair.liquidity_usd > result[addr].liquidity_usd
|
|
234
|
+
):
|
|
235
|
+
result[addr] = pair
|
|
236
|
+
|
|
237
|
+
return result
|
|
238
|
+
|
|
239
|
+
async def search_pairs(self, query: str) -> list[DexPairData]:
|
|
240
|
+
"""Search for pairs by token name, symbol, or address."""
|
|
241
|
+
data = await self._get_with_retry(
|
|
242
|
+
f"{_BASE_URL}/latest/dex/search",
|
|
243
|
+
"search",
|
|
244
|
+
params={"q": query},
|
|
245
|
+
)
|
|
246
|
+
raw_pairs = data.get("pairs", []) if data else []
|
|
247
|
+
return self._parse_pairs(raw_pairs)
|
|
248
|
+
|
|
249
|
+
async def get_boosted_tokens(
|
|
250
|
+
self,
|
|
251
|
+
chain: str | None = None,
|
|
252
|
+
) -> list[dict]:
|
|
253
|
+
"""Get currently boosted (promoted) tokens.
|
|
254
|
+
|
|
255
|
+
Boosted tokens are paid promotions — treat with caution.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
chain: Optional chain filter (e.g., "solana"). If None, returns all chains.
|
|
259
|
+
"""
|
|
260
|
+
cache_key = f"boosted:{chain or 'all'}"
|
|
261
|
+
cached = self._cache_get(cache_key)
|
|
262
|
+
if cached is not None:
|
|
263
|
+
return cached
|
|
264
|
+
|
|
265
|
+
data = await self._get_with_retry(
|
|
266
|
+
f"{_BASE_URL}/token-boosts/latest/v1",
|
|
267
|
+
"boosted",
|
|
268
|
+
)
|
|
269
|
+
items = data if isinstance(data, list) else []
|
|
270
|
+
if chain:
|
|
271
|
+
items = [item for item in items if item.get("chainId") == chain]
|
|
272
|
+
|
|
273
|
+
self._cache_set(cache_key, items, ttl=30.0)
|
|
274
|
+
return items
|
|
275
|
+
|
|
276
|
+
async def is_token_boosted(
|
|
277
|
+
self,
|
|
278
|
+
chain: str,
|
|
279
|
+
token_address: str,
|
|
280
|
+
) -> bool:
|
|
281
|
+
"""Check if a token is currently boosted (paid promotion)."""
|
|
282
|
+
boosted = await self.get_boosted_tokens(chain)
|
|
283
|
+
return any(
|
|
284
|
+
b.get("tokenAddress", "").lower() == token_address.lower()
|
|
285
|
+
for b in boosted
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# -- Internal helpers ----------------------------------------------------
|
|
289
|
+
|
|
290
|
+
async def _wait_429_cooldown(self) -> None:
|
|
291
|
+
now = time.time()
|
|
292
|
+
if now < self._429_cooldown_until:
|
|
293
|
+
wait = self._429_cooldown_until - now
|
|
294
|
+
logger.info("dexscreener 429 cooldown: waiting %.1fs", wait)
|
|
295
|
+
await asyncio.sleep(wait)
|
|
296
|
+
|
|
297
|
+
def _trigger_429_cooldown(self, endpoint: str) -> None:
|
|
298
|
+
self._429_consecutive += 1
|
|
299
|
+
backoff = 3.0 * (2 ** min(self._429_consecutive - 1, 3))
|
|
300
|
+
self._429_cooldown_until = time.time() + backoff
|
|
301
|
+
logger.warning(
|
|
302
|
+
"dexscreener 429 cooldown set: endpoint=%s consecutive=%d backoff=%.1fs",
|
|
303
|
+
endpoint,
|
|
304
|
+
self._429_consecutive,
|
|
305
|
+
backoff,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _clear_429_cooldown(self) -> None:
|
|
309
|
+
if self._429_consecutive > 0:
|
|
310
|
+
self._429_consecutive = 0
|
|
311
|
+
|
|
312
|
+
async def _rate_limit_acquire(self) -> None:
|
|
313
|
+
"""Simple token bucket rate limiter."""
|
|
314
|
+
now = time.monotonic()
|
|
315
|
+
elapsed = now - self._rate_last_refill
|
|
316
|
+
self._rate_tokens = min(
|
|
317
|
+
self._rate_limit,
|
|
318
|
+
self._rate_tokens + elapsed * self._rate_limit,
|
|
319
|
+
)
|
|
320
|
+
self._rate_last_refill = now
|
|
321
|
+
|
|
322
|
+
if self._rate_tokens < 1.0:
|
|
323
|
+
wait = (1.0 - self._rate_tokens) / self._rate_limit
|
|
324
|
+
await asyncio.sleep(wait)
|
|
325
|
+
self._rate_tokens = 0.0
|
|
326
|
+
else:
|
|
327
|
+
self._rate_tokens -= 1.0
|
|
328
|
+
|
|
329
|
+
async def _get_with_retry(
|
|
330
|
+
self,
|
|
331
|
+
url: str,
|
|
332
|
+
endpoint: str,
|
|
333
|
+
params: dict[str, Any] | None = None,
|
|
334
|
+
) -> Any:
|
|
335
|
+
"""GET with 429 retry + global cooldown."""
|
|
336
|
+
if self._http is None:
|
|
337
|
+
raise RuntimeError("DexScreenerClient.startup() not called")
|
|
338
|
+
|
|
339
|
+
await self._wait_429_cooldown()
|
|
340
|
+
|
|
341
|
+
for attempt in range(_MAX_429_RETRIES + 1):
|
|
342
|
+
await self._rate_limit_acquire()
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
resp = await self._http.get(url, params=params)
|
|
346
|
+
|
|
347
|
+
if resp.status_code == 429:
|
|
348
|
+
self._trigger_429_cooldown(endpoint)
|
|
349
|
+
backoff = _429_BASE_BACKOFF * (2 ** attempt)
|
|
350
|
+
logger.warning(
|
|
351
|
+
"dexscreener 429: endpoint=%s attempt=%d backoff=%.1fs",
|
|
352
|
+
endpoint,
|
|
353
|
+
attempt + 1,
|
|
354
|
+
backoff,
|
|
355
|
+
)
|
|
356
|
+
await asyncio.sleep(backoff)
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
self._clear_429_cooldown()
|
|
360
|
+
|
|
361
|
+
if resp.status_code != 200:
|
|
362
|
+
logger.warning(
|
|
363
|
+
"dexscreener HTTP %d: endpoint=%s",
|
|
364
|
+
resp.status_code,
|
|
365
|
+
endpoint,
|
|
366
|
+
)
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
return resp.json()
|
|
370
|
+
|
|
371
|
+
except Exception as exc:
|
|
372
|
+
logger.warning(
|
|
373
|
+
"dexscreener request failed: endpoint=%s error=%s",
|
|
374
|
+
endpoint,
|
|
375
|
+
exc,
|
|
376
|
+
)
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
logger.warning(
|
|
380
|
+
"dexscreener 429 exhausted retries: endpoint=%s",
|
|
381
|
+
endpoint,
|
|
382
|
+
)
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
# -- Cache ---------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
def _cache_get(self, key: str) -> Any:
|
|
388
|
+
entry = self._cache.get(key)
|
|
389
|
+
if entry is None:
|
|
390
|
+
return None
|
|
391
|
+
value, expires_at = entry
|
|
392
|
+
if time.monotonic() > expires_at:
|
|
393
|
+
del self._cache[key]
|
|
394
|
+
return None
|
|
395
|
+
return value
|
|
396
|
+
|
|
397
|
+
def _cache_set(self, key: str, value: Any, ttl: float | None = None) -> None:
|
|
398
|
+
self._cache[key] = (value, time.monotonic() + (ttl or self._cache_ttl))
|
|
399
|
+
|
|
400
|
+
# -- Parsing -------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
def _parse_pairs(self, raw_pairs: list[dict]) -> list[DexPairData]:
|
|
403
|
+
"""Parse raw pair data into structured DexPairData objects."""
|
|
404
|
+
pairs: list[DexPairData] = []
|
|
405
|
+
|
|
406
|
+
for raw in raw_pairs:
|
|
407
|
+
if not isinstance(raw, dict):
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
base = raw.get("baseToken", {})
|
|
411
|
+
quote = raw.get("quoteToken", {})
|
|
412
|
+
volume = raw.get("volume", {})
|
|
413
|
+
price_change = raw.get("priceChange", {})
|
|
414
|
+
txns = raw.get("txns", {})
|
|
415
|
+
liquidity = raw.get("liquidity", {})
|
|
416
|
+
|
|
417
|
+
txns_5m = txns.get("m5", {})
|
|
418
|
+
txns_1h = txns.get("h1", {})
|
|
419
|
+
txns_24h = txns.get("h24", {})
|
|
420
|
+
|
|
421
|
+
pair = DexPairData(
|
|
422
|
+
chain_id=raw.get("chainId", ""),
|
|
423
|
+
dex_id=raw.get("dexId", ""),
|
|
424
|
+
pair_address=raw.get("pairAddress", ""),
|
|
425
|
+
base_token_address=base.get("address", ""),
|
|
426
|
+
base_token_symbol=base.get("symbol", ""),
|
|
427
|
+
base_token_name=base.get("name", ""),
|
|
428
|
+
quote_token_address=quote.get("address", ""),
|
|
429
|
+
quote_token_symbol=quote.get("symbol", ""),
|
|
430
|
+
price_usd=_safe_decimal(raw.get("priceUsd")),
|
|
431
|
+
price_native=_safe_decimal(raw.get("priceNative")),
|
|
432
|
+
market_cap=_safe_decimal(raw.get("marketCap")),
|
|
433
|
+
fdv=_safe_decimal(raw.get("fdv")),
|
|
434
|
+
liquidity_usd=_safe_decimal(liquidity.get("usd")),
|
|
435
|
+
liquidity_base=_safe_decimal(liquidity.get("base")),
|
|
436
|
+
liquidity_quote=_safe_decimal(liquidity.get("quote")),
|
|
437
|
+
volume_5m=_safe_decimal(volume.get("m5")),
|
|
438
|
+
volume_1h=_safe_decimal(volume.get("h1")),
|
|
439
|
+
volume_6h=_safe_decimal(volume.get("h6")),
|
|
440
|
+
volume_24h=_safe_decimal(volume.get("h24")),
|
|
441
|
+
price_change_5m=_safe_decimal(price_change.get("m5")),
|
|
442
|
+
price_change_1h=_safe_decimal(price_change.get("h1")),
|
|
443
|
+
price_change_6h=_safe_decimal(price_change.get("h6")),
|
|
444
|
+
price_change_24h=_safe_decimal(price_change.get("h24")),
|
|
445
|
+
buys_5m=_safe_int(txns_5m.get("buys")),
|
|
446
|
+
sells_5m=_safe_int(txns_5m.get("sells")),
|
|
447
|
+
buys_1h=_safe_int(txns_1h.get("buys")),
|
|
448
|
+
sells_1h=_safe_int(txns_1h.get("sells")),
|
|
449
|
+
buys_24h=_safe_int(txns_24h.get("buys")),
|
|
450
|
+
sells_24h=_safe_int(txns_24h.get("sells")),
|
|
451
|
+
pair_created_at=_safe_int(raw.get("pairCreatedAt")),
|
|
452
|
+
is_boosted=bool(raw.get("boosts")),
|
|
453
|
+
url=raw.get("url", ""),
|
|
454
|
+
raw=raw,
|
|
455
|
+
)
|
|
456
|
+
pairs.append(pair)
|
|
457
|
+
|
|
458
|
+
return pairs
|
dexscreener/models.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""DexPairData model — typed representation of a DexScreener trading pair."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class DexPairData:
|
|
13
|
+
"""Parsed pair data from DexScreener."""
|
|
14
|
+
|
|
15
|
+
# Identity
|
|
16
|
+
chain_id: str = ""
|
|
17
|
+
dex_id: str = ""
|
|
18
|
+
pair_address: str = ""
|
|
19
|
+
base_token_address: str = ""
|
|
20
|
+
base_token_symbol: str = ""
|
|
21
|
+
base_token_name: str = ""
|
|
22
|
+
quote_token_address: str = ""
|
|
23
|
+
quote_token_symbol: str = ""
|
|
24
|
+
|
|
25
|
+
# Price
|
|
26
|
+
price_usd: Decimal = Decimal("0")
|
|
27
|
+
price_native: Decimal = Decimal("0")
|
|
28
|
+
|
|
29
|
+
# Market data
|
|
30
|
+
market_cap: Decimal = Decimal("0")
|
|
31
|
+
fdv: Decimal = Decimal("0")
|
|
32
|
+
liquidity_usd: Decimal = Decimal("0")
|
|
33
|
+
liquidity_base: Decimal = Decimal("0")
|
|
34
|
+
liquidity_quote: Decimal = Decimal("0")
|
|
35
|
+
|
|
36
|
+
# Volume (USD)
|
|
37
|
+
volume_5m: Decimal = Decimal("0")
|
|
38
|
+
volume_1h: Decimal = Decimal("0")
|
|
39
|
+
volume_6h: Decimal = Decimal("0")
|
|
40
|
+
volume_24h: Decimal = Decimal("0")
|
|
41
|
+
|
|
42
|
+
# Price changes (percentage)
|
|
43
|
+
price_change_5m: Decimal = Decimal("0")
|
|
44
|
+
price_change_1h: Decimal = Decimal("0")
|
|
45
|
+
price_change_6h: Decimal = Decimal("0")
|
|
46
|
+
price_change_24h: Decimal = Decimal("0")
|
|
47
|
+
|
|
48
|
+
# Transactions
|
|
49
|
+
buys_5m: int = 0
|
|
50
|
+
sells_5m: int = 0
|
|
51
|
+
buys_1h: int = 0
|
|
52
|
+
sells_1h: int = 0
|
|
53
|
+
buys_24h: int = 0
|
|
54
|
+
sells_24h: int = 0
|
|
55
|
+
|
|
56
|
+
# Age
|
|
57
|
+
pair_created_at: int = 0 # Unix timestamp ms
|
|
58
|
+
|
|
59
|
+
# Metadata
|
|
60
|
+
is_boosted: bool = False
|
|
61
|
+
url: str = ""
|
|
62
|
+
|
|
63
|
+
# Raw response
|
|
64
|
+
raw: dict = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def buy_sell_ratio_5m(self) -> float:
|
|
68
|
+
"""Buy/sell ratio over 5 minutes. >1 = more buying."""
|
|
69
|
+
total = self.buys_5m + self.sells_5m
|
|
70
|
+
if total == 0:
|
|
71
|
+
return 1.0
|
|
72
|
+
return self.buys_5m / max(self.sells_5m, 1)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def buy_sell_ratio_1h(self) -> float:
|
|
76
|
+
"""Buy/sell ratio over 1 hour."""
|
|
77
|
+
total = self.buys_1h + self.sells_1h
|
|
78
|
+
if total == 0:
|
|
79
|
+
return 1.0
|
|
80
|
+
return self.buys_1h / max(self.sells_1h, 1)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def has_liquidity(self) -> bool:
|
|
84
|
+
"""Token has meaningful liquidity (>$1000)."""
|
|
85
|
+
return self.liquidity_usd > Decimal("1000")
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def pair_age_seconds(self) -> int:
|
|
89
|
+
"""How old the pair is in seconds."""
|
|
90
|
+
if self.pair_created_at <= 0:
|
|
91
|
+
return 0
|
|
92
|
+
return max(0, int(time.time()) - (self.pair_created_at // 1000))
|
dexscreener/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dexscreener-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async DexScreener API client with rate limiting, 429 retry, and response caching.
|
|
5
|
+
Project-URL: Homepage, https://github.com/JinUltimate1995/dexscreener-python
|
|
6
|
+
Project-URL: Repository, https://github.com/JinUltimate1995/dexscreener-python
|
|
7
|
+
Project-URL: Issues, https://github.com/JinUltimate1995/dexscreener-python/issues
|
|
8
|
+
Author: JinUltimate
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,crypto,defi,dex,dexscreener,ethereum,solana,trading
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: httpx>=0.24.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
<div align="center">
|
|
32
|
+
<h1>dexscreener-python</h1>
|
|
33
|
+
<p><strong>async dexscreener api client for python.</strong></p>
|
|
34
|
+
<p>rate limiting. 429 retry. response caching. request dedup. works on any chain.</p>
|
|
35
|
+
|
|
36
|
+
<br/>
|
|
37
|
+
|
|
38
|
+
<a href="https://github.com/JinUltimate1995/dexscreener-python/actions"><img src="https://img.shields.io/github/actions/workflow/status/JinUltimate1995/dexscreener-python/ci.yml?branch=main&style=flat-square&label=tests" /></a>
|
|
39
|
+
<a href="https://pypi.org/project/dexscreener-python/"><img src="https://img.shields.io/pypi/v/dexscreener-python?style=flat-square" /></a>
|
|
40
|
+
<img src="https://img.shields.io/pypi/pyversions/dexscreener-python?style=flat-square" />
|
|
41
|
+
<img src="https://img.shields.io/badge/typed-py.typed-blue?style=flat-square" />
|
|
42
|
+
<img src="https://img.shields.io/badge/async-first-blue?style=flat-square" />
|
|
43
|
+
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" />
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
> extracted from a production trading system. handles rate limits gracefully.
|
|
49
|
+
|
|
50
|
+
## 🛡️ Why this library?
|
|
51
|
+
|
|
52
|
+
dexscreener's api is free and powerful, but:
|
|
53
|
+
|
|
54
|
+
- **429s kill your app** if you don't handle them
|
|
55
|
+
- **no python client exists** that handles rate limits properly
|
|
56
|
+
- **concurrent requests** for the same token waste your quota
|
|
57
|
+
|
|
58
|
+
this library solves all three:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from dexscreener import DexScreenerClient
|
|
62
|
+
|
|
63
|
+
async with DexScreenerClient() as client:
|
|
64
|
+
# get all trading pairs for a token
|
|
65
|
+
pairs = await client.get_token_pairs("solana", "So11111111111111111111111111111111111111112")
|
|
66
|
+
|
|
67
|
+
for pair in pairs:
|
|
68
|
+
print(f"{pair.base_token_symbol}: ${pair.price_usd} | liq: ${pair.liquidity_usd}")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
429s → automatic exponential backoff (3s → 6s → 12s).
|
|
72
|
+
duplicate requests → deduplicated into one HTTP call.
|
|
73
|
+
results → cached with configurable TTL.
|
|
74
|
+
|
|
75
|
+
## 📦 Install
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install dexscreener-python
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
requires python 3.11+
|
|
82
|
+
|
|
83
|
+
## ⚡ Features
|
|
84
|
+
|
|
85
|
+
| feature | description |
|
|
86
|
+
|---|---|
|
|
87
|
+
| **any chain** | solana, ethereum, base, bsc, arbitrum — anything dexscreener supports |
|
|
88
|
+
| **adaptive rate limiting** | token bucket that backs off on 429 and recovers after success |
|
|
89
|
+
| **global 429 cooldown** | one 429 pauses ALL requests briefly (prevents request storms) |
|
|
90
|
+
| **response cache** | configurable TTL per endpoint. concurrent calls share one request. |
|
|
91
|
+
| **batch support** | fetch up to 30 tokens in a single request |
|
|
92
|
+
| **typed data** | `DexPairData` dataclass with computed properties (buy/sell ratio, age, etc.) |
|
|
93
|
+
|
|
94
|
+
## 🔧 Usage
|
|
95
|
+
|
|
96
|
+
### 💰 Token Pairs
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
pairs = await client.get_token_pairs("solana", token_address)
|
|
100
|
+
|
|
101
|
+
best_pair = pairs[0] # sorted by liquidity (highest first)
|
|
102
|
+
print(f"Price: ${best_pair.price_usd}")
|
|
103
|
+
print(f"Liquidity: ${best_pair.liquidity_usd}")
|
|
104
|
+
print(f"24h Volume: ${best_pair.volume_24h}")
|
|
105
|
+
print(f"Buy/Sell 5m: {best_pair.buy_sell_ratio_5m:.2f}")
|
|
106
|
+
print(f"Age: {best_pair.pair_age_seconds // 3600}h")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 💵 Token Price
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
price = await client.get_token_price("solana", token_address)
|
|
113
|
+
print(f"${price}")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### 📦 Batch Fetch (up to 30 tokens)
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
data = await client.get_tokens_batch("solana", [addr1, addr2, addr3])
|
|
120
|
+
for addr, pair in data.items():
|
|
121
|
+
print(f"{pair.base_token_symbol}: ${pair.price_usd}")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### 🔍 Search
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
results = await client.search_pairs("BONK")
|
|
128
|
+
for pair in results:
|
|
129
|
+
print(f"{pair.base_token_symbol} on {pair.dex_id}: ${pair.price_usd}")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### 🚀 Boosted Tokens
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
boosted = await client.get_boosted_tokens("solana")
|
|
136
|
+
# boosted tokens are paid promotions — treat with caution
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 🎯 Specific Pair
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
pair = await client.get_pair_by_address("solana", pair_address)
|
|
143
|
+
print(f"{pair.dex_id}: ${pair.price_usd}")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## 📋 DexPairData
|
|
147
|
+
|
|
148
|
+
all methods return `DexPairData` objects with these fields:
|
|
149
|
+
|
|
150
|
+
| field | type | description |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `chain_id` | str | chain identifier |
|
|
153
|
+
| `dex_id` | str | dex identifier (raydium, uniswap_v3, etc.) |
|
|
154
|
+
| `pair_address` | str | pair contract address |
|
|
155
|
+
| `base_token_address` | str | base token mint/address |
|
|
156
|
+
| `base_token_symbol` | str | token symbol |
|
|
157
|
+
| `price_usd` | Decimal | current price in USD |
|
|
158
|
+
| `price_native` | Decimal | price in native token (SOL, ETH, etc.) |
|
|
159
|
+
| `liquidity_usd` | Decimal | total liquidity in USD |
|
|
160
|
+
| `volume_5m / 1h / 6h / 24h` | Decimal | volume by timeframe |
|
|
161
|
+
| `price_change_5m / 1h / 6h / 24h` | Decimal | price change % |
|
|
162
|
+
| `buys_5m / 1h / 24h` | int | buy transactions |
|
|
163
|
+
| `sells_5m / 1h / 24h` | int | sell transactions |
|
|
164
|
+
| `pair_created_at` | int | unix timestamp (ms) |
|
|
165
|
+
|
|
166
|
+
computed properties:
|
|
167
|
+
|
|
168
|
+
| property | description |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `buy_sell_ratio_5m` | buy/sell ratio over 5 minutes (>1 = more buying) |
|
|
171
|
+
| `buy_sell_ratio_1h` | buy/sell ratio over 1 hour |
|
|
172
|
+
| `has_liquidity` | True if liquidity > $1000 |
|
|
173
|
+
| `pair_age_seconds` | pair age in seconds |
|
|
174
|
+
|
|
175
|
+
## ⚙️ Configuration
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
client = DexScreenerClient(
|
|
179
|
+
rate_limit=5.0, # requests/second (default: 5.0, dexscreener limit is ~300/min)
|
|
180
|
+
cache_ttl=8.0, # default cache TTL in seconds
|
|
181
|
+
)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 🆚 Comparison
|
|
187
|
+
|
|
188
|
+
| | dexscreener-python | raw `httpx` / `requests` |
|
|
189
|
+
|---|---|---|
|
|
190
|
+
| Rate limiting | ✅ adaptive token bucket | ❌ manual |
|
|
191
|
+
| 429 retry | ✅ exponential backoff | ❌ crash |
|
|
192
|
+
| Global cooldown | ✅ one 429 pauses all | ❌ per-request only |
|
|
193
|
+
| Response cache | ✅ TTL + dedup | ❌ manual |
|
|
194
|
+
| Typed data | ✅ `DexPairData` dataclass | ❌ raw dicts |
|
|
195
|
+
| Batch support | ✅ up to 30 tokens | ❌ manual loop |
|
|
196
|
+
| Async | ✅ native | depends |
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## 📦 Also by JinUltimate1995
|
|
205
|
+
|
|
206
|
+
- **[jupiter-swap-python](https://github.com/JinUltimate1995/jupiter-swap-python)** — Jupiter swap client for Python. Async. Typed.
|
|
207
|
+
- **[pumpfun-python](https://github.com/JinUltimate1995/pumpfun-python)** — PumpFun bonding curve + PumpSwap AMM. Direct swaps from Python.
|
|
208
|
+
- **[solana-rpc-resilient](https://github.com/JinUltimate1995/solana-rpc-resilient)** — Fault-tolerant Solana RPC with automatic failover.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
dexscreener/__init__.py,sha256=b9cOxsJdhv41bgyCC5nUDGJss5JbZLlhKjC7ZrvUBVY,212
|
|
2
|
+
dexscreener/client.py,sha256=P24VzaLKR9U1lN51ffqfqNYWoOum0Axia2PST_z54T4,14937
|
|
3
|
+
dexscreener/models.py,sha256=bo-NOqb7PCxfXgdxFbbPdSKR8oOgKg7N4X1iCwyDZiQ,2489
|
|
4
|
+
dexscreener/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
dexscreener_python-0.1.0.dist-info/METADATA,sha256=xX1apb1FTeWrwJjjzkLD5WeLghdADgOcaNUkVxDrG3I,7271
|
|
6
|
+
dexscreener_python-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
dexscreener_python-0.1.0.dist-info/licenses/LICENSE,sha256=QMTxmT1VJd2-bhYmxC2QwPx9CoAXNPflIa8uhLdG2nE,1068
|
|
8
|
+
dexscreener_python-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JinUltimate
|
|
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.
|