working version, before optimalization

This commit is contained in:
2026-01-06 09:47:49 +01:00
parent c29dc2c8ac
commit a166d33012
36 changed files with 5394 additions and 901 deletions

View File

@ -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")
}