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,272 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Telegram confirmation helper for Uniswap trade execution.
4
+
5
+ Sends trade details to Telegram with inline Approve/Reject buttons,
6
+ then polls for callback response. Pure stdlib — no curl dependency.
7
+
8
+ Usage:
9
+ from uniswap_autopilot.execute.telegram_confirm import request_trade_confirmation
10
+ if not request_trade_confirmation(trade_details):
11
+ sys.exit(0)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import tempfile
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any
22
+ from urllib.error import HTTPError, URLError
23
+ from urllib.request import Request, urlopen
24
+
25
+ # ── config ──────────────────────────────────────────────────────────────────
26
+
27
+
28
+ def _read_config_file(config_path: str | None = None) -> tuple[str, str, list[str]]:
29
+ """Return (bot_token, chat_id, allowed_user_ids) from a JSON config file.
30
+
31
+ Config is expected to be a dict with optional nested keys, e.g.
32
+ {"botToken": "...", "chatId": "...", "allowFrom": [...]} or
33
+ {"channels": {"telegram": {"botToken": "...", "allowFrom": [...]}}}
34
+ """
35
+ path = Path(config_path or os.environ.get("TELEGRAM_CONFIG_PATH", ""))
36
+ if not path.is_file():
37
+ raise FileNotFoundError(f"Config file not found: {path}")
38
+
39
+ with open(path) as f:
40
+ cfg = json.load(f)
41
+
42
+ bot_token = cfg.get("botToken", "")
43
+ chat_id = cfg.get("chatId", "")
44
+ allow_from = cfg.get("allowFrom") or []
45
+
46
+ if not bot_token:
47
+ tg = cfg.get("channels", {}).get("telegram", {})
48
+ bot_token = tg.get("botToken", "")
49
+ if not chat_id:
50
+ allow = tg.get("allowFrom", [])
51
+ chat_id = str(allow[-1]) if allow else ""
52
+ if not allow_from:
53
+ allow_from = tg.get("allowFrom") or []
54
+
55
+ if not bot_token:
56
+ raise RuntimeError("Telegram botToken not found in config file")
57
+ return bot_token, chat_id, [str(x) for x in allow_from]
58
+
59
+
60
+ BOT_TOKEN, CHAT_ID = "", ""
61
+ ALLOWED_USER_IDS: list[str] = []
62
+
63
+
64
+ def _ensure_config():
65
+ """Lazily load config on first use to avoid import-time failure."""
66
+ global BOT_TOKEN, CHAT_ID, ALLOWED_USER_IDS
67
+ if BOT_TOKEN:
68
+ return
69
+ BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
70
+ CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
71
+ env_allow = os.environ.get("TELEGRAM_ALLOWED_USER_IDS", "")
72
+ if env_allow:
73
+ ALLOWED_USER_IDS = [x.strip() for x in env_allow.split(",") if x.strip()]
74
+ if BOT_TOKEN and CHAT_ID and ALLOWED_USER_IDS:
75
+ return
76
+ try:
77
+ token, chat, allow = _read_config_file()
78
+ BOT_TOKEN = BOT_TOKEN or token
79
+ CHAT_ID = CHAT_ID or chat
80
+ if not ALLOWED_USER_IDS:
81
+ ALLOWED_USER_IDS = allow
82
+ except (FileNotFoundError, RuntimeError):
83
+ pass
84
+
85
+
86
+ def _state_file(confirmation_id: str | None = None) -> Path:
87
+ """Cross-platform state file path.
88
+
89
+ The previous implementation used a single shared file for ALL
90
+ confirmation requests, which made it impossible to run concurrent trade
91
+ flows safely (one would overwrite another's state). We now namespace the
92
+ file by ``confirmation_id`` so each request owns its own state slot.
93
+ """
94
+ name = "uniswap_trade_confirmation_state"
95
+ if confirmation_id:
96
+ # Strip any characters that would be problematic on disk.
97
+ safe = "".join(c for c in confirmation_id if c.isalnum() or c in ("_", "-"))
98
+ name = f"{name}_{safe}"
99
+ return Path(tempfile.gettempdir()) / f"{name}.json"
100
+
101
+
102
+ # ── helpers ─────────────────────────────────────────────────────────────────
103
+
104
+
105
+ def _tg_api(method: str, payload: dict[str, Any]) -> dict[str, Any]:
106
+ """Call Telegram Bot API via urllib (no curl dependency)."""
107
+ _ensure_config()
108
+ if not BOT_TOKEN:
109
+ raise RuntimeError(
110
+ "Telegram bot token not configured. "
111
+ "Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID env vars, "
112
+ "or provide a config file via TELEGRAM_CONFIG_PATH"
113
+ )
114
+ url = f"https://api.telegram.org/bot{BOT_TOKEN}/{method}"
115
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
116
+ req = Request(url, data=data, headers={"Content-Type": "application/json"})
117
+ try:
118
+ with urlopen(req, timeout=15) as resp:
119
+ return json.loads(resp.read())
120
+ except (HTTPError, URLError) as e:
121
+ return {"ok": False, "error": str(e)}
122
+
123
+
124
+ def _poll_updates(offset: int | None = None, timeout: int = 2) -> list[dict]:
125
+ """Short-poll getUpdates for callback_query only."""
126
+ payload: dict[str, Any] = {
127
+ "allowed_updates": ["callback_query"],
128
+ "timeout": timeout,
129
+ }
130
+ if offset is not None:
131
+ payload["offset"] = offset
132
+ resp = _tg_api("getUpdates", payload)
133
+ return resp.get("result", [])
134
+
135
+
136
+ # ── main API ────────────────────────────────────────────────────────────────
137
+
138
+
139
+ def request_trade_confirmation(
140
+ trade_details: dict[str, Any],
141
+ timeout_seconds: int = 300,
142
+ ) -> bool:
143
+ """
144
+ Send trade confirmation to Telegram and wait for user response.
145
+
146
+ Returns True if approved, False if rejected or timeout.
147
+ """
148
+ _ensure_config()
149
+ confirmation_id = f"uni_{int(time.time()) % 100000:05d}"
150
+
151
+ lines = [
152
+ "🔍 *Uniswap Trade Confirmation*",
153
+ "",
154
+ f"📍 Chain: `{trade_details.get('chain', '?')}`",
155
+ f"🔄 {trade_details.get('tokenIn', '?')} → {trade_details.get('tokenOut', '?')}",
156
+ f"💰 {trade_details.get('amountIn', '?')} → {trade_details.get('amountOut', '?')}",
157
+ "",
158
+ f"⏳ Timeout: {timeout_seconds // 60} min",
159
+ f"🆔 `{confirmation_id}`",
160
+ ]
161
+
162
+ payload = {
163
+ "chat_id": CHAT_ID,
164
+ "text": "\n".join(lines),
165
+ "parse_mode": "Markdown",
166
+ "reply_markup": {
167
+ "inline_keyboard": [[
168
+ {"text": "✅ Approve", "callback_data": f"uap_{confirmation_id}"},
169
+ {"text": "❌ Reject", "callback_data": f"urj_{confirmation_id}"},
170
+ ]]
171
+ },
172
+ }
173
+
174
+ resp = _tg_api("sendMessage", payload)
175
+ if not resp.get("ok"):
176
+ print(f"⚠️ Telegram sendMessage failed: {resp}")
177
+ return False
178
+
179
+ msg_id = resp["result"]["message_id"]
180
+ print(f"📤 Confirmation sent (msg_id={msg_id}, id={confirmation_id})")
181
+
182
+ state = {
183
+ "confirmation_id": confirmation_id,
184
+ "status": "pending",
185
+ "created_at": time.time(),
186
+ }
187
+ state_path = _state_file(confirmation_id)
188
+ state_path.write_text(json.dumps(state))
189
+
190
+ deadline = time.time() + timeout_seconds
191
+ last_offset: int | None = None
192
+
193
+ while time.time() < deadline:
194
+ try:
195
+ updates = _poll_updates(
196
+ offset=last_offset,
197
+ timeout=min(5, int(deadline - time.time())),
198
+ )
199
+ except Exception as e:
200
+ print(f"⚠️ Poll error: {e}, retrying...")
201
+ time.sleep(2)
202
+ continue
203
+
204
+ for upd in updates:
205
+ last_offset = upd["update_id"] + 1
206
+ cb = upd.get("callback_query")
207
+ if not cb:
208
+ continue
209
+ from_user = cb.get("from", {}) if isinstance(cb, dict) else {}
210
+ from_id = str(from_user.get("id", "")).strip()
211
+ if ALLOWED_USER_IDS and from_id not in ALLOWED_USER_IDS:
212
+ _tg_api("answerCallbackQuery", {
213
+ "callback_query_id": cb.get("id", ""),
214
+ "text": "Not authorized",
215
+ "show_alert": True,
216
+ })
217
+ continue
218
+ data = cb.get("data", "")
219
+ if data in (f"uap_{confirmation_id}", f"urj_{confirmation_id}"):
220
+ is_approved = data.startswith("uap_")
221
+ decision = "✅ Approved" if is_approved else "❌ Rejected"
222
+ state["status"] = "approved" if is_approved else "rejected"
223
+ state["resolved_at"] = time.time()
224
+ state_path.write_text(json.dumps(state))
225
+ _tg_api("editMessageReplyMarkup", {
226
+ "chat_id": CHAT_ID,
227
+ "message_id": msg_id,
228
+ })
229
+ _tg_api("editMessageText", {
230
+ "chat_id": CHAT_ID,
231
+ "message_id": msg_id,
232
+ "text": "\n".join(lines) + "\n\n🔒 *Decision: " + decision + "*",
233
+ "parse_mode": "Markdown",
234
+ })
235
+ _tg_api("answerCallbackQuery", {
236
+ "callback_query_id": cb["id"],
237
+ })
238
+ print(f"{decision} by user")
239
+ return is_approved
240
+
241
+ time.sleep(0.5)
242
+
243
+ print("⏰ Confirmation timeout")
244
+ state["status"] = "timeout"
245
+ state_path.write_text(json.dumps(state))
246
+ return False
247
+
248
+
249
+ # ── CLI test ────────────────────────────────────────────────────────────────
250
+
251
+
252
+ def main() -> None:
253
+ import argparse
254
+ parser = argparse.ArgumentParser(description="Test Telegram trade confirmation")
255
+ parser.add_argument("--test", action="store_true")
256
+ args = parser.parse_args()
257
+
258
+ if args.test:
259
+ ok = request_trade_confirmation({
260
+ "chain": "Base",
261
+ "tokenIn": "ETH",
262
+ "tokenOut": "USDC",
263
+ "amountIn": "0.1 ETH",
264
+ "amountOut": "~200 USDC",
265
+ }, timeout_seconds=120)
266
+ print(f"\nResult: {'APPROVED' if ok else 'REJECTED/TIMEOUT'}")
267
+ else:
268
+ print("Use --test to send a test confirmation")
269
+
270
+
271
+ if __name__ == "__main__":
272
+ main()
@@ -0,0 +1,338 @@
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
+
10
+
11
+ from uniswap_autopilot.common.common import (
12
+ V3_FEE_TIERS,
13
+ dump_json,
14
+ load_local_env,
15
+ normalize_chain,
16
+ resolve_token,
17
+ sort_token_addresses,
18
+ )
19
+ from uniswap_autopilot.lp.v3.pool import query_pool_full_info
20
+ from uniswap_autopilot.search.search import _ds_lookup, _fetch_json
21
+
22
+ DEFILLAMA_YIELDS_URL = "https://yields.llama.fi/pools"
23
+
24
+ DEFILLAMA_CHAIN_MAP: dict[str, str] = {
25
+ "avalanche": "avax",
26
+ }
27
+
28
+
29
+ def _defillama_chain_name(chain_key: str) -> str:
30
+ mapped = DEFILLAMA_CHAIN_MAP.get(chain_key, chain_key)
31
+ return mapped.capitalize()
32
+
33
+
34
+ def fetch_defillama_yields(chain: str) -> dict[str, dict]:
35
+ chain_key = chain.strip().lower()
36
+ dl_chain = _defillama_chain_name(chain_key)
37
+
38
+ try:
39
+ data = _fetch_json(DEFILLAMA_YIELDS_URL, timeout=30)
40
+ except Exception as exc:
41
+ print(f" [warn] DefiLlama yields fetch failed: {exc}", file=sys.stderr)
42
+ return {}
43
+
44
+ pools = data.get("data", [])
45
+ result: dict[str, dict] = {}
46
+ for pool in pools:
47
+ if pool.get("project") != "uniswap-v3":
48
+ continue
49
+ if (pool.get("chain") or "").lower() != dl_chain.lower():
50
+ continue
51
+ pool_addr = (pool.get("pool") or "").lower()
52
+ if not pool_addr:
53
+ continue
54
+ result[pool_addr] = {
55
+ "apy": pool.get("apy"),
56
+ "apyBase": pool.get("apyBase"),
57
+ "tvlUsd": pool.get("tvlUsd"),
58
+ "volumeUsd1d": pool.get("volumeUsd1d"),
59
+ }
60
+ return result
61
+
62
+
63
+ def fetch_dexscreener_pair(
64
+ chain: str, token0_addr: str, token1_addr: str
65
+ ) -> dict | None:
66
+ try:
67
+ ds = _ds_lookup(chain, token0_addr)
68
+ except Exception:
69
+ ds = None
70
+ if not ds:
71
+ try:
72
+ ds = _ds_lookup(chain, token1_addr)
73
+ except Exception:
74
+ return None
75
+ if not ds:
76
+ return None
77
+ return {
78
+ "volume24h": ds.get("volume24h"),
79
+ "liquidityUsd": ds.get("liquidityUsd"),
80
+ "priceUsd": ds.get("priceUsd"),
81
+ }
82
+
83
+
84
+ def _fmt_usd(val: float | None) -> str:
85
+ if val is None:
86
+ return "-"
87
+ if val >= 1_000_000_000:
88
+ return f"${val / 1_000_000_000:.2f}B"
89
+ if val >= 1_000_000:
90
+ return f"${val / 1_000_000:.2f}M"
91
+ if val >= 1_000:
92
+ return f"${val / 1_000:.1f}K"
93
+ if val > 0:
94
+ return f"${val:.4f}"
95
+ return "-"
96
+
97
+
98
+ def _fmt_pct(val: float | None) -> str:
99
+ if val is None:
100
+ return "-"
101
+ return f"{val:.2f}%"
102
+
103
+
104
+ def _safe_float(val: Any, default: float = 0.0) -> float:
105
+ if val is None:
106
+ return default
107
+ try:
108
+ return float(val)
109
+ except (ValueError, TypeError):
110
+ return default
111
+
112
+
113
+ def compare_pools_for_pair(
114
+ chain_name: str, token_a: str, token_b: str, rpc_url: str | None
115
+ ) -> dict[str, Any]:
116
+ chain = normalize_chain(chain_name)
117
+ token_a_info = resolve_token(chain, token_a, rpc_url)
118
+ token_b_info = resolve_token(chain, token_b, rpc_url)
119
+
120
+ def _addr_or_wrapped(info: dict[str, Any]) -> str:
121
+ addr = info["address"]
122
+ if addr != "NATIVE":
123
+ return addr
124
+ wrapped = chain.tokens.get(chain.wrapped_native_symbol.upper())
125
+ if wrapped:
126
+ return wrapped.address
127
+ wtoken = resolve_token(chain, chain.wrapped_native_symbol, rpc_url)
128
+ return wtoken["address"]
129
+
130
+ addr_a = _addr_or_wrapped(token_a_info)
131
+ addr_b = _addr_or_wrapped(token_b_info)
132
+ token0_addr, token1_addr = sort_token_addresses(addr_a, addr_b)
133
+
134
+ pool_results: list[dict[str, Any]] = []
135
+ fee_tiers_sorted = sorted(V3_FEE_TIERS)
136
+
137
+ for fee_tier in fee_tiers_sorted:
138
+ try:
139
+ info = query_pool_full_info(
140
+ chain_name, token_a, token_b, fee_tier, rpc_url
141
+ )
142
+ except Exception as exc:
143
+ pool_results.append({
144
+ "feeTier": fee_tier,
145
+ "feePct": f"{fee_tier / 10000:.2f}%",
146
+ "exists": False,
147
+ "error": str(exc),
148
+ })
149
+ continue
150
+
151
+ if not info.get("exists"):
152
+ pool_results.append({
153
+ "feeTier": fee_tier,
154
+ "feePct": f"{fee_tier / 10000:.2f}%",
155
+ "poolAddress": None,
156
+ "exists": False,
157
+ })
158
+ continue
159
+
160
+ pool_addr = info.get("poolAddress", "").lower()
161
+ pool_results.append({
162
+ "feeTier": fee_tier,
163
+ "feePct": f"{fee_tier / 10000:.2f}%",
164
+ "poolAddress": info.get("poolAddress"),
165
+ "exists": True,
166
+ "currentTick": info.get("currentTick"),
167
+ "currentPrice": info.get("currentPrice"),
168
+ "liquidity": info.get("liquidity"),
169
+ "_addr_lower": pool_addr,
170
+ })
171
+
172
+ # Fetch DefiLlama yields for the chain
173
+ dl_yields = fetch_defillama_yields(chain_name)
174
+
175
+ # Fetch DexScreener data for the pair
176
+ ds_data = fetch_dexscreener_pair(chain.key, token0_addr, token1_addr)
177
+
178
+ # Enrich pool results with yield and volume data
179
+ for pr in pool_results:
180
+ if not pr.get("exists"):
181
+ pr["tvlUsd"] = None
182
+ pr["volume24h"] = None
183
+ pr["apy"] = None
184
+ pr["apyBase"] = None
185
+ continue
186
+
187
+ addr_lower = pr.get("_addr_lower", "")
188
+ dl = dl_yields.get(addr_lower, {})
189
+ pr["tvlUsd"] = dl.get("tvlUsd")
190
+ pr["volumeUsd1d"] = dl.get("volumeUsd1d")
191
+ pr["apy"] = dl.get("apy")
192
+ pr["apyBase"] = dl.get("apyBase")
193
+
194
+ if ds_data:
195
+ pr["dexVolume24h"] = ds_data.get("volume24h")
196
+ pr["dexLiquidityUsd"] = ds_data.get("liquidityUsd")
197
+ pr["dexPriceUsd"] = ds_data.get("priceUsd")
198
+ else:
199
+ pr["dexVolume24h"] = None
200
+ pr["dexLiquidityUsd"] = None
201
+ pr["dexPriceUsd"] = None
202
+
203
+ # Clean internal key
204
+ pr.pop("_addr_lower", None)
205
+
206
+ # Determine recommendation
207
+ eligible = [pr for pr in pool_results if pr.get("exists")]
208
+ recommendation = None
209
+ if eligible:
210
+ high_tvl = [p for p in eligible if (_safe_float(p.get("tvlUsd")) or _safe_float(p.get("dexLiquidityUsd"))) > 100_000]
211
+ if high_tvl:
212
+ best = max(high_tvl, key=lambda p: _safe_float(p.get("apy")))
213
+ reason = "highest APY"
214
+ else:
215
+ best = max(eligible, key=lambda p: _safe_float(p.get("tvlUsd")) or _safe_float(p.get("dexLiquidityUsd")))
216
+ reason = "highest TVL (no pool above $100K TVL)"
217
+ recommendation = {
218
+ "feeTier": best["feeTier"],
219
+ "feePct": best["feePct"],
220
+ "poolAddress": best["poolAddress"],
221
+ "reason": reason,
222
+ }
223
+
224
+ return {
225
+ "action": "compare_pools",
226
+ "chain": {"key": chain.key, "chainId": chain.chain_id},
227
+ "tokenA": token_a_info,
228
+ "tokenB": token_b_info,
229
+ "token0": token0_addr,
230
+ "token1": token1_addr,
231
+ "pools": pool_results,
232
+ "recommendation": recommendation,
233
+ }
234
+
235
+
236
+ def _print_comparison_table(result: dict[str, Any]) -> None:
237
+ chain = result["chain"]["key"]
238
+ ta = result["tokenA"]["symbol"]
239
+ tb = result["tokenB"]["symbol"]
240
+ print(f"\nPool Comparison: {ta}/{tb} on {chain}")
241
+ print()
242
+ header = (
243
+ f"{'Fee Tier':<10} "
244
+ f"{'Pool Address':<22} "
245
+ f"{'TVL':>14} "
246
+ f"{'Volume 24h':>14} "
247
+ f"{'APY':>10} "
248
+ f"{'Liquidity':>16}"
249
+ )
250
+ print(header)
251
+ print("-" * len(header) + "-" * 10)
252
+
253
+ for pool in result["pools"]:
254
+ fee_pct = pool["feePct"]
255
+ if not pool.get("exists"):
256
+ print(
257
+ f"{fee_pct:<10} "
258
+ f"{'not found':<22} "
259
+ f"{'-':>14} "
260
+ f"{'-':>14} "
261
+ f"{'-':>10} "
262
+ f"{'-':>16}"
263
+ )
264
+ continue
265
+
266
+ addr = pool.get("poolAddress", "")
267
+ addr_short = addr[:8] + "..." + addr[-4:] if len(addr) > 14 else addr
268
+ tvl = _safe_float(pool.get("tvlUsd")) or _safe_float(pool.get("dexLiquidityUsd"))
269
+ vol = _safe_float(pool.get("volumeUsd1d")) or _safe_float(pool.get("dexVolume24h"))
270
+ apy = pool.get("apy")
271
+ liq = pool.get("liquidity", "-")
272
+
273
+ tvl_str = _fmt_usd(tvl) if tvl > 0 else "-"
274
+ vol_str = _fmt_usd(vol) if vol > 0 else "-"
275
+ apy_str = _fmt_pct(apy) if apy is not None else "-"
276
+ liq_str = str(liq) if liq != "-" else "-"
277
+
278
+ print(
279
+ f"{fee_pct:<10} "
280
+ f"{addr_short:<22} "
281
+ f"{tvl_str:>14} "
282
+ f"{vol_str:>14} "
283
+ f"{apy_str:>10} "
284
+ f"{liq_str:>16}"
285
+ )
286
+
287
+ print("-" * len(header) + "-" * 10)
288
+
289
+ rec = result.get("recommendation")
290
+ if rec:
291
+ print(f"\nRecommended: {rec['feePct']} ({rec['reason']})")
292
+ print(f" Pool: {rec['poolAddress']}")
293
+ else:
294
+ print("\nNo recommendation (no pools found)")
295
+
296
+ print()
297
+
298
+
299
+ def main() -> None:
300
+ parser = argparse.ArgumentParser(
301
+ description="Compare Uniswap v3 pools across fee tiers for a token pair"
302
+ )
303
+ parser.add_argument(
304
+ "--chain", required=True, help="Chain name, e.g. base / ethereum / arbitrum"
305
+ )
306
+ parser.add_argument(
307
+ "--token-a", required=True, help="Token symbol or address"
308
+ )
309
+ parser.add_argument(
310
+ "--token-b", required=True, help="Token symbol or address"
311
+ )
312
+ parser.add_argument("--rpc-url", help="RPC URL (reads from env if not provided)")
313
+ parser.add_argument("--output", help="Output JSON file path")
314
+
315
+ args = parser.parse_args()
316
+
317
+ try:
318
+ load_local_env()
319
+ result = compare_pools_for_pair(
320
+ args.chain, args.token_a, args.token_b, args.rpc_url
321
+ )
322
+ _print_comparison_table(result)
323
+ if args.output:
324
+ Path(args.output).write_text(
325
+ json.dumps(result, ensure_ascii=False, indent=2) + "\n",
326
+ encoding="utf-8",
327
+ )
328
+ dump_json(result)
329
+ except Exception as exc:
330
+ print(
331
+ json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2),
332
+ file=sys.stderr,
333
+ )
334
+ sys.exit(1)
335
+
336
+
337
+ if __name__ == "__main__":
338
+ main()
File without changes