From e31079cdbb8e0e55b25622c123de3be184a2b1e7 Mon Sep 17 00:00:00 2001 From: DiTus Date: Sun, 14 Dec 2025 19:03:50 +0100 Subject: [PATCH] clp hedge zones --- clp_hedger/clp_scalper_hedger.py | 287 +++++++++++++++++------- clp_hedger/hedge_status.json | 364 ++++++++++++++++++++++++++++++- clp_hedger/uniswap_manager.py | 281 ++++++++++++++---------- 3 files changed, 742 insertions(+), 190 deletions(-) diff --git a/clp_hedger/clp_scalper_hedger.py b/clp_hedger/clp_scalper_hedger.py index de92a39..4f0de2c 100644 --- a/clp_hedger/clp_scalper_hedger.py +++ b/clp_hedger/clp_scalper_hedger.py @@ -30,13 +30,28 @@ setup_logging("normal", "SCALPER_HEDGER") # --- CONFIGURATION --- COIN_SYMBOL = "ETH" -CHECK_INTERVAL = 10 # Faster check for scalper +CHECK_INTERVAL = 1 # Faster check for scalper LEVERAGE = 5 STATUS_FILE = "hedge_status.json" -# Gap Recovery Configuration -PRICE_BUFFER_PCT = 0.002 # 0.25% buffer -TIME_BUFFER_SECONDS = 120 # 2 minutes wait +# --- STRATEGY ZONES (Percent of Range Width) --- +# Bottom Hedge Zone: 0% to 15% -> Active Hedging +ZONE_BOTTOM_HEDGE_LIMIT = 0.1 + +# Close Zone: 15% to 20% -> Close All Hedges (Flatten) +ZONE_CLOSE_START = 0.18 +ZONE_CLOSE_END = 0.20 + +# Middle Zone: 20% to 85% -> Idle (No new orders, keep existing) +# Implied by gaps between other zones. + +# Top Hedge Zone: 85% to 100% -> Active Hedging +ZONE_TOP_HEDGE_START = 0.8 + +# --- ORDER SETTINGS --- +PRICE_BUFFER_PCT = 0.0005 # 0.05% price move triggers order update +MIN_THRESHOLD_ETH = 0.005 # Minimum trade size in ETH +MIN_ORDER_VALUE_USD = 10.0 # Minimum order value for API safety def get_active_automatic_position(): if not os.path.exists(STATUS_FILE): @@ -51,6 +66,29 @@ def get_active_automatic_position(): logging.error(f"ERROR reading status file: {e}") return None +def update_position_zones_in_json(token_id, zones_data): + """Updates the active position in JSON with calculated zone prices.""" + if not os.path.exists(STATUS_FILE): return + try: + with open(STATUS_FILE, 'r') as f: + data = json.load(f) + + updated = False + for entry in data: + if entry.get('type') == 'AUTOMATIC' and entry.get('status') == 'OPEN' and entry.get('token_id') == token_id: + # Update keys + for k, v in zones_data.items(): + entry[k] = v + updated = True + break + + if updated: + with open(STATUS_FILE, 'w') as f: + json.dump(data, f, indent=2) + logging.info(f"Updated JSON with Zone Prices for Position {token_id}") + except Exception as e: + logging.error(f"Error updating JSON zones: {e}") + def round_to_sig_figs(x, sig_figs=5): if x == 0: return 0.0 return round(x, sig_figs - int(math.floor(math.log10(abs(x)))) - 1) @@ -85,7 +123,6 @@ class HyperliquidStrategy: self.L = 0.0 # Method 1: Use Amount0 (WETH) - # Formula: L = amount0 / (1/sqrtP - 1/sqrtPb) if entry_amount0 > 0: amount0_eth = entry_amount0 / 10**18 denom0 = (1/sqrt_P) - (1/sqrt_Pb) @@ -93,20 +130,15 @@ class HyperliquidStrategy: self.L = amount0_eth / denom0 logging.info(f"Calculated L from Amount0: {self.L:.4f}") - # Method 2: Use Amount1 (USDC) if Method 1 failed or yielded 0 - # Formula: L = amount1 / (sqrtP - sqrtPa) - # Note: Price in formula is Token1/Token0? No, sqrtPrice is sqrt(Token1/Token0). - # Yes. Amount1 = L * (sqrtP - sqrtPa) + # Method 2: Use Amount1 (USDC) if self.L == 0.0 and entry_amount1 > 0: - amount1_usdc = entry_amount1 / 10**6 # USDC is 6 decimals + amount1_usdc = entry_amount1 / 10**6 denom1 = sqrt_P - sqrt_Pa if denom1 > 0.00000001: self.L = amount1_usdc / denom1 logging.info(f"Calculated L from Amount1: {self.L:.4f}") - # Method 3: Fallback Heuristic using Target Value - # Max ETH = Value / LowerPrice. - # L = MaxETH / (1/sqrtPa - 1/sqrtPb) + # Method 3: Fallback Heuristic if self.L == 0.0: logging.warning("Amounts missing or 0. Using Target Value Heuristic.") max_eth_heuristic = target_value / low_range @@ -137,33 +169,7 @@ class HyperliquidStrategy: pool_delta = self.get_pool_delta(current_price) raw_target_short = pool_delta + self.static_long - entry_upper = self.entry_price * (1 + PRICE_BUFFER_PCT) - entry_lower = self.entry_price * (1 - PRICE_BUFFER_PCT) - - desired_mode = self.current_mode - - if self.current_mode == "NORMAL": - if current_price > entry_upper and current_price < self.recovery_target: - desired_mode = "RECOVERY" - elif self.current_mode == "RECOVERY": - if current_price < entry_lower or current_price >= self.recovery_target: - desired_mode = "NORMAL" - - now = time.time() - if desired_mode != self.current_mode: - if (now - self.last_switch_time) >= TIME_BUFFER_SECONDS: - logging.info(f"🔄 MODE SWITCH: {self.current_mode} -> {desired_mode} (Px: {current_price:.2f})") - self.current_mode = desired_mode - self.last_switch_time = now - else: - logging.info(f"⏳ Mode Switch Delayed (Time Buffer). Pending: {desired_mode}") - - if self.current_mode == "RECOVERY": - target_short_size = 0.0 - logging.info(f"🩹 RECOVERY MODE ACTIVE (0% Hedge). Target: {self.recovery_target:.2f}") - else: - target_short_size = raw_target_short - + target_short_size = raw_target_short diff = target_short_size - abs(current_short_position_size) return { @@ -173,7 +179,7 @@ class HyperliquidStrategy: "current_short": abs(current_short_position_size), "diff": diff, "action": "SELL" if diff > 0 else "BUY", - "mode": self.current_mode + "mode": "NORMAL" } class ScalperHedger: @@ -198,6 +204,8 @@ class ScalperHedger: self.strategy = None self.sz_decimals = self._get_sz_decimals(COIN_SYMBOL) self.active_position_id = None + self.active_order = None + logging.info(f"Scalper Hedger initialized. Agent: {self.account.address}") def _init_strategy(self, position_data): @@ -249,6 +257,20 @@ class ScalperHedger: except: pass return None + def get_order_book_mid(self, coin): + try: + l2_snapshot = self.info.l2_snapshot(coin) + if l2_snapshot and 'levels' in l2_snapshot: + bids = l2_snapshot['levels'][0] + asks = l2_snapshot['levels'][1] + if bids and asks: + best_bid = float(bids[0]['px']) + best_ask = float(asks[0]['px']) + return (best_bid + best_ask) / 2 + return self.get_market_price(coin) + except: + return self.get_market_price(coin) + def get_funding_rate(self, coin): try: meta, asset_ctxs = self.info.meta_and_asset_ctxs() @@ -267,29 +289,101 @@ class ScalperHedger: return 0.0 except: return 0.0 - def execute_trade(self, coin, is_buy, size, price): - logging.info(f"🚀 EXECUTING: {coin} {'BUY' if is_buy else 'SELL'} {size} @ ~{price}") + def get_open_orders(self): + try: + return self.info.open_orders(self.vault_address or self.account.address) + except: return [] + + def cancel_order(self, coin, oid): + logging.info(f"Cancelling order {oid}...") + try: + return self.exchange.cancel(coin, oid) + except Exception as e: + logging.error(f"Error cancelling order: {e}") + + def place_limit_order(self, coin, is_buy, size, price): + logging.info(f"🕒 PLACING LIMIT: {coin} {'BUY' if is_buy else 'SELL'} {size} @ {price:.2f}") reduce_only = is_buy try: - raw_limit_px = price * (1.05 if is_buy else 0.95) - limit_px = round_to_sig_figs(raw_limit_px, 5) + # Gtc order (Maker) + limit_px = round_to_sig_figs(price, 5) - order_result = self.exchange.order(coin, is_buy, size, limit_px, {"limit": {"tif": "Ioc"}}, reduce_only=reduce_only) + order_result = self.exchange.order(coin, is_buy, size, limit_px, {"limit": {"tif": "Gtc"}}, reduce_only=reduce_only) status = order_result["status"] if status == "ok": response_data = order_result["response"]["data"] - if "statuses" in response_data and "error" in response_data["statuses"][0]: - logging.error(f"Order API Error: {response_data['statuses'][0]['error']}") - else: - logging.info(f"✅ Trade Success") + if "statuses" in response_data: + status_obj = response_data["statuses"][0] + + if "error" in status_obj: + logging.error(f"Order API Error: {status_obj['error']}") + return None + + # Parse OID from nested structure + oid = None + if "resting" in status_obj: + oid = status_obj["resting"]["oid"] + elif "filled" in status_obj: + oid = status_obj["filled"]["oid"] + logging.info("Order filled immediately.") + + if oid: + logging.info(f"✅ Limit Order Placed: OID {oid}") + return oid + else: + logging.warning(f"Order placed but OID not found in: {status_obj}") + return None else: logging.error(f"Order Failed: {order_result}") + return None except Exception as e: logging.error(f"Exception during trade: {e}") + return None + + def manage_orders(self): + """ + Checks open orders. + Returns: True if an order exists and is valid (don't trade), False if no order (can trade). + """ + open_orders = self.get_open_orders() + my_orders = [o for o in open_orders if o['coin'] == COIN_SYMBOL] + + if not my_orders: + self.active_order = None + return False + + if len(my_orders) > 1: + logging.warning("Multiple open orders found. Cancelling all for safety.") + for o in my_orders: + self.cancel_order(COIN_SYMBOL, o['oid']) + self.active_order = None + return False + + order = my_orders[0] + oid = order['oid'] + order_price = float(order['limitPx']) + + current_mid = self.get_order_book_mid(COIN_SYMBOL) + pct_diff = abs(current_mid - order_price) / order_price + + if pct_diff > PRICE_BUFFER_PCT: + logging.info(f"Price moved {pct_diff*100:.3f}% > {PRICE_BUFFER_PCT*100}%. Cancelling/Replacing order {oid}.") + self.cancel_order(COIN_SYMBOL, oid) + self.active_order = None + return False + else: + logging.info(f"Pending Order {oid} @ {order_price:.2f} is within range ({pct_diff*100:.3f}%). Waiting.") + return True def close_all_positions(self): - logging.info("Closing all positions (Safety/Closed State)...") + logging.info("Closing all positions (Market Order)...") try: + # Cancel open orders first + open_orders = self.get_open_orders() + for o in open_orders: + if o['coin'] == COIN_SYMBOL: + self.cancel_order(COIN_SYMBOL, o['oid']) + price = self.get_market_price(COIN_SYMBOL) current_pos = self.get_current_position(COIN_SYMBOL) if current_pos == 0: return @@ -298,7 +392,8 @@ class ScalperHedger: final_size = round_to_sz_decimals(abs(current_pos), self.sz_decimals) if final_size == 0: return - self.execute_trade(COIN_SYMBOL, is_buy, final_size, price) + # Market order for closing + self.exchange.order(COIN_SYMBOL, is_buy, final_size, round_to_sig_figs(price * (1.05 if is_buy else 0.95), 5), {"limit": {"tif": "Ioc"}}, reduce_only=True) self.active_position_id = None except Exception as e: logging.error(f"Error closing: {e}") @@ -321,7 +416,6 @@ class ScalperHedger: time.sleep(CHECK_INTERVAL) continue - # Initialize Strategy if needed if self.strategy is None or self.active_position_id != active_pos['token_id']: logging.info(f"New position {active_pos['token_id']} detected or strategy not initialized. Initializing strategy.") self._init_strategy(active_pos) @@ -329,12 +423,15 @@ class ScalperHedger: time.sleep(CHECK_INTERVAL) continue - # Double Check Strategy validity - if self.strategy is None: + if self.strategy is None: continue + + # --- ORDER MANAGEMENT --- + if self.manage_orders(): + time.sleep(CHECK_INTERVAL) continue # 2. Market Data - price = self.get_market_price(COIN_SYMBOL) + price = self.get_order_book_mid(COIN_SYMBOL) if price is None: time.sleep(5) continue @@ -342,7 +439,7 @@ class ScalperHedger: funding_rate = self.get_funding_rate(COIN_SYMBOL) current_pos_size = self.get_current_position(COIN_SYMBOL) - # 3. Calculate + # 3. Calculate Logic calc = self.strategy.calculate_rebalance(price, current_pos_size) diff_abs = abs(calc['diff']) @@ -351,26 +448,68 @@ class ScalperHedger: sqrt_Pb = math.sqrt(self.strategy.high_range) max_potential_eth = self.strategy.L * ((1/sqrt_Pa) - (1/sqrt_Pb)) - min_threshold = 0.001 - rebalance_threshold = max(min_threshold, max_potential_eth * 0.05) + # Use MIN_THRESHOLD_ETH from config + rebalance_threshold = max(MIN_THRESHOLD_ETH, max_potential_eth * 0.05) - # 5. Execute with Min Order Value check - if diff_abs > rebalance_threshold: - trade_size = round_to_sz_decimals(diff_abs, self.sz_decimals) - - min_order_value_usd = 10.0 - min_trade_size = min_order_value_usd / price - - if trade_size < min_trade_size: - logging.info(f"Idle. Trade size {trade_size} < Min Order Size {min_trade_size:.4f} (${min_order_value_usd})") - elif trade_size > 0: - logging.info(f"⚡ THRESHOLD TRIGGERED ({diff_abs:.4f} >= {rebalance_threshold:.4f})") - is_buy = (calc['action'] == "BUY") - self.execute_trade(COIN_SYMBOL, is_buy, trade_size, price) + # 5. Determine Hedge Zone + clp_low_range = self.strategy.low_range + clp_high_range = self.strategy.high_range + range_width = clp_high_range - clp_low_range + + # Calculate Prices for Zones + zone_bottom_limit_price = clp_low_range + (range_width * ZONE_BOTTOM_HEDGE_LIMIT) + zone_close_start_price = clp_low_range + (range_width * ZONE_CLOSE_START) + zone_close_end_price = clp_low_range + (range_width * ZONE_CLOSE_END) + zone_top_start_price = clp_low_range + (range_width * ZONE_TOP_HEDGE_START) + + # Update JSON with zone prices if missing + if 'zone_bottom_limit_price' not in active_pos: + update_position_zones_in_json(active_pos['token_id'], { + 'zone_bottom_limit_price': zone_bottom_limit_price, + 'zone_close_start_price': zone_close_start_price, + 'zone_close_end_price': zone_close_end_price, + 'zone_top_start_price': zone_top_start_price + }) + + # Check Zones + in_close_zone = (price >= zone_close_start_price and price <= zone_close_end_price) + in_hedge_zone = (price <= zone_bottom_limit_price) or (price >= zone_top_start_price) + + # --- Execute Logic --- + if in_close_zone: + logging.info(f"ZONE: CLOSE ({price:.2f} in {zone_close_start_price:.2f}-{zone_close_end_price:.2f}). Closing all hedge positions.") + self.close_all_positions() + time.sleep(CHECK_INTERVAL) + continue + + elif in_hedge_zone: + # HEDGE NORMALLY + if diff_abs > rebalance_threshold: + trade_size = round_to_sz_decimals(diff_abs, self.sz_decimals) + + # --- SOFT START LOGIC (Bottom Zone Only) --- + # If in Bottom Zone, opening a NEW Short (SELL), and current position is 0 -> Cut size by 50% + if (price <= zone_bottom_limit_price) and (current_pos_size == 0) and (calc['action'] == "SELL"): + logging.info(f"🔰 SOFT START: Reducing initial hedge size by 50% in Bottom Zone.") + trade_size = round_to_sz_decimals(trade_size * 0.5, self.sz_decimals) + + min_trade_size = MIN_ORDER_VALUE_USD / price + + if trade_size < min_trade_size: + logging.info(f"Idle. Trade size {trade_size} < Min Order Size {min_trade_size:.4f} (${MIN_ORDER_VALUE_USD:.2f})") + elif trade_size > 0: + logging.info(f"⚡ THRESHOLD TRIGGERED ({diff_abs:.4f} >= {rebalance_threshold:.4f}). In Hedge Zone.") + is_buy = (calc['action'] == "BUY") + self.place_limit_order(COIN_SYMBOL, is_buy, trade_size, price) + else: + logging.info("Trade size rounds to 0. Skipping.") else: - logging.info("Trade size rounds to 0. Skipping.") + logging.info(f"Idle. Diff {diff_abs:.4f} < Threshold {rebalance_threshold:.4f}. In Hedge Zone.") + else: - logging.info(f"Idle. Diff {diff_abs:.4f} < Threshold {rebalance_threshold:.4f}") + # MIDDLE ZONE (IDLE) + pct_position = (price - clp_low_range) / range_width + logging.info(f"Idle. In Middle Zone ({pct_position*100:.1f}%). No Actions.") time.sleep(CHECK_INTERVAL) diff --git a/clp_hedger/hedge_status.json b/clp_hedger/hedge_status.json index 35ae299..ad32c92 100644 --- a/clp_hedger/hedge_status.json +++ b/clp_hedger/hedge_status.json @@ -2,7 +2,7 @@ { "type": "AUTOMATIC", "token_id": 5154921, - "status": "OPEN", + "status": "CLOSED", "entry_price": 3088.180203068298, "range_lower": 3071.745207606606, "range_upper": 3102.615208978462, @@ -11,6 +11,368 @@ "amount1_initial": 0, "static_long": 0.0, "timestamp_open": 1765575924, + "timestamp_close": 1765613747 + }, + { + "type": "AUTOMATIC", + "token_id": 5155502, + "status": "CLOSED", + "entry_price": 3105.4778071503983, + "range_lower": 3090.230154007496, + "range_upper": 3118.1663529424395, + "target_value": 81.22159710646565, + "amount0_initial": 0, + "amount1_initial": 0, + "static_long": 0.0, + "timestamp_open": 1765613789, + "timestamp_close": 1765614083 + }, + { + "type": "AUTOMATIC", + "token_id": 5155511, + "status": "CLOSED", + "entry_price": 3122.1562247614547, + "range_lower": 3105.7192207366634, + "range_upper": 3136.930649460415, + "target_value": 98.20653967768193, + "amount0_initial": 0, + "amount1_initial": 0, + "static_long": 0.0, + "timestamp_open": 1765614124, + "timestamp_close": 1765617105 + }, + { + "type": "AUTOMATIC", + "token_id": 5155580, + "status": "CLOSED", + "entry_price": 3120.03330314008, + "range_lower": 3111.93656358668, + "range_upper": 3124.4086137206154, + "target_value": 258.2420686245357, + "amount0_initial": 0, + "amount1_initial": 0, + "static_long": 0.0, + "timestamp_open": 1765617197, + "timestamp_close": 1765617236 + }, + { + "type": "AUTOMATIC", + "token_id": 5155610, + "status": "CLOSED", + "entry_price": 3118.03462860249, + "range_lower": 3056.425578524254, + "range_upper": 3177.9749053788623, + "target_value": 348.982123656927, + "amount0_initial": 54654586929109032, + "amount1_initial": 178567229, + "static_long": 0.0, + "timestamp_open": 1765619246, "timestamp_close": null + }, + { + "type": "AUTOMATIC", + "token_id": 5155618, + "status": "CLOSED", + "entry_price": 3120.854321555066, + "range_lower": 3111.93656358668, + "range_upper": 3127.5344286932063, + "target_value": 342.45943993806645, + "amount0_initial": 46935127322790001, + "amount1_initial": 195981745, + "static_long": 0.0, + "timestamp_open": 1765619616, + "timestamp_close": 1765621159 + }, + { + "type": "AUTOMATIC", + "token_id": 5155660, + "status": "CLOSED", + "entry_price": 3129.521502331058, + "range_lower": 3121.285922844486, + "range_upper": 3136.930649460415, + "target_value": 345.19101843135434, + "amount0_initial": 52148054681776174, + "amount1_initial": 181992560, + "static_long": 0.0, + "timestamp_open": 1765621204, + "timestamp_close": 1765625900 + }, + { + "type": "AUTOMATIC", + "token_id": 5155742, + "status": "CLOSED", + "entry_price": 3120.452464830275, + "range_lower": 3111.93656358668, + "range_upper": 3127.5344286932063, + "target_value": 330.2607520468071, + "amount0_initial": 45273020063291068, + "amount1_initial": 188988445, + "static_long": 0.0, + "timestamp_open": 1765625947, + "timestamp_close": 1765629916 + }, + { + "type": "AUTOMATIC", + "token_id": 5155807, + "status": "CLOSED", + "entry_price": 3111.8306135157013, + "range_lower": 3102.615208978462, + "range_upper": 3118.1663529424395, + "target_value": 342.2298529154781, + "amount0_initial": 44749390699692539, + "amount1_initial": 202977329, + "static_long": 0.0, + "timestamp_open": 1765629968, + "timestamp_close": null + }, + { + "type": "AUTOMATIC", + "token_id": 5155828, + "status": "CLOSED", + "entry_price": 3116.7126648332624, + "range_lower": 3099.514299525495, + "range_upper": 3130.663370887762, + "target_value": 347.83537144876755, + "amount0_initial": 49847371623870561, + "amount1_initial": 192475437, + "static_long": 0.0, + "timestamp_open": 1765630905, + "timestamp_close": 1765632623 + }, + { + "type": "AUTOMATIC", + "token_id": 5155863, + "status": "CLOSED", + "entry_price": 3097.40295247475, + "range_lower": 3080.973817800786, + "range_upper": 3111.93656358668, + "target_value": 308.3116676933205, + "amount0_initial": 39654626336294149, + "amount1_initial": 185485311, + "static_long": 0.0, + "timestamp_open": 1765632672, + "timestamp_close": 1765634422 + }, + { + "type": "AUTOMATIC", + "token_id": 5155882, + "status": "CLOSED", + "entry_price": 3112.8609359236384, + "range_lower": 3096.4164892771637, + "range_upper": 3127.5344286932063, + "target_value": 343.5299941433273, + "amount0_initial": 51896697111974758, + "amount1_initial": 181982793, + "static_long": 0.0, + "timestamp_open": 1765634468, + "timestamp_close": 1765661569 + }, + { + "type": "AUTOMATIC", + "token_id": 5156323, + "status": "CLOSED", + "entry_price": 3083.0072388847652, + "range_lower": 3065.6081631285606, + "range_upper": 3096.4164892771637, + "target_value": 312.46495296583043, + "amount0_initial": 37786473705449745, + "amount1_initial": 195968981, + "static_long": 0.0, + "timestamp_open": 1765661623, + "timestamp_close": 1765661755 + }, + { + "type": "AUTOMATIC", + "token_id": 5156327, + "status": "CLOSED", + "entry_price": 3099.025060823837, + "range_lower": 3080.973817800786, + "range_upper": 3111.93656358668, + "target_value": 341.5043895497362, + "amount0_initial": 44705050404757454, + "amount1_initial": 202962318, + "static_long": 0.0, + "timestamp_open": 1765661800, + "timestamp_close": 1765663051 + }, + { + "type": "AUTOMATIC", + "token_id": 5156339, + "status": "CLOSED", + "entry_price": 3114.5494347315303, + "range_lower": 3096.4164892771637, + "range_upper": 3127.5344286932063, + "target_value": 313.18766451496026, + "amount0_initial": 47209859594870944, + "amount1_initial": 166150223, + "static_long": 0.0, + "timestamp_open": 1765663096, + "timestamp_close": 1765675725, + "zone_bottom_limit_price": 3099.528283218768, + "zone_close_start_price": 3102.017718372051, + "zone_close_end_price": 3102.640077160372, + "zone_top_start_price": 3121.310840809998 + }, + { + "type": "AUTOMATIC", + "token_id": 5156507, + "status": "CLOSED", + "entry_price": 3128.29006521609, + "range_lower": 3111.93656358668, + "range_upper": 3143.2104745051906, + "target_value": 347.15268590066694, + "amount0_initial": 52797230582023401, + "amount1_initial": 181987634, + "static_long": 0.0, + "timestamp_open": 1765675770, + "timestamp_close": 1765687389, + "zone_bottom_limit_price": 3115.0639546785314, + "zone_close_start_price": 3117.565867552012, + "zone_close_end_price": 3118.191345770382, + "zone_top_start_price": 3136.9556923214886 + }, + { + "type": "AUTOMATIC", + "token_id": 5156576, + "status": "CLOSED", + "entry_price": 3109.1484174484244, + "range_lower": 3093.3217751359653, + "range_upper": 3124.4086137206154, + "target_value": 349.75269804513647, + "amount0_initial": 55081765825023475, + "amount1_initial": 178495313, + "static_long": 0.0, + "timestamp_open": 1765687433, + "timestamp_close": 1765712073, + "zone_bottom_limit_price": 3096.4304589944304, + "zone_close_start_price": 3098.9174060812024, + "zone_close_end_price": 3099.539142852895, + "zone_top_start_price": 3118.1912460036856 + }, + { + "type": "AUTOMATIC", + "token_id": 5156880, + "status": "CLOSED", + "entry_price": 3092.1804685415204, + "range_lower": 3074.8183354682296, + "range_upper": 3105.7192207366634, + "target_value": 348.0802699013006, + "amount0_initial": 49191436738181486, + "amount1_initial": 195971470, + "static_long": 0.0, + "timestamp_open": 1765712124, + "timestamp_close": 1765712700, + "zone_bottom_limit_price": 3077.908423995073, + "zone_close_start_price": 3080.3804948165475, + "zone_close_end_price": 3080.9985125219164, + "zone_top_start_price": 3099.5390436829766 + }, + { + "type": "AUTOMATIC", + "token_id": 5156912, + "status": "CLOSED", + "entry_price": 3080.3709911881006, + "range_lower": 3062.5442403757074, + "range_upper": 3093.3217751359653, + "target_value": 291.15223765283383, + "amount0_initial": 47732710466839755, + "amount1_initial": 144117781, + "static_long": 0.0, + "timestamp_open": 1765712910, + "timestamp_close": 1765714350, + "zone_bottom_limit_price": 3065.6219938517334, + "zone_close_start_price": 3068.084196632554, + "zone_close_end_price": 3068.699747327759, + "zone_top_start_price": 3087.166268183914 + }, + { + "type": "AUTOMATIC", + "token_id": 5156972, + "status": "CLOSED", + "entry_price": 3090.0637108037877, + "range_lower": 3074.8183354682296, + "range_upper": 3102.615208978462, + "target_value": 271.3892587233541, + "amount0_initial": 51605992189032833, + "amount1_initial": 111923455, + "static_long": 0.0, + "timestamp_open": 1765714399, + "timestamp_close": 1765715701, + "zone_bottom_limit_price": 3077.598022819253, + "zone_close_start_price": 3079.8217727000715, + "zone_close_end_price": 3080.3777101702763, + "zone_top_start_price": 3097.055834276415 + }, + { + "type": "AUTOMATIC", + "token_id": 5157018, + "status": "CLOSED", + "entry_price": 3101.5146208910464, + "range_lower": 3084.056178426586, + "range_upper": 3115.0499008952183, + "target_value": 334.88770454868376, + "amount0_initial": 49662753969037209, + "amount1_initial": 180857947, + "static_long": 0.0, + "timestamp_open": 1765715747, + "timestamp_close": 1765722919, + "zone_bottom_limit_price": 3087.1555506734494, + "zone_close_start_price": 3089.6350484709396, + "zone_close_end_price": 3090.2549229203123, + "zone_top_start_price": 3108.851156401492 + }, + { + "type": "AUTOMATIC", + "token_id": 5157176, + "status": "CLOSED", + "entry_price": 3079.8157532039463, + "range_lower": 3062.5442403757074, + "range_upper": 3093.3217751359653, + "target_value": 272.62430135026136, + "amount0_initial": 24888578243851017, + "amount1_initial": 195972066, + "static_long": 0.0, + "timestamp_open": 1765722970, + "timestamp_close": 1765729241, + "zone_bottom_limit_price": 3065.6219938517334, + "zone_close_start_price": 3068.084196632554, + "zone_close_end_price": 3068.699747327759, + "zone_top_start_price": 3087.166268183914 + }, + { + "type": "AUTOMATIC", + "token_id": 5157312, + "status": "CLOSED", + "entry_price": 3093.971464080226, + "range_lower": 3077.8945378409912, + "range_upper": 3108.8263379038003, + "target_value": 326.92184420403566, + "amount0_initial": 46843176767023226, + "amount1_initial": 181990392, + "static_long": 0.0, + "timestamp_open": 1765729286, + "timestamp_close": 1765733514, + "zone_bottom_limit_price": 3080.987717847272, + "zone_close_start_price": 3083.4622618522967, + "zone_close_end_price": 3084.080897853553, + "zone_top_start_price": 3102.6399778912387 + }, + { + "type": "AUTOMATIC", + "token_id": 5157395, + "status": "OPEN", + "entry_price": 3079.3931567773757, + "range_lower": 3062.5442403757074, + "range_upper": 3093.3217751359653, + "target_value": 344.4599070677894, + "amount0_initial": 50492037278704046, + "amount1_initial": 188975073, + "static_long": 0.0, + "timestamp_open": 1765733564, + "timestamp_close": null, + "zone_bottom_limit_price": 3065.6219938517334, + "zone_close_start_price": 3068.084196632554, + "zone_close_end_price": 3068.699747327759, + "zone_top_start_price": 3087.166268183914 } ] \ No newline at end of file diff --git a/clp_hedger/uniswap_manager.py b/clp_hedger/uniswap_manager.py index 4a29a80..2e6e330 100644 --- a/clp_hedger/uniswap_manager.py +++ b/clp_hedger/uniswap_manager.py @@ -12,11 +12,13 @@ def clean_address(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 @@ -25,54 +27,71 @@ def from_wei(amount, 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 + 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") -POSITION_TOKEN_ID = int(os.environ.get("POSITION_TOKEN_ID", "0")) PRIVATE_KEY = os.environ.get("MAIN_WALLET_PRIVATE_KEY") or os.environ.get("PRIVATE_KEY") +# Script behavior flags MONITOR_INTERVAL_SECONDS = 30 -COLLECT_FEES_ENABLED = False -CLOSE_POSITION_ENABLED = True -CLOSE_IF_OUT_OF_RANGE_ONLY = True +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 -OPEN_POSITION_ENABLED = True -TARGET_INVESTMENT_VALUE_TOKEN1 = 100.0 -RANGE_WIDTH_PCT = 0.005 +# New Position Parameters +TARGET_INVESTMENT_VALUE_TOKEN1 = 350.0 # Target total investment value in Token1 terms (e.g. 350 USDC) +RANGE_WIDTH_PCT = 0.005 # +/- 2% range for new positions +# JSON File for tracking position state STATUS_FILE = "hedge_status.json" # --- 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: @@ -98,6 +117,11 @@ def get_all_open_positions(): return [] def update_hedge_status_file(action, position_data): + """ + Updates the hedge_status.json file. + action: "OPEN" or "CLOSE" + position_data: Dict containing details (token_id, entry_price, range, etc.) + """ current_data = [] if os.path.exists(STATUS_FILE): try: @@ -114,9 +138,9 @@ def update_hedge_status_file(action, position_data): "entry_price": position_data['entry_price'], "range_lower": position_data['range_lower'], "range_upper": position_data['range_upper'], - "target_value": position_data.get('target_value', 0.0), - "amount0_initial": position_data.get('amount0', 0), - "amount1_initial": position_data.get('amount1', 0), + "target_value": position_data.get('target_value', 0.0), # Save Actual Value as Target for hedging accuracy + "amount0_initial": position_data.get('amount0_initial', 0), + "amount1_initial": position_data.get('amount1_initial', 0), "static_long": 0.0, "timestamp_open": int(time.time()), "timestamp_close": None @@ -148,6 +172,8 @@ def update_hedge_status_file(action, position_data): # 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"}, @@ -243,27 +269,27 @@ def calculate_mint_amounts(current_tick, tick_lower, tick_upper, investment_valu 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 (e.g., WETH price in USDC) + # 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 for a Test Liquidity (L_test) + # 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 test amounts for decimals to get "Real Units" (e.g., 0.1 WETH, 500 USDC) + # 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 (e.g., Total in USDC) + # 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: return 0, 0 - # 5. Scale to Target Investment Value + # 5. Scale scale = investment_value_token1 / value_test - # 6. Calculate Final Amounts (raw integer units for contract call) + # 6. Final Amounts final_amt0 = int(amt0_test * scale) final_amt1 = int(amt1_test * scale) @@ -275,87 +301,73 @@ def check_and_swap(w3_instance, router_contract, account, token0, token1, amount 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 --- - # Check if we need WETH and have Native ETH - # WETH Address Check (Case insensitive) weth_addr_lower = WETH_ADDRESS.lower() - # Check Token0 (Deficit0) + # 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 = 2 * 10**16 # 0.02 ETH gas reserve + gas_reserve = 5 * 10**15 # 0.005 ETH (Reduced for L2) available_native = max(0, native_bal - gas_reserve) - # Determine how much to wrap - # If we have deficit1 (need USDC), we likely need to wrap more WETH to swap it. - # Strategy: If deficit1 > 0, wrap ALL available native ETH (up to reasonable limit?). - # Or just wrap what we have. - amount_to_wrap = 0 if deficit0 > 0: amount_to_wrap = deficit0 if deficit1 > 0: - # We need to buy Token1. We need surplus Token0. - # Wrap all remaining available native ETH to facilitate swap. amount_to_wrap = available_native - # Safety clamp 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 + '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) - - # Refresh Balance 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)}, Have: {from_wei(available_native, 18)}") + print(f"Insufficient Native ETH to wrap. Need: {from_wei(deficit0, 18)}, Available: {from_wei(available_native, 18)}") - # Check Token1 (Deficit1) - Assuming Token1 could be WETH too + # 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 = 10**16 + 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 + '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) - - # Refresh Balance bal1 = token1_contract.functions.balanceOf(account.address).call() deficit1 = max(0, amount1_needed - bal1) @@ -387,6 +399,12 @@ def check_and_swap(w3_instance, router_contract, account, token0, token1, amount 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: @@ -414,6 +432,12 @@ def check_and_swap(w3_instance, router_contract, account, token0, token1, amount 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.") @@ -421,8 +445,8 @@ def check_and_swap(w3_instance, router_contract, account, token0, token1, amount 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) + token0_contract = w3_instance.eth.contract(address=token0, abi=ERC20_ABI) + token1_contract = w3_instance.eth.contract(address=token1, abi=ERC20_ABI) b0 = token0_contract.functions.balanceOf(account_address).call() b1 = token1_contract.functions.balanceOf(account_address).call() return b0, b1 @@ -486,36 +510,29 @@ def mint_new_position(w3_instance, npm_contract, account, token0, token1, amount result_data = {'token_id': None, 'liquidity': 0, 'amount0': 0, 'amount1': 0} - # Event Topics - transfer_topic = "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" - increase_liquidity_topic = "3067048beee31b25b2f1681f88dac838c8bba36af25bfb2b7cf7473a5847e35f" - - for log in receipt['logs']: - topic0 = log['topics'][0].hex().replace("0x", "") + # 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 - # Parse Token ID from Transfer - if topic0 == transfer_topic and len(log['topics']) > 3: - result_data['token_id'] = int(log['topics'][3].hex(), 16) - - # Parse Amounts from IncreaseLiquidity - # Event: IncreaseLiquidity(uint256 indexed tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) - # Indexed args are in topics. Non-indexed in data. - # tokenId is indexed (Topic 1). - # Data: liquidity (32 bytes), amount0 (32 bytes), amount1 (32 bytes) = 96 bytes total - if topic0 == increase_liquidity_topic: - data_hex = log['data'].hex().replace("0x", "") - if len(data_hex) >= 192: # 3 * 64 chars - # Split data into 3 chunks of 64 chars (32 bytes) - liquidity_hex = data_hex[0:64] - amount0_hex = data_hex[64:128] - amount1_hex = data_hex[128:192] + # 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 - result_data['liquidity'] = int(liquidity_hex, 16) - result_data['amount0'] = int(amount0_hex, 16) - result_data['amount1'] = int(amount1_hex, 16) - print(f"Captured Actual Mint Amounts: {result_data['amount0']} Token0 / {result_data['amount1']} Token1") + 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 @@ -573,7 +590,7 @@ def main(): 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'), None) + active_automatic_position = next((p for p in all_positions if p['type'] == 'AUTOMATIC' and p['status'] == 'OPEN'), None) if all_positions: print("\n" + "="*60) @@ -625,8 +642,45 @@ def main(): collect_fees(w3, npm_contract, account, token_id) update_hedge_status_file("CLOSE", {'token_id': token_id}) print("Position Closed & Status Updated.") - # We don't break loop here, let it finish monitoring others, - # but next main loop iteration will see it closed. + + # --- REBALANCE ON CLOSE (If Price Dropped) --- + if 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: print("Liquidity 0. Marking closed.") update_hedge_status_file("CLOSE", {'token_id': token_id}) @@ -651,50 +705,47 @@ def main(): upper = (tick + tick_delta) // spacing * spacing # Amounts - token0_c = w3.eth.contract(address=token0, abi=ERC20_ABI) - token1_c = w3.eth.contract(address=token1, abi=ERC20_ABI) 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: - print("Error fetching decimals") - time.sleep(5) + except Exception as e: + print(f"Error fetching decimals: {e}") + time.sleep(MONITOR_INTERVAL_SECONDS) continue amt0, amt1 = calculate_mint_amounts(tick, lower, upper, TARGET_INVESTMENT_VALUE_TOKEN1, d0, d1, pool_data['sqrtPriceX96']) amt0_buf, amt1_buf = int(amt0 * 1.02), int(amt1 * 1.02) - # 4. Swap & Mint 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: - # Calculate Actual Value - try: - s0 = token0_c.functions.symbol().call() - s1 = token1_c.functions.symbol().call() - except: - s0, s1 = "T0", "T1" + if mint_result: # Calculate Actual Value + 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) - entry_price = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1) - - # Value in Token1 terms (e.g. USDC) - actual_value = (real_amt0 * entry_price) + real_amt1 - print(f"ACTUAL MINT VALUE: {actual_value:.2f} {s1}/{s0}") - - 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, # Save Actual Value as Target for hedging accuracy - 'amount0_initial': mint_result['amount0'], - 'amount1_initial': mint_result['amount1'] - } - update_hedge_status_file("OPEN", pos_data) - print("Cycle Complete. Monitoring.") + real_amt0 = from_wei(mint_result['amount0'], d0) + real_amt1 = from_wei(mint_result['amount1'], d1) + entry_price = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1) + actual_value = (real_amt0 * entry_price) + real_amt1 + print(f"ACTUAL MINT VALUE: {actual_value:.2f} {s1}/{s0}") + + 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'] + } + update_hedge_status_file("OPEN", pos_data) + print("Cycle Complete. Monitoring.") + elif not all_positions: print("No open positions (Manual or Automatic). Waiting...") @@ -708,4 +759,4 @@ def main(): time.sleep(MONITOR_INTERVAL_SECONDS) if __name__ == "__main__": - main() \ No newline at end of file + main()