working version, before optimalization
This commit is contained in:
491
clp_manager.py
491
clp_manager.py
@ -63,65 +63,34 @@ formatter = logging.Formatter('%(unix_ms)d, %(asctime)s - %(name)s - %(levelname
|
||||
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": "uint32", "name": "feeProtocol", "type": "uint32"}, {"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": "tickSpacing", "outputs": [{"internalType": "int24", "name": "", "type": "int24"}], "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"}
|
||||
]
|
||||
''')
|
||||
from clp_abis import (
|
||||
NONFUNGIBLE_POSITION_MANAGER_ABI,
|
||||
UNISWAP_V3_POOL_ABI,
|
||||
ERC20_ABI,
|
||||
UNISWAP_V3_FACTORY_ABI,
|
||||
AERODROME_FACTORY_ABI,
|
||||
AERODROME_POOL_ABI,
|
||||
AERODROME_NPM_ABI,
|
||||
SWAP_ROUTER_ABI,
|
||||
WETH9_ABI
|
||||
)
|
||||
|
||||
from clp_config import get_current_config, STATUS_FILE
|
||||
from tools.universal_swapper import execute_swap
|
||||
|
||||
# --- GET ACTIVE DEX CONFIG ---
|
||||
CONFIG = get_current_config()
|
||||
|
||||
DEX_TO_CHAIN = {
|
||||
"UNISWAP_V3": "ARBITRUM",
|
||||
"UNISWAP_wide": "ARBITRUM",
|
||||
"PANCAKESWAP_BNB": "BSC",
|
||||
"WETH_CBBTC_BASE": "BASE",
|
||||
"UNISWAP_BASE_CL": "BASE",
|
||||
"AERODROME_BASE_CL": "BASE",
|
||||
"AERODROME_WETH-USDC_008": "BASE"
|
||||
}
|
||||
|
||||
# --- CONFIGURATION FROM STRATEGY ---
|
||||
MONITOR_INTERVAL_SECONDS = CONFIG.get("MONITOR_INTERVAL_SECONDS", 60)
|
||||
CLOSE_POSITION_ENABLED = CONFIG.get("CLOSE_POSITION_ENABLED", True)
|
||||
@ -130,11 +99,77 @@ REBALANCE_ON_CLOSE_BELOW_RANGE = CONFIG.get("REBALANCE_ON_CLOSE_BELOW_RANGE", Tr
|
||||
TARGET_INVESTMENT_VALUE_USDC = CONFIG.get("TARGET_INVESTMENT_AMOUNT", 2000)
|
||||
INITIAL_HEDGE_CAPITAL_USDC = CONFIG.get("INITIAL_HEDGE_CAPITAL", 1000)
|
||||
RANGE_WIDTH_PCT = CONFIG.get("RANGE_WIDTH_PCT", Decimal("0.01"))
|
||||
RANGE_MODE = CONFIG.get("RANGE_MODE", "FIXED")
|
||||
SLIPPAGE_TOLERANCE = CONFIG.get("SLIPPAGE_TOLERANCE", Decimal("0.02"))
|
||||
TRANSACTION_TIMEOUT_SECONDS = CONFIG.get("TRANSACTION_TIMEOUT_SECONDS", 30)
|
||||
|
||||
# --- AUTO RANGE HELPERS ---
|
||||
|
||||
def get_market_indicators() -> Optional[Dict]:
|
||||
file_path = os.path.join("market_data", "indicators.json")
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check Freshness (5m)
|
||||
last_updated_str = data.get("last_updated")
|
||||
if not last_updated_str: return None
|
||||
|
||||
last_updated = datetime.fromisoformat(last_updated_str)
|
||||
if (datetime.now() - last_updated).total_seconds() > 300:
|
||||
logger.warning("⚠️ Market indicators file is stale (>5m).")
|
||||
return None
|
||||
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading indicators: {e}")
|
||||
return None
|
||||
|
||||
def calculate_dynamic_range_pct(coin: str) -> Optional[Decimal]:
|
||||
indicators = get_market_indicators()
|
||||
if not indicators: return None
|
||||
|
||||
# Normalize symbols (Hyperliquid uses ETH, BNB while DEX uses WETH, WBNB)
|
||||
symbol_map = {"WETH": "ETH", "WBNB": "BNB"}
|
||||
lookup_coin = symbol_map.get(coin.upper(), coin.upper())
|
||||
|
||||
coin_data = indicators.get("data", {}).get(lookup_coin)
|
||||
if not coin_data: return None
|
||||
|
||||
try:
|
||||
price = Decimal(str(coin_data["current_price"]))
|
||||
bb12 = coin_data["bb"]["12h"]
|
||||
bb_low = Decimal(str(bb12["lower"]))
|
||||
bb_high = Decimal(str(bb12["upper"]))
|
||||
ma88 = Decimal(str(coin_data["ma"]["88"]))
|
||||
|
||||
# Condition 2: Price inside BB 12h
|
||||
if not (bb_low <= price <= bb_high):
|
||||
logger.warning(f"⚖️ AUTO: Price {price:.2f} is outside BB 12h ({bb_low:.2f} - {bb_high:.2f}). Skipping AUTO.")
|
||||
return None
|
||||
|
||||
# Condition 3: MA 88 inside BB 12h
|
||||
if not (bb_low <= ma88 <= bb_high):
|
||||
logger.warning(f"⚖️ AUTO: MA 88 {ma88:.2f} is outside BB 12h. Skipping AUTO.")
|
||||
return None
|
||||
|
||||
# Calculation: Max distance to BB edge
|
||||
dist_low = abs(price - bb_low)
|
||||
dist_high = abs(price - bb_high)
|
||||
max_dist = max(dist_low, dist_high)
|
||||
|
||||
range_pct = max_dist / price
|
||||
return range_pct
|
||||
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
logger.error(f"Error in dynamic range calc: {e}")
|
||||
return None
|
||||
|
||||
# --- CONFIGURATION CONSTANTS ---
|
||||
NONFUNGIBLE_POSITION_MANAGER_ADDRESS = CONFIG["NPM_ADDRESS"]
|
||||
# Router address not strictly needed for Manager if using universal_swapper, but kept for ref
|
||||
UNISWAP_V3_SWAP_ROUTER_ADDRESS = CONFIG["ROUTER_ADDRESS"]
|
||||
# Arbitrum WETH/USDC (or generic T0/T1)
|
||||
WETH_ADDRESS = CONFIG["WRAPPED_NATIVE_ADDRESS"]
|
||||
@ -311,7 +346,8 @@ def get_position_details(w3: Web3, npm_contract, factory_contract, token_id: int
|
||||
if pool_address == '0x0000000000000000000000000000000000000000':
|
||||
return None, None
|
||||
|
||||
pool_contract = w3.eth.contract(address=pool_address, abi=UNISWAP_V3_POOL_ABI)
|
||||
pool_abi = AERODROME_POOL_ABI if "AERODROME" in CONFIG.get("NAME", "").upper() else UNISWAP_V3_POOL_ABI
|
||||
pool_contract = w3.eth.contract(address=pool_address, abi=pool_abi)
|
||||
|
||||
return {
|
||||
"token0_address": token0_address, "token1_address": token1_address,
|
||||
@ -397,6 +433,7 @@ def ensure_allowance(w3: Web3, account: LocalAccount, token_address: str, spende
|
||||
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.
|
||||
Uses universal_swapper for the swap execution.
|
||||
"""
|
||||
token0 = clean_address(token0)
|
||||
token1 = clean_address(token1)
|
||||
@ -444,12 +481,12 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount,
|
||||
# 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
|
||||
chain_name = DEX_TO_CHAIN.get(os.environ.get("TARGET_DEX", "UNISWAP_V3"), "ARBITRUM")
|
||||
|
||||
buffer_multiplier = Decimal("1.02") # 2% buffer for slippage/price moves
|
||||
token_in_sym, token_out_sym = None, None
|
||||
amount_in_float = 0.0
|
||||
|
||||
buffer_multiplier = Decimal("1.03")
|
||||
if deficit0 > 0 and bal1 > amount1_needed:
|
||||
# Need T0 (ETH), Have extra T1 (USDC)
|
||||
# Swap T1 -> T0
|
||||
@ -462,8 +499,11 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount,
|
||||
surplus1 = bal1 - amount1_needed
|
||||
|
||||
if surplus1 >= amount_in_needed:
|
||||
token_in, token_out = token1, token0
|
||||
amount_in = amount_in_needed
|
||||
# Get Symbols
|
||||
token_in_sym = token1_c.functions.symbol().call().upper()
|
||||
token_out_sym = token0_c.functions.symbol().call().upper()
|
||||
amount_in_float = float(Decimal(amount_in_needed) / Decimal(10**d1))
|
||||
|
||||
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}")
|
||||
@ -479,49 +519,48 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount,
|
||||
surplus0 = bal0 - amount0_needed
|
||||
|
||||
if surplus0 >= amount_in_needed:
|
||||
token_in, token_out = token0, token1
|
||||
amount_in = amount_in_needed
|
||||
token_in_sym = token0_c.functions.symbol().call().upper()
|
||||
token_out_sym = token1_c.functions.symbol().call().upper()
|
||||
amount_in_float = float(Decimal(amount_in_needed) / Decimal(10**d0))
|
||||
|
||||
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
|
||||
if token_in_sym and amount_in_float > 0:
|
||||
logger.info(f"🔄 Delegating Swap to Universal Swapper: {amount_in_float} {token_in_sym} -> {token_out_sym} on {chain_name}...")
|
||||
try:
|
||||
# Use Standard Fee (500) if configured fee is weird (like 1 for Aerodrome tickSpacing)
|
||||
# This ensures the standard router finds a valid pool (WETH/USDC 0.05%)
|
||||
swap_fee = POOL_FEE if POOL_FEE >= 100 else 500
|
||||
|
||||
params = (
|
||||
token_in, token_out, POOL_FEE, 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
|
||||
# Call Universal Swapper
|
||||
execute_swap(chain_name, token_in_sym, token_out_sym, amount_in_float, fee_tier=swap_fee)
|
||||
|
||||
# Wait for node indexing
|
||||
logger.info("⏳ Waiting for balance update...")
|
||||
time.sleep(2)
|
||||
|
||||
# Retry check loop
|
||||
for i in range(3):
|
||||
bal0 = token0_c.functions.balanceOf(account.address).call()
|
||||
bal1 = token1_c.functions.balanceOf(account.address).call()
|
||||
|
||||
# Human-readable format
|
||||
f_bal0 = Decimal(bal0) / Decimal(10**d0)
|
||||
f_need0 = Decimal(amount0_needed) / Decimal(10**d0)
|
||||
f_bal1 = Decimal(bal1) / Decimal(10**d1)
|
||||
f_need1 = Decimal(amount1_needed) / Decimal(10**d1)
|
||||
|
||||
tgt_inv = CONFIG.get("TARGET_INVESTMENT_AMOUNT", "N/A")
|
||||
range_w = CONFIG.get("RANGE_WIDTH_PCT", "N/A")
|
||||
|
||||
logger.warning(f"❌ Insufficient funds. Settings: [Target=${tgt_inv}, Range={range_w}]. Wallet: T0: {f_bal0:.4f} / {f_need0:.4f}, T1: {f_bal1:.4f} / {f_need1:.4f}")
|
||||
if bal0 >= amount0_needed and bal1 >= amount1_needed:
|
||||
logger.info("✅ Balances sufficient.")
|
||||
return True
|
||||
|
||||
if i < 2:
|
||||
logger.info(f"⏳ Balance not updated yet, retrying ({i+1}/3)...")
|
||||
time.sleep(2)
|
||||
|
||||
logger.warning(f"⚠️ Swap executed but still short? T0: {bal0}/{amount0_needed}, T1: {bal1}/{amount1_needed}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Universal Swap Failed: {e}")
|
||||
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, d0: int, d1: int) -> Optional[Dict]:
|
||||
@ -540,14 +579,20 @@ def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str
|
||||
amount1_min = int(Decimal(amount1) * (Decimal(1) - SLIPPAGE_TOLERANCE))
|
||||
|
||||
# 3. Mint
|
||||
params = (
|
||||
base_params = [
|
||||
token0, token1, POOL_FEE,
|
||||
tick_lower, tick_upper,
|
||||
amount0, amount1,
|
||||
amount0_min, amount1_min,
|
||||
account.address,
|
||||
int(time.time()) + 180
|
||||
)
|
||||
]
|
||||
|
||||
# Aerodrome Slipstream expects sqrtPriceX96 as the last parameter
|
||||
if "AERODROME" in os.environ.get("TARGET_DEX", "").upper():
|
||||
base_params.append(0) # sqrtPriceX96
|
||||
|
||||
params = tuple(base_params)
|
||||
|
||||
receipt = send_transaction_robust(w3, account, npm_contract.functions.mint(params), extra_msg="Mint Position")
|
||||
|
||||
@ -705,9 +750,43 @@ def update_position_status(token_id: int, status: str, extra_data: Dict = {}):
|
||||
save_status_data(data)
|
||||
logger.info(f"💾 Updated Position {token_id} status to {status}")
|
||||
|
||||
import argparse
|
||||
import requests
|
||||
|
||||
# --- REAL-TIME ORACLE HELPER ---
|
||||
def get_realtime_price(coin: str) -> Optional[Decimal]:
|
||||
"""Fetches current mid-price directly from Hyperliquid API (low latency)."""
|
||||
try:
|
||||
url = "https://api.hyperliquid.xyz/info"
|
||||
response = requests.post(url, json={"type": "allMids"}, timeout=2)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
# Hyperliquid symbols are usually clean (ETH, BNB)
|
||||
# Map common variations just in case
|
||||
target = coin.upper().replace("WETH", "ETH").replace("WBNB", "BNB")
|
||||
|
||||
if target in data:
|
||||
return Decimal(data[target])
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Failed to fetch realtime Oracle price: {e}")
|
||||
return None
|
||||
|
||||
# --- MAIN LOOP ---
|
||||
|
||||
def main():
|
||||
# --- ARGUMENT PARSING ---
|
||||
parser = argparse.ArgumentParser(description="Uniswap CLP Manager")
|
||||
parser.add_argument("--force", type=float, help="Force open a position with specific range width (e.g., 0.75), ignoring AUTO safe checks.")
|
||||
args = parser.parse_args()
|
||||
|
||||
force_mode_active = False
|
||||
force_width_pct = Decimal("0")
|
||||
|
||||
if args.force:
|
||||
force_mode_active = True
|
||||
force_width_pct = Decimal(str(args.force)) / 100 # Convert 0.75 -> 0.0075
|
||||
logger.warning(f"🚨 FORCE MODE ACTIVE: Will bypass safe checks for FIRST position with width {args.force}%")
|
||||
|
||||
logger.info(f"🔷 {CONFIG['NAME']} Manager V2 Starting...")
|
||||
load_dotenv(override=True)
|
||||
|
||||
@ -731,22 +810,34 @@ def main():
|
||||
logger.info(f"👤 Wallet: {account.address}")
|
||||
|
||||
# Contracts
|
||||
npm = w3.eth.contract(address=clean_address(NONFUNGIBLE_POSITION_MANAGER_ADDRESS), abi=NONFUNGIBLE_POSITION_MANAGER_ABI)
|
||||
target_dex_name = os.environ.get("TARGET_DEX", "").upper()
|
||||
if "AERODROME" in target_dex_name or "AERODROME" in CONFIG.get("NAME", "").upper():
|
||||
logger.info("✈️ Using Aerodrome NPM ABI")
|
||||
npm = w3.eth.contract(address=clean_address(NONFUNGIBLE_POSITION_MANAGER_ADDRESS), abi=AERODROME_NPM_ABI)
|
||||
else:
|
||||
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)
|
||||
|
||||
# Select Factory ABI based on DEX type
|
||||
if "AERODROME" in target_dex_name or "AERODROME" in CONFIG.get("NAME", "").upper():
|
||||
logger.info("✈️ Using Aerodrome Factory ABI (tickSpacing instead of fee)")
|
||||
factory_abi = AERODROME_FACTORY_ABI
|
||||
else:
|
||||
factory_abi = UNISWAP_V3_FACTORY_ABI
|
||||
|
||||
factory = w3.eth.contract(address=factory_addr, abi=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()
|
||||
# Include CLOSING status to ensure we finish what we started (fee collection retries)
|
||||
open_positions = [p for p in status_data if p.get('status') in ['OPEN', 'CLOSING']]
|
||||
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']
|
||||
current_status = active_auto_pos.get('status')
|
||||
pos_details, pool_c = get_position_details(w3, npm, factory, token_id)
|
||||
|
||||
if pos_details:
|
||||
@ -834,7 +925,15 @@ def main():
|
||||
"clp_TotPnL": round(float(total_pnl_usd), 2)
|
||||
})
|
||||
|
||||
pnl_text = f" | TotPnL: ${total_pnl_usd:.2f} (Fees: ${total_fees_usd:.2f})"
|
||||
# Calculate Fees/h
|
||||
fees_per_h_str = "0.00"
|
||||
ts_open = active_auto_pos.get('timestamp_open')
|
||||
if ts_open:
|
||||
hours_open = (time.time() - ts_open) / 3600
|
||||
if hours_open > 0.01:
|
||||
fees_per_h_str = f"{float(total_fees_usd) / hours_open:.2f}"
|
||||
|
||||
pnl_text = f" | TotPnL: ${total_pnl_usd:.2f} (Fees: ${total_fees_usd:.2f} | ${fees_per_h_str}/h)"
|
||||
logger.info(f"Position {token_id}: {status_msg} | Price: {current_price:.4f} [{lower_price:.4f} - {upper_price:.4f}]{pnl_text}")
|
||||
|
||||
# --- KPI LOGGING ---
|
||||
@ -853,33 +952,55 @@ def main():
|
||||
'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)
|
||||
|
||||
# --- CLOSING LOGIC ---
|
||||
if current_status == "CLOSING" or (not in_range and CLOSE_POSITION_ENABLED):
|
||||
if current_status != "CLOSING":
|
||||
logger.warning(f"🛑 Closing Position {token_id} (Out of Range)")
|
||||
update_position_status(token_id, "CLOSING")
|
||||
# --- REPOSITION LOGIC ---
|
||||
pos_range_mode = active_auto_pos.get("range_mode", RANGE_MODE)
|
||||
|
||||
if pos_range_mode == "AUTO" and CLOSE_POSITION_ENABLED:
|
||||
coin_for_dynamic = pos_details['token0_symbol'] if not is_t0_stable else pos_details['token1_symbol']
|
||||
new_range_width = calculate_dynamic_range_pct(coin_for_dynamic)
|
||||
|
||||
# 1. Remove Liquidity (if any left)
|
||||
liq_to_remove = pos_details['liquidity']
|
||||
success_liq = True
|
||||
if liq_to_remove > 0:
|
||||
success_liq = decrease_liquidity(w3, npm, account, token_id, liq_to_remove, pos_details['token0_decimals'], pos_details['token1_decimals'])
|
||||
|
||||
# 2. Collect Fees (Retry if previous attempt failed or if liquidity was just removed)
|
||||
if success_liq:
|
||||
if 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
|
||||
else:
|
||||
logger.error(f"❌ Fee collection failed for {token_id}. Will retry in next loop.")
|
||||
if new_range_width:
|
||||
# Use initial width from JSON, or current config width as fallback
|
||||
old_range_width = Decimal(str(active_auto_pos.get("range_width_initial", RANGE_WIDTH_PCT)))
|
||||
|
||||
# Condition A: Difference > 20%
|
||||
width_diff_pct = abs(new_range_width - old_range_width) / old_range_width
|
||||
|
||||
# Condition B: Profit > 0.1%
|
||||
profit_pct = total_pnl_usd / initial_value
|
||||
|
||||
logger.info(f"📊 AUTO Check: CurRange {old_range_width*100:.2f}%, NewRange {new_range_width*100:.2f}% | Diff {width_diff_pct*100:.1f}% | Profit {profit_pct*100:.2f}%")
|
||||
|
||||
if width_diff_pct > 0.20 and profit_pct > 0.001:
|
||||
logger.warning(f"🔄 REPOSITION TRIGGERED: Width Diff {width_diff_pct*100:.1f}%, Profit {profit_pct*100:.2f}%")
|
||||
# Set in_range to False to force the closing logic below
|
||||
in_range = False
|
||||
else:
|
||||
logger.error(f"❌ Liquidity removal failed for {token_id}. Will retry in next loop.")
|
||||
logger.warning(f"⚖️ AUTO Check Skipped: Market indicators for {coin_for_dynamic} are stale or conditions not met.")
|
||||
|
||||
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'], pos_details['token0_decimals'], pos_details['token1_decimals']):
|
||||
# 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 (Fast scan: 37s)...")
|
||||
@ -896,14 +1017,73 @@ def main():
|
||||
fee = POOL_FEE
|
||||
|
||||
pool_addr = factory.functions.getPool(token0, token1, fee).call()
|
||||
pool_c = w3.eth.contract(address=pool_addr, abi=UNISWAP_V3_POOL_ABI)
|
||||
pool_abi = AERODROME_POOL_ABI if "AERODROME" in CONFIG.get("NAME", "").upper() else UNISWAP_V3_POOL_ABI
|
||||
pool_c = w3.eth.contract(address=pool_addr, abi=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))
|
||||
|
||||
# --- PRE-CALCULATE ESSENTIALS ---
|
||||
# Fetch Decimals & Symbols immediately (Required for Oracle Check)
|
||||
t0_c = w3.eth.contract(address=token0, abi=ERC20_ABI)
|
||||
t1_c = w3.eth.contract(address=token1, abi=ERC20_ABI)
|
||||
d0 = t0_c.functions.decimals().call()
|
||||
d1 = t1_c.functions.decimals().call()
|
||||
|
||||
t0_sym = t0_c.functions.symbol().call().upper()
|
||||
t1_sym = t1_c.functions.symbol().call().upper()
|
||||
stable_symbols = ["USDC", "USDT", "DAI", "FDUSD", "USDS"]
|
||||
|
||||
is_t1_stable = any(s in t1_sym for s in stable_symbols)
|
||||
is_t0_stable = any(s in t0_sym for s in stable_symbols)
|
||||
|
||||
price_0_in_1 = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1)
|
||||
|
||||
# Define coin_sym early for Guard Rails
|
||||
coin_sym = CONFIG.get("COIN_SYMBOL", "ETH")
|
||||
|
||||
# --- ORACLE GUARD RAIL ---
|
||||
# Protect against Pool/Oracle divergence (Manipulation/Depeg/Lag)
|
||||
if not force_mode_active:
|
||||
oracle_price = get_realtime_price(coin_sym)
|
||||
|
||||
if oracle_price:
|
||||
pool_price_dec = price_0_in_1 if is_t1_stable else (Decimal("1") / price_0_in_1)
|
||||
divergence = abs(pool_price_dec - oracle_price) / oracle_price
|
||||
|
||||
if divergence > Decimal("0.0025"): # 0.25% Tolerance
|
||||
logger.warning(f"⚠️ Price Divergence! Pool: {pool_price_dec:.2f} vs Oracle: {oracle_price:.2f} (Diff: {divergence*100:.2f}%). Aborting.")
|
||||
time.sleep(10)
|
||||
continue
|
||||
else:
|
||||
logger.warning("⚠️ Could not fetch Oracle price. Proceeding with caution (or consider aborting).")
|
||||
|
||||
# --- DYNAMIC RANGE CALCULATION ---
|
||||
active_range_width = RANGE_WIDTH_PCT
|
||||
current_range_mode = RANGE_MODE
|
||||
|
||||
# 1. PRIORITY: Force Mode
|
||||
if force_mode_active:
|
||||
logger.warning(f"🚨 FORCE OVERRIDE: Using forced width {force_width_pct*100:.2f}% (Ignoring safe checks)")
|
||||
active_range_width = force_width_pct
|
||||
current_range_mode = "FIXED"
|
||||
|
||||
# 2. AUTO Mode (Only if not forced)
|
||||
elif RANGE_MODE == "AUTO":
|
||||
dynamic_width = calculate_dynamic_range_pct(coin_sym)
|
||||
if dynamic_width:
|
||||
active_range_width = dynamic_width
|
||||
logger.info(f"⚖️ AUTO Range Activated: {active_range_width*100:.4f}%")
|
||||
else:
|
||||
logger.info(f"⛔ AUTO conditions not met. Waiting for safe entry...")
|
||||
time.sleep(MONITOR_INTERVAL_SECONDS)
|
||||
continue # Skip logic
|
||||
|
||||
# 3. FIXED Mode (Default Fallback) is already set by initial active_range_width
|
||||
|
||||
# Define Range
|
||||
tick_delta = int(math.log(1 + float(active_range_width)) / math.log(1.0001))
|
||||
|
||||
# Fetch actual tick spacing from pool
|
||||
tick_spacing = pool_c.functions.tickSpacing().call()
|
||||
@ -914,28 +1094,10 @@ def main():
|
||||
|
||||
# Calculate Amounts
|
||||
# Target Value logic
|
||||
d0 = 18 # Default WETH (Corrected below if needed, but we rely on raw logic)
|
||||
# Actually, we should fetch decimals from contract to be safe, but config assumes standard.
|
||||
|
||||
# Fetch Decimals for precision
|
||||
t0_c = w3.eth.contract(address=token0, abi=ERC20_ABI)
|
||||
t1_c = w3.eth.contract(address=token1, abi=ERC20_ABI)
|
||||
d0 = t0_c.functions.decimals().call()
|
||||
d1 = t1_c.functions.decimals().call()
|
||||
|
||||
# Determine Investment Value in Token1 terms
|
||||
target_usd = Decimal(str(TARGET_INVESTMENT_VALUE_USDC))
|
||||
|
||||
# Check which is stable
|
||||
t0_sym = t0_c.functions.symbol().call().upper()
|
||||
t1_sym = t1_c.functions.symbol().call().upper()
|
||||
stable_symbols = ["USDC", "USDT", "DAI", "FDUSD", "USDS"]
|
||||
|
||||
is_t1_stable = any(s in t1_sym for s in stable_symbols)
|
||||
is_t0_stable = any(s in t0_sym for s in stable_symbols)
|
||||
|
||||
price_0_in_1 = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1)
|
||||
|
||||
investment_val_token1 = Decimal("0")
|
||||
|
||||
if str(TARGET_INVESTMENT_VALUE_USDC).upper() == "MAX":
|
||||
@ -960,11 +1122,32 @@ def main():
|
||||
amt0, amt1 = calculate_mint_amounts(tick, tick_lower, tick_upper, investment_val_token1, d0, d1, pool_data['sqrtPriceX96'])
|
||||
|
||||
if check_and_swap_for_deposit(w3, router, account, token0, token1, amt0, amt1, pool_data['sqrtPriceX96'], d0, d1):
|
||||
# --- STALE DATA PROTECTION (Pre-Mint) ---
|
||||
# Check if price moved significantly during calculation/swap
|
||||
pre_mint_data = get_pool_dynamic_data(pool_c)
|
||||
if pre_mint_data:
|
||||
tick_diff = abs(pre_mint_data['tick'] - pool_data['tick'])
|
||||
# 13 ticks ~ 0.13% price move. Abort if volatile.
|
||||
if tick_diff > 13:
|
||||
logger.warning(f"⚠️ Price moved too much ({tick_diff} ticks) during setup/swap. Aborting mint to prevent bad entry.")
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
minted = mint_new_position(w3, npm, account, token0, token1, amt0, amt1, tick_lower, tick_upper, d0, d1)
|
||||
if minted:
|
||||
# Calculate entry price from TICK to ensure consistency with Range
|
||||
# (SqrtPrice can sometimes slightly diverge or have precision artifacts)
|
||||
price_0_in_1 = price_from_tick(pool_data['tick'], d0, d1)
|
||||
# --- DISABLE FORCE MODE AFTER FIRST MINT ---
|
||||
if force_mode_active:
|
||||
logger.info("🛑 FORCE MODE CONSUMED: Returning to standard AUTO checks for future positions.")
|
||||
force_mode_active = False
|
||||
|
||||
# --- RE-FETCH PRICE FOR ACCURATE ENTRY DATA (Post-Mint) ---
|
||||
fresh_pool_data = get_pool_dynamic_data(pool_c)
|
||||
if fresh_pool_data:
|
||||
fresh_tick = fresh_pool_data['tick']
|
||||
price_0_in_1 = price_from_tick(fresh_tick, d0, d1)
|
||||
logger.info(f"🔄 Refreshed Entry Tick: {fresh_tick} (Was: {pool_data['tick']})")
|
||||
else:
|
||||
price_0_in_1 = price_from_tick(pool_data['tick'], d0, d1)
|
||||
|
||||
fmt_amt0 = float(Decimal(minted['amount0']) / Decimal(10**d0))
|
||||
fmt_amt1 = float(Decimal(minted['amount1']) / Decimal(10**d1))
|
||||
@ -993,6 +1176,8 @@ def main():
|
||||
"range_lower": round(r_lower, 4),
|
||||
"token0_decimals": d0,
|
||||
"token1_decimals": d1,
|
||||
"range_mode": current_range_mode,
|
||||
"range_width_initial": float(active_range_width),
|
||||
"timestamp_open": int(time.time()),
|
||||
"time_open": datetime.now().strftime("%d.%m.%y %H:%M:%S")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user