uni-exec-engine 0.2.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.
Files changed (80) hide show
  1. uni_exec_engine-0.2.0.dist-info/METADATA +576 -0
  2. uni_exec_engine-0.2.0.dist-info/RECORD +80 -0
  3. uni_exec_engine-0.2.0.dist-info/WHEEL +5 -0
  4. uni_exec_engine-0.2.0.dist-info/licenses/LICENSE +21 -0
  5. uni_exec_engine-0.2.0.dist-info/top_level.txt +1 -0
  6. uniswap_autopilot/__init__.py +3 -0
  7. uniswap_autopilot/analytics/__init__.py +0 -0
  8. uniswap_autopilot/analytics/il_calculator.py +525 -0
  9. uniswap_autopilot/analytics/portfolio.py +234 -0
  10. uniswap_autopilot/analytics/position.py +433 -0
  11. uniswap_autopilot/analytics/range_suggest.py +270 -0
  12. uniswap_autopilot/audit.py +194 -0
  13. uniswap_autopilot/common/__init__.py +0 -0
  14. uniswap_autopilot/common/approval_cleanup.py +255 -0
  15. uniswap_autopilot/common/check_balance.py +64 -0
  16. uniswap_autopilot/common/common.py +804 -0
  17. uniswap_autopilot/common/deep_link.py +132 -0
  18. uniswap_autopilot/common/gas.py +141 -0
  19. uniswap_autopilot/data/auto_trade_policy.example.json +29 -0
  20. uniswap_autopilot/data/chains.json +135 -0
  21. uniswap_autopilot/data/common-token-addresses.json +777 -0
  22. uniswap_autopilot/execute/__init__.py +0 -0
  23. uniswap_autopilot/execute/_internal/__init__.py +5 -0
  24. uniswap_autopilot/execute/_internal/constants.py +15 -0
  25. uniswap_autopilot/execute/_internal/preflight.py +150 -0
  26. uniswap_autopilot/execute/_internal/pure_signer.py +182 -0
  27. uniswap_autopilot/execute/_internal/rpc.py +462 -0
  28. uniswap_autopilot/execute/_internal/signer.py +298 -0
  29. uniswap_autopilot/execute/_internal/submit.py +73 -0
  30. uniswap_autopilot/execute/_internal/tx.py +370 -0
  31. uniswap_autopilot/execute/broadcast.py +380 -0
  32. uniswap_autopilot/execute/detect.py +52 -0
  33. uniswap_autopilot/execute/telegram_confirm.py +272 -0
  34. uniswap_autopilot/lp/compare_pools.py +338 -0
  35. uniswap_autopilot/lp/v2/__init__.py +0 -0
  36. uniswap_autopilot/lp/v2/approve.py +100 -0
  37. uniswap_autopilot/lp/v2/build_tx.py +226 -0
  38. uniswap_autopilot/lp/v2/flow.py +204 -0
  39. uniswap_autopilot/lp/v2/pair.py +135 -0
  40. uniswap_autopilot/lp/v2/positions.py +177 -0
  41. uniswap_autopilot/lp/v3/__init__.py +0 -0
  42. uniswap_autopilot/lp/v3/approve.py +109 -0
  43. uniswap_autopilot/lp/v3/auto_rebalance.py +282 -0
  44. uniswap_autopilot/lp/v3/build_tx.py +465 -0
  45. uniswap_autopilot/lp/v3/compound.py +258 -0
  46. uniswap_autopilot/lp/v3/flow.py +358 -0
  47. uniswap_autopilot/lp/v3/pool.py +175 -0
  48. uniswap_autopilot/lp/v3/position.py +112 -0
  49. uniswap_autopilot/lp/v3/tick.py +75 -0
  50. uniswap_autopilot/lp/v4/__init__.py +0 -0
  51. uniswap_autopilot/lp/v4/approve.py +105 -0
  52. uniswap_autopilot/lp/v4/build_tx.py +669 -0
  53. uniswap_autopilot/lp/v4/flow.py +368 -0
  54. uniswap_autopilot/lp/v4/pool.py +174 -0
  55. uniswap_autopilot/lp/v4/position.py +185 -0
  56. uniswap_autopilot/policy.py +371 -0
  57. uniswap_autopilot/price_feed.py +100 -0
  58. uniswap_autopilot/py.typed +0 -0
  59. uniswap_autopilot/search/__init__.py +0 -0
  60. uniswap_autopilot/search/risk.py +200 -0
  61. uniswap_autopilot/search/search.py +580 -0
  62. uniswap_autopilot/state_machine.py +315 -0
  63. uniswap_autopilot/swap/__init__.py +1 -0
  64. uniswap_autopilot/swap/deep_link.py +69 -0
  65. uniswap_autopilot/swap/extensions/__init__.py +2 -0
  66. uniswap_autopilot/swap/extensions/bridge.py +197 -0
  67. uniswap_autopilot/swap/extensions/limit_order.py +272 -0
  68. uniswap_autopilot/swap/extensions/slippage.py +123 -0
  69. uniswap_autopilot/swap/flow.py +693 -0
  70. uniswap_autopilot/swap/flow_core/__init__.py +2 -0
  71. uniswap_autopilot/swap/flow_core/artifacts.py +11 -0
  72. uniswap_autopilot/swap/flow_core/broadcast.py +50 -0
  73. uniswap_autopilot/swap/flow_core/diagnostics.py +216 -0
  74. uniswap_autopilot/swap/flow_core/paper.py +113 -0
  75. uniswap_autopilot/swap/flow_core/policy.py +142 -0
  76. uniswap_autopilot/swap/links/__init__.py +2 -0
  77. uniswap_autopilot/swap/links/deep_link.py +69 -0
  78. uniswap_autopilot/swap/trading_api/permit.py +41 -0
  79. uniswap_autopilot/swap/trading_api/quote.py +282 -0
  80. uniswap_autopilot/swap/trading_api/swap.py +248 -0
