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,177 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from decimal import Decimal
7
+ from typing import Any
8
+
9
+
10
+ from uniswap_autopilot.common.common import (
11
+ dump_json,
12
+ get_v2_factory_address,
13
+ load_local_env,
14
+ normalize_chain,
15
+ resolve_token,
16
+ resolve_wallet_address,
17
+ sort_token_addresses,
18
+ )
19
+ from uniswap_autopilot.execute._internal.rpc import resolve_rpc_url
20
+ from uniswap_autopilot.lp.v2.pair import query_lp_balance, query_pair_address, query_reserves, query_total_supply
21
+
22
+ V2_CHAINS = ["ethereum", "optimism", "bsc", "polygon", "arbitrum", "avalanche", "world_chain"]
23
+
24
+ COMMON_PAIRS = [
25
+ ("WETH", "USDC"), ("WETH", "USDT"), ("WETH", "DAI"),
26
+ ("WBTC", "WETH"), ("WBTC", "USDC"),
27
+ ("UNI", "WETH"), ("LINK", "WETH"),
28
+ ("USDC", "USDT"),
29
+ ("WBNB", "USDT"), ("WBNB", "USDC"), ("WBNB", "ETH"),
30
+ ("WAVAX", "USDC"), ("WAVAX", "USDT"), ("WAVAX", "WETH"),
31
+ ("WLD", "USDC"), ("WLD", "WETH"),
32
+ ("OP", "WETH"), ("OP", "USDC"),
33
+ ("ARB", "WETH"), ("ARB", "USDC"),
34
+ ("CAKE", "WBNB"),
35
+ ("WMATIC", "USDC"), ("WMATIC", "WETH"),
36
+ ]
37
+
38
+
39
+ def _base_unit_to_human(raw: int, decimals: int) -> str:
40
+ if raw == 0:
41
+ return "0"
42
+ d = Decimal(raw) / Decimal(10 ** decimals)
43
+ return f"{d:.6f}".rstrip("0").rstrip(".")
44
+
45
+
46
+ def scan_v2_positions(
47
+ chain_name: str,
48
+ wallet: str | None = None,
49
+ rpc_url: str | None = None,
50
+ pairs: list[tuple[str, str]] | None = None,
51
+ chain_tokens: dict[str, dict] | None = None,
52
+ ) -> list[dict[str, Any]]:
53
+ chain = normalize_chain(chain_name)
54
+ wallet_addr = resolve_wallet_address(wallet)
55
+ if not wallet_addr:
56
+ raise ValueError("wallet address required")
57
+
58
+ rpc = rpc_url or resolve_rpc_url(None, chain.chain_id)[0]
59
+ try:
60
+ factory = get_v2_factory_address(chain_name)
61
+ except ValueError:
62
+ return []
63
+
64
+ scan_pairs = pairs or COMMON_PAIRS
65
+ token_cache: dict[str, dict] = dict(chain_tokens or {})
66
+
67
+ positions: list[dict[str, Any]] = []
68
+
69
+ for sym_a, sym_b in scan_pairs:
70
+ try:
71
+ if sym_a not in token_cache:
72
+ token_cache[sym_a] = resolve_token(chain, sym_a, rpc)
73
+ if sym_b not in token_cache:
74
+ token_cache[sym_b] = resolve_token(chain, sym_b, rpc)
75
+ except Exception:
76
+ continue
77
+
78
+ tok_a = token_cache[sym_a]
79
+ tok_b = token_cache[sym_b]
80
+
81
+ t0, t1 = sort_token_addresses(tok_a["address"], tok_b["address"])
82
+
83
+ pair_addr = query_pair_address(t0, t1, factory, rpc)
84
+ if not pair_addr:
85
+ continue
86
+
87
+ lp_bal = query_lp_balance(pair_addr, wallet_addr, rpc)
88
+ if lp_bal == 0:
89
+ continue
90
+
91
+ total_supply = query_total_supply(pair_addr, rpc)
92
+ reserves = query_reserves(pair_addr, rpc)
93
+
94
+ share_pct = Decimal(lp_bal) / Decimal(total_supply) * Decimal("100") if total_supply > 0 else Decimal("0")
95
+
96
+ # Determine which reserve maps to which token
97
+ reserve_a = reserves["reserve0"] if tok_a["address"].lower() == t0.lower() else reserves["reserve1"]
98
+ reserve_b = reserves["reserve1"] if tok_a["address"].lower() == t0.lower() else reserves["reserve0"]
99
+
100
+ my_a = int(Decimal(reserve_a) * Decimal(lp_bal) / Decimal(total_supply)) if total_supply > 0 else 0
101
+ my_b = int(Decimal(reserve_b) * Decimal(lp_bal) / Decimal(total_supply)) if total_supply > 0 else 0
102
+
103
+ positions.append({
104
+ "chain": chain_name,
105
+ "pair": f"{sym_a}/{sym_b}",
106
+ "pairAddress": pair_addr,
107
+ "lpBalance": str(lp_bal),
108
+ "totalSupply": str(total_supply),
109
+ "sharePct": f"{share_pct:.6f}",
110
+ "tokenA": {"symbol": sym_a, "amount": str(my_a), "human": _base_unit_to_human(my_a, tok_a["decimals"])},
111
+ "tokenB": {"symbol": sym_b, "amount": str(my_b), "human": _base_unit_to_human(my_b, tok_b["decimals"])},
112
+ })
113
+
114
+ return positions
115
+
116
+
117
+ def main() -> None:
118
+ parser = argparse.ArgumentParser(description="查询钱包 V2 LP 仓位")
119
+ parser.add_argument("--chain", default="", help="链名(--all-chains 时可省略)")
120
+ parser.add_argument("--wallet", help="钱包地址")
121
+ parser.add_argument("--rpc-url", help="RPC URL")
122
+ parser.add_argument("--pair", action="append", help="指定交易对,如 WETH/USDC(可多次指定)")
123
+ parser.add_argument("--all-chains", action="store_true", help="扫描所有 V2 链")
124
+ args = parser.parse_args()
125
+
126
+ load_local_env()
127
+
128
+ custom_pairs: list[tuple[str, str]] | None = None
129
+ if args.pair:
130
+ custom_pairs = []
131
+ for p in args.pair:
132
+ parts = p.split("/")
133
+ if len(parts) != 2:
134
+ print(f"invalid pair format: {p}, expected TOKEN_A/TOKEN_B", file=sys.stderr)
135
+ sys.exit(1)
136
+ custom_pairs.append((parts[0].strip(), parts[1].strip()))
137
+
138
+ if args.all_chains:
139
+ all_positions: list[dict[str, Any]] = []
140
+ for c in V2_CHAINS:
141
+ try:
142
+ positions = scan_v2_positions(c, args.wallet, args.rpc_url, custom_pairs)
143
+ all_positions.extend(positions)
144
+ except Exception as e:
145
+ print(f" {c}: {e}", file=sys.stderr)
146
+ if not all_positions:
147
+ print("No V2 LP positions found on any chain.")
148
+ else:
149
+ print(f"\nV2 LP Positions ({len(all_positions)} found):")
150
+ print("-" * 60)
151
+ for pos in all_positions:
152
+ print(f" {pos['chain']:12s} {pos['pair']:15s} share={pos['sharePct']}%")
153
+ print(f" tokenA: {pos['tokenA']['human']} {pos['tokenA']['symbol']}")
154
+ print(f" tokenB: {pos['tokenB']['human']} {pos['tokenB']['symbol']}")
155
+ print(f" pair: {pos['pairAddress']}")
156
+ print("-" * 60)
157
+ dump_json({"action": "v2_positions_all", "positions": all_positions, "count": len(all_positions)})
158
+ else:
159
+ if not args.chain:
160
+ parser.error("--chain is required when not using --all-chains")
161
+ positions = scan_v2_positions(args.chain, args.wallet, args.rpc_url, custom_pairs)
162
+ if not positions:
163
+ print(f"No V2 LP positions found on {args.chain}.")
164
+ else:
165
+ print(f"\nV2 LP Positions on {args.chain} ({len(positions)} found):")
166
+ print("-" * 60)
167
+ for pos in positions:
168
+ print(f" {pos['pair']:15s} share={pos['sharePct']}%")
169
+ print(f" tokenA: {pos['tokenA']['human']} {pos['tokenA']['symbol']}")
170
+ print(f" tokenB: {pos['tokenB']['human']} {pos['tokenB']['symbol']}")
171
+ print(f" pair: {pos['pairAddress']}")
172
+ print("-" * 60)
173
+ dump_json({"action": "v2_positions", "chain": args.chain, "positions": positions, "count": len(positions)})
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()
File without changes
@@ -0,0 +1,109 @@
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
+ get_position_manager_address,
14
+ load_local_env,
15
+ normalize_chain,
16
+ resolve_token,
17
+ )
18
+ from uniswap_autopilot.execute._internal.rpc import (
19
+ build_calldata, encode_address, encode_uint, query_erc20_allowance, resolve_rpc_url,
20
+ )
21
+
22
+
23
+ def check_lp_approvals(
24
+ token0_address: str,
25
+ token1_address: str,
26
+ owner: str,
27
+ chain_name: str,
28
+ rpc_url: str | None = None,
29
+ ) -> dict[str, Any]:
30
+ chain = normalize_chain(chain_name)
31
+ pm = get_position_manager_address(chain_name)
32
+ rpc, _ = resolve_rpc_url(rpc_url, chain.chain_id)
33
+ if not rpc:
34
+ raise RuntimeError(f"RPC URL not configured for {chain_name}")
35
+
36
+ allowance0 = query_erc20_allowance(token0_address, owner, pm, rpc)
37
+ allowance1 = query_erc20_allowance(token1_address, owner, pm, rpc)
38
+
39
+ return {
40
+ "action": "lp_approval_check",
41
+ "owner": owner,
42
+ "spender": pm,
43
+ "token0": {"address": token0_address, "allowance": str(allowance0), "needsApproval": allowance0 == 0},
44
+ "token1": {"address": token1_address, "allowance": str(allowance1), "needsApproval": allowance1 == 0},
45
+ }
46
+
47
+
48
+ def build_approval_calldata(token: str, spender: str, amount: str = None) -> str:
49
+ approve_amount = amount or str(2**256 - 1)
50
+ return build_calldata(
51
+ "approve(address,uint256)",
52
+ encode_address(spender),
53
+ encode_uint(int(approve_amount)),
54
+ )
55
+
56
+
57
+ def build_approval_tx(
58
+ token: str,
59
+ owner: str,
60
+ chain_name: str,
61
+ amount: str | None = None,
62
+ ) -> dict[str, Any]:
63
+ chain = normalize_chain(chain_name)
64
+ pm = get_position_manager_address(chain_name)
65
+ calldata = build_approval_calldata(token, pm, amount)
66
+ return {
67
+ "kind": "approval",
68
+ "to": token,
69
+ "data": calldata,
70
+ "value": "0",
71
+ "chainId": chain.chain_id,
72
+ "from": owner,
73
+ }
74
+
75
+
76
+ def main() -> None:
77
+ parser = argparse.ArgumentParser(description="检查/构造 LP 所需的 ERC20 approval")
78
+ parser.add_argument("--chain", required=True)
79
+ parser.add_argument("--token-a", required=True, help="代币 symbol 或地址")
80
+ parser.add_argument("--token-b", required=True, help="代币 symbol 或地址")
81
+ parser.add_argument("--owner", required=True, help="钱包地址")
82
+ parser.add_argument("--rpc-url")
83
+ parser.add_argument("--output")
84
+ args = parser.parse_args()
85
+
86
+ try:
87
+ load_local_env()
88
+ chain = normalize_chain(args.chain)
89
+ token_a = resolve_token(chain, args.token_a)
90
+ token_b = resolve_token(chain, args.token_b)
91
+ from uniswap_autopilot.common.common import sort_token_addresses
92
+ t0, t1 = sort_token_addresses(token_a["address"], token_b["address"])
93
+ result = check_lp_approvals(t0, t1, args.owner, args.chain, args.rpc_url)
94
+
95
+ if result["token0"]["needsApproval"]:
96
+ result["token0"]["approvalTx"] = build_approval_tx(t0, args.owner, args.chain)
97
+ if result["token1"]["needsApproval"]:
98
+ result["token1"]["approvalTx"] = build_approval_tx(t1, args.owner, args.chain)
99
+
100
+ if args.output:
101
+ Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
102
+ dump_json(result)
103
+ except Exception as exc:
104
+ print(json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
105
+ sys.exit(1)
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ from uniswap_autopilot.common.common import dump_json, load_local_env, normalize_chain, resolve_wallet_address, resolve_token, decimal_to_base_units
11
+ from uniswap_autopilot.lp.v3.position import query_position
12
+ from uniswap_autopilot.analytics.position import analyze_position, analyze_positions_by_owner
13
+ from uniswap_autopilot.analytics.range_suggest import suggest_ranges
14
+ from uniswap_autopilot.lp.v3.build_tx import (
15
+ build_decrease_liquidity_transaction,
16
+ build_collect_transaction,
17
+ build_mint_transaction,
18
+ )
19
+
20
+ REBALANCE_PROFILES = {"CONSERVATIVE", "MODERATE", "AGGRESSIVE"}
21
+
22
+
23
+ def scan_rebalance_candidates(
24
+ chain_name: str,
25
+ wallet: str,
26
+ rpc_url: str | None = None,
27
+ ) -> dict[str, Any]:
28
+ chain = normalize_chain(chain_name)
29
+ analysis = analyze_positions_by_owner(chain_name, wallet, rpc_url)
30
+
31
+ candidates: list[dict[str, Any]] = []
32
+ in_range: list[dict[str, Any]] = []
33
+
34
+ for pos in analysis.get("positions", []):
35
+ entry = {
36
+ "tokenId": pos.get("tokenId"),
37
+ "token0": pos.get("token0", {}).get("symbol", "?"),
38
+ "token1": pos.get("token1", {}).get("symbol", "?"),
39
+ "feeTier": pos.get("feeTier"),
40
+ "inRange": pos.get("inRange"),
41
+ "tickLower": pos.get("tickLower"),
42
+ "tickUpper": pos.get("tickUpper"),
43
+ "totalValueUsd": pos.get("totalValueUsd", 0.0),
44
+ "uncollectedFeesUsd": pos.get("uncollectedFees", {}).get("totalUsd", 0.0),
45
+ }
46
+ if not pos.get("inRange", True):
47
+ candidates.append(entry)
48
+ else:
49
+ in_range.append(entry)
50
+
51
+ return {
52
+ "action": "rebalance_scan",
53
+ "chain": {"key": chain.key, "chainId": chain.chain_id},
54
+ "wallet": wallet,
55
+ "totalPositions": len(analysis.get("positions", [])),
56
+ "outOfRange": candidates,
57
+ "inRange": in_range,
58
+ "candidatesForRebalance": [c["tokenId"] for c in candidates],
59
+ }
60
+
61
+
62
+ def execute_rebalance(
63
+ chain_name: str,
64
+ token_id: int,
65
+ profile: str = "MODERATE",
66
+ slippage_pct: float = 0.5,
67
+ wallet: str | None = None,
68
+ rpc_url: str | None = None,
69
+ request_only: bool = True,
70
+ ) -> dict[str, Any]:
71
+ chain = normalize_chain(chain_name)
72
+ owner = resolve_wallet_address(wallet) or ""
73
+ if not owner:
74
+ raise ValueError("wallet address required")
75
+
76
+ profile = profile.upper()
77
+ if profile not in REBALANCE_PROFILES:
78
+ raise ValueError(f"profile must be one of {REBALANCE_PROFILES}, got '{profile}'")
79
+
80
+ # Step 1: query current position
81
+ pos = query_position(token_id, chain_name, rpc_url)
82
+ tick_lower = pos["tickLower"]
83
+ tick_upper = pos["tickUpper"]
84
+ fee_tier = pos["fee"]
85
+ tok0_addr = pos["token0"]
86
+ tok1_addr = pos["token1"]
87
+ current_liq = int(pos["liquidity"])
88
+ if current_liq == 0:
89
+ raise ValueError(f"position {token_id} has no liquidity")
90
+
91
+ # Get analyzed position for human-readable amounts + fees
92
+ analyzed = analyze_position(chain_name, token_id, rpc_url)
93
+ tok0_amount_human = float(analyzed["token0"]["amount"])
94
+ tok1_amount_human = float(analyzed["token1"]["amount"])
95
+ fee0_human = float(analyzed["uncollectedFees"]["token0"] or "0")
96
+ fee1_human = float(analyzed["uncollectedFees"]["token1"] or "0")
97
+
98
+ # Step 2: get suggested ranges
99
+ ranges = suggest_ranges(chain_name, tok0_addr, tok1_addr, fee_tier, rpc_url)
100
+ suggestions = ranges.get("suggestions", [])
101
+ target = None
102
+ for s in suggestions:
103
+ if s["profile"] == profile:
104
+ target = s
105
+ break
106
+ if not target:
107
+ raise ValueError(f"profile '{profile}' not found in range suggestions")
108
+
109
+ new_tick_lower = target["tickLower"]
110
+ new_tick_upper = target["tickUpper"]
111
+
112
+ # Step 3: decrease 100%
113
+ decrease_result = build_decrease_liquidity_transaction(
114
+ chain_name=chain_name,
115
+ token_id=token_id,
116
+ liquidity_pct=100.0,
117
+ slippage_pct=slippage_pct,
118
+ wallet=owner,
119
+ rpc_url=rpc_url,
120
+ )
121
+
122
+ # Step 4: collect all
123
+ collect_result = build_collect_transaction(
124
+ chain_name=chain_name,
125
+ token_id=token_id,
126
+ recipient=owner,
127
+ )
128
+
129
+ # Step 5: mint new position with suggested range
130
+ # Convert human amounts + fees to base units for the mint
131
+ from decimal import Decimal as D
132
+ tok0_info = resolve_token(chain, tok0_addr)
133
+ tok1_info = resolve_token(chain, tok1_addr)
134
+ total0 = D(str(tok0_amount_human)) + D(str(fee0_human))
135
+ total1 = D(str(tok1_amount_human)) + D(str(fee1_human))
136
+ amount0 = decimal_to_base_units(total0, tok0_info["decimals"])
137
+ amount1 = decimal_to_base_units(total1, tok1_info["decimals"])
138
+
139
+ mint_result = build_mint_transaction(
140
+ chain_name=chain_name,
141
+ token_a=tok0_addr,
142
+ token_b=tok1_addr,
143
+ fee_tier=fee_tier,
144
+ tick_lower=new_tick_lower,
145
+ tick_upper=new_tick_upper,
146
+ amount0=amount0,
147
+ amount1=amount1,
148
+ slippage_pct=slippage_pct,
149
+ wallet=owner,
150
+ rpc_url=rpc_url,
151
+ request_only=request_only,
152
+ )
153
+
154
+ return {
155
+ "action": "rebalance",
156
+ "chain": {"key": chain.key, "chainId": chain.chain_id},
157
+ "tokenId": token_id,
158
+ "profile": profile,
159
+ "feeTier": fee_tier,
160
+ "oldRange": {"tickLower": tick_lower, "tickUpper": tick_upper},
161
+ "newRange": {"tickLower": new_tick_lower, "tickUpper": new_tick_upper},
162
+ "newRangeWidthPct": target.get("rangeWidthPct"),
163
+ "currentLiquidity": str(current_liq),
164
+ "steps": {
165
+ "decrease": decrease_result,
166
+ "collect": collect_result,
167
+ "mint": mint_result,
168
+ },
169
+ "broadcastReady": not request_only,
170
+ }
171
+
172
+
173
+ def batch_rebalance(
174
+ chain_name: str,
175
+ wallet: str,
176
+ profile: str = "MODERATE",
177
+ slippage_pct: float = 0.5,
178
+ rpc_url: str | None = None,
179
+ request_only: bool = True,
180
+ ) -> dict[str, Any]:
181
+ chain = normalize_chain(chain_name)
182
+ scan = scan_rebalance_candidates(chain_name, wallet, rpc_url)
183
+ candidates = scan["candidatesForRebalance"]
184
+
185
+ succeeded: list[int] = []
186
+ failed: list[dict[str, Any]] = []
187
+ results: list[dict[str, Any]] = []
188
+
189
+ for tid in candidates:
190
+ try:
191
+ result = execute_rebalance(
192
+ chain_name=chain_name,
193
+ token_id=tid,
194
+ profile=profile,
195
+ slippage_pct=slippage_pct,
196
+ wallet=wallet,
197
+ rpc_url=rpc_url,
198
+ request_only=request_only,
199
+ )
200
+ succeeded.append(tid)
201
+ results.append({
202
+ "tokenId": tid,
203
+ "status": "success",
204
+ "newRange": result["newRange"],
205
+ "steps": result["steps"],
206
+ })
207
+ except Exception as exc:
208
+ failed.append({"tokenId": tid, "error": str(exc)})
209
+ results.append({"tokenId": tid, "status": "error", "error": str(exc)})
210
+
211
+ return {
212
+ "action": "batch_rebalance",
213
+ "chain": {"key": chain.key, "chainId": chain.chain_id},
214
+ "wallet": wallet,
215
+ "profile": profile,
216
+ "totalAttempted": len(candidates),
217
+ "succeeded": succeeded,
218
+ "failed": failed,
219
+ "results": results,
220
+ }
221
+
222
+
223
+ def main() -> None:
224
+ parser = argparse.ArgumentParser(description="V3 LP auto-rebalance: close out-of-range and open new range")
225
+ sub = parser.add_subparsers(dest="command")
226
+
227
+ s = sub.add_parser("scan", help="Find out-of-range positions eligible for rebalance")
228
+ s.add_argument("--chain", required=True)
229
+ s.add_argument("--wallet", required=True)
230
+ s.add_argument("--rpc-url")
231
+ s.add_argument("--output")
232
+
233
+ e = sub.add_parser("execute", help="Execute rebalance: decrease + collect + mint with new range")
234
+ e.add_argument("--chain", required=True)
235
+ e.add_argument("--token-id", type=int, required=True)
236
+ e.add_argument("--profile", choices=["CONSERVATIVE", "MODERATE", "AGGRESSIVE"], default="MODERATE")
237
+ e.add_argument("--wallet")
238
+ e.add_argument("--slippage", type=float, default=0.5)
239
+ e.add_argument("--rpc-url")
240
+ e.add_argument("--output")
241
+
242
+ b = sub.add_parser("batch", help="Batch rebalance all out-of-range positions")
243
+ b.add_argument("--chain", required=True)
244
+ b.add_argument("--wallet", required=True)
245
+ b.add_argument("--profile", choices=["CONSERVATIVE", "MODERATE", "AGGRESSIVE"], default="MODERATE")
246
+ b.add_argument("--slippage", type=float, default=0.5)
247
+ b.add_argument("--rpc-url")
248
+ b.add_argument("--output")
249
+
250
+ args = parser.parse_args()
251
+ load_local_env()
252
+
253
+ if args.command == "scan":
254
+ result = scan_rebalance_candidates(args.chain, args.wallet, args.rpc_url)
255
+ n = len(result["candidatesForRebalance"])
256
+ print(f"Found {n}/{result['totalPositions']} out-of-range positions eligible for rebalance")
257
+ if args.output:
258
+ Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
259
+ dump_json(result)
260
+ elif args.command == "execute":
261
+ result = execute_rebalance(
262
+ args.chain, args.token_id, args.profile, args.slippage,
263
+ args.wallet, args.rpc_url,
264
+ )
265
+ print(f"Rebalance plan for position #{args.token_id}: decrease -> collect -> mint ({args.profile})")
266
+ if args.output:
267
+ Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
268
+ dump_json(result)
269
+ elif args.command == "batch":
270
+ result = batch_rebalance(
271
+ args.chain, args.wallet, args.profile, args.slippage, args.rpc_url,
272
+ )
273
+ print(f"Batch rebalance: {len(result['succeeded'])} succeeded, {len(result['failed'])} failed / {result['totalAttempted']} attempted")
274
+ if args.output:
275
+ Path(args.output).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
276
+ dump_json(result)
277
+ else:
278
+ parser.print_help()
279
+
280
+
281
+ if __name__ == "__main__":
282
+ main()