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.
- uni_exec_engine-0.2.0.dist-info/METADATA +576 -0
- uni_exec_engine-0.2.0.dist-info/RECORD +80 -0
- uni_exec_engine-0.2.0.dist-info/WHEEL +5 -0
- uni_exec_engine-0.2.0.dist-info/licenses/LICENSE +21 -0
- uni_exec_engine-0.2.0.dist-info/top_level.txt +1 -0
- uniswap_autopilot/__init__.py +3 -0
- uniswap_autopilot/analytics/__init__.py +0 -0
- uniswap_autopilot/analytics/il_calculator.py +525 -0
- uniswap_autopilot/analytics/portfolio.py +234 -0
- uniswap_autopilot/analytics/position.py +433 -0
- uniswap_autopilot/analytics/range_suggest.py +270 -0
- uniswap_autopilot/audit.py +194 -0
- uniswap_autopilot/common/__init__.py +0 -0
- uniswap_autopilot/common/approval_cleanup.py +255 -0
- uniswap_autopilot/common/check_balance.py +64 -0
- uniswap_autopilot/common/common.py +804 -0
- uniswap_autopilot/common/deep_link.py +132 -0
- uniswap_autopilot/common/gas.py +141 -0
- uniswap_autopilot/data/auto_trade_policy.example.json +29 -0
- uniswap_autopilot/data/chains.json +135 -0
- uniswap_autopilot/data/common-token-addresses.json +777 -0
- uniswap_autopilot/execute/__init__.py +0 -0
- uniswap_autopilot/execute/_internal/__init__.py +5 -0
- uniswap_autopilot/execute/_internal/constants.py +15 -0
- uniswap_autopilot/execute/_internal/preflight.py +150 -0
- uniswap_autopilot/execute/_internal/pure_signer.py +182 -0
- uniswap_autopilot/execute/_internal/rpc.py +462 -0
- uniswap_autopilot/execute/_internal/signer.py +298 -0
- uniswap_autopilot/execute/_internal/submit.py +73 -0
- uniswap_autopilot/execute/_internal/tx.py +370 -0
- uniswap_autopilot/execute/broadcast.py +380 -0
- uniswap_autopilot/execute/detect.py +52 -0
- uniswap_autopilot/execute/telegram_confirm.py +272 -0
- uniswap_autopilot/lp/compare_pools.py +338 -0
- uniswap_autopilot/lp/v2/__init__.py +0 -0
- uniswap_autopilot/lp/v2/approve.py +100 -0
- uniswap_autopilot/lp/v2/build_tx.py +226 -0
- uniswap_autopilot/lp/v2/flow.py +204 -0
- uniswap_autopilot/lp/v2/pair.py +135 -0
- uniswap_autopilot/lp/v2/positions.py +177 -0
- uniswap_autopilot/lp/v3/__init__.py +0 -0
- uniswap_autopilot/lp/v3/approve.py +109 -0
- uniswap_autopilot/lp/v3/auto_rebalance.py +282 -0
- uniswap_autopilot/lp/v3/build_tx.py +465 -0
- uniswap_autopilot/lp/v3/compound.py +258 -0
- uniswap_autopilot/lp/v3/flow.py +358 -0
- uniswap_autopilot/lp/v3/pool.py +175 -0
- uniswap_autopilot/lp/v3/position.py +112 -0
- uniswap_autopilot/lp/v3/tick.py +75 -0
- uniswap_autopilot/lp/v4/__init__.py +0 -0
- uniswap_autopilot/lp/v4/approve.py +105 -0
- uniswap_autopilot/lp/v4/build_tx.py +669 -0
- uniswap_autopilot/lp/v4/flow.py +368 -0
- uniswap_autopilot/lp/v4/pool.py +174 -0
- uniswap_autopilot/lp/v4/position.py +185 -0
- uniswap_autopilot/policy.py +371 -0
- uniswap_autopilot/price_feed.py +100 -0
- uniswap_autopilot/py.typed +0 -0
- uniswap_autopilot/search/__init__.py +0 -0
- uniswap_autopilot/search/risk.py +200 -0
- uniswap_autopilot/search/search.py +580 -0
- uniswap_autopilot/state_machine.py +315 -0
- uniswap_autopilot/swap/__init__.py +1 -0
- uniswap_autopilot/swap/deep_link.py +69 -0
- uniswap_autopilot/swap/extensions/__init__.py +2 -0
- uniswap_autopilot/swap/extensions/bridge.py +197 -0
- uniswap_autopilot/swap/extensions/limit_order.py +272 -0
- uniswap_autopilot/swap/extensions/slippage.py +123 -0
- uniswap_autopilot/swap/flow.py +693 -0
- uniswap_autopilot/swap/flow_core/__init__.py +2 -0
- uniswap_autopilot/swap/flow_core/artifacts.py +11 -0
- uniswap_autopilot/swap/flow_core/broadcast.py +50 -0
- uniswap_autopilot/swap/flow_core/diagnostics.py +216 -0
- uniswap_autopilot/swap/flow_core/paper.py +113 -0
- uniswap_autopilot/swap/flow_core/policy.py +142 -0
- uniswap_autopilot/swap/links/__init__.py +2 -0
- uniswap_autopilot/swap/links/deep_link.py +69 -0
- uniswap_autopilot/swap/trading_api/permit.py +41 -0
- uniswap_autopilot/swap/trading_api/quote.py +282 -0
- 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()
|