@@ -0,0 +1,580 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import quote
10
+ from urllib.request import Request, urlopen
11
+
12
+
13
+ from uniswap_autopilot.common.common import dump_json, cache_token_from_search
14
+
15
+ # --- DexScreener (no key, 300 req/min) ---
16
+ DS_SEARCH = "https://api.dexscreener.com/latest/dex/search?q="
17
+ DS_TOKENS = "https://api.dexscreener.com/tokens/v1"
18
+
19
+ # --- GeckoTerminal (no key, ~10 req/min) ---
20
+ GT_BASE = "https://api.geckoterminal.com/api/v2"
21
+ GT_HEADERS = {"Accept": "application/json;version=20230203", "User-Agent": "uniswap-autopilot/1.0"}
22
+
23
+ # Load chain config from chains.json
24
+ _DATA_ROOT = Path(__file__).resolve().parent.parent / "data"
25
+ _CHAINS_CFG = json.loads(
26
+ (_DATA_ROOT / "chains.json").read_text(encoding="utf-8")
27
+ )
28
+ SUPPORTED_CHAINS = sorted(_CHAINS_CFG.keys())
29
+ GT_NETWORK_IDS: dict[str, str] = {
30
+ key: cfg["geckoterminalNetworkId"] for key, cfg in _CHAINS_CFG.items()
31
+ }
32
+
33
+ # Load token catalog for decimals lookup
34
+ _TOKEN_CATALOG: dict[str, dict[str, Any]] = json.loads(
35
+ (_DATA_ROOT / "common-token-addresses.json").read_text(encoding="utf-8")
36
+ )
37
+
38
+
39
+ def _fetch_json(url: str, headers: dict[str, str] | None = None, timeout: int = 15) -> Any:
40
+ hdrs = {"Accept": "application/json", "User-Agent": "uniswap-autopilot/1.0"}
41
+ if headers:
42
+ hdrs.update(headers)
43
+ request = Request(url, headers=hdrs)
44
+ with urlopen(request, timeout=timeout) as resp:
45
+ return json.loads(resp.read().decode("utf-8"))
46
+
47
+
48
+ def _safe_float(val: Any, default: float = 0.0) -> float:
49
+ if val is None:
50
+ return default
51
+ try:
52
+ return float(val)
53
+ except (ValueError, TypeError):
54
+ return default
55
+
56
+
57
+ def _fmt_usd(val: float) -> str:
58
+ if val >= 1_000_000_000:
59
+ return f"${val / 1_000_000_000:.2f}B"
60
+ if val >= 1_000_000:
61
+ return f"${val / 1_000_000:.2f}M"
62
+ if val >= 1_000:
63
+ return f"${val / 1_000:.1f}K"
64
+ if val > 0:
65
+ return f"${val:.4f}"
66
+ return "-"
67
+
68
+
69
+ def _fmt_pct(val: float) -> str:
70
+ if val == 0:
71
+ return "-"
72
+ sign = "+" if val > 0 else ""
73
+ return f"{sign}{val:.1f}%"
74
+
75
+
76
+ def _fmt_price(val: Any) -> str:
77
+ if val is None:
78
+ return "-"
79
+ v = _safe_float(val, -1)
80
+ if v < 0:
81
+ return str(val)
82
+ if v == 0:
83
+ return "$0"
84
+ if v < 0.0001:
85
+ return f"${v:.10f}".rstrip("0").rstrip(".")
86
+ if v < 1:
87
+ return f"${v:.6f}"
88
+ if v < 1000:
89
+ return f"${v:.4f}"
90
+ return f"${v:,.2f}"
91
+
92
+
93
+ # ============================================================
94
+ # GeckoTerminal helpers
95
+ # ============================================================
96
+
97
+ def _gt_pool_to_token(pool: dict, included: list[dict]) -> dict[str, Any]:
98
+ attrs = pool.get("attributes", {})
99
+ rels = pool.get("relationships", {})
100
+
101
+ base_id = rels.get("base_token", {}).get("data", {}).get("id", "")
102
+ quote_id = rels.get("quote_token", {}).get("data", {}).get("id", "")
103
+
104
+ base_info = {"symbol": "?", "name": "", "address": "", "decimals": 18}
105
+ quote_info = {"symbol": "?", "address": ""}
106
+ for inc in included:
107
+ if inc.get("type") == "token":
108
+ inc_id = inc.get("id", "")
109
+ inc_a = inc.get("attributes", {})
110
+ if inc_id == base_id:
111
+ base_info = {
112
+ "symbol": inc_a.get("symbol", "?"),
113
+ "name": inc_a.get("name", ""),
114
+ "address": inc_a.get("address", ""),
115
+ "decimals": int(inc_a.get("decimals") or 18),
116
+ }
117
+ elif inc_id == quote_id:
118
+ quote_info = {
119
+ "symbol": inc_a.get("symbol", "?"),
120
+ "address": inc_a.get("address", ""),
121
+ }
122
+
123
+ price_usd = attrs.get("base_token_price_usd")
124
+ liq = _safe_float(attrs.get("reserve_in_usd"))
125
+ vol24 = _safe_float((attrs.get("volume_usd") or {}).get("h24"))
126
+ mcap = _safe_float(attrs.get("market_cap_usd") or attrs.get("fdv_usd"))
127
+ pct = attrs.get("price_change_percentage") or {}
128
+ chg24 = _safe_float(pct.get("h24"))
129
+
130
+ # Extract chain from pool id like "base_0x..." or from network
131
+ pool_id = pool.get("id", "")
132
+ chain = pool_id.split("_")[0] if "_" in pool_id else ""
133
+
134
+ return {
135
+ "chain": chain,
136
+ "symbol": base_info["symbol"],
137
+ "name": base_info["name"],
138
+ "address": base_info["address"],
139
+ "decimals": base_info["decimals"],
140
+ "priceUsd": price_usd,
141
+ "priceChange24h": chg24,
142
+ "liquidityUsd": liq,
143
+ "volume24h": vol24,
144
+ "marketCap": mcap,
145
+ "dexId": (rels.get("dex", {}).get("data", {}).get("id", "")),
146
+ "pairAddress": attrs.get("address", ""),
147
+ "quoteSymbol": quote_info["symbol"],
148
+ "quoteAddress": quote_info["address"],
149
+ "source": "geckoterminal",
150
+ }
151
+
152
+
153
+ def _gt_search(query: str, chain: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
154
+ if chain:
155
+ net_id = GT_NETWORK_IDS.get(chain)
156
+ if not net_id:
157
+ return []
158
+ url = f"{GT_BASE}/search/pools?query={quote(query)}&network={net_id}&include=base_token,quote_token,dex"
159
+ else:
160
+ return []
161
+ data = _fetch_json(url, headers=GT_HEADERS)
162
+ included = data.get("included", [])
163
+ results: list[dict[str, Any]] = []
164
+ seen: set[str] = set()
165
+ for pool in data.get("data", []):
166
+ r = _gt_pool_to_token(pool, included)
167
+ key = f"{r['chain']}:{r['address']}"
168
+ if key in seen or not r["address"]:
169
+ continue
170
+ seen.add(key)
171
+ results.append(r)
172
+ results.sort(key=lambda r: r["liquidityUsd"], reverse=True)
173
+ return results[:limit]
174
+
175
+
176
+ def _gt_trending(chain: str, limit: int = 15) -> list[dict[str, Any]]:
177
+ net_id = GT_NETWORK_IDS.get(chain)
178
+ if not net_id:
179
+ return []
180
+ url = f"{GT_BASE}/networks/{net_id}/trending_pools?include=base_token,quote_token,dex"
181
+ data = _fetch_json(url, headers=GT_HEADERS)
182
+ included = data.get("included", [])
183
+ results: list[dict[str, Any]] = []
184
+ seen: set[str] = set()
185
+ for pool in data.get("data", []):
186
+ r = _gt_pool_to_token(pool, included)
187
+ key = f"{r['chain']}:{r['address']}"
188
+ if key in seen or not r["address"]:
189
+ continue
190
+ seen.add(key)
191
+ results.append(r)
192
+ return results[:limit]
193
+
194
+
195
+ def _gt_top_pools(chain: str, limit: int = 20) -> list[dict[str, Any]]:
196
+ net_id = GT_NETWORK_IDS.get(chain)
197
+ if not net_id:
198
+ return []
199
+ url = f"{GT_BASE}/networks/{net_id}/pools?include=base_token,quote_token,dex&page=1"
200
+ data = _fetch_json(url, headers=GT_HEADERS)
201
+ included = data.get("included", [])
202
+ results: list[dict[str, Any]] = []
203
+ seen: set[str] = set()
204
+ for pool in data.get("data", []):
205
+ r = _gt_pool_to_token(pool, included)
206
+ key = f"{r['chain']}:{r['address']}"
207
+ if key in seen or not r["address"]:
208
+ continue
209
+ seen.add(key)
210
+ results.append(r)
211
+ return results[:limit]
212
+
213
+
214
+ # ============================================================
215
+ # DexScreener helpers
216
+ # ============================================================
217
+
218
+ def _ds_search(query: str, chain: str | None = None, limit: int = 10) -> list[dict[str, Any]]:
219
+ url = f"{DS_SEARCH}{quote(query)}"
220
+ data = _fetch_json(url)
221
+
222
+ seen: set[str] = set()
223
+ results: list[dict[str, Any]] = []
224
+
225
+ for pair in data.get("pairs", []):
226
+ chain_id = pair.get("chainId", "")
227
+ if chain and chain_id != chain:
228
+ continue
229
+
230
+ base = pair.get("baseToken", {})
231
+ addr = base.get("address", "")
232
+ key = f"{chain_id}:{addr}"
233
+ if key in seen:
234
+ continue
235
+ seen.add(key)
236
+
237
+ liq = _safe_float((pair.get("liquidity") or {}).get("usd"))
238
+ vol24 = _safe_float((pair.get("volume") or {}).get("h24"))
239
+ mcap = _safe_float(pair.get("marketCap") or pair.get("fdv"))
240
+ price_usd = pair.get("priceUsd")
241
+ price_change = (pair.get("priceChange") or {}).get("h24")
242
+
243
+ results.append({
244
+ "chain": chain_id,
245
+ "symbol": base.get("symbol", ""),
246
+ "name": base.get("name", ""),
247
+ "address": addr,
248
+ "decimals": None,
249
+ "priceUsd": price_usd,
250
+ "priceChange24h": _safe_float(price_change),
251
+ "liquidityUsd": liq,
252
+ "volume24h": vol24,
253
+ "marketCap": mcap,
254
+ "dexId": pair.get("dexId", ""),
255
+ "source": "dexscreener",
256
+ })
257
+
258
+ results.sort(key=lambda r: r["liquidityUsd"], reverse=True)
259
+ return results[:limit]
260
+
261
+
262
+ def _ds_lookup(chain: str, address: str) -> dict[str, Any] | None:
263
+ url = f"{DS_TOKENS}/{chain}/{address}"
264
+ data = _fetch_json(url)
265
+
266
+ pairs = data if isinstance(data, list) else data.get("pairs", [])
267
+ if not pairs:
268
+ return None
269
+
270
+ best = max(pairs, key=lambda p: _safe_float((p.get("liquidity") or {}).get("usd")))
271
+ base = best.get("baseToken", {})
272
+ quote_token = best.get("quoteToken", {})
273
+
274
+ liq = _safe_float((best.get("liquidity") or {}).get("usd"))
275
+ vol24 = _safe_float((best.get("volume") or {}).get("h24"))
276
+ mcap = _safe_float(best.get("marketCap") or best.get("fdv"))
277
+ price_usd = best.get("priceUsd")
278
+ price_change = (best.get("priceChange") or {})
279
+
280
+ return {
281
+ "chain": best.get("chainId", chain),
282
+ "symbol": base.get("symbol", ""),
283
+ "name": base.get("name", ""),
284
+ "address": base.get("address", ""),
285
+ "priceUsd": price_usd,
286
+ "priceChange": {k: _safe_float(v) for k, v in price_change.items()},
287
+ "liquidityUsd": liq,
288
+ "volume24h": vol24,
289
+ "marketCap": mcap,
290
+ "dexId": best.get("dexId", ""),
291
+ "pairAddress": best.get("pairAddress", ""),
292
+ "pairCreatedAt": best.get("pairCreatedAt"),
293
+ "quoteSymbol": quote_token.get("symbol", ""),
294
+ "quoteAddress": quote_token.get("address", ""),
295
+ "source": "dexscreener",
296
+ }
297
+
298
+
299
+ # ============================================================
300
+ # Unified API
301
+ # ============================================================
302
+
303
+ def search_tokens(query: str, chain: str | None = None, limit: int = 10) -> list[dict[str, Any]]:
304
+ results: list[dict[str, Any]] = []
305
+ seen: set[str] = set()
306
+
307
+ # Per-chain: GeckoTerminal first (no 30-pair limit), then DexScreener
308
+ if chain:
309
+ try:
310
+ for r in _gt_search(query, chain, limit=limit):
311
+ key = f"{r['chain']}:{r['address']}"
312
+ if key not in seen:
313
+ seen.add(key)
314
+ results.append(r)
315
+ except Exception:
316
+ pass
317
+
318
+ if len(results) < limit:
319
+ try:
320
+ for r in _ds_search(query, chain, limit=limit):
321
+ key = f"{r['chain']}:{r['address']}"
322
+ if key not in seen:
323
+ seen.add(key)
324
+ results.append(r)
325
+ except Exception:
326
+ pass
327
+ else:
328
+ # No chain: DexScreener global search only (GeckoTerminal requires network)
329
+ try:
330
+ results = _ds_search(query, chain, limit=limit)
331
+ except Exception:
332
+ pass
333
+
334
+ results.sort(key=lambda r: r["liquidityUsd"], reverse=True)
335
+
336
+ # Fill in decimals from catalog for tokens missing it
337
+ for r in results:
338
+ if r.get("decimals") is not None:
339
+ continue
340
+ chain_key = r.get("chain", "")
341
+ addr = r.get("address", "").lower()
342
+ cat = _TOKEN_CATALOG.get(chain_key, {})
343
+ for tok in cat.values():
344
+ if tok.get("address", "").lower() == addr:
345
+ r["decimals"] = tok.get("decimals")
346
+ break
347
+
348
+ return results[:limit]
349
+
350
+
351
+ def lookup_token(chain: str, address: str) -> dict[str, Any] | None:
352
+ try:
353
+ return _ds_lookup(chain, address)
354
+ except Exception:
355
+ return None
356
+
357
+
358
+ def trending_tokens(chain: str, limit: int = 15) -> list[dict[str, Any]]:
359
+ try:
360
+ return _gt_trending(chain, limit)
361
+ except Exception:
362
+ return []
363
+
364
+
365
+ def top_pools(chain: str, limit: int = 20) -> list[dict[str, Any]]:
366
+ try:
367
+ return _gt_top_pools(chain, limit)
368
+ except Exception:
369
+ return []
370
+
371
+
372
+ # ============================================================
373
+ # Risk badge
374
+ # ============================================================
375
+
376
+ def _compute_risk_badge(liquidity_usd: float, volume_24h: float) -> str:
377
+ if liquidity_usd >= 500_000 and volume_24h >= 100_000:
378
+ return "LOW"
379
+ if liquidity_usd >= 100_000 and volume_24h >= 10_000:
380
+ return "MED"
381
+ if liquidity_usd >= 10_000 or volume_24h >= 1_000:
382
+ return "HIGH"
383
+ return "EXT"
384
+
385
+
386
+ # ============================================================
387
+ # Output formatters
388
+ # ============================================================
389
+
390
+ def _print_search_table(results: list[dict[str, Any]]) -> None:
391
+ print(f"{'Chain':<10} {'Symbol':<10} {'Price':>14} {'24h':>8} {'Risk':>5} {'Liquidity':>12} {'Volume24h':>12} {'MarketCap':>12} Name")
392
+ print("-" * 124)
393
+ for r in results:
394
+ price = _fmt_price(r.get("priceUsd"))
395
+ liq = _safe_float(r.get("liquidityUsd"))
396
+ vol = _safe_float(r.get("volume24h"))
397
+ mcap = _safe_float(r.get("marketCap"))
398
+ chg = _fmt_pct(_safe_float(r.get("priceChange24h")))
399
+ risk = r.get("riskBadge", "")
400
+
401
+ print(f"{r['chain']:<10} {r['symbol']:<10} {price:>14} {chg:>8} {risk:>5} {_fmt_usd(liq):>12} {_fmt_usd(vol):>12} {_fmt_usd(mcap):>12} {r.get('name', '')[:30]}")
402
+
403
+ print()
404
+ for r in results:
405
+ src = r.get("source", "")
406
+ dec = r.get("decimals")
407
+ dec_str = f" dec={dec}" if dec is not None else ""
408
+ print(f" {r['chain']}.{r['symbol']}: {r['address']}{dec_str} [{src}]")
409
+
410
+
411
+ def _print_lookup(result: dict[str, Any]) -> None:
412
+ print(f"Chain: {result['chain']}")
413
+ print(f"Symbol: {result['symbol']}")
414
+ print(f"Name: {result['name']}")
415
+ print(f"Address: {result['address']}")
416
+ print(f"Price: {_fmt_price(result.get('priceUsd'))}")
417
+ chg = result.get("priceChange", {})
418
+ if chg:
419
+ parts = []
420
+ for period, label in [("m5", "5m"), ("h1", "1h"), ("h6", "6h"), ("h24", "24h")]:
421
+ v = chg.get(period)
422
+ if v is not None:
423
+ parts.append(f"{label}:{_fmt_pct(v)}")
424
+ if parts:
425
+ print(f"Changes: {' '.join(parts)}")
426
+ print(f"Liquidity: {_fmt_usd(_safe_float(result.get('liquidityUsd')))}")
427
+ print(f"Volume24h: {_fmt_usd(_safe_float(result.get('volume24h')))}")
428
+ print(f"MarketCap: {_fmt_usd(_safe_float(result.get('marketCap')))}")
429
+ print(f"DEX: {result.get('dexId', '-')}")
430
+ print(f"Pair: {result['symbol']}/{result.get('quoteSymbol', '?')} ({result.get('pairAddress', '-')})")
431
+
432
+
433
+ def _print_pool_table(results: list[dict[str, Any]], title: str) -> None:
434
+ print(f" {title}")
435
+ print()
436
+ print(f"{'Symbol':<10} {'Price':>14} {'24h':>8} {'Liquidity':>12} {'Volume24h':>12} {'MarketCap':>12} Name")
437
+ print("-" * 106)
438
+ for r in results:
439
+ price = _fmt_price(r.get("priceUsd"))
440
+ liq = _safe_float(r.get("liquidityUsd"))
441
+ vol = _safe_float(r.get("volume24h"))
442
+ mcap = _safe_float(r.get("marketCap"))
443
+ chg = _fmt_pct(_safe_float(r.get("priceChange24h")))
444
+ print(f"{r['symbol']:<10} {price:>14} {chg:>8} {_fmt_usd(liq):>12} {_fmt_usd(vol):>12} {_fmt_usd(mcap):>12} {r.get('name', '')[:30]}")
445
+
446
+ print()
447
+ for r in results:
448
+ print(f" {r['symbol']}: {r['address']}")
449
+
450
+
451
+ # ============================================================
452
+ # CLI
453
+ # ============================================================
454
+
455
+ def main() -> None:
456
+ parser = argparse.ArgumentParser(description="搜索/查询链上代币(DexScreener + GeckoTerminal,无需 API key)")
457
+ sub = parser.add_subparsers(dest="command")
458
+
459
+ # search
460
+ s = sub.add_parser("search", help="按关键词搜索代币(symbol / name)")
461
+ s.add_argument("query", help="搜索关键词")
462
+ s.add_argument("--chain", "-c", help="过滤链名,如 base / ethereum / arbitrum")
463
+ s.add_argument("--limit", "-n", type=int, default=10, help="返回数量,默认 10")
464
+ s.add_argument("--min-liq", type=float, default=0, help="最低流动性(USD),默认 0")
465
+ s.add_argument("--risk-filter", choices=["LOW", "MED", "HIGH", "EXT"], help="按风险等级过滤结果")
466
+ s.add_argument("--min-tvl", type=float, default=0, help="最低 TVL(USD)")
467
+ s.add_argument("--min-volume", type=float, default=0, help="最低 24h 交易量(USD)")
468
+ s.add_argument("--output", help="输出 JSON 文件路径")
469
+
470
+ # lookup
471
+ lk = sub.add_parser("lookup", help="按地址查代币详情")
472
+ lk.add_argument("--chain", "-c", required=True, help="链名,如 base / ethereum")
473
+ lk.add_argument("address", help="代币合约地址")
474
+ lk.add_argument("--output", help="输出 JSON 文件路径")
475
+
476
+ # trending
477
+ tr = sub.add_parser("trending", help="查看链上热门代币(GeckoTerminal trending pools)")
478
+ tr.add_argument("--chain", "-c", required=True, help="链名")
479
+ tr.add_argument("--limit", "-n", type=int, default=15, help="返回数量,默认 15")
480
+ tr.add_argument("--output", help="输出 JSON 文件路径")
481
+
482
+ # top
483
+ tp = sub.add_parser("top", help="查看链上交易量最高的代币(GeckoTerminal top pools)")
484
+ tp.add_argument("--chain", "-c", required=True, help="链名")
485
+ tp.add_argument("--limit", "-n", type=int, default=15, help="返回数量,默认 15")
486
+ tp.add_argument("--output", help="输出 JSON 文件路径")
487
+
488
+ args = parser.parse_args()
489
+
490
+ try:
491
+ if args.command == "lookup":
492
+ result = lookup_token(args.chain, args.address)
493
+ if not result:
494
+ print(json.dumps({"error": f"token not found: {args.chain}/{args.address}"}, ensure_ascii=False, indent=2), file=sys.stderr)
495
+ sys.exit(1)
496
+ _print_lookup(result)
497
+ # Auto-cache lookup results
498
+ if result.get("address") and result.get("symbol") and result.get("chain"):
499
+ try:
500
+ cache_token_from_search(
501
+ chain_key=result["chain"],
502
+ symbol=result["symbol"],
503
+ address=result["address"],
504
+ decimals=18, # DexScreener doesn't return decimals
505
+ )
506
+ except Exception:
507
+ pass
508
+ output = {"action": "token_lookup", "result": result}
509
+ if args.output:
510
+ Path(args.output).write_text(json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
511
+ dump_json(output)
512
+
513
+ elif args.command == "trending":
514
+ results = trending_tokens(args.chain, args.limit)
515
+ if not results:
516
+ print(json.dumps({"error": f"no trending tokens found on {args.chain}"}, ensure_ascii=False, indent=2), file=sys.stderr)
517
+ sys.exit(1)
518
+ _print_pool_table(results, f"Trending on {args.chain} (GeckoTerminal)")
519
+ output = {"action": "token_trending", "chain": args.chain, "count": len(results), "results": results}
520
+ if args.output:
521
+ Path(args.output).write_text(json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
522
+ dump_json(output)
523
+
524
+ elif args.command == "top":
525
+ results = top_pools(args.chain, args.limit)
526
+ if not results:
527
+ print(json.dumps({"error": f"no top pools found on {args.chain}"}, ensure_ascii=False, indent=2), file=sys.stderr)
528
+ sys.exit(1)
529
+ _print_pool_table(results, f"Top pools on {args.chain} (GeckoTerminal)")
530
+ output = {"action": "token_top", "chain": args.chain, "count": len(results), "results": results}
531
+ if args.output:
532
+ Path(args.output).write_text(json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
533
+ dump_json(output)
534
+
535
+ elif args.command == "search":
536
+ results = search_tokens(args.query, args.chain, limit=50)
537
+ # Add risk badges
538
+ for r in results:
539
+ r["riskBadge"] = _compute_risk_badge(_safe_float(r.get("liquidityUsd")), _safe_float(r.get("volume24h")))
540
+ if args.min_liq:
541
+ results = [r for r in results if _safe_float(r.get("liquidityUsd")) >= args.min_liq]
542
+ if args.min_tvl:
543
+ results = [r for r in results if _safe_float(r.get("liquidityUsd")) >= args.min_tvl]
544
+ if args.min_volume:
545
+ results = [r for r in results if _safe_float(r.get("volume24h")) >= args.min_volume]
546
+ if args.risk_filter:
547
+ results = [r for r in results if r.get("riskBadge") == args.risk_filter]
548
+ results = results[:args.limit]
549
+ if not results:
550
+ print(json.dumps({"error": f"no results for '{args.query}'"}, ensure_ascii=False, indent=2), file=sys.stderr)
551
+ sys.exit(1)
552
+ # Auto-cache search results for future resolve_token lookups
553
+ for r in results:
554
+ if r.get("address") and r.get("symbol") and r.get("chain"):
555
+ try:
556
+ cache_token_from_search(
557
+ chain_key=r["chain"],
558
+ symbol=r["symbol"],
559
+ address=r["address"],
560
+ decimals=r.get("decimals") or 18,
561
+ is_stable=False,
562
+ )
563
+ except Exception:
564
+ pass
565
+ _print_search_table(results)
566
+ output = {"action": "token_search", "query": args.query, "chain": args.chain, "count": len(results), "results": results}
567
+ if args.output:
568
+ Path(args.output).write_text(json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
569
+ dump_json(output)
570
+ else:
571
+ parser.print_help()
572
+ sys.exit(1)
573
+
574
+ except Exception as exc:
575
+ print(json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
576
+ sys.exit(1)
577
+
578
+
579
+ if __name__ == "__main__":
580
+ main()