387 lines
16 KiB
Python
387 lines
16 KiB
Python
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() |