1195 lines
66 KiB
Python
1195 lines
66 KiB
Python
import os
|
|
import sys
|
|
import time
|
|
import json
|
|
import re
|
|
import logging
|
|
import math
|
|
from datetime import datetime
|
|
from web3 import Web3
|
|
from eth_account import Account
|
|
from dotenv import load_dotenv
|
|
|
|
# --- LOGGING SETUP ---
|
|
# Import logging utils for consistent logging
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
project_root = os.path.dirname(current_dir)
|
|
sys.path.append(current_dir)
|
|
|
|
from logging_utils import setup_logging
|
|
|
|
# Configure logging for Uniswap Manager
|
|
logger = setup_logging("normal", "UNISWAP_MANAGER")
|
|
|
|
# --- Helper Functions ---
|
|
def clean_address(addr):
|
|
return re.sub(r'[^0-9a-fA-FxX]', '', addr)
|
|
|
|
def price_from_sqrt_price_x96(sqrt_price_x96, token0_decimals, token1_decimals):
|
|
price = (sqrt_price_x96 / (2**96))**2
|
|
# Adjust for token decimals assuming price is Token1 per Token0
|
|
price = price * (10**(token0_decimals - token1_decimals))
|
|
return price
|
|
|
|
def price_from_tick(tick, token0_decimals, token1_decimals):
|
|
price = 1.0001**tick
|
|
# Adjust for token decimals assuming price is Token1 per Token0
|
|
price = price * (10**(token0_decimals - token1_decimals))
|
|
return price
|
|
|
|
def from_wei(amount, decimals):
|
|
return amount / (10**decimals)
|
|
|
|
# --- V3 Math Helpers ---
|
|
def get_sqrt_ratio_at_tick(tick):
|
|
# Returns sqrt(price) as a Q96 number
|
|
return int((1.0001 ** (tick / 2)) * (2 ** 96))
|
|
|
|
def get_liquidity_for_amount0(sqrt_ratio_a, sqrt_ratio_b, amount0):
|
|
# This function is not used directly in the current calculate_mint_amounts logic,
|
|
# but is a common V3 helper
|
|
if sqrt_ratio_a > sqrt_ratio_b:
|
|
sqrt_ratio_a, sqrt_ratio_b = sqrt_ratio_b, sqrt_ratio_a
|
|
# This formula is for a single-sided deposit when current price is outside the range
|
|
return int(amount0 * sqrt_ratio_a * sqrt_ratio_b / (sqrt_ratio_b - sqrt_ratio_a))
|
|
|
|
def get_liquidity_for_amount1(sqrt_ratio_a, sqrt_ratio_b, amount1):
|
|
# This function is not used directly in the current calculate_mint_amounts logic,
|
|
# but is a common V3 helper
|
|
if sqrt_ratio_a > sqrt_ratio_b:
|
|
sqrt_ratio_a, sqrt_ratio_b = sqrt_ratio_b, sqrt_ratio_a
|
|
# This formula is for a single-sided deposit when current price is outside the range
|
|
return int(amount1 / (sqrt_ratio_b - sqrt_ratio_a))
|
|
|
|
def get_amounts_for_liquidity(sqrt_ratio_current, sqrt_ratio_a, sqrt_ratio_b, liquidity):
|
|
# Calculates the required amount of token0 and token1 for a given liquidity and price range
|
|
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 # 2^96
|
|
|
|
# Current price below the lower tick boundary
|
|
if sqrt_ratio_current <= sqrt_ratio_a:
|
|
amount0 = ((liquidity * Q96) // sqrt_ratio_a) - ((liquidity * Q96) // sqrt_ratio_b)
|
|
amount1 = 0
|
|
# Current price within the range
|
|
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
|
|
# Current price above the upper tick boundary
|
|
else:
|
|
amount1 = (liquidity * (sqrt_ratio_b - sqrt_ratio_a)) // Q96
|
|
amount0 = 0
|
|
|
|
return amount0, amount1
|
|
|
|
# --- Configuration ---
|
|
# RPC URL and Private Key are loaded from .env
|
|
RPC_URL = os.environ.get("MAINNET_RPC_URL")
|
|
PRIVATE_KEY = os.environ.get("MAIN_WALLET_PRIVATE_KEY") or os.environ.get("PRIVATE_KEY")
|
|
|
|
# Script behavior flags
|
|
MONITOR_INTERVAL_SECONDS = 60
|
|
COLLECT_FEES_ENABLED = False # If True, will attempt to collect fees once and exit if no open auto position
|
|
CLOSE_POSITION_ENABLED = True # If True, will attempt to close auto position when out of range
|
|
CLOSE_IF_OUT_OF_RANGE_ONLY = True # If True, closes only if out of range; if False, closes immediately
|
|
OPEN_POSITION_ENABLED = True # If True, will open a new position if no auto position exists
|
|
REBALANCE_ON_CLOSE_BELOW_RANGE = True # If True, will sell 50% of WETH to USDC when closing below range
|
|
|
|
# New Position Parameters
|
|
TARGET_INVESTMENT_VALUE_TOKEN1 = "MAX" # Target total investment value in Token1 terms (e.g. 350 USDC)
|
|
RANGE_WIDTH_PCT = 0.025 # +/- 2.5% range for new positions
|
|
|
|
# JSON File for tracking position state
|
|
STATUS_FILE = "hedge_status.json"
|
|
|
|
# --- Gas and Transaction Configuration ---
|
|
GAS_LIMIT_WRAP = 100000
|
|
GAS_LIMIT_SWAP = 300000
|
|
GAS_LIMIT_MINT = 800000
|
|
GAS_LIMIT_DECREASE = 1000000
|
|
TRANSACTION_TIMEOUT_SECONDS = 300
|
|
|
|
# --- Safety Buffers ---
|
|
INVESTMENT_BUFFER_USD = 200
|
|
GAS_RESERVE_ETH = 0.005
|
|
|
|
# --- Agent Thresholds (sync with other modules) ---
|
|
EDGE_PROXIMITY_PCT = 0.05
|
|
VELOCITY_THRESHOLD_PCT = 0.008
|
|
|
|
# --- Token Addresses ---
|
|
WETH_ADDRESS = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1" # Arbitrum WETH
|
|
USDC_ADDRESS = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" # Arbitrum USDC
|
|
|
|
# --- JSON State Helpers ---
|
|
def get_active_automatic_position():
|
|
"""Reads hedge_status.json and returns the first OPEN AUTOMATIC position dict, or None."""
|
|
if not os.path.exists(STATUS_FILE):
|
|
return None
|
|
try:
|
|
with open(STATUS_FILE, 'r') as f:
|
|
data = json.load(f)
|
|
for entry in data:
|
|
if entry.get('type') == 'AUTOMATIC' and entry.get('status') == 'OPEN':
|
|
return entry
|
|
except Exception as e:
|
|
logger.error(f"ERROR reading status file: {e}")
|
|
return None
|
|
|
|
def get_all_open_positions():
|
|
"""Reads hedge_status.json and returns a list of all OPEN positions (Manual and Automatic)."""
|
|
if not os.path.exists(STATUS_FILE):
|
|
return []
|
|
try:
|
|
with open(STATUS_FILE, 'r') as f:
|
|
data = json.load(f)
|
|
return [entry for entry in data if entry.get('status') == 'OPEN']
|
|
except Exception as e:
|
|
logger.error(f"ERROR reading status file: {e}")
|
|
return []
|
|
|
|
def set_position_status_and_data(action, position_data):
|
|
"""
|
|
Updates the hedge_status.json file.
|
|
action: "PENDING_HEDGE", "OPEN", "CLOSING", "CLOSE"
|
|
position_data: Dict containing details (token_id, entry_price, range, etc.)
|
|
"""
|
|
current_data = []
|
|
if os.path.exists(STATUS_FILE):
|
|
try:
|
|
with open(STATUS_FILE, 'r') as f:
|
|
current_data = json.load(f)
|
|
except:
|
|
current_data = []
|
|
|
|
if action == "PENDING_HEDGE" or action == "OPEN":
|
|
# Check if entry exists
|
|
existing_index = -1
|
|
for i, entry in enumerate(current_data):
|
|
if entry.get('token_id') == position_data['token_id']:
|
|
existing_index = i
|
|
break
|
|
|
|
# Format Timestamp
|
|
open_ts = position_data.get('timestamp_open', int(time.time()))
|
|
opened_str = time.strftime('%H:%M %d/%m/%y', time.localtime(open_ts))
|
|
|
|
# Scale Amounts (if provided)
|
|
raw_amt0 = position_data.get('amount0_initial', 0)
|
|
raw_amt1 = position_data.get('amount1_initial', 0)
|
|
|
|
if raw_amt0 > 1000: fmt_amt0 = round(raw_amt0 / 10**18, 4)
|
|
else: fmt_amt0 = round(raw_amt0, 4)
|
|
|
|
if raw_amt1 > 1000: fmt_amt1 = round(raw_amt1 / 10**6, 2)
|
|
else: fmt_amt1 = round(raw_amt1, 2)
|
|
|
|
new_entry = {
|
|
"type": "AUTOMATIC",
|
|
"token_id": position_data['token_id'],
|
|
"opened": opened_str,
|
|
"status": action, # PENDING_HEDGE or OPEN
|
|
"entry_price": round(position_data.get('entry_price', 0), 2),
|
|
"target_value": round(position_data.get('target_value', 0), 2),
|
|
"amount0_initial": fmt_amt0,
|
|
"amount1_initial": fmt_amt1,
|
|
|
|
"range_upper": round(position_data.get('range_upper', 0), 2),
|
|
"zone_top_start_price": round(position_data['zone_top_start_price'], 2) if 'zone_top_start_price' in position_data else None,
|
|
"zone_close_top_price": round(position_data['zone_close_end_price'], 2) if 'zone_close_end_price' in position_data else None,
|
|
"zone_close_bottom_price": round(position_data['zone_close_start_price'], 2) if 'zone_close_start_price' in position_data else None,
|
|
"zone_bottom_limit_price": round(position_data['zone_bottom_limit_price'], 2) if 'zone_bottom_limit_price' in position_data else None,
|
|
"range_lower": round(position_data.get('range_lower', 0), 2),
|
|
|
|
"static_long": 0.0,
|
|
"timestamp_open": open_ts,
|
|
"timestamp_close": None
|
|
}
|
|
|
|
if existing_index >= 0:
|
|
# Update existing (merge/overwrite)
|
|
current_data[existing_index].update(new_entry)
|
|
logger.info(f"Updated position {position_data['token_id']} status to {action}")
|
|
else:
|
|
# Create new
|
|
current_data.append(new_entry)
|
|
logger.info(f"Created new position {position_data['token_id']} with status {action}")
|
|
|
|
elif action == "CLOSING":
|
|
found = False
|
|
for entry in current_data:
|
|
if (
|
|
entry.get('type') == "AUTOMATIC" and
|
|
entry.get('status') == "OPEN" and
|
|
entry.get('token_id') == position_data['token_id']
|
|
):
|
|
entry['status'] = "CLOSING"
|
|
found = True
|
|
logger.info(f"🔄 Position {entry['token_id']} marked CLOSING in {STATUS_FILE}")
|
|
break
|
|
if not found:
|
|
logger.warning(f"⚠️ Could not find open AUTOMATIC position {position_data['token_id']} to mark closing.")
|
|
|
|
elif action == "CLOSE":
|
|
found = False
|
|
for entry in current_data:
|
|
if (
|
|
entry.get('type') == "AUTOMATIC" and
|
|
(entry.get('status') == "OPEN" or entry.get('status') == "CLOSING") and
|
|
entry.get('token_id') == position_data['token_id']
|
|
):
|
|
|
|
entry['status'] = "CLOSED"
|
|
entry['timestamp_close'] = int(time.time())
|
|
|
|
# Add Closing Stats if provided
|
|
if 'fees_collected_usd' in position_data:
|
|
entry['fees_collected_usd'] = round(position_data['fees_collected_usd'], 2)
|
|
if 'closed_position_value_usd' in position_data:
|
|
entry['closed_position_value_usd'] = round(position_data['closed_position_value_usd'], 2)
|
|
|
|
found = True
|
|
print(f"Marked position {entry['token_id']} as CLOSED in {STATUS_FILE}")
|
|
break
|
|
if not found:
|
|
print(f"WARNING: Could not find open AUTOMATIC position {position_data['token_id']} to close.")
|
|
|
|
with open(STATUS_FILE, 'w') as f:
|
|
json.dump(current_data, f, indent=2)
|
|
|
|
# --- ABIs ---
|
|
# Simplified for length, usually loaded from huge string
|
|
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"}
|
|
]
|
|
''')
|
|
|
|
NONFUNGIBLE_POSITION_MANAGER_ADDRESS = bytes.fromhex("C36442b4" + "a4522E87" + "1399CD71" + "7aBDD847" + "Ab11FE88")
|
|
UNISWAP_V3_SWAP_ROUTER_ADDRESS = bytes.fromhex("E592427A0AEce92De3Edee1F18E0157C05861564")
|
|
WETH_ADDRESS = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1" # Arbitrum WETH
|
|
|
|
# --- Core Logic Functions ---
|
|
def get_position_details(w3_instance, npm_c, factory_c, token_id):
|
|
try:
|
|
position_data = npm_c.functions.positions(token_id).call()
|
|
(nonce, operator, token0_address, token1_address, fee, tickLower, tickUpper, liquidity,
|
|
feeGrowthInside0, feeGrowthInside1, tokensOwed0, tokensOwed1) = position_data
|
|
|
|
token0_contract = w3_instance.eth.contract(address=token0_address, abi=ERC20_ABI)
|
|
token1_contract = w3_instance.eth.contract(address=token1_address, abi=ERC20_ABI)
|
|
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_c.functions.getPool(token0_address, token1_address, fee).call()
|
|
if pool_address == '0x0000000000000000000000000000000000000000':
|
|
return None, None
|
|
|
|
pool_contract = w3_instance.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: {e}")
|
|
return None, None
|
|
|
|
def get_pool_dynamic_data(pool_c):
|
|
try:
|
|
slot0_data = pool_c.functions.slot0().call()
|
|
return {"sqrtPriceX96": slot0_data[0], "tick": slot0_data[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, decimals0, decimals1, sqrt_price_current_x96):
|
|
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)
|
|
|
|
# 1. Get Price of Token0 in terms of Token1
|
|
price_of_token0_in_token1_units = price_from_sqrt_price_x96(sqrt_price_current_x96, decimals0, decimals1)
|
|
|
|
# 2. Estimate Amounts
|
|
L_test = 1 << 128
|
|
amt0_test, amt1_test = get_amounts_for_liquidity(sqrt_price_current, sqrt_price_lower, sqrt_price_upper, L_test)
|
|
|
|
# 3. Adjust for decimals
|
|
real_amt0_test = amt0_test / (10**decimals0)
|
|
real_amt1_test = amt1_test / (10**decimals1)
|
|
|
|
# 4. Calculate Total Value of Test Position in Token1 terms
|
|
value_test = (real_amt0_test * price_of_token0_in_token1_units) + real_amt1_test
|
|
|
|
if value_test <= 0: # Catch zero and negative values
|
|
logger.warning(f"⚠️ Invalid value_test in calculate_mint_amounts: {value_test}")
|
|
return 0, 0
|
|
|
|
# 5. Scale
|
|
scale = investment_value_token1 / value_test
|
|
|
|
# 6. Final Amounts
|
|
final_amt0 = int(amt0_test * scale)
|
|
final_amt1 = int(amt1_test * scale)
|
|
|
|
return final_amt0, final_amt1
|
|
|
|
def check_and_swap(w3_instance, router_contract, account, token0, token1, amount0_needed, amount1_needed):
|
|
token0_contract = w3_instance.eth.contract(address=token0, abi=ERC20_ABI)
|
|
token1_contract = w3_instance.eth.contract(address=token1, abi=ERC20_ABI)
|
|
bal0 = token0_contract.functions.balanceOf(account.address).call()
|
|
bal1 = token1_contract.functions.balanceOf(account.address).call()
|
|
|
|
# Debug Balances
|
|
s0 = token0_contract.functions.symbol().call()
|
|
s1 = token1_contract.functions.symbol().call()
|
|
d0 = token0_contract.functions.decimals().call()
|
|
d1 = token1_contract.functions.decimals().call()
|
|
|
|
print(f"\n--- WALLET CHECK ---")
|
|
print(f"Required: {from_wei(amount0_needed, d0):.6f} {s0} | {from_wei(amount1_needed, d1):.2f} {s1}")
|
|
print(f"Balance : {from_wei(bal0, d0):.6f} {s0} | {from_wei(bal1, d1):.2f} {s1}")
|
|
|
|
deficit0 = max(0, amount0_needed - bal0)
|
|
deficit1 = max(0, amount1_needed - bal1)
|
|
|
|
if deficit0 > 0: print(f"Deficit {s0}: {from_wei(deficit0, d0):.6f}")
|
|
if deficit1 > 0: print(f"Deficit {s1}: {from_wei(deficit1, d1):.2f}")
|
|
|
|
# --- AUTO-WRAP ETH LOGIC ---
|
|
weth_addr_lower = WETH_ADDRESS.lower()
|
|
|
|
# Wrap for Token0 Deficit
|
|
if (deficit0 > 0 or deficit1 > 0) and token0.lower() == weth_addr_lower:
|
|
native_bal = w3_instance.eth.get_balance(account.address)
|
|
gas_reserve = 5 * 10**15 # 0.005 ETH (Reduced for L2)
|
|
available_native = max(0, native_bal - gas_reserve)
|
|
|
|
amount_to_wrap = 0
|
|
if deficit0 > 0:
|
|
amount_to_wrap = deficit0
|
|
|
|
if deficit1 > 0:
|
|
amount_to_wrap = available_native
|
|
|
|
amount_to_wrap = min(amount_to_wrap, available_native)
|
|
|
|
if amount_to_wrap > 0:
|
|
print(f"Auto-Wrapping {from_wei(amount_to_wrap, 18)} ETH to WETH...")
|
|
weth_contract = w3_instance.eth.contract(address=token0, abi=WETH9_ABI)
|
|
wrap_txn = weth_contract.functions.deposit().build_transaction({
|
|
'from': account.address, 'value': amount_to_wrap, 'nonce': w3_instance.eth.get_transaction_count(account.address), 'gas': 100000, 'maxFeePerGas': w3_instance.eth.gas_price * 2, 'maxPriorityFeePerGas': w3_instance.eth.max_priority_fee, 'chainId': w3_instance.eth.chain_id
|
|
})
|
|
signed_wrap = w3_instance.eth.account.sign_transaction(wrap_txn, private_key=account.key)
|
|
raw_wrap = signed_wrap.rawTransaction if hasattr(signed_wrap, 'rawTransaction') else signed_wrap.raw_transaction
|
|
tx_hash = w3_instance.eth.send_raw_transaction(raw_wrap)
|
|
print(f"Wrap Sent: {tx_hash.hex()}")
|
|
w3_instance.eth.wait_for_transaction_receipt(tx_hash)
|
|
bal0 = token0_contract.functions.balanceOf(account.address).call()
|
|
deficit0 = max(0, amount0_needed - bal0)
|
|
else:
|
|
if deficit0 > 0:
|
|
print(f"Insufficient Native ETH to wrap. Need: {from_wei(deficit0, 18)}, Available: {from_wei(available_native, 18)}")
|
|
|
|
# Wrap for Token1 Deficit (if Token1 is WETH)
|
|
if deficit1 > 0 and token1.lower() == weth_addr_lower:
|
|
native_bal = w3_instance.eth.get_balance(account.address)
|
|
gas_reserve = 5 * 10**15 # 0.005 ETH
|
|
available_native = max(0, native_bal - gas_reserve)
|
|
if available_native >= deficit1:
|
|
print(f"Auto-Wrapping {from_wei(deficit1, 18)} ETH to WETH...")
|
|
weth_contract = w3_instance.eth.contract(address=token1, abi=WETH9_ABI)
|
|
wrap_txn = weth_contract.functions.deposit().build_transaction({
|
|
'from': account.address, 'value': deficit1, 'nonce': w3_instance.eth.get_transaction_count(account.address), 'gas': 100000, 'maxFeePerGas': w3_instance.eth.gas_price * 2, 'maxPriorityFeePerGas': w3_instance.eth.max_priority_fee, 'chainId': w3_instance.eth.chain_id
|
|
})
|
|
signed_wrap = w3_instance.eth.account.sign_transaction(wrap_txn, private_key=account.key)
|
|
raw_wrap = signed_wrap.rawTransaction if hasattr(signed_wrap, 'rawTransaction') else signed_wrap.raw_transaction
|
|
tx_hash = w3_instance.eth.send_raw_transaction(raw_wrap)
|
|
print(f"Wrap Sent: {tx_hash.hex()}")
|
|
w3_instance.eth.wait_for_transaction_receipt(tx_hash)
|
|
bal1 = token1_contract.functions.balanceOf(account.address).call()
|
|
deficit1 = max(0, amount1_needed - bal1)
|
|
|
|
if deficit0 == 0 and deficit1 == 0:
|
|
return True
|
|
|
|
if deficit0 > 0 and bal1 > amount1_needed:
|
|
surplus1 = bal1 - amount1_needed
|
|
print(f"Swapping surplus Token1 ({surplus1}) for Token0...")
|
|
|
|
approve_txn = token1_contract.functions.approve(router_contract.address, surplus1).build_transaction({
|
|
'from': account.address, 'nonce': w3_instance.eth.get_transaction_count(account.address),
|
|
'gas': 100000, 'maxFeePerGas': w3_instance.eth.gas_price * 2, 'maxPriorityFeePerGas': w3_instance.eth.max_priority_fee,
|
|
'chainId': w3_instance.eth.chain_id
|
|
})
|
|
signed = w3_instance.eth.account.sign_transaction(approve_txn, private_key=account.key)
|
|
raw = signed.rawTransaction if hasattr(signed, 'rawTransaction') else signed.raw_transaction
|
|
w3_instance.eth.send_raw_transaction(raw)
|
|
time.sleep(2)
|
|
|
|
params = (token1, token0, 500, account.address, int(time.time()) + 120, surplus1, 0, 0)
|
|
swap_txn = router_contract.functions.exactInputSingle(params).build_transaction({
|
|
'from': account.address, 'nonce': w3_instance.eth.get_transaction_count(account.address),
|
|
'gas': 300000, 'maxFeePerGas': w3_instance.eth.gas_price * 2, 'maxPriorityFeePerGas': w3_instance.eth.max_priority_fee,
|
|
'chainId': w3_instance.eth.chain_id
|
|
})
|
|
signed_swap = w3_instance.eth.account.sign_transaction(swap_txn, private_key=account.key)
|
|
raw_swap = signed_swap.rawTransaction if hasattr(signed_swap, 'rawTransaction') else signed_swap.raw_transaction
|
|
tx_hash = w3_instance.eth.send_raw_transaction(raw_swap)
|
|
print(f"Swap Sent: {tx_hash.hex()}")
|
|
w3_instance.eth.wait_for_transaction_receipt(tx_hash)
|
|
|
|
# Verify Balance After Swap
|
|
bal0 = token0_contract.functions.balanceOf(account.address).call()
|
|
if bal0 < amount0_needed:
|
|
print(f"❌ Swap insufficient. Have {bal0}, Need {amount0_needed}")
|
|
return False
|
|
return True
|
|
|
|
elif deficit1 > 0 and bal0 > amount0_needed:
|
|
surplus0 = bal0 - amount0_needed
|
|
print(f"Swapping surplus Token0 ({surplus0}) for Token1...")
|
|
|
|
approve_txn = token0_contract.functions.approve(router_contract.address, surplus0).build_transaction({
|
|
'from': account.address, 'nonce': w3_instance.eth.get_transaction_count(account.address),
|
|
'gas': 100000, 'maxFeePerGas': w3_instance.eth.gas_price * 2, 'maxPriorityFeePerGas': w3_instance.eth.max_priority_fee,
|
|
'chainId': w3_instance.eth.chain_id
|
|
})
|
|
signed = w3_instance.eth.account.sign_transaction(approve_txn, private_key=account.key)
|
|
raw = signed.rawTransaction if hasattr(signed, 'rawTransaction') else signed.raw_transaction
|
|
w3_instance.eth.send_raw_transaction(raw)
|
|
time.sleep(2)
|
|
|
|
params = (token0, token1, 500, account.address, int(time.time()) + 120, surplus0, 0, 0)
|
|
swap_txn = router_contract.functions.exactInputSingle(params).build_transaction({
|
|
'from': account.address, 'nonce': w3_instance.eth.get_transaction_count(account.address),
|
|
'gas': 300000, 'maxFeePerGas': w3_instance.eth.gas_price * 2, 'maxPriorityFeePerGas': w3_instance.eth.max_priority_fee,
|
|
'chainId': w3_instance.eth.chain_id
|
|
})
|
|
signed_swap = w3_instance.eth.account.sign_transaction(swap_txn, private_key=account.key)
|
|
raw_swap = signed_swap.rawTransaction if hasattr(signed_swap, 'rawTransaction') else signed_swap.raw_transaction
|
|
tx_hash = w3_instance.eth.send_raw_transaction(raw_swap)
|
|
print(f"Swap Sent: {tx_hash.hex()}")
|
|
w3_instance.eth.wait_for_transaction_receipt(tx_hash)
|
|
|
|
# Verify Balance After Swap
|
|
bal1 = token1_contract.functions.balanceOf(account.address).call()
|
|
if bal1 < amount1_needed:
|
|
print(f"❌ Swap insufficient. Have {bal1}, Need {amount1_needed}")
|
|
return False
|
|
return True
|
|
|
|
print("❌ Insufficient funds for required amounts.")
|
|
return False
|
|
|
|
def get_token_balances(w3_instance, account_address, token0_address, token1_address):
|
|
try:
|
|
token0_contract = w3_instance.eth.contract(address=token0_address, abi=ERC20_ABI)
|
|
token1_contract = w3_instance.eth.contract(address=token1_address, abi=ERC20_ABI)
|
|
b0 = token0_contract.functions.balanceOf(account_address).call()
|
|
b1 = token1_contract.functions.balanceOf(account_address).call()
|
|
return b0, b1
|
|
except Exception as e:
|
|
logger.error(f"❌ Balance fetch failed: {e}")
|
|
return 0, 0
|
|
|
|
def decrease_liquidity(w3_instance, npm_contract, account, position_id, liquidity_amount):
|
|
try:
|
|
# First check if position still has liquidity
|
|
current_position = npm_contract.functions.positions(position_id).call()
|
|
current_liquidity = current_position[7] # liquidity is at index 7
|
|
|
|
if current_liquidity == 0:
|
|
logger.info(f"Position {position_id} already has 0 liquidity. Skipping decrease.")
|
|
return True
|
|
|
|
txn = npm_contract.functions.decreaseLiquidity((position_id, liquidity_amount, 0, 0, int(time.time()) + 180)).build_transaction({
|
|
'from': account.address, 'gas': 1000000, 'maxFeePerGas': w3_instance.eth.gas_price * 2, 'maxPriorityFeePerGas': w3_instance.eth.max_priority_fee, 'nonce': w3_instance.eth.get_transaction_count(account.address), 'chainId': w3_instance.eth.chain_id
|
|
})
|
|
signed = w3_instance.eth.account.sign_transaction(txn, private_key=account.key)
|
|
raw = signed.rawTransaction if hasattr(signed, 'rawTransaction') else signed.raw_transaction
|
|
tx_hash = w3_instance.eth.send_raw_transaction(raw)
|
|
print(f"Decrease Sent: {tx_hash.hex()}")
|
|
w3_instance.eth.wait_for_transaction_receipt(tx_hash, timeout=TRANSACTION_TIMEOUT_SECONDS)
|
|
|
|
# Verify liquidity was actually decreased
|
|
post_position = npm_contract.functions.positions(position_id).call()
|
|
post_liquidity = post_position[7]
|
|
if post_liquidity == 0:
|
|
logger.info(f"✅ Position {position_id} liquidity successfully decreased to 0")
|
|
return True
|
|
else:
|
|
logger.warning(f"⚠️ Position {position_id} still has {post_liquidity} liquidity after decrease")
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f"Error decreasing: {e}")
|
|
return False
|
|
|
|
def mint_new_position(w3_instance, npm_contract, account, token0, token1, amount0, amount1, tick_lower, tick_upper):
|
|
logger.info(f"🚀 INITIATING MINT: Delta-Zero hedge setup required")
|
|
try:
|
|
token0_c = w3_instance.eth.contract(address=token0, abi=ERC20_ABI)
|
|
token1_c = w3_instance.eth.contract(address=token1, abi=ERC20_ABI)
|
|
|
|
# Approve 0
|
|
txn0 = token0_c.functions.approve(npm_contract.address, amount0).build_transaction({
|
|
'from': account.address, 'nonce': w3_instance.eth.get_transaction_count(account.address),
|
|
'gas': 100000, 'maxFeePerGas': w3_instance.eth.gas_price * 2, 'maxPriorityFeePerGas': w3_instance.eth.max_priority_fee, 'chainId': w3_instance.eth.chain_id
|
|
})
|
|
signed0 = w3_instance.eth.account.sign_transaction(txn0, private_key=account.key)
|
|
raw0 = signed0.rawTransaction if hasattr(signed0, 'rawTransaction') else signed0.raw_transaction
|
|
w3_instance.eth.send_raw_transaction(raw0)
|
|
time.sleep(2)
|
|
|
|
# Approve 1
|
|
txn1 = token1_c.functions.approve(npm_contract.address, amount1).build_transaction({
|
|
'from': account.address, 'nonce': w3_instance.eth.get_transaction_count(account.address),
|
|
'gas': 100000, 'maxFeePerGas': w3_instance.eth.gas_price * 2, 'maxPriorityFeePerGas': w3_instance.eth.max_priority_fee, 'chainId': w3_instance.eth.chain_id
|
|
})
|
|
signed1 = w3_instance.eth.account.sign_transaction(txn1, private_key=account.key)
|
|
raw1 = signed1.rawTransaction if hasattr(signed1, 'rawTransaction') else signed1.raw_transaction
|
|
w3_instance.eth.send_raw_transaction(raw1)
|
|
time.sleep(2)
|
|
|
|
# Mint
|
|
params = (token0, token1, 500, tick_lower, tick_upper, amount0, amount1, 0, 0, account.address, int(time.time()) + 180)
|
|
mint_txn = npm_contract.functions.mint(params).build_transaction({
|
|
'from': account.address, 'nonce': w3_instance.eth.get_transaction_count(account.address),
|
|
'gas': 800000, 'maxFeePerGas': w3_instance.eth.gas_price * 2, 'maxPriorityFeePerGas': w3_instance.eth.max_priority_fee, 'chainId': w3_instance.eth.chain_id
|
|
})
|
|
signed_mint = w3_instance.eth.account.sign_transaction(mint_txn, private_key=account.key)
|
|
raw_mint = signed_mint.rawTransaction if hasattr(signed_mint, 'rawTransaction') else signed_mint.raw_transaction
|
|
tx_hash = w3_instance.eth.send_raw_transaction(raw_mint)
|
|
print(f"Mint Sent: {tx_hash.hex()}")
|
|
|
|
receipt = w3_instance.eth.wait_for_transaction_receipt(tx_hash)
|
|
if receipt.status == 1:
|
|
logger.info("✅ MINT SUCCESSFUL!")
|
|
|
|
result_data = {'token_id': None, 'liquidity': 0, 'amount0': 0, 'amount1': 0}
|
|
|
|
# Web3.py Event Processing to capture ID and Amounts
|
|
try:
|
|
# 1. Capture Token ID from Transfer event
|
|
transfer_events = npm_contract.events.Transfer().process_receipt(receipt)
|
|
for event in transfer_events:
|
|
if event['args']['from'] == "0x0000000000000000000000000000000000000000":
|
|
result_data['token_id'] = event['args']['tokenId']
|
|
break
|
|
|
|
# 2. Capture Amounts from IncreaseLiquidity event
|
|
inc_liq_events = npm_contract.events.IncreaseLiquidity().process_receipt(receipt)
|
|
for event in inc_liq_events:
|
|
if result_data['token_id'] and event['args']['tokenId'] == result_data['token_id']:
|
|
result_data['amount0'] = event['args']['amount0']
|
|
result_data['amount1'] = event['args']['amount1']
|
|
result_data['liquidity'] = event['args']['liquidity']
|
|
break
|
|
|
|
except Exception as e:
|
|
print(f"Event Processing Warning: {e}")
|
|
|
|
if result_data['token_id']:
|
|
print(f"Captured: ID {result_data['token_id']}, Amt0 {result_data['amount0']}, Amt1 {result_data['amount1']}")
|
|
return result_data
|
|
|
|
return None
|
|
else:
|
|
logger.error("❌ MINT FAILED!")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"❌ MINT ERROR: {e}")
|
|
return None
|
|
|
|
def collect_fees(w3_instance, npm_contract, account, position_id):
|
|
try:
|
|
txn = npm_contract.functions.collect((position_id, account.address, 2**128-1, 2**128-1)).build_transaction({
|
|
'from': account.address, 'gas': 1000000, 'maxFeePerGas': w3_instance.eth.gas_price * 2, 'maxPriorityFeePerGas': w3_instance.eth.max_priority_fee, 'nonce': w3_instance.eth.get_transaction_count(account.address), 'chainId': w3_instance.eth.chain_id
|
|
})
|
|
signed = w3_instance.eth.account.sign_transaction(txn, private_key=account.key)
|
|
raw = signed.rawTransaction if hasattr(signed, 'rawTransaction') else signed.raw_transaction
|
|
tx_hash = w3_instance.eth.send_raw_transaction(raw)
|
|
print(f"Collect Sent: {tx_hash.hex()}")
|
|
w3_instance.eth.wait_for_transaction_receipt(tx_hash)
|
|
return True
|
|
except: return False
|
|
|
|
def main():
|
|
logger.info(f"Uniswap Manager starting. CWD: {os.getcwd()}")
|
|
logger.info(f"Process ID: {os.getpid()} - Monitor Interval: {MONITOR_INTERVAL_SECONDS}s")
|
|
# Load .env from current directory
|
|
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.")
|
|
return
|
|
|
|
w3 = Web3(Web3.HTTPProvider(rpc_url))
|
|
if not w3.is_connected():
|
|
logger.error("RPC Connection Failed")
|
|
return
|
|
logger.info(f"Connected to Chain ID: {w3.eth.chain_id}")
|
|
|
|
account = Account.from_key(private_key)
|
|
w3.eth.default_account = account.address
|
|
logger.info(f"Wallet: {account.address}")
|
|
|
|
npm_contract = w3.eth.contract(address=NONFUNGIBLE_POSITION_MANAGER_ADDRESS, abi=NONFUNGIBLE_POSITION_MANAGER_ABI)
|
|
factory_addr = npm_contract.functions.factory().call()
|
|
factory_contract = w3.eth.contract(address=factory_addr, abi=UNISWAP_V3_FACTORY_ABI)
|
|
router_contract = w3.eth.contract(address=UNISWAP_V3_SWAP_ROUTER_ADDRESS, abi=SWAP_ROUTER_ABI)
|
|
|
|
logger.info("=== 🔷 DELTA-ZERO UNISWAP LIFECYCLE MANAGER ===")
|
|
logger.info("🛡️ Edge Protection: ARMED | 🌊 Velocity Monitoring: ACTIVE | ⏱️ Cooldown: ENABLED")
|
|
|
|
while True:
|
|
try:
|
|
# 1. Get All Open Positions
|
|
all_positions = get_all_open_positions()
|
|
|
|
# Check if we have an active AUTOMATIC position
|
|
active_automatic_position = next((p for p in all_positions if p['type'] == 'AUTOMATIC' and p['status'] == 'OPEN'), None)
|
|
|
|
if all_positions:
|
|
logger.info("="*60)
|
|
logger.info(f"Monitoring cycle at: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())} - {len(all_positions)} open positions")
|
|
|
|
for position in all_positions:
|
|
token_id = position['token_id']
|
|
pos_type = position['type']
|
|
|
|
# Fetch Details
|
|
pos_details, pool_c = get_position_details(w3, npm_contract, factory_contract, token_id)
|
|
if not pos_details:
|
|
print(f"ERROR: Could not get details for Position {token_id}. Skipping.")
|
|
continue
|
|
|
|
pool_data = get_pool_dynamic_data(pool_c)
|
|
current_tick = pool_data['tick']
|
|
|
|
# Calculate Fees (Simulation)
|
|
unclaimed0 = 0
|
|
unclaimed1 = 0
|
|
try:
|
|
fees_sim = npm_contract.functions.collect((token_id, "0x0000000000000000000000000000000000000000", 2**128-1, 2**128-1)).call()
|
|
unclaimed0 = from_wei(fees_sim[0], pos_details['token0_decimals'])
|
|
unclaimed1 = from_wei(fees_sim[1], pos_details['token1_decimals'])
|
|
except: pass
|
|
|
|
# Calculate Total Fee Value in Token1 (USDC)
|
|
# Get Current Price from Pool Data
|
|
current_price = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], pos_details['token0_decimals'], pos_details['token1_decimals'])
|
|
total_fees_usd = (unclaimed0 * current_price) + unclaimed1
|
|
|
|
# Check Range
|
|
is_out_of_range = False
|
|
status_str = "IN RANGE"
|
|
if current_tick < pos_details['tickLower']:
|
|
is_out_of_range = True
|
|
status_str = "OUT OF RANGE (BELOW)"
|
|
elif current_tick >= pos_details['tickUpper']:
|
|
is_out_of_range = True
|
|
status_str = "OUT OF RANGE (ABOVE)"
|
|
|
|
# Enhanced position monitoring with agent terminology
|
|
fee_value_text = f"Fees: {unclaimed0:.4f}/{unclaimed1:.4f} (~${total_fees_usd:.2f})"
|
|
|
|
# Calculate edge distances for better monitoring
|
|
range_width = position['range_upper'] - position['range_lower']
|
|
distance_from_bottom = ((current_price - position['range_lower']) / range_width) * 100 if range_width > 0 else 0
|
|
distance_from_top = ((position['range_upper'] - current_price) / range_width) * 100 if range_width > 0 else 0
|
|
|
|
logger.info(f"🛡️ Position {token_id} ({pos_type}): {status_str}")
|
|
logger.info(f"📏 Range: ${position['range_lower']:.2f}-${position['range_upper']:.2f} | Edge: {distance_from_bottom:.1f}%↑/{distance_from_top:.1f}%↓")
|
|
logger.info(f"💰 {fee_value_text} | 🔷 Delta-Zero: {'ACTIVE' if pos_type == 'AUTOMATIC' else 'N/A'}")
|
|
|
|
# --- AUTO CLOSE LOGIC (AUTOMATIC ONLY) ---
|
|
if pos_type == 'AUTOMATIC' and CLOSE_POSITION_ENABLED and is_out_of_range:
|
|
logger.warning(f"⚠️ CLOSE TRIGGERED: Position {token_id} OUT OF RANGE | Delta-Zero hedge unwind required")
|
|
liq = pos_details['liquidity']
|
|
if liq > 0:
|
|
# Mark as CLOSING immediately to notify Hedger
|
|
set_position_status_and_data("CLOSING", {'token_id': token_id})
|
|
|
|
# Capture Balances Before Close
|
|
b0_start, b1_start = get_token_balances(w3, account.address, pos_details['token0_address'], pos_details['token1_address'])
|
|
|
|
# Execute Close
|
|
decrease_success = decrease_liquidity(w3, npm_contract, account, token_id, liq)
|
|
time.sleep(2)
|
|
collect_fees(w3, npm_contract, account, token_id)
|
|
|
|
if decrease_success:
|
|
# Capture Balances After Close
|
|
b0_end, b1_end = get_token_balances(w3, account.address, pos_details['token0_address'], pos_details['token1_address'])
|
|
|
|
# Calculate Deltas (Principal + Fees)
|
|
delta0 = from_wei(b0_end - b0_start, pos_details['token0_decimals'])
|
|
delta1 = from_wei(b1_end - b1_start, pos_details['token1_decimals'])
|
|
|
|
# Calculate Values
|
|
total_exit_usd = (delta0 * current_price) + delta1
|
|
# We calculated total_fees_usd earlier in the loop
|
|
|
|
update_data = {
|
|
'token_id': token_id,
|
|
'fees_collected_usd': total_fees_usd,
|
|
'closed_position_value_usd': total_exit_usd
|
|
}
|
|
set_position_status_and_data("CLOSE", update_data)
|
|
logger.info(f"✅ CLOSE COMPLETE: Position {token_id} | Exit ${total_exit_usd:.2f} | Fees ${total_fees_usd:.2f}")
|
|
|
|
# --- REBALANCE ON CLOSE (If Price Dropped) ---
|
|
if REBALANCE_ON_CLOSE_BELOW_RANGE and status_str == "OUT OF RANGE (BELOW)":
|
|
print("📉 Position closed BELOW range (100% ETH). Selling 50% of WETH inventory to USDC...")
|
|
try:
|
|
# Get WETH Balance
|
|
token0_c = w3.eth.contract(address=pos_details['token0_address'], abi=ERC20_ABI)
|
|
weth_bal = token0_c.functions.balanceOf(account.address).call()
|
|
|
|
amount_in = weth_bal // 2
|
|
|
|
if amount_in > 0:
|
|
# Approve Router
|
|
approve_txn = token0_c.functions.approve(router_contract.address, amount_in).build_transaction({
|
|
'from': account.address, 'nonce': w3.eth.get_transaction_count(account.address),
|
|
'gas': 100000, 'maxFeePerGas': w3.eth.gas_price * 2, 'maxPriorityFeePerGas': w3.eth.max_priority_fee,
|
|
'chainId': w3.eth.chain_id
|
|
})
|
|
signed = w3.eth.account.sign_transaction(approve_txn, private_key=account.key)
|
|
raw = signed.rawTransaction if hasattr(signed, 'rawTransaction') else signed.raw_transaction
|
|
w3.eth.send_raw_transaction(raw)
|
|
time.sleep(2)
|
|
|
|
# Swap WETH -> USDC
|
|
params = (pos_details['token0_address'], pos_details['token1_address'], 500, account.address, int(time.time()) + 120, amount_in, 0, 0)
|
|
swap_txn = router_contract.functions.exactInputSingle(params).build_transaction({
|
|
'from': account.address, 'nonce': w3.eth.get_transaction_count(account.address),
|
|
'gas': 300000, 'maxFeePerGas': w3.eth.gas_price * 2, 'maxPriorityFeePerGas': w3.eth.max_priority_fee,
|
|
'chainId': w3.eth.chain_id
|
|
})
|
|
signed_swap = w3.eth.account.sign_transaction(swap_txn, private_key=account.key)
|
|
raw_swap = signed_swap.rawTransaction if hasattr(signed_swap, 'rawTransaction') else signed_swap.raw_transaction
|
|
tx_hash = w3.eth.send_raw_transaction(raw_swap)
|
|
print(f"⚖️ Rebalance Swap Sent: {tx_hash.hex()}")
|
|
w3.eth.wait_for_transaction_receipt(tx_hash)
|
|
print("✅ Rebalance Complete.")
|
|
except Exception as e:
|
|
print(f"Error during rebalance swap: {e}")
|
|
|
|
else:
|
|
logger.warning("Liquidity 0. Marking closed.")
|
|
set_position_status_and_data("CLOSE", {'token_id': token_id, 'fees_collected_usd': 0.0, 'closed_position_value_usd': 0.0})
|
|
|
|
# --- HANDLE STUCK CLOSING POSITIONS ---
|
|
closing_positions = [p for p in all_positions if p['status'] == 'CLOSING' and p['type'] == 'AUTOMATIC']
|
|
for closing_pos in closing_positions:
|
|
token_id = closing_pos['token_id']
|
|
logger.info(f"🔍 Checking stuck CLOSING position {token_id}...")
|
|
|
|
try:
|
|
# Check if position still has liquidity
|
|
pos_details, pool_c = get_position_details(w3, npm_contract, factory_contract, token_id)
|
|
if pos_details and pos_details['liquidity'] == 0:
|
|
logger.info(f"✅ Position {token_id} already has 0 liquidity. Marking as CLOSED.")
|
|
set_position_status_and_data("CLOSE", {'token_id': token_id, 'fees_collected_usd': 0.0, 'closed_position_value_usd': 0.0})
|
|
else:
|
|
logger.warning(f"⚠️ Position {token_id} still has liquidity. Attempting to close again...")
|
|
# Try to close it again
|
|
if pos_details and pos_details['liquidity'] > 0:
|
|
decrease_success = decrease_liquidity(w3, npm_contract, account, token_id, pos_details['liquidity'])
|
|
time.sleep(2)
|
|
collect_fees(w3, npm_contract, account, token_id)
|
|
|
|
if decrease_success:
|
|
set_position_status_and_data("CLOSE", {'token_id': token_id, 'fees_collected_usd': 0.0, 'closed_position_value_usd': 0.0})
|
|
logger.info(f"✅ Successfully closed stuck position {token_id}")
|
|
else:
|
|
logger.error(f"❌ Failed to close stuck position {token_id}. Will retry next cycle.")
|
|
except Exception as e:
|
|
logger.error(f"Error checking stuck position {token_id}: {e}")
|
|
|
|
# 2. Opening Logic (If no active automatic position)
|
|
if not active_automatic_position and OPEN_POSITION_ENABLED:
|
|
logger.info("No active automatic position. Starting Open Sequence...")
|
|
# Get Pool (WETH/USDC)
|
|
token0 = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1" # WETH
|
|
token1 = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" # USDC
|
|
pool_addr = factory_contract.functions.getPool(token0, token1, 500).call()
|
|
pool_c = w3.eth.contract(address=pool_addr, abi=UNISWAP_V3_POOL_ABI)
|
|
|
|
pool_data = get_pool_dynamic_data(pool_c)
|
|
tick = pool_data['tick']
|
|
|
|
# Range +/- 2%
|
|
tick_delta = int(math.log(1 + RANGE_WIDTH_PCT) / math.log(1.0001))
|
|
spacing = 10
|
|
lower = (tick - tick_delta) // spacing * spacing
|
|
upper = (tick + tick_delta) // spacing * spacing
|
|
|
|
# Amounts
|
|
try:
|
|
token0_c = w3.eth.contract(address=token0, abi=ERC20_ABI)
|
|
token1_c = w3.eth.contract(address=token1, abi=ERC20_ABI)
|
|
d0 = token0_c.functions.decimals().call()
|
|
d1 = token1_c.functions.decimals().call()
|
|
except Exception as e:
|
|
print(f"Error fetching decimals: {e}")
|
|
time.sleep(MONITOR_INTERVAL_SECONDS)
|
|
continue
|
|
|
|
# Determine Investment Value
|
|
investment_val = TARGET_INVESTMENT_VALUE_TOKEN1
|
|
|
|
if investment_val == "MAX":
|
|
try:
|
|
# Get Balances
|
|
bal0 = token0_c.functions.balanceOf(account.address).call()
|
|
bal1 = token1_c.functions.balanceOf(account.address).call()
|
|
|
|
# Convert to Float
|
|
f_bal0 = from_wei(bal0, d0)
|
|
f_bal1 = from_wei(bal1, d1)
|
|
|
|
# Get Price (USDC per ETH) from Pool
|
|
price_eth_usdc = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1)
|
|
|
|
# Total Value in USDC
|
|
total_val_usd = (f_bal0 * price_eth_usdc) + f_bal1
|
|
|
|
# Apply Buffer ($200)
|
|
investment_val = max(0, total_val_usd - 200)
|
|
|
|
logger.info(f"🎯 MAX Investment Mode: Wallet ${total_val_usd:.2f} -> Target ${investment_val:.2f} (Buffer $200)")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error calculating MAX investment: {e}")
|
|
investment_val = 0 # Safety fallthrough
|
|
|
|
amt0, amt1 = calculate_mint_amounts(tick, lower, upper, investment_val, d0, d1, pool_data['sqrtPriceX96'])
|
|
amt0_buf, amt1_buf = int(amt0 * 1.02), int(amt1 * 1.02)
|
|
|
|
if check_and_swap(w3, router_contract, account, token0, token1, amt0_buf, amt1_buf):
|
|
mint_result = mint_new_position(w3, npm_contract, account, token0, token1, amt0, amt1, lower, upper)
|
|
|
|
if mint_result:
|
|
# --- STEP 1: IMMEDIATE 'PENDING_HEDGE' STATUS ---
|
|
# Use available data to notify Hedger ASAP
|
|
try:
|
|
token0_c = w3.eth.contract(address=token0, abi=ERC20_ABI)
|
|
token1_c = w3.eth.contract(address=token1, abi=ERC20_ABI)
|
|
d0 = token0_c.functions.decimals().call()
|
|
d1 = token1_c.functions.decimals().call()
|
|
|
|
entry_price = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1)
|
|
|
|
# Initial basic data for rapid hedging start
|
|
pending_data = {
|
|
'token_id': mint_result['token_id'],
|
|
'entry_price': entry_price,
|
|
'range_lower': price_from_tick(lower, d0, d1),
|
|
'range_upper': price_from_tick(upper, d0, d1),
|
|
'target_value': TARGET_INVESTMENT_VALUE_TOKEN1, # Use target as estimate
|
|
'amount0_initial': mint_result['amount0'],
|
|
'amount1_initial': mint_result['amount1'],
|
|
'timestamp_open': int(time.time())
|
|
}
|
|
set_position_status_and_data("PENDING_HEDGE", pending_data)
|
|
logger.info(f"🚀 PENDING_HEDGE status set for Position {mint_result['token_id']}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error setting PENDING_HEDGE status: {e}")
|
|
|
|
# --- STEP 2: FULL PROCESSING & 'OPEN' STATUS ---
|
|
try:
|
|
s0 = token0_c.functions.symbol().call()
|
|
s1 = token1_c.functions.symbol().call()
|
|
except:
|
|
s0, s1 = "T0", "T1"
|
|
|
|
real_amt0 = from_wei(mint_result['amount0'], d0)
|
|
real_amt1 = from_wei(mint_result['amount1'], d1)
|
|
# Recalculate exact entry price/value if needed or use previous
|
|
actual_value = (real_amt0 * entry_price) + real_amt1
|
|
logger.info(f"Position {mint_result['token_id']} OPENED - Value: {actual_value:.2f} {s1} | Investment: ${actual_value:.2f}")
|
|
|
|
pos_data = {
|
|
'token_id': mint_result['token_id'],
|
|
'entry_price': entry_price,
|
|
'range_lower': price_from_tick(lower, d0, d1),
|
|
'range_upper': price_from_tick(upper, d0, d1),
|
|
'target_value': actual_value,
|
|
'amount0_initial': mint_result['amount0'],
|
|
'amount1_initial': mint_result['amount1']
|
|
}
|
|
set_position_status_and_data("OPEN", pos_data)
|
|
print("Cycle Complete. Monitoring.")
|
|
|
|
elif not all_positions:
|
|
logger.info("No open positions (Manual or Automatic). Monitoring continues...")
|
|
|
|
time.sleep(MONITOR_INTERVAL_SECONDS)
|
|
|
|
except KeyboardInterrupt:
|
|
logger.info("🛑 Manager stopped by user.")
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"❌ MAIN LOOP ERROR: {e}")
|
|
time.sleep(MONITOR_INTERVAL_SECONDS)
|
|
|
|
# --- Hedge Execution Functions ---
|
|
def get_token_symbol(w3_instance, token_address):
|
|
"""Get token symbol from contract"""
|
|
try:
|
|
token_contract = w3_instance.eth.contract(address=token_address, abi=ERC20_ABI)
|
|
return token_contract.functions.symbol().call()
|
|
except Exception as e:
|
|
logger.error(f"Error getting token symbol for {token_address}: {e}")
|
|
return "UNKNOWN"
|
|
|
|
def get_token_decimals(w3_instance, token_address):
|
|
"""Get token decimals from contract"""
|
|
try:
|
|
token_contract = w3_instance.eth.contract(address=token_address, abi=ERC20_ABI)
|
|
return token_contract.functions.decimals().call()
|
|
except Exception as e:
|
|
logger.error(f"Error getting token decimals for {token_address}: {e}")
|
|
return 18 # Default to 18 for most tokens
|
|
|
|
async def record_hedge_execution(hedge_info):
|
|
"""Record hedge execution to trades log"""
|
|
try:
|
|
trades_file = "logs/trades.json"
|
|
os.makedirs("logs", exist_ok=True)
|
|
|
|
# Load existing trades
|
|
trades = []
|
|
if os.path.exists(trades_file):
|
|
try:
|
|
with open(trades_file, 'r') as f:
|
|
trades = json.load(f)
|
|
except:
|
|
trades = []
|
|
|
|
# Add new hedge execution
|
|
trades.append({
|
|
"timestamp": hedge_info["timestamp"],
|
|
"action": "hedge_execute",
|
|
"token_address": hedge_info["token_address"],
|
|
"token_symbol": hedge_info["token_symbol"],
|
|
"amount": hedge_info["hedge_amount"],
|
|
"transaction_hash": hedge_info["transaction_hash"],
|
|
"status": "success"
|
|
})
|
|
|
|
# Save to file
|
|
with open(trades_file, 'w') as f:
|
|
json.dump(trades, f, indent=2)
|
|
|
|
logger.info(f"📝 Hedge execution recorded in trades log")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error recording hedge execution: {e}")
|
|
|
|
def execute_hedge_sync(w3_instance, router_contract, account, token_address: str, hedge_amount: float) -> bool:
|
|
"""Execute hedge trade on Uniswap"""
|
|
try:
|
|
# Validate inputs
|
|
if hedge_amount <= 0:
|
|
logger.warning(f"Invalid hedge amount: {hedge_amount}")
|
|
return False
|
|
|
|
# Get token information
|
|
token_symbol = get_token_symbol(w3_instance, token_address)
|
|
token_decimals = get_token_decimals(w3_instance, token_address)
|
|
|
|
# Calculate token amount in wei (adjust for decimals)
|
|
token_amount_wei = int(hedge_amount * (10 ** token_decimals))
|
|
|
|
logger.info(
|
|
f"🔄 Executing hedge: {token_symbol} - {hedge_amount:.6f} tokens "
|
|
f"({token_amount_wei} wei)"
|
|
)
|
|
|
|
# For CLP, we'll swap from WETH to the token (buying the token)
|
|
# If we already hold the token, this balances our exposure
|
|
|
|
# Get WETH address and contract
|
|
weth_address = "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"
|
|
weth_contract = w3_instance.eth.contract(address=weth_address, abi=ERC20_ABI)
|
|
|
|
# Check WETH balance
|
|
weth_balance = weth_contract.functions.balanceOf(account.address).call()
|
|
|
|
if weth_balance < token_amount_wei:
|
|
logger.warning(f"Insufficient WETH balance for hedge. Have: {weth_balance}, Need: {token_amount_wei}")
|
|
return False
|
|
|
|
# Approve router to spend WETH
|
|
approve_txn = weth_contract.functions.approve(router_contract.address, token_amount_wei).build_transaction({
|
|
'from': account.address,
|
|
'nonce': w3_instance.eth.get_transaction_count(account.address),
|
|
'gas': 100000,
|
|
'maxFeePerGas': w3_instance.eth.gas_price * 2,
|
|
'maxPriorityFeePerGas': w3_instance.eth.max_priority_fee,
|
|
'chainId': w3_instance.eth.chain_id
|
|
})
|
|
|
|
signed_approve = w3_instance.eth.account.sign_transaction(approve_txn, private_key=account.key)
|
|
raw_approve = signed_approve.rawTransaction if hasattr(signed_approve, 'rawTransaction') else signed_approve.raw_transaction
|
|
approve_tx_hash = w3_instance.eth.send_raw_transaction(raw_approve)
|
|
logger.info(f"📋 Approval sent: {approve_tx_hash.hex()}")
|
|
w3_instance.eth.wait_for_transaction_receipt(approve_tx_hash)
|
|
|
|
# Execute swap WETH -> target token
|
|
swap_params = (
|
|
weth_address, # tokenIn
|
|
token_address, # tokenOut
|
|
500, # fee (0.05%)
|
|
account.address, # recipient
|
|
int(time.time()) + 120, # deadline
|
|
token_amount_wei, # amountIn
|
|
0, # amountOutMinimum (0 for now)
|
|
0 # sqrtPriceLimitX96 (0 for no limit)
|
|
)
|
|
|
|
swap_txn = router_contract.functions.exactInputSingle(swap_params).build_transaction({
|
|
'from': account.address,
|
|
'nonce': w3_instance.eth.get_transaction_count(account.address),
|
|
'gas': 300000,
|
|
'maxFeePerGas': w3_instance.eth.gas_price * 2,
|
|
'maxPriorityFeePerGas': w3_instance.eth.max_priority_fee,
|
|
'chainId': w3_instance.eth.chain_id
|
|
})
|
|
|
|
signed_swap = w3_instance.eth.account.sign_transaction(swap_txn, private_key=account.key)
|
|
raw_swap = signed_swap.rawTransaction if hasattr(signed_swap, 'rawTransaction') else signed_swap.raw_transaction
|
|
swap_tx_hash = w3_instance.eth.send_raw_transaction(raw_swap)
|
|
logger.info(f"🔄 Swap sent: {swap_tx_hash.hex()}")
|
|
|
|
receipt = w3_instance.eth.wait_for_transaction_receipt(swap_tx_hash)
|
|
|
|
if receipt.status == 1:
|
|
# Record successful hedge
|
|
hedge_info = {
|
|
"token_address": token_address,
|
|
"token_symbol": token_symbol,
|
|
"hedge_amount": hedge_amount,
|
|
"token_amount_wei": token_amount_wei,
|
|
"transaction_hash": swap_tx_hash.hex(),
|
|
"timestamp": datetime.now().isoformat(),
|
|
"status": "executed"
|
|
}
|
|
|
|
logger.info(
|
|
f"✅ Hedge executed successfully:\n"
|
|
f" Token: {token_symbol} ({token_address})\n"
|
|
f" Amount: {hedge_amount:.6f}\n"
|
|
f" Tx Hash: {hedge_info['transaction_hash']}\n"
|
|
f" Time: {hedge_info['timestamp']}"
|
|
)
|
|
|
|
# Record hedge in local storage (synchronously for simplicity)
|
|
try:
|
|
trades_file = "logs/trades.json"
|
|
os.makedirs("logs", exist_ok=True)
|
|
|
|
# Load existing trades
|
|
trades = []
|
|
if os.path.exists(trades_file):
|
|
try:
|
|
with open(trades_file, 'r') as f:
|
|
trades = json.load(f)
|
|
except:
|
|
trades = []
|
|
|
|
# Add new hedge execution
|
|
trades.append({
|
|
"timestamp": hedge_info["timestamp"],
|
|
"action": "hedge_execute",
|
|
"token_address": hedge_info["token_address"],
|
|
"token_symbol": hedge_info["token_symbol"],
|
|
"amount": hedge_info["hedge_amount"],
|
|
"transaction_hash": hedge_info["transaction_hash"],
|
|
"status": "success"
|
|
})
|
|
|
|
# Save to file
|
|
with open(trades_file, 'w') as f:
|
|
json.dump(trades, f, indent=2)
|
|
|
|
logger.info(f"📝 Hedge execution recorded in trades log")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error recording hedge execution: {e}")
|
|
|
|
return True
|
|
else:
|
|
logger.error(f"❌ Hedge transaction failed: {swap_tx_hash.hex()}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Hedge execution failed: {str(e)}", exc_info=True)
|
|
return False
|
|
|
|
if __name__ == "__main__":
|
|
main()
|