import os import sys import time import json import re import logging import math from decimal import Decimal, getcontext from datetime import datetime from typing import Optional, Dict, Tuple, Any, List from web3 import Web3 from web3.exceptions import TimeExhausted, ContractLogicError from eth_account import Account from eth_account.signers.local import LocalAccount from dotenv import load_dotenv # --- IMPORTS FOR KPI --- try: from tools.kpi_tracker import log_kpi_snapshot except ImportError: logging.warning("KPI Tracker not found. Performance logging disabled.") log_kpi_snapshot = None # Set Decimal precision high enough for EVM math getcontext().prec = 60 # --- LOGGING SETUP --- current_dir = os.path.dirname(os.path.abspath(__file__)) sys.path.append(current_dir) # Ensure logs directory exists log_dir = os.path.join(current_dir, 'logs') os.makedirs(log_dir, exist_ok=True) try: from logging_utils import setup_logging # Assuming setup_logging might handle file logging if configured, # but to be safe and explicit as requested, we'll add a FileHandler here # or rely on setup_logging if it supports it. # Since I don't see setup_logging code, I will manually add a file handler to the logger. logger = setup_logging("normal", "UNISWAP_MANAGER") except ImportError: logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger("UNISWAP_MANAGER") # Custom Filter for Millisecond Unix Timestamp class UnixMsLogFilter(logging.Filter): def filter(self, record): record.unix_ms = int(record.created * 1000) return True # Add File Handler log_file = os.path.join(log_dir, 'uniswap_manager.log') file_handler = logging.FileHandler(log_file, encoding='utf-8') file_handler.setLevel(logging.INFO) file_handler.addFilter(UnixMsLogFilter()) formatter = logging.Formatter('%(unix_ms)d, %(asctime)s - %(name)s - %(levelname)s - %(message)s') file_handler.setFormatter(formatter) logger.addHandler(file_handler) # --- ABIs --- # (Kept minimal for brevity, normally would load from files) NONFUNGIBLE_POSITION_MANAGER_ABI = json.loads(''' [ {"anonymous": false, "inputs": [{"indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"indexed": false, "internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"indexed": false, "internalType": "uint256", "name": "amount0", "type": "uint256"}, {"indexed": false, "internalType": "uint256", "name": "amount1", "type": "uint256"}], "name": "IncreaseLiquidity", "type": "event"}, {"anonymous": false, "inputs": [{"indexed": true, "internalType": "address", "name": "from", "type": "address"}, {"indexed": true, "internalType": "address", "name": "to", "type": "address"}, {"indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256"}], "name": "Transfer", "type": "event"}, {"inputs": [], "name": "factory", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}], "name": "positions", "outputs": [{"internalType": "uint96", "name": "nonce", "type": "uint96"}, {"internalType": "address", "name": "operator", "type": "address"}, {"internalType": "address", "name": "token0", "type": "address"}, {"internalType": "address", "name": "token1", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}, {"internalType": "int24", "name": "tickLower", "type": "int24"}, {"internalType": "int24", "name": "tickUpper", "type": "int24"}, {"internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"internalType": "uint256", "name": "feeGrowthInside0LastX128", "type": "uint256"}, {"internalType": "uint256", "name": "feeGrowthInside1LastX128", "type": "uint256"}, {"internalType": "uint128", "name": "tokensOwed0", "type": "uint128"}, {"internalType": "uint128", "name": "tokensOwed1", "type": "uint128"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"components": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint128", "name": "amount0Max", "type": "uint128"}, {"internalType": "uint128", "name": "amount1Max", "type": "uint128"}], "internalType": "struct INonfungiblePositionManager.CollectParams", "name": "params", "type": "tuple"}], "name": "collect", "outputs": [{"internalType": "uint256", "name": "amount0", "type": "uint256"}, {"internalType": "uint256", "name": "amount1", "type": "uint256"}], "stateMutability": "payable", "type": "function"}, {"inputs": [{"components": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"internalType": "uint256", "name": "amount0Min", "type": "uint256"}, {"internalType": "uint256", "name": "amount1Min", "type": "uint256"}, {"internalType": "uint256", "name": "deadline", "type": "uint256"}], "internalType": "struct INonfungiblePositionManager.DecreaseLiquidityParams", "name": "params", "type": "tuple"}], "name": "decreaseLiquidity", "outputs": [{"internalType": "uint256", "name": "amount0", "type": "uint256"}, {"internalType": "uint256", "name": "amount1", "type": "uint256"}], "stateMutability": "payable", "type": "function"}, {"inputs": [{"components": [{"internalType": "address", "name": "token0", "type": "address"}, {"internalType": "address", "name": "token1", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}, {"internalType": "int24", "name": "tickLower", "type": "int24"}, {"internalType": "int24", "name": "tickUpper", "type": "int24"}, {"internalType": "uint256", "name": "amount0Desired", "type": "uint256"}, {"internalType": "uint256", "name": "amount1Desired", "type": "uint256"}, {"internalType": "uint256", "name": "amount0Min", "type": "uint256"}, {"internalType": "uint256", "name": "amount1Min", "type": "uint256"}, {"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint256", "name": "deadline", "type": "uint256"}], "internalType": "struct INonfungiblePositionManager.MintParams", "name": "params", "type": "tuple"}], "name": "mint", "outputs": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"internalType": "uint256", "name": "amount0", "type": "uint256"}, {"internalType": "uint256", "name": "amount1", "type": "uint256"}], "stateMutability": "payable", "type": "function"} ] ''') UNISWAP_V3_POOL_ABI = json.loads(''' [ {"inputs": [], "name": "slot0", "outputs": [{"internalType": "uint160", "name": "sqrtPriceX96", "type": "uint160"}, {"internalType": "int24", "name": "tick", "type": "int24"}, {"internalType": "uint16", "name": "observationIndex", "type": "uint16"}, {"internalType": "uint16", "name": "observationCardinality", "type": "uint16"}, {"internalType": "uint16", "name": "observationCardinalityNext", "type": "uint16"}, {"internalType": "uint8", "name": "feeProtocol", "type": "uint8"}, {"internalType": "bool", "name": "unlocked", "type": "bool"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "token0", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "token1", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "fee", "outputs": [{"internalType": "uint24", "name": "", "type": "uint24"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "liquidity", "outputs": [{"internalType": "uint128", "name": "", "type": "uint128"}], "stateMutability": "view", "type": "function"} ] ''') ERC20_ABI = json.loads(''' [ {"inputs": [], "name": "decimals", "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], "stateMutability": "view", "type": "function"}, {"inputs": [], "name": "symbol", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "address", "name": "account", "type": "address"}], "name": "balanceOf", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, {"inputs": [{"internalType": "address", "name": "spender", "type": "address"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "approve", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "nonpayable", "type": "function"}, {"inputs": [{"internalType": "address", "name": "owner", "type": "address"}, {"internalType": "address", "name": "spender", "type": "address"}], "name": "allowance", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"} ] ''') UNISWAP_V3_FACTORY_ABI = json.loads(''' [ {"inputs": [{"internalType": "address", "name": "tokenA", "type": "address"}, {"internalType": "address", "name": "tokenB", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}], "name": "getPool", "outputs": [{"internalType": "address", "name": "pool", "type": "address"}], "stateMutability": "view", "type": "function"} ] ''') SWAP_ROUTER_ABI = json.loads(''' [ {"inputs": [{"components": [{"internalType": "address", "name": "tokenIn", "type": "address"}, {"internalType": "address", "name": "tokenOut", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}, {"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint256", "name": "deadline", "type": "uint256"}, {"internalType": "uint256", "name": "amountIn", "type": "uint256"}, {"internalType": "uint256", "name": "amountOutMinimum", "type": "uint256"}, {"internalType": "uint160", "name": "sqrtPriceLimitX96", "type": "uint160"}], "internalType": "struct ISwapRouter.ExactInputSingleParams", "name": "params", "type": "tuple"}], "name": "exactInputSingle", "outputs": [{"internalType": "uint256", "name": "amountOut", "type": "uint256"}], "stateMutability": "payable", "type": "function"} ] ''') WETH9_ABI = json.loads(''' [ {"constant": false, "inputs": [], "name": "deposit", "outputs": [], "payable": true, "stateMutability": "payable", "type": "function"}, {"constant": false, "inputs": [{"name": "wad", "type": "uint256"}], "name": "withdraw", "outputs": [], "payable": false, "stateMutability": "nonpayable", "type": "function"} ] ''') # --- CONFIGURATION --- NONFUNGIBLE_POSITION_MANAGER_ADDRESS = "0xC36442b4a4522E871399CD717aBDD847Ab11FE88" UNISWAP_V3_SWAP_ROUTER_ADDRESS = "0xE592427A0AEce92De3Edee1F18E0157C05861564" # Arbitrum WETH/USDC WETH_ADDRESS = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1" USDC_ADDRESS = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" STATUS_FILE = "hedge_status.json" MONITOR_INTERVAL_SECONDS = 666 CLOSE_POSITION_ENABLED = True OPEN_POSITION_ENABLED = True REBALANCE_ON_CLOSE_BELOW_RANGE = True TARGET_INVESTMENT_VALUE_USDC = 2000 INITIAL_HEDGE_CAPITAL_USDC = 2000 # Your starting Hyperliquid balance for Benchmark calc RANGE_WIDTH_PCT = Decimal("0.01") # +/- 1% (2% total width) SLIPPAGE_TOLERANCE = Decimal("0.02") # do not change, or at least remember it ( 0.02 = 2.0% slippage tolerance) TRANSACTION_TIMEOUT_SECONDS = 30 # --- HELPER FUNCTIONS --- def clean_address(addr: str) -> str: """Ensure address is checksummed.""" if not Web3.is_address(addr): raise ValueError(f"Invalid address: {addr}") return Web3.to_checksum_address(addr) def to_decimal(value: Any, decimals: int = 0) -> Decimal: """Convert value to Decimal, optionally scaling down by decimals.""" if isinstance(value, Decimal): return value return Decimal(value) / (Decimal(10) ** decimals) def to_wei_int(value: Decimal, decimals: int) -> int: """Convert Decimal value to integer Wei representation.""" return int(value * (Decimal(10) ** decimals)) def get_gas_params(w3: Web3) -> Dict[str, int]: """Get dynamic gas parameters for EIP-1559.""" latest_block = w3.eth.get_block("latest") base_fee = latest_block['baseFeePerGas'] # Priority fee: 0.1 gwei or dynamic max_priority_fee = w3.eth.max_priority_fee or Web3.to_wei(0.1, 'gwei') # Max Fee = Base Fee * 1.5 + Priority Fee max_fee = int(base_fee * 1.25) + max_priority_fee return { 'maxFeePerGas': max_fee, 'maxPriorityFeePerGas': max_priority_fee } def send_transaction_robust( w3: Web3, account: LocalAccount, func_call: Any, value: int = 0, gas_limit: Optional[int] = None, extra_msg: str = "" ) -> Optional[Any]: """ Builds, signs, sends, and waits for a transaction with timeout and status check. """ try: # 1. Prepare Params tx_params = { 'from': account.address, 'nonce': w3.eth.get_transaction_count(account.address), 'value': value, 'chainId': w3.eth.chain_id, } # 2. Add Gas Params gas_fees = get_gas_params(w3) tx_params.update(gas_fees) # 3. Simulate (Call) & Estimate Gas try: # If function call object provided if hasattr(func_call, 'call'): func_call.call({'from': account.address, 'value': value}) # Safety Dry-Run estimated_gas = func_call.estimate_gas({'from': account.address, 'value': value}) else: # Raw transaction construction if func_call is just params dict (rare here) estimated_gas = 200000 tx_params['gas'] = gas_limit if gas_limit else int(estimated_gas * 1.2) # 20% buffer # Build if hasattr(func_call, 'build_transaction'): tx = func_call.build_transaction(tx_params) else: raise ValueError("Invalid function call object") except ContractLogicError as e: logger.error(f"❌ Simulation/Estimation failed for {extra_msg}: {e}") return None # 4. Sign signed_tx = account.sign_transaction(tx) # 5. Send tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) logger.info(f"📤 Sent {extra_msg} | Hash: {tx_hash.hex()}") # 6. Wait for Receipt receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=TRANSACTION_TIMEOUT_SECONDS) # 7. Verify Status if receipt.status == 1: logger.info(f"✅ Executed {extra_msg} | Block: {receipt.blockNumber}") return receipt else: logger.error(f"❌ Transaction Reverted {extra_msg} | Hash: {tx_hash.hex()}") return None except TimeExhausted: logger.error(f"⌛ Transaction Timeout {extra_msg} - Check Mempool") # In a full production bot, we would implement gas bumping here. return None except Exception as e: logger.error(f"❌ Transaction Error {extra_msg}: {e}") return None def price_from_sqrt_price_x96(sqrt_price_x96: int, token0_decimals: int, token1_decimals: int) -> Decimal: """ Returns price of Token0 in terms of Token1. """ sqrt_price = Decimal(sqrt_price_x96) q96 = Decimal(2) ** 96 price = (sqrt_price / q96) ** 2 # Adjust for decimals: Price = (T1 / 10^d1) / (T0 / 10^d0) # = (T1/T0) * (10^d0 / 10^d1) adjustment = Decimal(10) ** (token0_decimals - token1_decimals) return price * adjustment def price_from_tick(tick: int, token0_decimals: int, token1_decimals: int) -> Decimal: price = Decimal("1.0001") ** tick adjustment = Decimal(10) ** (token0_decimals - token1_decimals) return price * adjustment def get_sqrt_ratio_at_tick(tick: int) -> int: return int((1.0001 ** (tick / 2)) * (2 ** 96)) def get_amounts_for_liquidity(sqrt_ratio_current: int, sqrt_ratio_a: int, sqrt_ratio_b: int, liquidity: int) -> Tuple[int, int]: if sqrt_ratio_a > sqrt_ratio_b: sqrt_ratio_a, sqrt_ratio_b = sqrt_ratio_b, sqrt_ratio_a amount0 = 0 amount1 = 0 Q96 = 1 << 96 # Calculations performed in high-precision integer math (EVM style) if sqrt_ratio_current <= sqrt_ratio_a: amount0 = (liquidity * Q96 // sqrt_ratio_a) - (liquidity * Q96 // sqrt_ratio_b) amount1 = 0 elif sqrt_ratio_current < sqrt_ratio_b: amount0 = (liquidity * Q96 // sqrt_ratio_current) - (liquidity * Q96 // sqrt_ratio_b) amount1 = (liquidity * (sqrt_ratio_current - sqrt_ratio_a)) // Q96 else: amount1 = (liquidity * (sqrt_ratio_b - sqrt_ratio_a)) // Q96 amount0 = 0 return amount0, amount1 # --- CORE LOGIC --- def get_position_details(w3: Web3, npm_contract, factory_contract, token_id: int): try: # Check ownership first to avoid errors? positions() works regardless of owner usually. position_data = npm_contract.functions.positions(token_id).call() (nonce, operator, token0_address, token1_address, fee, tickLower, tickUpper, liquidity, feeGrowthInside0, feeGrowthInside1, tokensOwed0, tokensOwed1) = position_data token0_contract = w3.eth.contract(address=token0_address, abi=ERC20_ABI) token1_contract = w3.eth.contract(address=token1_address, abi=ERC20_ABI) # Multi-call optimization could be used here, but keeping simple for now token0_symbol = token0_contract.functions.symbol().call() token1_symbol = token1_contract.functions.symbol().call() token0_decimals = token0_contract.functions.decimals().call() token1_decimals = token1_contract.functions.decimals().call() pool_address = factory_contract.functions.getPool(token0_address, token1_address, fee).call() if pool_address == '0x0000000000000000000000000000000000000000': return None, None pool_contract = w3.eth.contract(address=pool_address, abi=UNISWAP_V3_POOL_ABI) return { "token0_address": token0_address, "token1_address": token1_address, "token0_symbol": token0_symbol, "token1_symbol": token1_symbol, "token0_decimals": token0_decimals, "token1_decimals": token1_decimals, "fee": fee, "tickLower": tickLower, "tickUpper": tickUpper, "liquidity": liquidity, "pool_address": pool_address }, pool_contract except Exception as e: logger.error(f"❌ Error fetching position details for ID {token_id}: {e}") return None, None def get_pool_dynamic_data(pool_contract) -> Optional[Dict[str, Any]]: try: slot0 = pool_contract.functions.slot0().call() return {"sqrtPriceX96": slot0[0], "tick": slot0[1]} except Exception as e: logger.error(f"❌ Pool data fetch failed: {e}") return None def calculate_mint_amounts(current_tick, tick_lower, tick_upper, investment_value_token1: Decimal, decimals0, decimals1, sqrt_price_current_x96) -> Tuple[int, int]: """ Calculates required token amounts for a target investment value. Uses precise Decimal math. """ sqrt_price_current = get_sqrt_ratio_at_tick(current_tick) sqrt_price_lower = get_sqrt_ratio_at_tick(tick_lower) sqrt_price_upper = get_sqrt_ratio_at_tick(tick_upper) # Price of T0 in T1 price_t0_in_t1 = price_from_sqrt_price_x96(sqrt_price_current_x96, decimals0, decimals1) # Calculate amounts for a "Test" liquidity amount L_test = 1 << 128 amt0_test_wei, amt1_test_wei = get_amounts_for_liquidity(sqrt_price_current, sqrt_price_lower, sqrt_price_upper, L_test) amt0_test = Decimal(amt0_test_wei) / Decimal(10**decimals0) amt1_test = Decimal(amt1_test_wei) / Decimal(10**decimals1) # Value in Token1 terms value_test = (amt0_test * price_t0_in_t1) + amt1_test if value_test <= 0: return 0, 0 scale = investment_value_token1 / value_test final_amt0_wei = int(Decimal(amt0_test_wei) * scale) final_amt1_wei = int(Decimal(amt1_test_wei) * scale) return final_amt0_wei, final_amt1_wei def ensure_allowance(w3: Web3, account: LocalAccount, token_address: str, spender_address: str, amount_needed: int) -> bool: """ Checks if allowance is sufficient, approves if not. """ try: token_c = w3.eth.contract(address=token_address, abi=ERC20_ABI) allowance = token_c.functions.allowance(account.address, spender_address).call() if allowance >= amount_needed: return True logger.info(f"🔓 Approving {token_address} for {spender_address}...") # Some tokens (USDT) fail if approving from non-zero to non-zero. # Safe practice: Approve 0 first if allowance > 0, then new amount. if allowance > 0: send_transaction_robust(w3, account, token_c.functions.approve(spender_address, 0), extra_msg="Reset Allowance") # Approve receipt = send_transaction_robust( w3, account, token_c.functions.approve(spender_address, amount_needed), extra_msg=f"Approve {token_address}" ) return receipt is not None except Exception as e: logger.error(f"❌ Allowance check/approve failed: {e}") return False def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount, token0: str, token1: str, amount0_needed: int, amount1_needed: int, sqrt_price_x96: int, d0: int, d1: int) -> bool: """ Checks balances, wraps ETH if needed, and swaps ONLY the required surplus to meet deposit requirements. """ token0 = clean_address(token0) token1 = clean_address(token1) token0_c = w3.eth.contract(address=token0, abi=ERC20_ABI) token1_c = w3.eth.contract(address=token1, abi=ERC20_ABI) bal0 = token0_c.functions.balanceOf(account.address).call() bal1 = token1_c.functions.balanceOf(account.address).call() # Calculate Deficits deficit0 = max(0, amount0_needed - bal0) deficit1 = max(0, amount1_needed - bal1) weth_lower = WETH_ADDRESS.lower() # --- AUTO WRAP ETH --- if (deficit0 > 0 and token0.lower() == weth_lower) or (deficit1 > 0 and token1.lower() == weth_lower): eth_bal = w3.eth.get_balance(account.address) # Keep 0.01 ETH for gas gas_reserve = Web3.to_wei(0.01, 'ether') available_eth = max(0, eth_bal - gas_reserve) wrap_needed = 0 if token0.lower() == weth_lower: wrap_needed += deficit0 if token1.lower() == weth_lower: wrap_needed += deficit1 amount_to_wrap = min(wrap_needed, available_eth) if amount_to_wrap > 0: logger.info(f"🌯 Wrapping {Web3.from_wei(amount_to_wrap, 'ether')} ETH...") weth_c = w3.eth.contract(address=WETH_ADDRESS, abi=WETH9_ABI) receipt = send_transaction_robust(w3, account, weth_c.functions.deposit(), value=amount_to_wrap, extra_msg="Wrap ETH") if receipt: # Refresh Balances bal0 = token0_c.functions.balanceOf(account.address).call() bal1 = token1_c.functions.balanceOf(account.address).call() deficit0 = max(0, amount0_needed - bal0) deficit1 = max(0, amount1_needed - bal1) if deficit0 == 0 and deficit1 == 0: return True # --- SWAP SURPLUS --- # Smart Swap: Calculate exactly how much we need to swap # Price of Token0 in terms of Token1 price_0_in_1 = price_from_sqrt_price_x96(sqrt_price_x96, d0, d1) swap_call = None token_in, token_out = None, None amount_in = 0 buffer_multiplier = Decimal("1.02") # 2% buffer for slippage/price moves if deficit0 > 0 and bal1 > amount1_needed: # Need T0 (ETH), Have extra T1 (USDC) # Swap T1 -> T0 # Cost in T1 = Deficit0 * Price(T0 in T1) cost_in_t1 = Decimal(deficit0) / Decimal(10**d0) * price_0_in_1 # Convert back to T1 Wei and apply buffer amount_in_needed = int(cost_in_t1 * Decimal(10**d1) * buffer_multiplier) surplus1 = bal1 - amount1_needed if surplus1 >= amount_in_needed: token_in, token_out = token1, token0 amount_in = amount_in_needed logger.info(f"🧮 Calc: Need {deficit0} T0. Cost ~{amount_in_needed} T1. Surplus: {surplus1}") else: logger.warning(f"❌ Insufficient Surplus T1. Need {amount_in_needed}, Have {surplus1}") elif deficit1 > 0 and bal0 > amount0_needed: # Need T1 (USDC), Have extra T0 (ETH) # Swap T0 -> T1 # Cost in T0 = Deficit1 / Price(T0 in T1) if price_0_in_1 > 0: cost_in_t0 = (Decimal(deficit1) / Decimal(10**d1)) / price_0_in_1 amount_in_needed = int(cost_in_t0 * Decimal(10**d0) * buffer_multiplier) surplus0 = bal0 - amount0_needed if surplus0 >= amount_in_needed: token_in, token_out = token0, token1 amount_in = amount_in_needed logger.info(f"🧮 Calc: Need {deficit1} T1. Cost ~{amount_in_needed} T0. Surplus: {surplus0}") else: logger.warning(f"❌ Insufficient Surplus T0. Need {amount_in_needed}, Have {surplus0}") if token_in and amount_in > 0: logger.info(f"🔄 Swapping {amount_in} {token_in} to cover deficit...") if not ensure_allowance(w3, account, token_in, UNISWAP_V3_SWAP_ROUTER_ADDRESS, amount_in): return False params = ( token_in, token_out, 500, account.address, int(time.time()) + 120, amount_in, 0, # amountOutMin (Market swap for rebalance) 0 ) receipt = send_transaction_robust(w3, account, router_contract.functions.exactInputSingle(params), extra_msg="Swap Surplus") if receipt: # Final check - Recursive check to ensure we hit target or retry # But return True/False based on immediate check bal0 = token0_c.functions.balanceOf(account.address).call() bal1 = token1_c.functions.balanceOf(account.address).call() # If we are strictly >= needed, great. if bal0 >= amount0_needed and bal1 >= amount1_needed: return True else: logger.warning(f"⚠️ Swap executed but still short? T0: {bal0}/{amount0_needed}, T1: {bal1}/{amount1_needed}") return False logger.warning(f"❌ Insufficient funds (No suitable swap found). T0: {bal0}/{amount0_needed}, T1: {bal1}/{amount1_needed}") return False def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str, token1: str, amount0: int, amount1: int, tick_lower: int, tick_upper: int) -> Optional[Dict]: """ Approves tokens and mints a new V3 position. """ logger.info("🚀 Minting new position...") # 1. Approve if not ensure_allowance(w3, account, token0, NONFUNGIBLE_POSITION_MANAGER_ADDRESS, amount0): return None if not ensure_allowance(w3, account, token1, NONFUNGIBLE_POSITION_MANAGER_ADDRESS, amount1): return None # 2. Calculate Min Amounts (Slippage Protection) # Using 0.5% slippage tolerance amount0_min = int(Decimal(amount0) * (Decimal(1) - SLIPPAGE_TOLERANCE)) amount1_min = int(Decimal(amount1) * (Decimal(1) - SLIPPAGE_TOLERANCE)) # 3. Mint params = ( token0, token1, 500, tick_lower, tick_upper, amount0, amount1, amount0_min, amount1_min, account.address, int(time.time()) + 180 ) receipt = send_transaction_robust(w3, account, npm_contract.functions.mint(params), extra_msg="Mint Position") if receipt and receipt.status == 1: # Parse Logs try: # Transfer Event (Topic0) transfer_topic = Web3.keccak(text="Transfer(address,address,uint256)").hex() # IncreaseLiquidity Event (Topic0) increase_liq_topic = Web3.keccak(text="IncreaseLiquidity(uint256,uint128,uint256,uint256)").hex() minted_data = {'token_id': None, 'amount0': 0, 'amount1': 0} for log in receipt.logs: topics = [t.hex() for t in log['topics']] # Capture Token ID if topics[0] == transfer_topic: if "0000000000000000000000000000000000000000" in topics[1]: minted_data['token_id'] = int(topics[3], 16) # Capture Amounts if topics[0] == increase_liq_topic: # decoding data: liquidity(uint128), amount0(uint256), amount1(uint256) # data is a single hex string, we need to decode it data = log['data'].hex() if data.startswith('0x'): data = data[2:] # liquidity is first 32 bytes (padded), amt0 next 32, amt1 next 32 minted_data['amount0'] = int(data[64:128], 16) minted_data['amount1'] = int(data[128:192], 16) if minted_data['token_id']: # Format for Log # Assuming Token0=WETH (18), Token1=USDC (6) - should use fetched decimals ideally # We fetched decimals in main(), but here we can assume or pass them. # For simplicity, I'll pass them or use defaults since this is specific to this pair d0, d1 = 18, 6 fmt_amt0 = Decimal(minted_data['amount0']) / Decimal(10**d0) fmt_amt1 = Decimal(minted_data['amount1']) / Decimal(10**d1) logger.info(f"✅ POSITION OPENED | ID: {minted_data['token_id']} | Deposited: {fmt_amt0:.6f} WETH + {fmt_amt1:.2f} USDC") return minted_data except Exception as e: logger.warning(f"Minted but failed to parse details: {e}") return None def decrease_liquidity(w3: Web3, npm_contract, account: LocalAccount, token_id: int, liquidity: int) -> bool: if liquidity == 0: return True logger.info(f"📉 Decreasing Liquidity for {token_id}...") params = ( token_id, liquidity, 0, 0, # amountMin0, amountMin1 int(time.time()) + 180 ) receipt = send_transaction_robust(w3, account, npm_contract.functions.decreaseLiquidity(params), extra_msg=f"Decrease Liq {token_id}") if receipt and receipt.status == 1: try: # Parse DecreaseLiquidity Event decrease_topic = Web3.keccak(text="DecreaseLiquidity(uint256,uint128,uint256,uint256)").hex() amt0, amt1 = 0, 0 for log in receipt.logs: topics = [t.hex() for t in log['topics']] if topics[0] == decrease_topic: # Check tokenID (topic 1) if int(topics[1], 16) == token_id: data = log['data'].hex()[2:] # liquidity (32), amt0 (32), amt1 (32) amt0 = int(data[64:128], 16) amt1 = int(data[128:192], 16) break d0, d1 = 18, 6 # Assuming WETH/USDC fmt_amt0 = Decimal(amt0) / Decimal(10**d0) fmt_amt1 = Decimal(amt1) / Decimal(10**d1) logger.info(f"📉 POSITION CLOSED (Liquidity Removed) | ID: {token_id} | Withdrawn: {fmt_amt0:.6f} WETH + {fmt_amt1:.2f} USDC") except Exception as e: logger.warning(f"Closed but failed to parse details: {e}") return True return False def collect_fees(w3: Web3, npm_contract, account: LocalAccount, token_id: int) -> bool: logger.info(f"💰 Collecting Fees for {token_id}...") max_val = 2**128 - 1 params = ( token_id, account.address, max_val, max_val ) receipt = send_transaction_robust(w3, account, npm_contract.functions.collect(params), extra_msg=f"Collect Fees {token_id}") return receipt is not None # --- STATE MANAGEMENT --- def load_status_data() -> List[Dict]: if not os.path.exists(STATUS_FILE): return [] try: with open(STATUS_FILE, 'r') as f: return json.load(f) except: return [] def save_status_data(data: List[Dict]): with open(STATUS_FILE, 'w') as f: json.dump(data, f, indent=2) def update_position_status(token_id: int, status: str, extra_data: Dict = {}): data = load_status_data() # Find existing or create new entry = next((item for item in data if item.get('token_id') == token_id), None) if not entry: if status in ["OPEN", "PENDING_HEDGE"]: entry = {"type": "AUTOMATIC", "token_id": token_id} data.append(entry) else: return # Can't update non-existent position unless opening entry['status'] = status entry.update(extra_data) if status == "CLOSED": entry['timestamp_close'] = int(time.time()) save_status_data(data) logger.info(f"💾 Updated Position {token_id} status to {status}") # --- MAIN LOOP --- def main(): logger.info("🔷 Uniswap Manager V2 (Refactored) Starting...") load_dotenv(override=True) rpc_url = os.environ.get("MAINNET_RPC_URL") private_key = os.environ.get("MAIN_WALLET_PRIVATE_KEY") or os.environ.get("PRIVATE_KEY") if not rpc_url or not private_key: logger.error("❌ Missing RPC or Private Key in .env") return w3 = Web3(Web3.HTTPProvider(rpc_url)) if not w3.is_connected(): logger.error("❌ Could not connect to RPC") return account = Account.from_key(private_key) logger.info(f"👤 Wallet: {account.address}") # Contracts npm = w3.eth.contract(address=clean_address(NONFUNGIBLE_POSITION_MANAGER_ADDRESS), abi=NONFUNGIBLE_POSITION_MANAGER_ABI) factory_addr = npm.functions.factory().call() factory = w3.eth.contract(address=factory_addr, abi=UNISWAP_V3_FACTORY_ABI) router = w3.eth.contract(address=clean_address(UNISWAP_V3_SWAP_ROUTER_ADDRESS), abi=SWAP_ROUTER_ABI) while True: try: status_data = load_status_data() open_positions = [p for p in status_data if p.get('status') == 'OPEN'] active_auto_pos = next((p for p in open_positions if p.get('type') == 'AUTOMATIC'), None) if active_auto_pos: token_id = active_auto_pos['token_id'] pos_details, pool_c = get_position_details(w3, npm, factory, token_id) if pos_details: pool_data = get_pool_dynamic_data(pool_c) current_tick = pool_data['tick'] # Check Range tick_lower = pos_details['tickLower'] tick_upper = pos_details['tickUpper'] in_range = tick_lower <= current_tick < tick_upper # Calculate Prices for logging current_price = price_from_tick(current_tick, pos_details['token0_decimals'], pos_details['token1_decimals']) lower_price = price_from_tick(tick_lower, pos_details['token0_decimals'], pos_details['token1_decimals']) upper_price = price_from_tick(tick_upper, pos_details['token0_decimals'], pos_details['token1_decimals']) status_msg = "✅ IN RANGE" if in_range else "⚠️ OUT OF RANGE" # Calculate Unclaimed Fees (Simulation) unclaimed0, unclaimed1, total_fees_usd = 0, 0, 0 try: # Call collect with zero address to simulate fee estimation fees_sim = npm.functions.collect((token_id, "0x0000000000000000000000000000000000000000", 2**128-1, 2**128-1)).call({'from': account.address}) unclaimed0 = to_decimal(fees_sim[0], pos_details['token0_decimals']) unclaimed1 = to_decimal(fees_sim[1], pos_details['token1_decimals']) total_fees_usd = (unclaimed0 * current_price) + unclaimed1 except Exception as e: logger.debug(f"Fee simulation failed for {token_id}: {e}") # Calculate Total PnL (Fees + Price Appreciation/Depreciation) # We need the initial investment value (target_value) initial_value = Decimal(str(active_auto_pos.get('target_value', 0))) # Estimate Current Position Liquidity Value (approximate) # For exact value, we'd need amounts for liquidity at current tick # But we can approximate using the target value logic reversed or just assume target ~ current if range is tight and price is close. # BETTER: Use get_amounts_for_liquidity with current price to get current holdings curr_amt0_wei, curr_amt1_wei = get_amounts_for_liquidity( pool_data['sqrtPriceX96'], get_sqrt_ratio_at_tick(tick_lower), get_sqrt_ratio_at_tick(tick_upper), pos_details['liquidity'] ) curr_amt0 = Decimal(curr_amt0_wei) / Decimal(10**pos_details['token0_decimals']) curr_amt1 = Decimal(curr_amt1_wei) / Decimal(10**pos_details['token1_decimals']) current_pos_value_usd = (curr_amt0 * current_price) + curr_amt1 pnl_unrealized = current_pos_value_usd - initial_value total_pnl_usd = pnl_unrealized + total_fees_usd pnl_text = f" | TotPnL: ${total_pnl_usd:.2f} (Fees: ${total_fees_usd:.2f})" logger.info(f"Position {token_id}: {status_msg} | Price: {current_price:.4f} [{lower_price:.4f} - {upper_price:.4f}]{pnl_text}") # --- KPI LOGGING --- if log_kpi_snapshot: snapshot = { 'initial_eth': active_auto_pos.get('amount0_initial', 0), 'initial_usdc': active_auto_pos.get('amount1_initial', 0), 'initial_hedge_usdc': INITIAL_HEDGE_CAPITAL_USDC, 'current_eth_price': float(current_price), 'uniswap_pos_value_usd': float(current_pos_value_usd), 'uniswap_fees_claimed_usd': 0.0, # Not tracked accumulated yet in JSON, using Unclaimed mainly 'uniswap_fees_unclaimed_usd': float(total_fees_usd), # Hedge Data (from JSON updated by clp_hedger) 'hedge_equity_usd': float(active_auto_pos.get('hedge_equity_usd', 0.0)), 'hedge_pnl_realized_usd': active_auto_pos.get('hedge_pnl_realized', 0.0), 'hedge_fees_paid_usd': active_auto_pos.get('hedge_fees_paid', 0.0) } # We use 'target_value' as a proxy for 'Initial Hedge Equity' + 'Initial Uni Val' if strictly tracking strategy? # For now, let's pass what we have. # To get 'hedge_equity', we ideally need clp_hedger to write it to JSON. # Current implementation of kpi_tracker uses 'hedge_equity' in NAV. # If we leave it 0, NAV will be underreported. # WORKAROUND: Assume Hedge PnL Realized IS the equity change if we ignore margin. log_kpi_snapshot(snapshot) if not in_range and CLOSE_POSITION_ENABLED: logger.warning(f"🛑 Closing Position {token_id} (Out of Range)") update_position_status(token_id, "CLOSING") # 1. Remove Liquidity if decrease_liquidity(w3, npm, account, token_id, pos_details['liquidity']): # 2. Collect Fees collect_fees(w3, npm, account, token_id) update_position_status(token_id, "CLOSED") # 3. Optional Rebalance (Sell 50% WETH if fell below) if REBALANCE_ON_CLOSE_BELOW_RANGE and current_tick < tick_lower: # Simple rebalance logic here (similar to original check_and_swap surplus logic) pass elif OPEN_POSITION_ENABLED: logger.info("🔍 No active position. Analyzing market...") # Setup logic for new position token0 = clean_address(WETH_ADDRESS) token1 = clean_address(USDC_ADDRESS) fee = 500 pool_addr = factory.functions.getPool(token0, token1, fee).call() pool_c = w3.eth.contract(address=pool_addr, abi=UNISWAP_V3_POOL_ABI) pool_data = get_pool_dynamic_data(pool_c) if pool_data: tick = pool_data['tick'] # Define Range (+/- 2.5%) # log(1.025) / log(1.0001) approx 247 tick delta tick_delta = int(math.log(1 + float(RANGE_WIDTH_PCT)) / math.log(1.0001)) tick_spacing = 10 tick_lower = (tick - tick_delta) // tick_spacing * tick_spacing tick_upper = (tick + tick_delta) // tick_spacing * tick_spacing # Calculate Amounts # Target Value logic d0 = 18 # WETH d1 = 6 # USDC if str(TARGET_INVESTMENT_VALUE_USDC).upper() == "MAX": # Fetch balances token0_c = w3.eth.contract(address=token0, abi=ERC20_ABI) token1_c = w3.eth.contract(address=token1, abi=ERC20_ABI) bal0 = Decimal(token0_c.functions.balanceOf(account.address).call()) / Decimal(10**d0) bal1 = Decimal(token1_c.functions.balanceOf(account.address).call()) / Decimal(10**d1) price_eth_usdc = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1) total_val_usd = (bal0 * price_eth_usdc) + bal1 # Apply Buffer ($200) investment_val_dec = max(Decimal(0), total_val_usd - Decimal(200)) logger.info(f"🎯 MAX Investment Mode: Wallet ${total_val_usd:.2f} -> Target ${investment_val_dec:.2f} (Buffer $200)") else: investment_val_dec = Decimal(str(TARGET_INVESTMENT_VALUE_USDC)) amt0, amt1 = calculate_mint_amounts(tick, tick_lower, tick_upper, investment_val_dec, d0, d1, pool_data['sqrtPriceX96']) if check_and_swap_for_deposit(w3, router, account, token0, token1, amt0, amt1, pool_data['sqrtPriceX96'], d0, d1): minted = mint_new_position(w3, npm, account, token0, token1, amt0, amt1, tick_lower, tick_upper) if minted: # Calculate entry price and amounts for JSON compatibility entry_price = float(price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1)) fmt_amt0 = float(Decimal(minted['amount0']) / Decimal(10**d0)) fmt_amt1 = float(Decimal(minted['amount1']) / Decimal(10**d1)) # Calculate actual initial value actual_value = (fmt_amt0 * entry_price) + fmt_amt1 # Prepare ordered data with specific rounding new_position_data = { "type": "AUTOMATIC", # Will be handled by update_position_status logic if new "target_value": round(float(actual_value), 2), "entry_price": round(entry_price, 2), "amount0_initial": round(fmt_amt0, 4), "amount1_initial": round(fmt_amt1, 2), "range_upper": round(float(price_from_tick(tick_upper, d0, d1)), 2), "range_lower": round(float(price_from_tick(tick_lower, d0, d1)), 2), "timestamp_open": int(time.time()) } update_position_status(minted['token_id'], "OPEN", new_position_data) time.sleep(MONITOR_INTERVAL_SECONDS) except KeyboardInterrupt: logger.info("👋 Exiting...") break except Exception as e: logger.error(f"❌ Main Loop Error: {e}") time.sleep(MONITOR_INTERVAL_SECONDS) if __name__ == "__main__": main()