import os import time import logging import sys import math import json from dotenv import load_dotenv # --- FIX: Add project root to sys.path to import local modules --- current_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(current_dir) sys.path.append(project_root) # Now we can import from root from logging_utils import setup_logging from eth_account import Account from hyperliquid.exchange import Exchange from hyperliquid.info import Info from hyperliquid.utils import constants # Load environment variables from .env in current directory dotenv_path = os.path.join(current_dir, '.env') if os.path.exists(dotenv_path): load_dotenv(dotenv_path) else: # Fallback to default search load_dotenv() setup_logging("normal", "SCALPER_HEDGER") # --- CONFIGURATION --- COIN_SYMBOL = "ETH" CHECK_INTERVAL = 10 # 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 def get_active_automatic_position(): if not os.path.exists(STATUS_FILE): return None try: with open(STATUS_FILE, 'r') as f: data = json.load(f) for entry in data: if entry.get('type') == 'AUTOMATIC' and entry.get('status') == 'OPEN': return entry except Exception as e: logging.error(f"ERROR reading status file: {e}") return None 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) def round_to_sz_decimals(amount, sz_decimals=4): return round(abs(amount), sz_decimals) class HyperliquidStrategy: def __init__(self, entry_amount0, entry_amount1, target_value, entry_price, low_range, high_range, start_price, static_long=0.0): self.entry_amount0 = entry_amount0 self.entry_amount1 = entry_amount1 self.target_value = target_value self.entry_price = entry_price self.low_range = low_range self.high_range = high_range self.static_long = static_long self.start_price = start_price self.gap = max(0.0, entry_price - start_price) self.recovery_target = entry_price + (2 * self.gap) self.current_mode = "NORMAL" self.last_switch_time = 0 logging.info(f"Strategy Init. Start Px: {start_price:.2f} | Gap: {self.gap:.2f} | Recovery Tgt: {self.recovery_target:.2f}") try: sqrt_P = math.sqrt(entry_price) sqrt_Pa = math.sqrt(low_range) sqrt_Pb = math.sqrt(high_range) 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) if denom0 > 0.00000001: 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) if self.L == 0.0 and entry_amount1 > 0: amount1_usdc = entry_amount1 / 10**6 # USDC is 6 decimals 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) if self.L == 0.0: logging.warning("Amounts missing or 0. Using Target Value Heuristic.") max_eth_heuristic = target_value / low_range denom_h = (1/sqrt_Pa) - (1/sqrt_Pb) if denom_h > 0: self.L = max_eth_heuristic / denom_h logging.info(f"Calculated L from Target Value: {self.L:.4f}") else: logging.error("Critical: Denominator 0 in Heuristic. Invalid Range?") self.L = 0.0 except Exception as e: logging.error(f"Error calculating liquidity: {e}") sys.exit(1) def get_pool_delta(self, current_price): if current_price >= self.high_range: return 0.0 if current_price <= self.low_range: sqrt_Pa = math.sqrt(self.low_range) sqrt_Pb = math.sqrt(self.high_range) return self.L * ((1/sqrt_Pa) - (1/sqrt_Pb)) sqrt_P = math.sqrt(current_price) sqrt_Pb = math.sqrt(self.high_range) return self.L * ((1/sqrt_P) - (1/sqrt_Pb)) def calculate_rebalance(self, current_price, current_short_position_size): 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 diff = target_short_size - abs(current_short_position_size) return { "current_price": current_price, "pool_delta": pool_delta, "target_short": target_short_size, "current_short": abs(current_short_position_size), "diff": diff, "action": "SELL" if diff > 0 else "BUY", "mode": self.current_mode } class ScalperHedger: def __init__(self): self.private_key = os.environ.get("SCALPER_AGENT_PK") self.vault_address = os.environ.get("MAIN_WALLET_ADDRESS") if not self.private_key: logging.error("No SCALPER_AGENT_PK found in .env") sys.exit(1) self.account = Account.from_key(self.private_key) self.info = Info(constants.MAINNET_API_URL, skip_ws=True) self.exchange = Exchange(self.account, constants.MAINNET_API_URL, account_address=self.vault_address) try: logging.info(f"Setting leverage to {LEVERAGE}x (Cross)...") self.exchange.update_leverage(LEVERAGE, COIN_SYMBOL, is_cross=True) except Exception as e: logging.error(f"Failed to update leverage: {e}") self.strategy = None self.sz_decimals = self._get_sz_decimals(COIN_SYMBOL) self.active_position_id = None logging.info(f"Scalper Hedger initialized. Agent: {self.account.address}") def _init_strategy(self, position_data): try: entry_amount0 = position_data.get('amount0_initial', 0) entry_amount1 = position_data.get('amount1_initial', 0) target_value = position_data.get('target_value', 50.0) entry_price = position_data['entry_price'] lower = position_data['range_lower'] upper = position_data['range_upper'] static_long = position_data.get('static_long', 0.0) start_price = self.get_market_price(COIN_SYMBOL) if start_price is None: logging.warning("Waiting for initial price to start strategy...") return self.strategy = HyperliquidStrategy( entry_amount0=entry_amount0, entry_amount1=entry_amount1, target_value=target_value, entry_price=entry_price, low_range=lower, high_range=upper, start_price=start_price, static_long=static_long ) logging.info(f"Strategy Initialized for Position {position_data['token_id']}.") self.active_position_id = position_data['token_id'] except Exception as e: logging.error(f"Failed to init strategy: {e}") self.strategy = None def _get_sz_decimals(self, coin): try: meta = self.info.meta() for asset in meta["universe"]: if asset["name"] == coin: return asset["szDecimals"] return 4 except: return 4 def get_market_price(self, coin): try: mids = self.info.all_mids() if coin in mids: return float(mids[coin]) except: pass return None def get_funding_rate(self, coin): try: meta, asset_ctxs = self.info.meta_and_asset_ctxs() for i, asset in enumerate(meta["universe"]): if asset["name"] == coin: return float(asset_ctxs[i]["funding"]) return 0.0 except: return 0.0 def get_current_position(self, coin): try: user_state = self.info.user_state(self.vault_address or self.account.address) for pos in user_state["assetPositions"]: if pos["position"]["coin"] == coin: return float(pos["position"]["szi"]) 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}") 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) order_result = self.exchange.order(coin, is_buy, size, limit_px, {"limit": {"tif": "Ioc"}}, 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") else: logging.error(f"Order Failed: {order_result}") except Exception as e: logging.error(f"Exception during trade: {e}") def close_all_positions(self): logging.info("Closing all positions (Safety/Closed State)...") try: price = self.get_market_price(COIN_SYMBOL) current_pos = self.get_current_position(COIN_SYMBOL) if current_pos == 0: return is_buy = current_pos < 0 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) self.active_position_id = None except Exception as e: logging.error(f"Error closing: {e}") def run(self): logging.info(f"Starting Scalper Monitor Loop. Interval: {CHECK_INTERVAL}s") while True: try: active_pos = get_active_automatic_position() # Check Global Enable Switch if not active_pos or not active_pos.get('hedge_enabled', True): if self.strategy is not None: logging.info("Hedge Disabled or Position Closed. Closing remaining positions.") self.close_all_positions() self.strategy = None else: pass 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) if self.strategy is None: time.sleep(CHECK_INTERVAL) continue # Double Check Strategy validity if self.strategy is None: continue # 2. Market Data price = self.get_market_price(COIN_SYMBOL) if price is None: time.sleep(5) continue funding_rate = self.get_funding_rate(COIN_SYMBOL) current_pos_size = self.get_current_position(COIN_SYMBOL) # 3. Calculate calc = self.strategy.calculate_rebalance(price, current_pos_size) diff_abs = abs(calc['diff']) # 4. Dynamic Threshold Calculation sqrt_Pa = math.sqrt(self.strategy.low_range) 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) # 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) else: logging.info("Trade size rounds to 0. Skipping.") else: logging.info(f"Idle. Diff {diff_abs:.4f} < Threshold {rebalance_threshold:.4f}") time.sleep(CHECK_INTERVAL) except KeyboardInterrupt: logging.info("Stopping Hedger...") self.close_all_positions() break except Exception as e: logging.error(f"Loop Error: {e}", exc_info=True) time.sleep(10) if __name__ == "__main__": hedger = ScalperHedger() hedger.run()