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,234 @@
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
+ dump_json,
13
+ load_local_env,
14
+ normalize_chain,
15
+ resolve_token,
16
+ resolve_wallet_address,
17
+ )
18
+ from uniswap_autopilot.analytics.position import analyze_positions_by_owner, fetch_token_prices
19
+ from uniswap_autopilot.execute._internal.rpc import query_erc20_balance, resolve_rpc_url
20
+
21
+ V3_CHAINS = ["ethereum", "base", "arbitrum", "optimism", "polygon", "celo", "linea", "world_chain", "soneium"]
22
+
23
+ WALLET_TOKENS = {
24
+ "ethereum": ["WETH", "USDC", "USDT", "DAI", "WBTC", "UNI", "LINK"],
25
+ "base": ["WETH", "USDC", "USDbC", "DAI", "cbETH"],
26
+ "arbitrum": ["WETH", "USDC", "USDT", "ARB", "GMX"],
27
+ "optimism": ["WETH", "USDC", "USDT", "OP"],
28
+ "polygon": ["WETH", "USDC", "USDT", "WMATIC"],
29
+ "celo": ["CELO", "cUSD", "cEUR"],
30
+ "linea": ["WETH", "USDC", "USDT"],
31
+ "world_chain": ["WETH", "USDC", "WLD"],
32
+ "soneium": ["WETH", "USDC"],
33
+ }
34
+
35
+
36
+ def _query_token_balances(
37
+ chain_name: str,
38
+ wallet: str,
39
+ rpc_url: str,
40
+ token_symbols: list[str],
41
+ ) -> list[dict[str, Any]]:
42
+ chain = normalize_chain(chain_name)
43
+ balances: list[dict[str, Any]] = []
44
+ for sym in token_symbols:
45
+ try:
46
+ tok = resolve_token(chain, sym, rpc_url)
47
+ addr = tok["address"]
48
+ if addr == "NATIVE":
49
+ continue
50
+ raw = query_erc20_balance(wallet, addr, rpc_url)
51
+ decimals = tok["decimals"]
52
+ human = raw / (10 ** decimals) if raw > 0 else 0.0
53
+ if human == 0.0:
54
+ continue
55
+ balances.append({
56
+ "symbol": sym,
57
+ "address": addr,
58
+ "rawBalance": str(raw),
59
+ "humanBalance": round(human, 8),
60
+ "decimals": decimals,
61
+ })
62
+ except Exception:
63
+ continue
64
+ return balances
65
+
66
+
67
+ def _enrich_balances_with_usd(
68
+ chain_name: str,
69
+ balances: list[dict[str, Any]],
70
+ ) -> list[dict[str, Any]]:
71
+ chain = normalize_chain(chain_name)
72
+ if not balances:
73
+ return balances
74
+ addrs = [b["address"] for b in balances]
75
+ price_map: dict[str, float | None] = {}
76
+ for i in range(0, len(addrs), 2):
77
+ batch = addrs[i:i + 2]
78
+ if len(batch) == 2:
79
+ p0, p1 = fetch_token_prices(chain.key, batch[0], batch[1])
80
+ price_map[batch[0]] = p0
81
+ price_map[batch[1]] = p1
82
+ else:
83
+ p0, _ = fetch_token_prices(chain.key, batch[0], batch[0])
84
+ price_map[batch[0]] = p0
85
+ for b in balances:
86
+ price = price_map.get(b["address"])
87
+ b["priceUsd"] = price
88
+ if price is not None:
89
+ b["balanceUsd"] = round(b["humanBalance"] * price, 2)
90
+ else:
91
+ b["balanceUsd"] = None
92
+ return balances
93
+
94
+
95
+ def portfolio_overview(
96
+ wallet: str,
97
+ chains: list[str] | None = None,
98
+ rpc_url: str | None = None,
99
+ include_balances: bool = True,
100
+ ) -> dict[str, Any]:
101
+ wallet = resolve_wallet_address(wallet) or wallet
102
+ target_chains = chains or V3_CHAINS
103
+
104
+ all_positions: list[dict[str, Any]] = []
105
+ chain_summaries: list[dict[str, Any]] = []
106
+ chain_errors: list[dict[str, str]] = []
107
+ total_value_usd = 0.0
108
+ total_fees_usd = 0.0
109
+ total_positions = 0
110
+ total_in_range = 0
111
+ total_out_of_range = 0
112
+
113
+ for chain_name in target_chains:
114
+ chain = normalize_chain(chain_name)
115
+ rpc, _ = resolve_rpc_url(rpc_url, chain.chain_id)
116
+ if not rpc:
117
+ chain_errors.append({"chain": chain_name, "error": "RPC URL not configured"})
118
+ continue
119
+
120
+ # V3 positions
121
+ try:
122
+ analysis = analyze_positions_by_owner(chain_name, wallet, rpc)
123
+ positions = analysis.get("positions", [])
124
+ chain_value = analysis.get("totalValueUsd", 0.0)
125
+ chain_fees = analysis.get("totalFeesUsd", 0.0)
126
+
127
+ for pos in positions:
128
+ pos["chain"] = chain_name
129
+ all_positions.append(pos)
130
+ total_positions += 1
131
+ if pos.get("inRange"):
132
+ total_in_range += 1
133
+ else:
134
+ total_out_of_range += 1
135
+
136
+ total_value_usd += chain_value
137
+ total_fees_usd += chain_fees
138
+ except Exception as exc:
139
+ chain_errors.append({"chain": chain_name, "error": str(exc)})
140
+ positions = []
141
+ chain_value = 0.0
142
+ chain_fees = 0.0
143
+
144
+ # Token balances
145
+ balances: list[dict[str, Any]] = []
146
+ balance_total_usd = 0.0
147
+ if include_balances:
148
+ try:
149
+ token_syms = WALLET_TOKENS.get(chain_name, ["WETH", "USDC"])
150
+ balances = _query_token_balances(chain_name, wallet, rpc, token_syms)
151
+ balances = _enrich_balances_with_usd(chain_name, balances)
152
+ balance_total_usd = sum(b.get("balanceUsd") or 0 for b in balances)
153
+ except Exception:
154
+ pass
155
+
156
+ chain_summaries.append({
157
+ "chain": chain_name,
158
+ "chainId": chain.chain_id,
159
+ "positionCount": len(positions),
160
+ "positionValueUsd": round(chain_value, 2),
161
+ "uncollectedFeesUsd": round(chain_fees, 2),
162
+ "tokenBalances": balances,
163
+ "tokenBalanceUsd": round(balance_total_usd, 2),
164
+ })
165
+
166
+ return {
167
+ "action": "portfolio_overview",
168
+ "wallet": wallet,
169
+ "chains": chain_summaries,
170
+ "summary": {
171
+ "totalChains": len(chain_summaries),
172
+ "totalPositions": total_positions,
173
+ "inRange": total_in_range,
174
+ "outOfRange": total_out_of_range,
175
+ "totalPositionValueUsd": round(total_value_usd, 2),
176
+ "totalUncollectedFeesUsd": round(total_fees_usd, 2),
177
+ "totalTokenBalanceUsd": round(sum(cs.get("tokenBalanceUsd", 0) for cs in chain_summaries), 2),
178
+ "grandTotalUsd": round(
179
+ total_value_usd
180
+ + sum(cs.get("tokenBalanceUsd", 0) for cs in chain_summaries),
181
+ 2,
182
+ ),
183
+ },
184
+ "positions": all_positions,
185
+ "errors": chain_errors if chain_errors else None,
186
+ }
187
+
188
+
189
+ def main() -> None:
190
+ parser = argparse.ArgumentParser(description="Cross-chain portfolio overview for a Uniswap wallet")
191
+ parser.add_argument("--wallet", required=True)
192
+ parser.add_argument("--chains", help="Comma-separated chain names (default: all V3 chains)")
193
+ parser.add_argument("--rpc-url")
194
+ parser.add_argument("--no-balances", action="store_true", help="Skip ERC-20 balance queries")
195
+ parser.add_argument("--output")
196
+ args = parser.parse_args()
197
+
198
+ load_local_env()
199
+
200
+ chains = None
201
+ if args.chains:
202
+ chains = [c.strip() for c in args.chains.split(",") if c.strip()]
203
+
204
+ result = portfolio_overview(
205
+ wallet=args.wallet,
206
+ chains=chains,
207
+ rpc_url=args.rpc_url,
208
+ include_balances=not args.no_balances,
209
+ )
210
+
211
+ s = result["summary"]
212
+ print(f"Portfolio for {args.wallet}")
213
+ print(f" Chains: {s['totalChains']} Positions: {s['totalPositions']} ({s['inRange']} in range, {s['outOfRange']} out)")
214
+ print(f" Position Value: ${s['totalPositionValueUsd']:.2f} Uncollected Fees: ${s['totalUncollectedFeesUsd']:.2f}")
215
+ print(f" Token Balances: ${s['totalTokenBalanceUsd']:.2f} Grand Total: ${s['grandTotalUsd']:.2f}")
216
+
217
+ for cs in result["chains"]:
218
+ pos_val = cs["positionValueUsd"]
219
+ bal_val = cs["tokenBalanceUsd"]
220
+ n_pos = cs["positionCount"]
221
+ n_bal = len(cs.get("tokenBalances", []))
222
+ print(f" {cs['chain']:12s}: {n_pos} positions (${pos_val:.2f}), {n_bal} tokens (${bal_val:.2f})")
223
+
224
+ if result.get("errors"):
225
+ for err in result["errors"]:
226
+ print(f" {err['chain']}: {err['error']}", file=sys.stderr)
227
+
228
+ if args.output:
229
+ Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
230
+ dump_json(result)
231
+
232
+
233
+ if __name__ == "__main__":
234
+ main()
@@ -0,0 +1,433 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from typing import Any
8
+
9
+
10
+ from uniswap_autopilot.common.common import dump_json, load_local_env, normalize_chain, resolve_token
11
+ from uniswap_autopilot.execute._internal.rpc import (
12
+ decode_uint, eth_call, encode_selector, resolve_rpc_url,
13
+ )
14
+ from uniswap_autopilot.lp.v3.pool import query_slot0
15
+ from uniswap_autopilot.lp.v3.position import query_position, query_positions_by_owner
16
+
17
+
18
+ # Price feed integration
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # V3 position math helpers
23
+ # ---------------------------------------------------------------------------
24
+
25
+ def _sqrt_price(tick: int) -> float:
26
+ """Calculate sqrt(1.0001^tick) = 1.0001^(tick/2)."""
27
+ return 1.0001 ** (tick / 2)
28
+
29
+
30
+ def calculate_position_amounts(
31
+ liquidity: int,
32
+ current_tick: int,
33
+ tick_lower: int,
34
+ tick_upper: int,
35
+ decimals0: int,
36
+ decimals1: int,
37
+ ) -> tuple[float, float]:
38
+ """Return (amount0_human, amount1_human) for a V3 position.
39
+
40
+ Uses the standard Uniswap V3 liquidity math:
41
+ - currentTick <= tickLower: 100 % token0
42
+ - currentTick >= tickUpper: 100 % token1
43
+ - in-range: both tokens
44
+ """
45
+ L = float(liquidity)
46
+ if L == 0:
47
+ return 0.0, 0.0
48
+
49
+ sqrt_lower = _sqrt_price(tick_lower)
50
+ sqrt_upper = _sqrt_price(tick_upper)
51
+
52
+ if current_tick <= tick_lower:
53
+ # Entirely token0: amount0 = L * (1/sqrtLower - 1/sqrtUpper)
54
+ amount0_raw = L * (sqrt_upper - sqrt_lower) / (sqrt_lower * sqrt_upper)
55
+ amount1_raw = 0.0
56
+ elif current_tick >= tick_upper:
57
+ # Entirely token1: amount1 = L * (sqrtUpper - sqrtLower)
58
+ amount0_raw = 0.0
59
+ amount1_raw = L * (sqrt_upper - sqrt_lower)
60
+ else:
61
+ # In range: amount0 = L * (1/sqrtCurrent - 1/sqrtUpper), amount1 = L * (sqrtCurrent - sqrtLower)
62
+ sqrt_current = _sqrt_price(current_tick)
63
+ amount0_raw = L * (sqrt_upper - sqrt_current) / (sqrt_current * sqrt_upper)
64
+ amount1_raw = L * (sqrt_current - sqrt_lower)
65
+
66
+ amount0_human = amount0_raw / (10 ** decimals0)
67
+ amount1_human = amount1_raw / (10 ** decimals1)
68
+ return amount0_human, amount1_human
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Fee growth queries & fee estimation
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def query_fee_growth_global(pool_address: str, rpc_url: str) -> tuple[int, int]:
76
+ """Query feeGrowthGlobal0X128 and feeGrowthGlobal1X128 from the pool."""
77
+ sel0 = encode_selector("feeGrowthGlobal0X128()")
78
+ raw0 = eth_call(pool_address, sel0, rpc_url)
79
+ fg0 = decode_uint(raw0)
80
+
81
+ sel1 = encode_selector("feeGrowthGlobal1X128()")
82
+ raw1 = eth_call(pool_address, sel1, rpc_url)
83
+ fg1 = decode_uint(raw1)
84
+ return fg0, fg1
85
+
86
+
87
+ def estimate_uncollected_fees(
88
+ position: dict,
89
+ fee_growth_global0: int,
90
+ fee_growth_global1: int,
91
+ decimals0: int,
92
+ decimals1: int,
93
+ ) -> tuple[float, float]:
94
+ """Simple approximation of uncollected fees.
95
+
96
+ fee_token = liquidity * (feeGrowthGlobal - feeGrowthInsideLast) / 2^128
97
+
98
+ This is an upper-bound approximation because feeGrowthInsideLast accounts
99
+ for tick-range-specific growth, but we use the global value as a rough
100
+ estimate when per-tick data is not available.
101
+ """
102
+ L = int(position.get("liquidity", "0"))
103
+ if L == 0:
104
+ return 0.0, 0.0
105
+
106
+ fg_inside_last0 = int(position.get("feeGrowthInside0LastX128", "0"))
107
+ fg_inside_last1 = int(position.get("feeGrowthInside1LastX128", "0"))
108
+
109
+ Q128 = 2 ** 128
110
+
111
+ delta0 = fee_growth_global0 - fg_inside_last0
112
+ delta1 = fee_growth_global1 - fg_inside_last1
113
+
114
+ # Handle potential underflow (position was last updated when global was higher
115
+ # due to cross-tick movements) by taking absolute value.
116
+ fee0_raw = abs(L * delta0) / Q128
117
+ fee1_raw = abs(L * delta1) / Q128
118
+
119
+ fee0_human = float(fee0_raw) / (10 ** decimals0)
120
+ fee1_human = float(fee1_raw) / (10 ** decimals1)
121
+ return fee0_human, fee1_human
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Token prices via DefiLlama
126
+ # ---------------------------------------------------------------------------
127
+
128
+ def fetch_token_prices(
129
+ chain: str,
130
+ addr0: str,
131
+ addr1: str,
132
+ ) -> tuple[float | None, float | None]:
133
+ """Fetch current USD prices via price-feed (multi-source with fallback)."""
134
+ from price_feed import get_prices_batch
135
+ results = get_prices_batch([(chain, addr0), (chain, addr1)], tier="normal")
136
+ price0 = results.get(f"{chain}:{addr0.lower()}", {}).get("price")
137
+ price1 = results.get(f"{chain}:{addr1.lower()}", {}).get("price")
138
+ return price0, price1
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Resolve token decimals from position data
143
+ # ---------------------------------------------------------------------------
144
+
145
+ def _resolve_token_decimals(
146
+ chain_name: str,
147
+ token_address: str,
148
+ rpc_url: str,
149
+ ) -> int:
150
+ """Resolve decimals for a token address using the token catalog or on-chain."""
151
+ try:
152
+ chain = normalize_chain(chain_name)
153
+ token_info = resolve_token(chain, token_address, rpc_url)
154
+ return token_info["decimals"]
155
+ except (ValueError, RuntimeError):
156
+ return 18
157
+
158
+
159
+ def _resolve_token_symbol(
160
+ chain_name: str,
161
+ token_address: str,
162
+ rpc_url: str,
163
+ ) -> str:
164
+ """Resolve symbol for a token address."""
165
+ try:
166
+ chain = normalize_chain(chain_name)
167
+ token_info = resolve_token(chain, token_address, rpc_url)
168
+ return token_info.get("symbol", token_address)
169
+ except (ValueError, RuntimeError):
170
+ return token_address
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Pool address from factory (needed when we only have the position)
175
+ # ---------------------------------------------------------------------------
176
+
177
+ def _get_pool_address_from_position(
178
+ position: dict,
179
+ chain_name: str,
180
+ rpc_url: str,
181
+ ) -> str:
182
+ """Derive the pool address from position data by querying the factory."""
183
+ from uniswap_autopilot.common.common import get_v3_factory_address, sort_token_addresses
184
+
185
+ token0 = position["token0"]
186
+ token1 = position["token1"]
187
+ fee = position["fee"]
188
+
189
+ token0_addr, token1_addr = sort_token_addresses(token0, token1)
190
+ factory = get_v3_factory_address(chain_name)
191
+
192
+ from uniswap_autopilot.lp.v3.pool import query_pool_address
193
+ return query_pool_address(token0_addr, token1_addr, fee, factory, rpc_url)
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Core analytics functions
198
+ # ---------------------------------------------------------------------------
199
+
200
+ def analyze_position(
201
+ chain_name: str,
202
+ token_id: int,
203
+ rpc_url: str | None = None,
204
+ ) -> dict[str, Any]:
205
+ """Analyze a single V3 LP position by token ID.
206
+
207
+ Returns a comprehensive dict with position amounts, USD values,
208
+ fee estimates, and range status.
209
+ """
210
+ chain = normalize_chain(chain_name)
211
+ rpc, _ = resolve_rpc_url(rpc_url, chain.chain_id)
212
+ if not rpc:
213
+ raise RuntimeError(f"RPC URL not configured for {chain_name}")
214
+
215
+ # 1. Query position data
216
+ position = query_position(token_id, chain_name, rpc)
217
+
218
+ liquidity = int(position.get("liquidity", "0"))
219
+ tick_lower = position["tickLower"]
220
+ tick_upper = position["tickUpper"]
221
+ fee_tier = position["fee"]
222
+ addr0 = position["token0"]
223
+ addr1 = position["token1"]
224
+
225
+ # 2. Derive pool address and query current tick
226
+ pool_address = _get_pool_address_from_position(position, chain_name, rpc)
227
+ if not pool_address or pool_address == "0x" + "0" * 40:
228
+ raise RuntimeError(
229
+ f"Pool not found for token0={addr0} token1={addr1} fee={fee_tier}"
230
+ )
231
+
232
+ slot0 = query_slot0(pool_address, rpc)
233
+ current_tick = slot0["tick"]
234
+
235
+ # 3. Resolve token metadata
236
+ decimals0 = _resolve_token_decimals(chain_name, addr0, rpc)
237
+ decimals1 = _resolve_token_decimals(chain_name, addr1, rpc)
238
+ symbol0 = _resolve_token_symbol(chain_name, addr0, rpc)
239
+ symbol1 = _resolve_token_symbol(chain_name, addr1, rpc)
240
+
241
+ # 4. Calculate position amounts
242
+ amount0, amount1 = calculate_position_amounts(
243
+ liquidity, current_tick, tick_lower, tick_upper, decimals0, decimals1,
244
+ )
245
+
246
+ # 5. Fetch USD prices
247
+ price0, price1 = fetch_token_prices(chain.key, addr0, addr1)
248
+
249
+ # 6. Estimate uncollected fees
250
+ fg0, fg1 = query_fee_growth_global(pool_address, rpc)
251
+ fee0, fee1 = estimate_uncollected_fees(
252
+ position, fg0, fg1, decimals0, decimals1,
253
+ )
254
+
255
+ # 7. Compute USD values
256
+ amount0_usd = amount0 * price0 if price0 is not None and amount0 > 0 else 0.0
257
+ amount1_usd = amount1 * price1 if price1 is not None and amount1 > 0 else 0.0
258
+ total_value_usd = amount0_usd + amount1_usd
259
+
260
+ fee0_usd = fee0 * price0 if price0 is not None and fee0 > 0 else 0.0
261
+ fee1_usd = fee1 * price1 if price1 is not None and fee1 > 0 else 0.0
262
+ total_fees_usd = fee0_usd + fee1_usd
263
+
264
+ in_range = tick_lower < current_tick < tick_upper
265
+
266
+ # 8. Build position result
267
+ pos_result: dict[str, Any] = {
268
+ "tokenId": token_id,
269
+ "token0": {
270
+ "symbol": symbol0,
271
+ "address": addr0,
272
+ "amount": f"{amount0:.6f}".rstrip("0").rstrip("."),
273
+ "amountUsd": round(amount0_usd, 2),
274
+ },
275
+ "token1": {
276
+ "symbol": symbol1,
277
+ "address": addr1,
278
+ "amount": f"{amount1:.6f}".rstrip("0").rstrip("."),
279
+ "amountUsd": round(amount1_usd, 2),
280
+ },
281
+ "totalValueUsd": round(total_value_usd, 2),
282
+ "uncollectedFees": {
283
+ "token0": f"{fee0:.6f}".rstrip("0").rstrip("."),
284
+ "token1": f"{fee1:.6f}".rstrip("0").rstrip("."),
285
+ "totalUsd": round(total_fees_usd, 2),
286
+ },
287
+ "inRange": in_range,
288
+ "feeTier": fee_tier,
289
+ "tickLower": tick_lower,
290
+ "tickUpper": tick_upper,
291
+ "currentTick": current_tick,
292
+ "liquidity": str(liquidity),
293
+ "poolAddress": pool_address,
294
+ }
295
+
296
+ if price0 is None:
297
+ pos_result["token0"]["priceUsd"] = None
298
+ else:
299
+ pos_result["token0"]["priceUsd"] = price0
300
+ if price1 is None:
301
+ pos_result["token1"]["priceUsd"] = None
302
+ else:
303
+ pos_result["token1"]["priceUsd"] = price1
304
+
305
+ return pos_result
306
+
307
+
308
+ def analyze_positions_by_owner(
309
+ chain_name: str,
310
+ owner: str,
311
+ rpc_url: str | None = None,
312
+ ) -> dict[str, Any]:
313
+ """Analyze all V3 LP positions owned by an address.
314
+
315
+ Iterates over every token ID returned by query_positions_by_owner
316
+ and runs analyze_position on each.
317
+ """
318
+ chain = normalize_chain(chain_name)
319
+ rpc, _ = resolve_rpc_url(rpc_url, chain.chain_id)
320
+
321
+ token_ids = query_positions_by_owner(owner, chain_name, rpc)
322
+
323
+ positions: list[dict[str, Any]] = []
324
+ total_value = 0.0
325
+ total_fees = 0.0
326
+ errors: list[dict[str, str]] = []
327
+
328
+ for tid in token_ids:
329
+ try:
330
+ pos = analyze_position(chain_name, tid, rpc)
331
+ positions.append(pos)
332
+ total_value += pos.get("totalValueUsd", 0.0)
333
+ total_fees += pos.get("uncollectedFees", {}).get("totalUsd", 0.0)
334
+ except Exception as exc:
335
+ errors.append({"tokenId": str(tid), "error": str(exc)})
336
+
337
+ result: dict[str, Any] = {
338
+ "action": "v3_position_analytics",
339
+ "chain": {"key": chain.key, "chainId": chain.chain_id},
340
+ "owner": owner,
341
+ "positions": positions,
342
+ "totalValueUsd": round(total_value, 2),
343
+ "totalFeesUsd": round(total_fees, 0),
344
+ "positionCount": len(positions),
345
+ }
346
+ if errors:
347
+ result["errors"] = errors
348
+ return result
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # Human-readable output
353
+ # ---------------------------------------------------------------------------
354
+
355
+ def _print_position_summary(pos: dict) -> None:
356
+ """Print a human-readable summary of a single position."""
357
+ tid = pos.get("tokenId", "?")
358
+ t0 = pos.get("token0", {})
359
+ t1 = pos.get("token1", {})
360
+ symbol0 = t0.get("symbol", "?")
361
+ symbol1 = t1.get("symbol", "?")
362
+ amt0 = t0.get("amount", "0")
363
+ amt1 = t1.get("amount", "0")
364
+ total_usd = pos.get("totalValueUsd", 0)
365
+ fees = pos.get("uncollectedFees", {})
366
+ fees_usd = fees.get("totalUsd", 0)
367
+ in_range = pos.get("inRange", False)
368
+ fee_tier = pos.get("feeTier", "?")
369
+ current_tick = pos.get("currentTick", "?")
370
+ tick_lower = pos.get("tickLower", "?")
371
+ tick_upper = pos.get("tickUpper", "?")
372
+
373
+ range_status = "IN RANGE" if in_range else "OUT OF RANGE"
374
+ print(f" Position #{tid}:")
375
+ print(f" Pair: {symbol0}/{symbol1} Fee: {fee_tier / 10000:.2f}%")
376
+ print(f" {symbol0}: {amt0} ({t0.get('amountUsd', 0)} USD)")
377
+ print(f" {symbol1}: {amt1} ({t1.get('amountUsd', 0)} USD)")
378
+ print(f" Total Value: {total_usd:.2f} USD")
379
+ print(f" Uncollected Fees: {fees.get('token0', '0')} {symbol0} + {fees.get('token1', '0')} {symbol1} = {fees_usd:.2f} USD")
380
+ print(f" Range: {range_status} [tick {tick_lower}, {tick_upper}] current={current_tick}")
381
+
382
+
383
+ # ---------------------------------------------------------------------------
384
+ # CLI
385
+ # ---------------------------------------------------------------------------
386
+
387
+ def main() -> None:
388
+ parser = argparse.ArgumentParser(description="Uniswap V3 LP position analytics")
389
+ parser.add_argument("--chain", required=True, help="Chain name, e.g. base, ethereum")
390
+ source = parser.add_mutually_exclusive_group(required=True)
391
+ source.add_argument("--token-id", type=int, help="Single LP NFT token ID")
392
+ source.add_argument("--owner", help="Wallet address to analyze all positions")
393
+ parser.add_argument("--rpc-url", help="RPC URL (reads from env if not provided)")
394
+ args = parser.parse_args()
395
+
396
+ try:
397
+ load_local_env()
398
+
399
+ chain = normalize_chain(args.chain)
400
+
401
+ if args.token_id is not None:
402
+ pos = analyze_position(args.chain, args.token_id, args.rpc_url)
403
+ result: dict[str, Any] = {
404
+ "action": "v3_position_analytics",
405
+ "chain": {"key": chain.key, "chainId": chain.chain_id},
406
+ "positions": [pos],
407
+ "totalValueUsd": pos.get("totalValueUsd", 0),
408
+ "totalFeesUsd": pos.get("uncollectedFees", {}).get("totalUsd", 0),
409
+ }
410
+
411
+ _print_position_summary(pos)
412
+ print()
413
+ dump_json(result)
414
+ else:
415
+ result = analyze_positions_by_owner(args.chain, args.owner, args.rpc_url)
416
+ positions = result.get("positions", [])
417
+ if positions:
418
+ print(f"Found {len(positions)} position(s) for {args.owner}:")
419
+ for pos in positions:
420
+ _print_position_summary(pos)
421
+ print()
422
+ print(f"Total Value: {result.get('totalValueUsd', 0):.2f} USD")
423
+ print(f"Total Fees: {result.get('totalFeesUsd', 0):.2f} USD")
424
+ print()
425
+ dump_json(result)
426
+
427
+ except Exception as exc:
428
+ print(json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
429
+ sys.exit(1)
430
+
431
+
432
+ if __name__ == "__main__":
433
+ main()