refactor: Standardize CLP Manager and Hedger modules & cleanup

- **clp_manager.py**: Renamed from 'uniswap_manager.py'. Standardized logic for Uniswap V3 liquidity provision.
- **clp_hedger.py**: Renamed from 'unified_hedger.py'. Consolidated hedging logic including Delta Calculation fixes, EAC (Edge Avoidance), and Fishing order implementation.
- **Cleanup**: Removed legacy 'aerodrome' folder and tools.
- **Monitoring**: Added Telegram monitoring scripts.
- **Config**: Updated gitignore to exclude market data CSVs.
This commit is contained in:
2025-12-31 11:09:33 +01:00
parent 69fbf389c8
commit b22fdcf741
31 changed files with 3499 additions and 6869 deletions

View File

@ -1,6 +1,8 @@
{
"ui": {
"useAlternateBuffer": true
"useAlternateBuffer": true,
"incrementalRendering": true,
"multiline": true
},
"tools": {
"truncateToolOutputLines": 10000

View File

@ -108,7 +108,7 @@
{
"type": "AUTOMATIC",
"token_id": 6154897,
"status": "OPEN",
"status": "CLOSED",
"target_value": 998.49,
"entry_price": 849.3437,
"amount0_initial": 498.5177,
@ -118,6 +118,101 @@
"range_lower": 836.9493,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767001797
"timestamp_open": 1767001797,
"target_value_end": 1005.08,
"timestamp_close": 1767102435
},
{
"type": "AUTOMATIC",
"token_id": 6161247,
"status": "CLOSED",
"target_value": 995.73,
"entry_price": 862.6115,
"amount0_initial": 495.7523,
"amount1_initial": 0.5796,
"liquidity": "2299317483414760958984",
"range_upper": 875.5579,
"range_lower": 850.0224,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767102508,
"hedge_TotPnL": 0.523778
},
{
"type": "AUTOMATIC",
"token_id": 6162814,
"status": "CLOSED",
"target_value": 995.27,
"entry_price": 860.0,
"amount0_initial": 496.4754,
"amount1_initial": 0.58,
"liquidity": "8300226074094182294178",
"range_upper": 863.616,
"range_lower": 856.384,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767126772,
"hedge_TotPnL": -3.412645,
"hedge_fees_paid": 1.002278,
"timestamp_close": 1767144919
},
{
"type": "AUTOMATIC",
"token_id": 6163606,
"status": "CLOSED",
"target_value": 1000.01,
"entry_price": 862.0807,
"amount0_initial": 500.0068,
"amount1_initial": 0.58,
"liquidity": "8283150435973737393211",
"range_upper": 865.7014,
"range_lower": 858.46,
"timestamp_open": 1767145082,
"timestamp_close": 1767151372
},
{
"type": "AUTOMATIC",
"token_id": 6163987,
"status": "CLOSED",
"target_value": 995.26,
"entry_price": 857.9836,
"amount0_initial": 497.6305,
"amount1_initial": 0.58,
"liquidity": "8313185309628073121633",
"range_upper": 861.5872,
"range_lower": 854.3801,
"timestamp_open": 1767152045,
"timestamp_close": 1767158799
},
{
"type": "AUTOMATIC",
"token_id": 6164411,
"status": "CLOSED",
"target_value": 991.83,
"entry_price": 855.03,
"amount0_initial": 495.9174,
"amount1_initial": 0.58,
"liquidity": "8280770348281556176465",
"range_upper": 858.6211,
"range_lower": 851.4389,
"timestamp_open": 1767158967,
"timestamp_close": 1767163852
},
{
"type": "AUTOMATIC",
"token_id": 6164702,
"status": "OPEN",
"target_value": 981.88,
"entry_price": 846.4517,
"amount0_initial": 490.942,
"amount1_initial": 0.58,
"liquidity": "8220443727732589279738",
"range_upper": 869.8855,
"range_lower": 862.782,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767164052,
"hedge_TotPnL": -0.026171,
"hedge_fees_paid": 0.097756
}
]

View File

@ -298,6 +298,8 @@
"range_lower": 2827.096,
"token0_decimals": 18,
"token1_decimals": 6,
"timestamp_open": 1766968369
"timestamp_open": 1766968369,
"hedge_TotPnL": -5.078135,
"hedge_fees_paid": 2.029157
}
]

View File

@ -8,49 +8,56 @@ STATUS_FILE = os.environ.get("STATUS_FILE", f"{TARGET_DEX}_status.json")
# --- DEFAULT STRATEGY ---
DEFAULT_STRATEGY = {
"MONITOR_INTERVAL_SECONDS": 60, # How often the Manager checks for range status
"CLOSE_POSITION_ENABLED": True, # Allow the bot to automatically close out-of-range positions
"OPEN_POSITION_ENABLED": True, # Allow the bot to automatically open new positions
"REBALANCE_ON_CLOSE_BELOW_RANGE": True, # Strategy flag for specific closing behavior
"MONITOR_INTERVAL_SECONDS": 60, # How often the Manager checks for range status
"CLOSE_POSITION_ENABLED": True, # Allow the bot to automatically close out-of-range positions
"OPEN_POSITION_ENABLED": True, # Allow the bot to automatically open new positions
"REBALANCE_ON_CLOSE_BELOW_RANGE": True, # Strategy flag for specific closing behavior
# Investment Settings
"TARGET_INVESTMENT_AMOUNT": 2000, # Total USD value to deploy into the LP position
"INITIAL_HEDGE_CAPITAL": 1000, # Capital reserved on Hyperliquid for hedging
"VALUE_REFERENCE": "USD", # Base currency for all calculations
"WRAPPED_NATIVE_ADDRESS": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", # WETH/WBNB address
"TARGET_INVESTMENT_AMOUNT": 2000, # Total USD value to deploy into the LP position
"INITIAL_HEDGE_CAPITAL": 1000, # Capital reserved on Hyperliquid for hedging
"VALUE_REFERENCE": "USD", # Base currency for all calculations
# Range Settings
"RANGE_WIDTH_PCT": Decimal("0.01"), # LP width (e.g. 0.05 = +/- 5% from current price)
"SLIPPAGE_TOLERANCE": Decimal("0.02"), # Max allowed slippage for swaps and minting
"TRANSACTION_TIMEOUT_SECONDS": 30, # Timeout for blockchain transactions
# Hedging Settings
"MIN_HEDGE_THRESHOLD": Decimal("0.012"), # Minimum delta change (in coins) required to trigger a trade
"RANGE_WIDTH_PCT": Decimal("0.01"), # LP width (e.g. 0.05 = +/- 5% from current price)
"SLIPPAGE_TOLERANCE": Decimal("0.02"), # Max allowed slippage for swaps and minting
"TRANSACTION_TIMEOUT_SECONDS": 30, # Timeout for blockchain transactions
# Hedging Settings
"MIN_HEDGE_THRESHOLD": Decimal("0.012"), # Minimum delta change (in coins) required to trigger a trade
# Unified Hedger Settings
"CHECK_INTERVAL": 1, # Loop speed for the hedger (seconds)
"LEVERAGE": 5, # Leverage to use on Hyperliquid
"ZONE_BOTTOM_HEDGE_LIMIT": Decimal("1.0"), # Multiplier limit at the bottom of the range
"ZONE_CLOSE_START": Decimal("10.0"), # Distance (pct) from edge to start closing logic
"ZONE_CLOSE_END": Decimal("11.0"), # Distance (pct) from edge to finish closing logic
"ZONE_TOP_HEDGE_START": Decimal("10.0"),# Distance (pct) from top edge to adjust hedging
"PRICE_BUFFER_PCT": Decimal("0.0015"), # Buffer for limit order pricing (0.15%)
"MIN_ORDER_VALUE_USD": Decimal("10.0"), # Minimum order size allowed by Hyperliquid
"DYNAMIC_THRESHOLD_MULTIPLIER": Decimal("1.2"), # Expansion factor for thresholds
"MIN_TIME_BETWEEN_TRADES": 60, # Cooldown (seconds) between rebalance trades
"MAX_HEDGE_MULTIPLIER": Decimal("1.25"),# Max allowed hedge size relative to calculated target
"BASE_REBALANCE_THRESHOLD_PCT": Decimal("0.25"), # Base tolerance for delta drift (20%)
"EDGE_PROXIMITY_PCT": Decimal("0.04"), # Distance to range edge where protection activates
"VELOCITY_THRESHOLD_PCT": Decimal("0.0005"), # Minimum price velocity to trigger volatility logic
"POSITION_OPEN_EDGE_PROXIMITY_PCT": Decimal("0.06"), # Safety margin when opening new positions
"POSITION_CLOSED_EDGE_PROXIMITY_PCT": Decimal("0.025"), # Safety margin for closing positions
"LARGE_HEDGE_MULTIPLIER": Decimal("5.0"), # Multiplier to bypass trade cooldown for big moves
"ENABLE_EDGE_CLEANUP": True, # Force rebalances when price is at range boundaries
"EDGE_CLEANUP_MARGIN_PCT": Decimal("0.02"), # % of range width used for edge detection
"MAKER_ORDER_TIMEOUT": 600, # Timeout for resting Maker orders (seconds)
"SHADOW_ORDER_TIMEOUT": 600, # Timeout for theoretical shadow order tracking
"ENABLE_FISHING": False, # Use passive maker orders for rebalancing (advanced)
"FISHING_ORDER_SIZE_PCT": Decimal("0.10"), # Size of individual fishing orders
"CHECK_INTERVAL": 1, # Loop speed for the hedger (seconds)
"LEVERAGE": 5, # Leverage to use on Hyperliquid
"ZONE_BOTTOM_HEDGE_LIMIT": Decimal("1.0"), # Multiplier limit at the bottom of the range
"ZONE_CLOSE_START": Decimal("10.0"), # Distance (pct) from edge to start closing logic
"ZONE_CLOSE_END": Decimal("11.0"), # Distance (pct) from edge to finish closing logic
"ZONE_TOP_HEDGE_START": Decimal("10.0"), # Distance (pct) from top edge to adjust hedging
"PRICE_BUFFER_PCT": Decimal("0.0015"), # Buffer for limit order pricing (0.15%)
"MIN_ORDER_VALUE_USD": Decimal("10.0"), # Minimum order size allowed by Hyperliquid
"DYNAMIC_THRESHOLD_MULTIPLIER": Decimal("1.2"), # Expansion factor for thresholds
"MIN_TIME_BETWEEN_TRADES": 60, # Cooldown (seconds) between rebalance trades
"MAX_HEDGE_MULTIPLIER": Decimal("1.25"), # Max allowed hedge size relative to calculated target
"BASE_REBALANCE_THRESHOLD_PCT": Decimal("0.25"), # Base tolerance for delta drift (20%)
"EDGE_PROXIMITY_PCT": Decimal("0.04"), # Distance to range edge where protection activates
"VELOCITY_THRESHOLD_PCT": Decimal("0.0005"), # Minimum price velocity to trigger volatility logic
"POSITION_OPEN_EDGE_PROXIMITY_PCT": Decimal("0.06"), # Safety margin when opening new positions
"POSITION_CLOSED_EDGE_PROXIMITY_PCT": Decimal("0.025"), # Safety margin for closing positions
"LARGE_HEDGE_MULTIPLIER": Decimal("5.0"), # Multiplier to bypass trade cooldown for big moves
"ENABLE_EDGE_CLEANUP": True, # Force rebalances when price is at range boundaries
"EDGE_CLEANUP_MARGIN_PCT": Decimal("0.02"), # % of range width used for edge detection
"MAKER_ORDER_TIMEOUT": 600, # Timeout for resting Maker orders (seconds)
"SHADOW_ORDER_TIMEOUT": 600, # Timeout for theoretical shadow order tracking
"ENABLE_FISHING": False, # Use passive maker orders for rebalancing (advanced)
"FISHING_ORDER_SIZE_PCT": Decimal("0.10"), # Size of individual fishing orders
"FISHING_TIMEOUT_FALLBACK": 30, # Seconds before converting fishing order to taker
# EAC (Enhanced Asymmetric Compensation)
"EAC_NARROW_RANGE_THRESHOLD": Decimal("0.02"), # <2% = narrow
"EAC_MEDIUM_RANGE_THRESHOLD": Decimal("0.05"), # <5% = medium
"EAC_NARROW_BOOST": Decimal("0.15"), # 15% boost
"EAC_MEDIUM_BOOST": Decimal("0.10"), # 10% boost
"EAC_WIDE_BOOST": Decimal("0.075"), # 7.5% boost
}
# --- CLP PROFILES ---
@ -91,9 +98,17 @@ CLP_PROFILES = {
"TOKEN_B_ADDRESS": "0x55d398326f99059fF775485246999027B3197955", # USDT
"WRAPPED_NATIVE_ADDRESS": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c",
"POOL_FEE": 100,
"RANGE_WIDTH_PCT": Decimal("0.015"),
"RANGE_WIDTH_PCT": Decimal("0.004"),
"TARGET_INVESTMENT_AMOUNT": 1000,
"MIN_HEDGE_THRESHOLD": Decimal("0.05"), # ~$30 for BNB
"MIN_HEDGE_THRESHOLD": Decimal("0.015"),
"BASE_REBALANCE_THRESHOLD_PCT": Decimal("0.10"),
"EDGE_PROXIMITY_PCT": Decimal("0.015"),
"DYNAMIC_THRESHOLD_MULTIPLIER": Decimal("1.1"),
"MIN_TIME_BETWEEN_TRADES": 20,
"ENABLE_FISHING": False,
"FISHING_ORDER_SIZE_PCT": Decimal("0.05"),
"MAKER_ORDER_TIMEOUT": 180,
"FISHING_TIMEOUT_FALLBACK": 60,
},
"WETH_CBBTC_BASE": {
"NAME": "Aerodrome/Uni (Base) - WETH/cbBTC",

View File

@ -45,7 +45,7 @@ class UnixMsLogFilter(logging.Filter):
return True
# Configure Logging
logger = logging.getLogger("UNIFIED_HEDGER")
logger = logging.getLogger("CLP_HEDGER")
logger.setLevel(logging.INFO)
logger.propagate = False # Prevent double logging from root logger
logger.handlers.clear() # Clear existing handlers to prevent duplicates
@ -58,7 +58,7 @@ console_handler.setFormatter(console_fmt)
logger.addHandler(console_handler)
# File Handler
log_file = os.path.join(log_dir, 'unified_hedger.log')
log_file = os.path.join(log_dir, 'clp_hedger.log')
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setLevel(logging.INFO)
file_handler.addFilter(UnixMsLogFilter())
@ -281,7 +281,7 @@ class UnifiedHedger:
self.startup_time = time.time()
logger.info(f"[UNIFIED] Master Hedger initialized. Agent: {self.account.address}")
logger.info(f"[CLP_HEDGER] Master Hedger initialized. Agent: {self.account.address}")
self._init_coin_configs()
def _init_coin_configs(self):
@ -468,12 +468,17 @@ class UnifiedHedger:
liquidity_val, liquidity_scale
)
# Fix: Use persistent start time from JSON to track all fills
ts_open = position_data.get('timestamp_open')
start_time_ms = int(ts_open * 1000) if ts_open else int(time.time() * 1000)
self.strategies[key] = strat
self.strategy_states[key] = {
"coin": coin_symbol,
"start_time": int(time.time() * 1000),
"start_time": start_time_ms,
"pnl": to_decimal(position_data.get('hedge_pnl_realized', 0)),
"fees": to_decimal(position_data.get('hedge_fees_paid', 0)),
"hedge_TotPnL": to_decimal(position_data.get('hedge_TotPnL', 0)), # NEW: Total Closed PnL
"entry_price": entry_price, # Store for fishing logic
"status": position_data.get('status', 'OPEN')
}
@ -536,6 +541,46 @@ class UnifiedHedger:
except Exception as e:
logger.error(f"Error cancelling order: {e}")
def _update_closed_pnl(self, coin: str):
"""Fetch fills from API and sum closedPnl and fees for active strategies."""
try:
# 1. Identify relevant strategies for this coin
active_strats = [k for k, v in self.strategy_states.items() if v['coin'] == coin]
if not active_strats: return
# 2. Fetch all fills (This is heavy, maybe cache or limit?)
# SDK user_fills returns recent fills.
fills = self.info.user_fills(self.vault_address or self.account.address)
for key in active_strats:
start_time = self.strategy_states[key]['start_time']
total_closed_pnl = Decimal("0")
total_fees = Decimal("0")
for fill in fills:
if fill['coin'] == coin:
# Check timestamp
if fill['time'] >= start_time:
# Sum closedPnl
total_closed_pnl += to_decimal(fill.get('closedPnl', 0))
# Sum fees
total_fees += to_decimal(fill.get('fee', 0))
# Update State
self.strategy_states[key]['hedge_TotPnL'] = total_closed_pnl
self.strategy_states[key]['fees'] = total_fees
# Write to JSON
file_path, token_id = key
update_position_stats(file_path, token_id, {
"hedge_TotPnL": float(total_closed_pnl),
"hedge_fees_paid": float(total_fees)
})
logger.info(f"[PnL] Updated {coin} | Closed PnL: ${total_closed_pnl:.2f} | Fees: ${total_fees:.2f}")
except Exception as e:
logger.error(f"Failed to update closed PnL/Fees for {coin}: {e}")
def run(self):
logger.info("Starting Unified Hedger Loop...")
self.update_coin_decimals()
@ -587,12 +632,15 @@ class UnifiedHedger:
# Map current positions
current_positions = {} # Coin -> Size
current_pnls = {} # Coin -> Unrealized PnL
current_entry_pxs = {} # Coin -> Entry Price (NEW)
for pos in user_state["assetPositions"]:
c = pos["position"]["coin"]
s = to_decimal(pos["position"]["szi"])
u = to_decimal(pos["position"]["unrealizedPnl"])
e = to_decimal(pos["position"]["entryPx"])
current_positions[c] = s
current_pnls[c] = u
current_entry_pxs[c] = e
# 4. Aggregate Targets
# Coin -> { 'target_short': Decimal, 'contributors': int, 'is_at_edge': bool }
@ -692,6 +740,10 @@ class UnifiedHedger:
# Check Trigger
action_needed = diff_abs > dynamic_thresh
# Determine Intent (Moved UP for Order Logic)
is_buy_bool = diff > 0
side_str = "BUY" if is_buy_bool else "SELL"
# Manage Existing Orders
existing_orders = orders_map.get(coin, [])
force_taker_retry = False
@ -715,8 +767,16 @@ class UnifiedHedger:
# Price Check (within buffer)
dist_pct = abs(price - o_price) / price
# Fishing Timeout Check
# Maker Timeout Check (General)
maker_timeout = config.get("MAKER_ORDER_TIMEOUT", 300)
order_age_sec = (int(time.time()*1000) - o_timestamp) / 1000.0
if is_same_side and order_age_sec > maker_timeout:
logger.info(f"[TIMEOUT] {coin} Order {o_oid} expired ({order_age_sec:.1f}s > {maker_timeout}s). Cancelling to refresh.")
self.cancel_order(coin, o_oid)
continue
# Fishing Timeout Check
if enable_fishing and is_same_side and order_age_sec > fishing_timeout:
logger.info(f"[FISHING] {coin} Order {o_oid} timed out ({order_age_sec:.1f}s > {fishing_timeout}s). Cancelling for Taker retry.")
self.cancel_order(coin, o_oid)
@ -732,122 +792,122 @@ class UnifiedHedger:
logger.info(f"Cancelling stale order {o_oid} ({o_side} @ {o_price})")
self.cancel_order(coin, o_oid)
if order_matched:
continue # Order exists, wait for it
# --- EXECUTION LOGIC ---
if action_needed or force_taker_retry:
bypass_cooldown = False
force_maker = False
if not order_matched:
if action_needed or force_taker_retry:
bypass_cooldown = False
force_maker = False
# 0. Forced Taker Retry (Fishing Timeout)
if force_taker_retry:
bypass_cooldown = True
logger.info(f"[RETRY] {coin} Fishing Failed -> Force Taker")
# 0. Forced Taker Retry (Fishing Timeout)
if force_taker_retry:
bypass_cooldown = True
logger.info(f"[RETRY] {coin} Fishing Failed -> Force Taker")
# 1. Urgent Closing -> Taker
elif data.get('is_closing', False):
bypass_cooldown = True
logger.info(f"[URGENT] {coin} Closing Strategy -> Force Taker Exit")
# 2. Ghost/Cleanup -> Maker
elif data.get('contributors', 0) == 0:
if time.time() - self.startup_time > 5:
force_maker = True
logger.info(f"[CLEANUP] {coin} Ghost Position -> Force Maker Reduce")
else:
logger.info(f"[STARTUP] Skipping Ghost Cleanup for {coin} (Grace Period)")
continue # Skip execution for this coin
# 1. Urgent Closing -> Taker
elif data.get('is_closing', False):
bypass_cooldown = True
logger.info(f"[URGENT] {coin} Closing Strategy -> Force Taker Exit")
# 2. Ghost/Cleanup -> Maker
elif data.get('contributors', 0) == 0:
if time.time() - self.startup_time > 5:
force_maker = True
logger.info(f"[CLEANUP] {coin} Ghost Position -> Force Maker Reduce")
else:
logger.info(f"[STARTUP] Skipping Ghost Cleanup for {coin} (Grace Period)")
continue # Skip execution for this coin
# Large Hedge Check
large_hedge_mult = config.get("LARGE_HEDGE_MULTIPLIER", Decimal("5.0"))
if diff_abs > (dynamic_thresh * large_hedge_mult) and not force_maker:
bypass_cooldown = True
logger.info(f"[WARN] LARGE HEDGE: {diff_abs:.4f} > {dynamic_thresh:.4f} (x{large_hedge_mult})")
# Large Hedge Check (Only Force Taker if AT EDGE)
large_hedge_mult = config.get("LARGE_HEDGE_MULTIPLIER", Decimal("5.0"))
if diff_abs > (dynamic_thresh * large_hedge_mult) and not force_maker and data.get('is_at_edge', False):
bypass_cooldown = True
logger.info(f"[WARN] LARGE HEDGE (Edge Protection): {diff_abs:.4f} > {dynamic_thresh:.4f} (x{large_hedge_mult})")
elif diff_abs > (dynamic_thresh * large_hedge_mult) and not force_maker:
# Large hedge but safe zone -> Maker is fine, but maybe log it
logger.info(f"[INFO] Large Hedge (Safe Zone): {diff_abs:.4f}. Using Standard Execution.")
# Determine Intent
is_buy_bool = diff > 0
side_str = "BUY" if is_buy_bool else "SELL"
last_trade = self.last_trade_times.get(coin, 0)
min_time_trade = config.get("MIN_TIME_BETWEEN_TRADES", 60)
can_trade = False
if bypass_cooldown:
can_trade = True
elif time.time() - last_trade > min_time_trade:
can_trade = True
last_trade = self.last_trade_times.get(coin, 0)
if can_trade:
# Get Orderbook for Price
if coin not in l2_snapshots:
l2_snapshots[coin] = self.info.l2_snapshot(coin)
levels = l2_snapshots[coin]['levels']
if not levels[0] or not levels[1]: continue
bid = to_decimal(levels[0][0]['px'])
ask = to_decimal(levels[1][0]['px'])
# Price logic
create_shadow = False
# Decide Order Type: Taker (Ioc) or Maker (Alo)
# Taker if: Urgent (bypass_cooldown) OR Fishing Disabled OR Force Maker is False (wait, Force Maker means Alo)
# Logic:
# If Force Maker -> Alo
# Else if Urgent -> Ioc
# Else if Enable Fishing -> Alo
# Else -> Alo (Default non-urgent behavior was Alo anyway?)
# Let's clarify:
# Previous logic: if bypass_cooldown -> Ioc. Else -> Alo.
# New logic:
# If bypass_cooldown -> Ioc
# Else -> Alo (Fishing)
if bypass_cooldown and not force_maker:
exec_price = ask * Decimal("1.001") if is_buy_bool else bid * Decimal("0.999")
order_type = "Ioc"
create_shadow = True
else:
# Fishing / Standard Maker
exec_price = bid if is_buy_bool else ask
order_type = "Alo"
min_time_trade = config.get("MIN_TIME_BETWEEN_TRADES", 60)
can_trade = False
if bypass_cooldown:
can_trade = True
elif time.time() - last_trade > min_time_trade:
can_trade = True
logger.info(f"[TRIG] Net {coin}: {side_str} {diff_abs:.4f} | Tgt: {target_position:.4f} / Cur: {current_pos:.4f} | Thresh: {dynamic_thresh:.4f} | Type: {order_type}")
oid = self.place_limit_order(coin, is_buy_bool, diff_abs, exec_price, order_type)
if oid:
self.last_trade_times[coin] = time.time()
if can_trade:
# Get Orderbook for Price
if coin not in l2_snapshots:
l2_snapshots[coin] = self.info.l2_snapshot(coin)
# Shadow Order
if create_shadow:
shadow_price = bid if is_buy_bool else ask
shadow_timeout = config.get("SHADOW_ORDER_TIMEOUT", 600)
self.shadow_orders.append({
'coin': coin,
'side': side_str,
'price': shadow_price,
'expires_at': time.time() + shadow_timeout
})
logger.info(f"[SHADOW] Created Maker {side_str} @ {shadow_price:.2f}")
levels = l2_snapshots[coin]['levels']
if not levels[0] or not levels[1]: continue
# UPDATED: Sleep for API Lag (Phase 5.1)
logger.info("Sleeping 10s to allow position update...")
time.sleep(10)
bid = to_decimal(levels[0][0]['px'])
ask = to_decimal(levels[1][0]['px'])
# Price logic
create_shadow = False
# Decide Order Type: Taker (Ioc) or Maker (Alo)
# Taker if: Urgent (bypass_cooldown) OR Fishing Disabled OR Force Maker is False (wait, Force Maker means Alo)
# Logic:
# If Force Maker -> Alo
# Else if Urgent -> Ioc
# Else if Enable Fishing -> Alo
# Else -> Alo (Default non-urgent behavior was Alo anyway?)
# Let's clarify:
# Previous logic: if bypass_cooldown -> Ioc. Else -> Alo.
# New logic:
# If bypass_cooldown -> Ioc
# Else -> Alo (Fishing)
if bypass_cooldown and not force_maker:
exec_price = ask * Decimal("1.001") if is_buy_bool else bid * Decimal("0.999")
order_type = "Ioc"
create_shadow = True
else:
# Fishing / Standard Maker
exec_price = bid if is_buy_bool else ask
order_type = "Alo"
logger.info(f"[TRIG] Net {coin}: {side_str} {diff_abs:.4f} | Tgt: {target_position:.4f} / Cur: {current_pos:.4f} | Thresh: {dynamic_thresh:.4f} | Type: {order_type}")
oid = self.place_limit_order(coin, is_buy_bool, diff_abs, exec_price, order_type)
if oid:
self.last_trade_times[coin] = time.time()
# Shadow Order
if create_shadow:
shadow_price = bid if is_buy_bool else ask
shadow_timeout = config.get("SHADOW_ORDER_TIMEOUT", 600)
self.shadow_orders.append({
'coin': coin,
'side': side_str,
'price': shadow_price,
'expires_at': time.time() + shadow_timeout
})
logger.info(f"[SHADOW] Created Maker {side_str} @ {shadow_price:.2f}")
# UPDATED: Sleep for API Lag (Phase 5.1)
logger.info("Sleeping 10s to allow position update...")
time.sleep(10)
# --- UPDATE CLOSED PnL FROM API ---
self._update_closed_pnl(coin)
else:
# Cooldown log
pass
else:
# Cooldown log
pass
else:
# Action NOT needed
# Cleanup any dangling orders
if existing_orders:
for o in existing_orders:
logger.info(f"Cancelling idle order {o['oid']} ({o['side']} @ {o['limitPx']})")
self.cancel_order(coin, o['oid'])
# Action NOT needed
# Cleanup any dangling orders
if existing_orders:
for o in existing_orders:
logger.info(f"Cancelling idle order {o['oid']} ({o['side']} @ {o['limitPx']})")
self.cancel_order(coin, o['oid'])
# --- IDLE LOGGING (Restored Format) ---
# Calculate aggregate Gamma to estimate triggers
@ -876,11 +936,14 @@ class UnifiedHedger:
# PnL Calc
unrealized = current_pnls.get(coin, Decimal("0"))
realized = Decimal("0")
closed_pnl_total = Decimal("0")
fees_total = Decimal("0")
for k, s_state in self.strategy_states.items():
if s_state['coin'] == coin:
realized += (s_state['pnl'] - s_state['fees'])
total_pnl = realized + unrealized
closed_pnl_total += s_state.get('hedge_TotPnL', Decimal("0"))
fees_total += s_state.get('fees', Decimal("0"))
total_pnl = (closed_pnl_total - fees_total) + unrealized
pnl_pad = " " if unrealized >= 0 else ""
tot_pnl_pad = " " if total_pnl >= 0 else ""

991
florida/clp_manager.py Normal file
View File

@ -0,0 +1,991 @@
import os
import sys
import time
import json
import re
import logging
import math
from decimal import Decimal, getcontext
from datetime import datetime
from typing import Optional, Dict, Tuple, Any, List
from web3 import Web3
from web3.exceptions import TimeExhausted, ContractLogicError
from web3.middleware import ExtraDataToPOAMiddleware # FIX for Web3.py v6+
from eth_account import Account
from eth_account.signers.local import LocalAccount
from dotenv import load_dotenv
# --- IMPORTS FOR KPI ---
try:
from tools.kpi_tracker import log_kpi_snapshot
except ImportError:
logging.warning("KPI Tracker not found. Performance logging disabled.")
log_kpi_snapshot = None
# Set Decimal precision high enough for EVM math
getcontext().prec = 60
# --- LOGGING SETUP ---
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
# Ensure logs directory exists
log_dir = os.path.join(current_dir, 'logs')
os.makedirs(log_dir, exist_ok=True)
try:
from logging_utils import setup_logging
# Assuming setup_logging might handle file logging if configured,
# but to be safe and explicit as requested, we'll add a FileHandler here
# or rely on setup_logging if it supports it.
# Since I don't see setup_logging code, I will manually add a file handler to the logger.
logger = setup_logging("normal", "UNISWAP_MANAGER")
except ImportError:
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("UNISWAP_MANAGER")
# Custom Filter for Millisecond Unix Timestamp
class UnixMsLogFilter(logging.Filter):
def filter(self, record):
record.unix_ms = int(record.created * 1000)
return True
# Add File Handler
log_file = os.path.join(log_dir, 'uniswap_manager.log')
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setLevel(logging.INFO)
file_handler.addFilter(UnixMsLogFilter())
formatter = logging.Formatter('%(unix_ms)d, %(asctime)s - %(name)s - %(levelname)s - %(message)s')
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_config import get_current_config, STATUS_FILE
# --- GET ACTIVE DEX CONFIG ---
CONFIG = get_current_config()
# --- CONFIGURATION FROM STRATEGY ---
MONITOR_INTERVAL_SECONDS = CONFIG.get("MONITOR_INTERVAL_SECONDS", 60)
CLOSE_POSITION_ENABLED = CONFIG.get("CLOSE_POSITION_ENABLED", True)
OPEN_POSITION_ENABLED = CONFIG.get("OPEN_POSITION_ENABLED", True)
REBALANCE_ON_CLOSE_BELOW_RANGE = CONFIG.get("REBALANCE_ON_CLOSE_BELOW_RANGE", True)
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"))
SLIPPAGE_TOLERANCE = CONFIG.get("SLIPPAGE_TOLERANCE", Decimal("0.02"))
TRANSACTION_TIMEOUT_SECONDS = CONFIG.get("TRANSACTION_TIMEOUT_SECONDS", 30)
# --- CONFIGURATION CONSTANTS ---
NONFUNGIBLE_POSITION_MANAGER_ADDRESS = CONFIG["NPM_ADDRESS"]
UNISWAP_V3_SWAP_ROUTER_ADDRESS = CONFIG["ROUTER_ADDRESS"]
# Arbitrum WETH/USDC (or generic T0/T1)
WETH_ADDRESS = CONFIG["WRAPPED_NATIVE_ADDRESS"]
USDC_ADDRESS = CONFIG["TOKEN_B_ADDRESS"]
POOL_FEE = CONFIG.get("POOL_FEE", 500)
# --- HELPER FUNCTIONS ---
def clean_address(addr: str) -> str:
"""Ensure address is checksummed."""
if not Web3.is_address(addr):
raise ValueError(f"Invalid address: {addr}")
return Web3.to_checksum_address(addr)
def to_decimal(value: Any, decimals: int = 0) -> Decimal:
"""Convert value to Decimal, optionally scaling down by decimals."""
if isinstance(value, Decimal):
return value
return Decimal(value) / (Decimal(10) ** decimals)
def to_wei_int(value: Decimal, decimals: int) -> int:
"""Convert Decimal value to integer Wei representation."""
return int(value * (Decimal(10) ** decimals))
def get_gas_params(w3: Web3) -> Dict[str, int]:
"""Get dynamic gas parameters for EIP-1559."""
latest_block = w3.eth.get_block("latest")
base_fee = latest_block['baseFeePerGas']
# Priority fee: 0.1 gwei or dynamic
max_priority_fee = w3.eth.max_priority_fee or Web3.to_wei(0.1, 'gwei')
# Max Fee = Base Fee * 1.5 + Priority Fee
max_fee = int(base_fee * 1.25) + max_priority_fee
return {
'maxFeePerGas': max_fee,
'maxPriorityFeePerGas': max_priority_fee
}
def send_transaction_robust(
w3: Web3,
account: LocalAccount,
func_call: Any,
value: int = 0,
gas_limit: Optional[int] = None,
extra_msg: str = ""
) -> Optional[Any]:
"""
Builds, signs, sends, and waits for a transaction with timeout and status check.
"""
try:
# 1. Prepare Params
# Use 'pending' to ensure we get the correct nonce if a tx was just sent/mined
tx_params = {
'from': account.address,
'nonce': w3.eth.get_transaction_count(account.address, 'pending'),
'value': value,
'chainId': w3.eth.chain_id,
}
# 2. Add Gas Params
gas_fees = get_gas_params(w3)
tx_params.update(gas_fees)
# 3. Simulate (Call) & Estimate Gas
try:
# If function call object provided
if hasattr(func_call, 'call'):
func_call.call({'from': account.address, 'value': value}) # Safety Dry-Run
estimated_gas = func_call.estimate_gas({'from': account.address, 'value': value})
else:
# Raw transaction construction if func_call is just params dict (rare here)
estimated_gas = 200000
tx_params['gas'] = gas_limit if gas_limit else int(estimated_gas * 1.2) # 20% buffer
# Build
if hasattr(func_call, 'build_transaction'):
tx = func_call.build_transaction(tx_params)
else:
raise ValueError("Invalid function call object")
except ContractLogicError as e:
logger.error(f"❌ Simulation/Estimation failed for {extra_msg}: {e}")
return None
# 4. Sign
signed_tx = account.sign_transaction(tx)
# 5. Send
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
logger.info(f"📤 Sent {extra_msg} | Hash: {tx_hash.hex()}")
# 6. Wait for Receipt
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=TRANSACTION_TIMEOUT_SECONDS)
# 7. Verify Status
if receipt.status == 1:
logger.info(f"✅ Executed {extra_msg} | Block: {receipt.blockNumber}")
return receipt
else:
logger.error(f"❌ Transaction Reverted {extra_msg} | Hash: {tx_hash.hex()}")
return None
except TimeExhausted:
logger.error(f"⌛ Transaction Timeout {extra_msg} - Check Mempool")
# In a full production bot, we would implement gas bumping here.
return None
except Exception as e:
logger.error(f"❌ Transaction Error {extra_msg}: {e}")
return None
def price_from_sqrt_price_x96(sqrt_price_x96: int, token0_decimals: int, token1_decimals: int) -> Decimal:
"""
Returns price of Token0 in terms of Token1.
"""
sqrt_price = Decimal(sqrt_price_x96)
q96 = Decimal(2) ** 96
price = (sqrt_price / q96) ** 2
# Adjust for decimals: Price = (T1 / 10^d1) / (T0 / 10^d0)
# = (T1/T0) * (10^d0 / 10^d1)
adjustment = Decimal(10) ** (token0_decimals - token1_decimals)
return price * adjustment
def price_from_tick(tick: int, token0_decimals: int, token1_decimals: int) -> Decimal:
price = Decimal("1.0001") ** tick
adjustment = Decimal(10) ** (token0_decimals - token1_decimals)
return price * adjustment
def get_sqrt_ratio_at_tick(tick: int) -> int:
return int((1.0001 ** (tick / 2)) * (2 ** 96))
def get_amounts_for_liquidity(sqrt_ratio_current: int, sqrt_ratio_a: int, sqrt_ratio_b: int, liquidity: int) -> Tuple[int, int]:
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
# Calculations performed in high-precision integer math (EVM style)
if sqrt_ratio_current <= sqrt_ratio_a:
amount0 = (liquidity * Q96 // sqrt_ratio_a) - (liquidity * Q96 // sqrt_ratio_b)
amount1 = 0
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
else:
amount1 = (liquidity * (sqrt_ratio_b - sqrt_ratio_a)) // Q96
amount0 = 0
return amount0, amount1
# --- CORE LOGIC ---
def get_position_details(w3: Web3, npm_contract, factory_contract, token_id: int):
try:
# Check ownership first to avoid errors? positions() works regardless of owner usually.
position_data = npm_contract.functions.positions(token_id).call()
(nonce, operator, token0_address, token1_address, fee, tickLower, tickUpper, liquidity,
feeGrowthInside0, feeGrowthInside1, tokensOwed0, tokensOwed1) = position_data
token0_contract = w3.eth.contract(address=token0_address, abi=ERC20_ABI)
token1_contract = w3.eth.contract(address=token1_address, abi=ERC20_ABI)
# Multi-call optimization could be used here, but keeping simple for now
token0_symbol = token0_contract.functions.symbol().call()
token1_symbol = token1_contract.functions.symbol().call()
token0_decimals = token0_contract.functions.decimals().call()
token1_decimals = token1_contract.functions.decimals().call()
pool_address = factory_contract.functions.getPool(token0_address, token1_address, fee).call()
if pool_address == '0x0000000000000000000000000000000000000000':
return None, None
pool_contract = w3.eth.contract(address=pool_address, abi=UNISWAP_V3_POOL_ABI)
return {
"token0_address": token0_address, "token1_address": token1_address,
"token0_symbol": token0_symbol, "token1_symbol": token1_symbol,
"token0_decimals": token0_decimals, "token1_decimals": token1_decimals,
"fee": fee, "tickLower": tickLower, "tickUpper": tickUpper, "liquidity": liquidity,
"pool_address": pool_address
}, pool_contract
except Exception as e:
logger.error(f"❌ Error fetching position details for ID {token_id}: {e}")
return None, None
def get_pool_dynamic_data(pool_contract) -> Optional[Dict[str, Any]]:
try:
slot0 = pool_contract.functions.slot0().call()
return {"sqrtPriceX96": slot0[0], "tick": slot0[1]}
except Exception as e:
logger.error(f"❌ Pool data fetch failed: {e}")
return None
def calculate_mint_amounts(current_tick, tick_lower, tick_upper, investment_value_token1: Decimal, decimals0, decimals1, sqrt_price_current_x96) -> Tuple[int, int]:
"""
Calculates required token amounts for a target investment value.
Uses precise Decimal math.
"""
sqrt_price_current = get_sqrt_ratio_at_tick(current_tick)
sqrt_price_lower = get_sqrt_ratio_at_tick(tick_lower)
sqrt_price_upper = get_sqrt_ratio_at_tick(tick_upper)
# Price of T0 in T1
price_t0_in_t1 = price_from_sqrt_price_x96(sqrt_price_current_x96, decimals0, decimals1)
# Calculate amounts for a "Test" liquidity amount
L_test = 1 << 128
amt0_test_wei, amt1_test_wei = get_amounts_for_liquidity(sqrt_price_current, sqrt_price_lower, sqrt_price_upper, L_test)
amt0_test = Decimal(amt0_test_wei) / Decimal(10**decimals0)
amt1_test = Decimal(amt1_test_wei) / Decimal(10**decimals1)
# Value in Token1 terms
value_test = (amt0_test * price_t0_in_t1) + amt1_test
if value_test <= 0:
return 0, 0
scale = investment_value_token1 / value_test
final_amt0_wei = int(Decimal(amt0_test_wei) * scale)
final_amt1_wei = int(Decimal(amt1_test_wei) * scale)
return final_amt0_wei, final_amt1_wei
def ensure_allowance(w3: Web3, account: LocalAccount, token_address: str, spender_address: str, amount_needed: int) -> bool:
"""
Checks if allowance is sufficient, approves if not.
"""
try:
token_c = w3.eth.contract(address=token_address, abi=ERC20_ABI)
allowance = token_c.functions.allowance(account.address, spender_address).call()
if allowance >= amount_needed:
return True
logger.info(f"🔓 Approving {token_address} for {spender_address}...")
# Some tokens (USDT) fail if approving from non-zero to non-zero.
# Safe practice: Approve 0 first if allowance > 0, then new amount.
if allowance > 0:
send_transaction_robust(w3, account, token_c.functions.approve(spender_address, 0), extra_msg="Reset Allowance")
# Approve
receipt = send_transaction_robust(
w3, account,
token_c.functions.approve(spender_address, amount_needed),
extra_msg=f"Approve {token_address}"
)
return receipt is not None
except Exception as e:
logger.error(f"❌ Allowance check/approve failed: {e}")
return False
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.
"""
token0 = clean_address(token0)
token1 = clean_address(token1)
token0_c = w3.eth.contract(address=token0, abi=ERC20_ABI)
token1_c = w3.eth.contract(address=token1, abi=ERC20_ABI)
bal0 = token0_c.functions.balanceOf(account.address).call()
bal1 = token1_c.functions.balanceOf(account.address).call()
# Calculate Deficits
deficit0 = max(0, amount0_needed - bal0)
deficit1 = max(0, amount1_needed - bal1)
weth_lower = WETH_ADDRESS.lower()
# --- AUTO WRAP ETH ---
if (deficit0 > 0 and token0.lower() == weth_lower) or (deficit1 > 0 and token1.lower() == weth_lower):
eth_bal = w3.eth.get_balance(account.address)
# Keep 0.01 ETH for gas
gas_reserve = Web3.to_wei(0.01, 'ether')
available_eth = max(0, eth_bal - gas_reserve)
wrap_needed = 0
if token0.lower() == weth_lower: wrap_needed += deficit0
if token1.lower() == weth_lower: wrap_needed += deficit1
amount_to_wrap = min(wrap_needed, available_eth)
if amount_to_wrap > 0:
logger.info(f"🌯 Wrapping {Web3.from_wei(amount_to_wrap, 'ether')} ETH...")
weth_c = w3.eth.contract(address=WETH_ADDRESS, abi=WETH9_ABI)
receipt = send_transaction_robust(w3, account, weth_c.functions.deposit(), value=amount_to_wrap, extra_msg="Wrap ETH")
if receipt:
# Refresh Balances
bal0 = token0_c.functions.balanceOf(account.address).call()
bal1 = token1_c.functions.balanceOf(account.address).call()
deficit0 = max(0, amount0_needed - bal0)
deficit1 = max(0, amount1_needed - bal1)
if deficit0 == 0 and deficit1 == 0:
return True
# --- SWAP SURPLUS ---
# Smart Swap: Calculate exactly how much we need to swap
# 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
buffer_multiplier = Decimal("1.02") # 2% buffer for slippage/price moves
if deficit0 > 0 and bal1 > amount1_needed:
# Need T0 (ETH), Have extra T1 (USDC)
# Swap T1 -> T0
# Cost in T1 = Deficit0 * Price(T0 in T1)
cost_in_t1 = Decimal(deficit0) / Decimal(10**d0) * price_0_in_1
# Convert back to T1 Wei and apply buffer
amount_in_needed = int(cost_in_t1 * Decimal(10**d1) * buffer_multiplier)
surplus1 = bal1 - amount1_needed
if surplus1 >= amount_in_needed:
token_in, token_out = token1, token0
amount_in = amount_in_needed
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}")
elif deficit1 > 0 and bal0 > amount0_needed:
# Need T1 (USDC), Have extra T0 (ETH)
# Swap T0 -> T1
# Cost in T0 = Deficit1 / Price(T0 in T1)
if price_0_in_1 > 0:
cost_in_t0 = (Decimal(deficit1) / Decimal(10**d1)) / price_0_in_1
amount_in_needed = int(cost_in_t0 * Decimal(10**d0) * buffer_multiplier)
surplus0 = bal0 - amount0_needed
if surplus0 >= amount_in_needed:
token_in, token_out = token0, token1
amount_in = amount_in_needed
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
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
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]:
"""
Approves tokens and mints a new V3 position.
"""
logger.info("🚀 Minting new position...")
# 1. Approve
if not ensure_allowance(w3, account, token0, NONFUNGIBLE_POSITION_MANAGER_ADDRESS, amount0): return None
if not ensure_allowance(w3, account, token1, NONFUNGIBLE_POSITION_MANAGER_ADDRESS, amount1): return None
# 2. Calculate Min Amounts (Slippage Protection)
# Using 0.5% slippage tolerance
amount0_min = int(Decimal(amount0) * (Decimal(1) - SLIPPAGE_TOLERANCE))
amount1_min = int(Decimal(amount1) * (Decimal(1) - SLIPPAGE_TOLERANCE))
# 3. Mint
params = (
token0, token1, POOL_FEE,
tick_lower, tick_upper,
amount0, amount1,
amount0_min, amount1_min,
account.address,
int(time.time()) + 180
)
receipt = send_transaction_robust(w3, account, npm_contract.functions.mint(params), extra_msg="Mint Position")
if receipt and receipt.status == 1:
# Parse Logs
try:
# Transfer Event (Topic0)
transfer_topic = Web3.keccak(text="Transfer(address,address,uint256)").hex()
# IncreaseLiquidity Event (Topic0)
increase_liq_topic = Web3.keccak(text="IncreaseLiquidity(uint256,uint128,uint256,uint256)").hex()
minted_data = {'token_id': None, 'liquidity': 0, 'amount0': 0, 'amount1': 0}
for log in receipt.logs:
topics = [t.hex() for t in log['topics']]
# Capture Token ID
if topics[0] == transfer_topic:
if "0000000000000000000000000000000000000000" in topics[1]:
minted_data['token_id'] = int(topics[3], 16)
# Capture Amounts
if topics[0] == increase_liq_topic:
# decoding data: liquidity(uint128), amount0(uint256), amount1(uint256)
# data is a single hex string, we need to decode it
data = log['data'].hex()
if data.startswith('0x'):
data = data[2:]
# liquidity is first 32 bytes (padded), amt0 next 32, amt1 next 32
minted_data['liquidity'] = int(data[0:64], 16)
minted_data['amount0'] = int(data[64:128], 16)
minted_data['amount1'] = int(data[128:192], 16)
if minted_data['token_id']:
# Format for Log using actual decimals
fmt_amt0 = Decimal(minted_data['amount0']) / Decimal(10**d0)
fmt_amt1 = Decimal(minted_data['amount1']) / Decimal(10**d1)
logger.info(f"✅ POSITION OPENED | ID: {minted_data['token_id']} | Deposited: {fmt_amt0:.6f} + {fmt_amt1:.6f}")
# --- VERIFY TICKS ON-CHAIN ---
try:
pos_data = npm_contract.functions.positions(minted_data['token_id']).call()
# pos_data structure: nonce, operator, t0, t1, fee, tickLower, tickUpper, ...
minted_data['tick_lower'] = pos_data[5]
minted_data['tick_upper'] = pos_data[6]
logger.info(f"🔗 Verified Ticks: {minted_data['tick_lower']} <-> {minted_data['tick_upper']}")
except Exception as e:
logger.warning(f"⚠️ Could not verify ticks immediately: {e}")
# Fallback to requested ticks if fetch fails
minted_data['tick_lower'] = tick_lower
minted_data['tick_upper'] = tick_upper
return minted_data
except Exception as e:
logger.warning(f"Minted but failed to parse details: {e}")
return None
def decrease_liquidity(w3: Web3, npm_contract, account: LocalAccount, token_id: int, liquidity: int, d0: int, d1: int) -> bool:
if liquidity == 0: return True
logger.info(f"📉 Decreasing Liquidity for {token_id}...")
params = (
token_id,
liquidity,
0, 0, # amountMin0, amountMin1
int(time.time()) + 180
)
receipt = send_transaction_robust(w3, account, npm_contract.functions.decreaseLiquidity(params), extra_msg=f"Decrease Liq {token_id}")
if receipt and receipt.status == 1:
try:
# Parse DecreaseLiquidity Event
decrease_topic = Web3.keccak(text="DecreaseLiquidity(uint256,uint128,uint256,uint256)").hex()
amt0, amt1 = 0, 0
for log in receipt.logs:
topics = [t.hex() for t in log['topics']]
if topics[0] == decrease_topic:
# Check tokenID (topic 1)
if int(topics[1], 16) == token_id:
data = log['data'].hex()[2:]
# liquidity (32), amt0 (32), amt1 (32)
amt0 = int(data[64:128], 16)
amt1 = int(data[128:192], 16)
break
fmt_amt0 = Decimal(amt0) / Decimal(10**d0)
fmt_amt1 = Decimal(amt1) / Decimal(10**d1)
logger.info(f"📉 POSITION CLOSED (Liquidity Removed) | ID: {token_id} | Withdrawn: {fmt_amt0:.6f} + {fmt_amt1:.6f}")
except Exception as e:
logger.warning(f"Closed but failed to parse details: {e}")
return True
return False
def collect_fees(w3: Web3, npm_contract, account: LocalAccount, token_id: int) -> bool:
logger.info(f"💰 Collecting Fees for {token_id}...")
max_val = 2**128 - 1
params = (
token_id,
account.address,
max_val, max_val
)
receipt = send_transaction_robust(w3, account, npm_contract.functions.collect(params), extra_msg=f"Collect Fees {token_id}")
return receipt is not None
# --- STATE MANAGEMENT ---
def load_status_data() -> List[Dict]:
if not os.path.exists(STATUS_FILE):
return []
try:
with open(STATUS_FILE, 'r') as f:
return json.load(f)
except:
return []
def save_status_data(data: List[Dict]):
with open(STATUS_FILE, 'w') as f:
json.dump(data, f, indent=2)
def update_position_status(token_id: int, status: str, extra_data: Dict = {}):
data = load_status_data()
# Find existing or create new
entry = next((item for item in data if item.get('token_id') == token_id), None)
if not entry:
if status in ["OPEN", "PENDING_HEDGE"]:
entry = {"type": "AUTOMATIC", "token_id": token_id}
data.append(entry)
else:
return # Can't update non-existent position unless opening
entry['status'] = status
entry.update(extra_data)
if status == "CLOSED":
now = datetime.now()
entry['timestamp_close'] = int(now.timestamp())
entry['time_close'] = now.strftime("%d.%m.%y %H:%M:%S")
save_status_data(data)
logger.info(f"💾 Updated Position {token_id} status to {status}")
# --- MAIN LOOP ---
def main():
logger.info(f"🔷 {CONFIG['NAME']} Manager V2 Starting...")
load_dotenv(override=True)
# Dynamically load the RPC based on DEX Profile
rpc_url = os.environ.get(CONFIG["RPC_ENV_VAR"])
private_key = os.environ.get("MAIN_WALLET_PRIVATE_KEY") or os.environ.get("PRIVATE_KEY")
if not rpc_url or not private_key:
logger.error("❌ Missing RPC or Private Key in .env")
return
w3 = Web3(Web3.HTTPProvider(rpc_url))
if not w3.is_connected():
logger.error("❌ Could not connect to RPC")
return
# FIX: Inject POA middleware for BNB Chain/Polygon/etc. (Web3.py v6+)
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
account = Account.from_key(private_key)
logger.info(f"👤 Wallet: {account.address}")
# Contracts
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)
router = w3.eth.contract(address=clean_address(UNISWAP_V3_SWAP_ROUTER_ADDRESS), abi=SWAP_ROUTER_ABI)
while True:
try:
status_data = load_status_data()
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']
pos_details, pool_c = get_position_details(w3, npm, factory, token_id)
if pos_details:
pool_data = get_pool_dynamic_data(pool_c)
current_tick = pool_data['tick']
# Check Range
tick_lower = pos_details['tickLower']
tick_upper = pos_details['tickUpper']
in_range = tick_lower <= current_tick < tick_upper
# Calculate Prices for logging
price_0_in_1 = price_from_tick(current_tick, pos_details['token0_decimals'], pos_details['token1_decimals'])
# --- SMART STABLE DETECTION ---
# Determine which token is the "Stable" side to anchor USD value
stable_symbols = ["USDC", "USDT", "DAI", "FDUSD", "USDS"]
is_t1_stable = any(s in pos_details['token1_symbol'].upper() for s in stable_symbols)
is_t0_stable = any(s in pos_details['token0_symbol'].upper() for s in stable_symbols)
if is_t1_stable:
# Standard: T0=Volatile, T1=Stable. Price = T1 per T0
current_price = price_0_in_1
lower_price = price_from_tick(tick_lower, pos_details['token0_decimals'], pos_details['token1_decimals'])
upper_price = price_from_tick(tick_upper, pos_details['token0_decimals'], pos_details['token1_decimals'])
elif is_t0_stable:
# Inverted: T0=Stable, T1=Volatile. Price = T0 per T1
# We want Price of T1 in terms of T0
current_price = Decimal("1") / price_0_in_1
lower_price = Decimal("1") / price_from_tick(tick_upper, pos_details['token0_decimals'], pos_details['token1_decimals'])
upper_price = Decimal("1") / price_from_tick(tick_lower, pos_details['token0_decimals'], pos_details['token1_decimals'])
else:
# Fallback to T1
current_price = price_0_in_1
lower_price = price_from_tick(tick_lower, pos_details['token0_decimals'], pos_details['token1_decimals'])
upper_price = price_from_tick(tick_upper, pos_details['token0_decimals'], pos_details['token1_decimals'])
# --- RANGE DISPLAY ---
# Calculate ranges from ticks for display purposes
real_range_lower = round(float(lower_price), 4)
real_range_upper = round(float(upper_price), 4)
status_msg = "✅ IN RANGE" if in_range else "⚠️ OUT OF RANGE"
# Calculate Unclaimed Fees (Simulation)
unclaimed0, unclaimed1, total_fees_usd = 0, 0, 0
try:
# Call collect with zero address to simulate fee estimation
fees_sim = npm.functions.collect((token_id, "0x0000000000000000000000000000000000000000", 2**128-1, 2**128-1)).call({'from': account.address})
u0 = to_decimal(fees_sim[0], pos_details['token0_decimals'])
u1 = to_decimal(fees_sim[1], pos_details['token1_decimals'])
if is_t1_stable:
total_fees_usd = (u0 * current_price) + u1
else:
total_fees_usd = u0 + (u1 * current_price)
except Exception as e:
logger.debug(f"Fee simulation failed for {token_id}: {e}")
# Calculate Total PnL (Fees + Price Appreciation/Depreciation)
# We need the initial investment value (target_value)
initial_value = Decimal(str(active_auto_pos.get('target_value', 0)))
curr_amt0_wei, curr_amt1_wei = get_amounts_for_liquidity(
pool_data['sqrtPriceX96'],
get_sqrt_ratio_at_tick(tick_lower),
get_sqrt_ratio_at_tick(tick_upper),
pos_details['liquidity']
)
curr_amt0 = Decimal(curr_amt0_wei) / Decimal(10**pos_details['token0_decimals'])
curr_amt1 = Decimal(curr_amt1_wei) / Decimal(10**pos_details['token1_decimals'])
if is_t1_stable:
current_pos_value_usd = (curr_amt0 * current_price) + curr_amt1
else:
current_pos_value_usd = curr_amt0 + (curr_amt1 * current_price)
pnl_unrealized = current_pos_value_usd - initial_value
total_pnl_usd = pnl_unrealized + total_fees_usd
pnl_text = f" | TotPnL: ${total_pnl_usd:.2f} (Fees: ${total_fees_usd:.2f})"
logger.info(f"Position {token_id}: {status_msg} | Price: {current_price:.4f} [{lower_price:.4f} - {upper_price:.4f}]{pnl_text}")
# --- KPI LOGGING ---
if log_kpi_snapshot:
snapshot = {
'initial_eth': active_auto_pos.get('amount0_initial', 0),
'initial_usdc': active_auto_pos.get('amount1_initial', 0),
'initial_hedge_usdc': INITIAL_HEDGE_CAPITAL_USDC,
'current_eth_price': float(current_price),
'uniswap_pos_value_usd': float(current_pos_value_usd),
'uniswap_fees_claimed_usd': 0.0, # Not tracked accumulated yet in JSON, using Unclaimed mainly
'uniswap_fees_unclaimed_usd': float(total_fees_usd),
# Hedge Data (from JSON updated by clp_hedger)
'hedge_equity_usd': float(active_auto_pos.get('hedge_equity_usd', 0.0)),
'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)
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)...")
# Setup logic for new position
tA = clean_address(WETH_ADDRESS)
tB = clean_address(USDC_ADDRESS)
if tA.lower() < tB.lower():
token0, token1 = tA, tB
else:
token0, token1 = tB, tA
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_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))
# Fetch actual tick spacing from pool
tick_spacing = pool_c.functions.tickSpacing().call()
logger.info(f"📏 Tick Spacing: {tick_spacing}")
tick_lower = (tick - tick_delta) // tick_spacing * tick_spacing
tick_upper = (tick + tick_delta) // tick_spacing * tick_spacing
# 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":
# ... (Existing MAX logic needs update too, but skipping for brevity as user uses fixed amount)
pass
else:
if is_t1_stable:
# T1 is stable (e.g. ETH/USDC). Target 2000 USD = 2000 Token1.
investment_val_token1 = target_usd
elif is_t0_stable:
# T0 is stable (e.g. USDT/BNB). Target 2000 USD = 2000 Token0.
# We need value in Token1.
# Price 0 in 1 = (BNB per USDT) approx 0.0012
# Val T1 = Val T0 * Price(0 in 1)
investment_val_token1 = target_usd * price_0_in_1
logger.info(f"💱 Converted ${target_usd} -> {investment_val_token1:.4f} {t1_sym} (Price: {price_0_in_1:.6f})")
else:
# Fallback: Assume T1 is Stable (Dangerous but standard default)
logger.warning("⚠️ Could not detect Stable token. Assuming T1 is stable.")
investment_val_token1 = target_usd
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):
minted = mint_new_position(w3, npm, account, token0, token1, amt0, amt1, tick_lower, tick_upper, d0, d1)
if minted:
# Calculate entry price and amounts for JSON compatibility
price_0_in_1 = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1)
fmt_amt0 = float(Decimal(minted['amount0']) / Decimal(10**d0))
fmt_amt1 = float(Decimal(minted['amount1']) / Decimal(10**d1))
if is_t1_stable:
entry_price = float(price_0_in_1)
actual_value = (fmt_amt0 * entry_price) + fmt_amt1
r_upper = float(price_from_tick(minted['tick_upper'], d0, d1))
r_lower = float(price_from_tick(minted['tick_lower'], d0, d1))
else:
# Inverted (T0 is stable)
entry_price = float(Decimal("1") / price_0_in_1)
actual_value = fmt_amt0 + (fmt_amt1 * entry_price)
r_upper = float(Decimal("1") / price_from_tick(minted['tick_lower'], d0, d1))
r_lower = float(Decimal("1") / price_from_tick(minted['tick_upper'], d0, d1))
# Prepare ordered data with specific rounding
new_position_data = {
"type": "AUTOMATIC",
"target_value": round(float(actual_value), 2),
"entry_price": round(entry_price, 4),
"amount0_initial": round(fmt_amt0, 4),
"amount1_initial": round(fmt_amt1, 4),
"liquidity": str(minted['liquidity']),
"range_upper": round(r_upper, 4),
"range_lower": round(r_lower, 4),
"token0_decimals": d0,
"token1_decimals": d1,
"timestamp_open": int(time.time()),
"time_open": datetime.now().strftime("%d.%m.%y %H:%M:%S")
}
update_position_status(minted['token_id'], "OPEN", new_position_data)
# Dynamic Sleep: 37s if no position, else configured interval
sleep_time = MONITOR_INTERVAL_SECONDS if active_auto_pos else 37
time.sleep(sleep_time)
except KeyboardInterrupt:
logger.info("👋 Exiting...")
break
except Exception as e:
logger.error(f"❌ Main Loop Error: {e}")
time.sleep(MONITOR_INTERVAL_SECONDS)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
import os
import sys
import json
from dotenv import load_dotenv
from hyperliquid.info import Info
from hyperliquid.utils import constants
# Load env
load_dotenv()
address = os.environ.get("MAIN_WALLET_ADDRESS")
if not address:
print("No address found")
sys.exit(1)
info = Info(constants.MAINNET_API_URL, skip_ws=True)
try:
print(f"Fetching fills for {address}...")
fills = info.user_fills(address)
if fills:
print(f"Found {len(fills)} fills. Inspecting first one:")
print(json.dumps(fills[0], indent=2))
# Check for closedPnl
if 'closedPnl' in fills[0]:
print("'closedPnl' field FOUND!")
else:
print("'closedPnl' field NOT FOUND.")
else:
print("No fills found.")
except Exception as e:
print(f"Error: {e}")

View File

@ -0,0 +1,74 @@
import os
import sys
import json
import time
from decimal import Decimal
from dotenv import load_dotenv
from hyperliquid.info import Info
from hyperliquid.utils import constants
# Load env
current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(current_dir)
load_dotenv(os.path.join(current_dir, '.env'))
address = os.environ.get("MAIN_WALLET_ADDRESS")
if not address:
print("No address found")
sys.exit(1)
info = Info(constants.MAINNET_API_URL, skip_ws=True)
# Target Start Time: 2025-12-30 21:32:52 (From user JSON)
START_TIME_MS = 1767126772 * 1000
COIN = "BNB"
print(f"--- DEBUG PnL CHECK ---")
print(f"Address: {address}")
print(f"Coin: {COIN}")
print(f"Start Time: {START_TIME_MS}")
try:
fills = info.user_fills(address)
valid_fills = []
total_closed_pnl = Decimal("0")
total_fees = Decimal("0")
print(f"\n--- FILLS FOUND ---")
print(f"{'Time':<20} | {'Side':<5} | {'Sz':<8} | {'Px':<8} | {'Fee':<8} | {'ClosedPnL':<10}")
print("-" * 80)
for fill in fills:
if fill['coin'] == COIN and fill['time'] >= START_TIME_MS:
valid_fills.append(fill)
fee = Decimal(str(fill['fee']))
pnl = Decimal(str(fill['closedPnl']))
total_closed_pnl += pnl
total_fees += fee
ts_str = time.strftime('%H:%M:%S', time.localtime(fill['time']/1000))
print(f"{ts_str:<20} | {fill['side']:<5} | {fill['sz']:<8} | {fill['px']:<8} | {fee:<8.4f} | {pnl:<10.4f}")
print("-" * 80)
print(f"Count: {len(valid_fills)}")
print(f"Sum Closed PnL (Gross): {total_closed_pnl:.4f}")
print(f"Sum Fees: {total_fees:.4f}")
net_realized = total_closed_pnl - total_fees
print(f"NET REALIZED (Gross - Fees): {net_realized:.4f}")
# Check JSON
json_path = os.path.join(current_dir, "PANCAKESWAP_BNB_status.json")
if os.path.exists(json_path):
with open(json_path, 'r') as f:
data = json.load(f)
last_pos = data[-1]
print(f"\n--- JSON STATE ---")
print(f"hedge_TotPnL: {last_pos.get('hedge_TotPnL')}")
print(f"hedge_fees_paid: {last_pos.get('hedge_fees_paid')}")
except Exception as e:
print(f"Error: {e}")

View File

@ -0,0 +1,142 @@
import json
import os
import math
import sys
from decimal import Decimal, getcontext
from web3 import Web3
from web3.middleware import ExtraDataToPOAMiddleware
from eth_account import Account
from dotenv import load_dotenv
# Add project root to path
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from clp_config import CLP_PROFILES
# Load Env
load_dotenv()
# Config for PancakeSwap
PROFILE = CLP_PROFILES["PANCAKESWAP_BNB"]
RPC_URL = os.environ.get(PROFILE["RPC_ENV_VAR"])
STATUS_FILE = "PANCAKESWAP_BNB_status.json"
# Minimal ABI for NPM
NPM_ABI = [
{
"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"
}
]
def get_price_at_tick(tick):
return 1.0001 ** tick
def fetch_and_fix():
if not RPC_URL:
print("❌ Missing RPC URL in .env")
return
print(f"Connecting to RPC: {RPC_URL}")
w3 = Web3(Web3.HTTPProvider(RPC_URL))
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
if not w3.is_connected():
print("❌ Failed to connect to Web3")
return
npm = w3.eth.contract(address=PROFILE["NPM_ADDRESS"], abi=NPM_ABI)
with open(STATUS_FILE, 'r') as f:
data = json.load(f)
updated_count = 0
for entry in data:
token_id = entry.get('token_id')
status = entry.get('status')
# We check ALL positions to be safe, or just the problematic ones.
# Let's check any that seem to have suspect data or just refresh all active/recently active.
# The user mentioned 6164702 specifically.
print(f"🔍 Checking Token ID: {token_id} ({status})")
try:
pos = npm.functions.positions(token_id).call()
# Pos structure:
# 0: nonce, 1: operator, 2: token0, 3: token1, 4: fee,
# 5: tickLower, 6: tickUpper, 7: liquidity ...
tick_lower = pos[5]
tick_upper = pos[6]
liquidity = pos[7]
# Calculate Ranges
price_lower = get_price_at_tick(tick_lower)
price_upper = get_price_at_tick(tick_upper)
# Format to 4 decimals
new_lower = round(price_lower, 4)
new_upper = round(price_upper, 4)
old_lower = entry.get('range_lower', 0)
old_upper = entry.get('range_upper', 0)
# Check deviation
if abs(new_lower - old_lower) > 0.1 or abs(new_upper - old_upper) > 0.1:
print(f" ⚠️ Mismatch Found!")
print(f" Old: {old_lower} - {old_upper}")
print(f" New: {new_lower} - {new_upper}")
entry['range_lower'] = new_lower
entry['range_upper'] = new_upper
entry['liquidity'] = str(liquidity)
# Fix Entry Price if it looks wrong (e.g. 0 or way off range)
# If single sided (e.g. 862-869), and spot is 860.
# If we provided only Token0 (BNB), we are selling BNB as it goes UP.
# So we entered 'below' the range.
# If we assume the user just opened it, the 'entry_price' should roughly match
# the current market price or at least be consistent.
# Since we don't know the exact historical price, we can't perfectly fix 'entry_price'
# without event logs.
# HOWEVER, for the bot's logic, 'range_lower' and 'range_upper' are critical for 'in_range' checks.
# 'entry_price' is mostly for PnL est.
# If entry_price is wildly different from range (e.g. 844 vs 862-869), it's confusing.
# Let's see if we can infer something.
# For now, we update ranges as that's the request.
updated_count += 1
else:
print(f" ✅ Data looks solid.")
except Exception as e:
print(f" ❌ Error fetching chain data: {e}")
if updated_count > 0:
with open(STATUS_FILE, 'w') as f:
json.dump(data, f, indent=2)
print(f"💾 Updated {updated_count} entries in {STATUS_FILE}")
else:
print("No updates needed.")
if __name__ == "__main__":
fetch_and_fix()

View File

@ -90,7 +90,7 @@ class CandleRecorder:
print(f"WebSocket Error: {error}")
def on_close(self, ws, close_status_code, close_msg):
print("WebSocket Closed")
print(f"WebSocket Closed: {close_status_code} - {close_msg}")
def on_open(self, ws):
print("WebSocket Connected. Subscribing to allMids...")
@ -105,17 +105,25 @@ class CandleRecorder:
print(f"📂 Output: {self.output_file}")
print("Press Ctrl+C to stop.")
# Start WS in separate thread? No, run_forever is blocking usually.
# But we need to handle Ctrl+C.
self.ws = websocket.WebSocketApp(
WS_URL,
on_open=self.on_open,
on_message=self.on_message,
on_error=self.on_error,
on_close=self.on_close
)
self.ws.run_forever()
while self.running:
try:
self.ws = websocket.WebSocketApp(
WS_URL,
on_open=self.on_open,
on_message=self.on_message,
on_error=self.on_error,
on_close=self.on_close
)
# run_forever blocks until connection is lost
self.ws.run_forever(ping_interval=30, ping_timeout=10)
except Exception as e:
print(f"Critical Error in main loop: {e}")
if self.running:
print("Connection lost. Reconnecting in 5 seconds...")
time.sleep(5)
def signal_handler(sig, frame):
print("\nStopping recorder...")

View File

@ -0,0 +1,207 @@
import argparse
import csv
import os
import time
import json
import threading
import signal
import sys
import websocket
from datetime import datetime
# Setup
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MARKET_DATA_DIR = os.path.join(PROJECT_ROOT, 'market_data')
WS_URL = "wss://api.hyperliquid.xyz/ws"
class MarketDataRecorder:
def __init__(self, coin, file_prefix):
self.coin = coin
self.running = True
self.ws = None
# File paths
self.book_file = f"{file_prefix}_book.csv"
self.trades_file = f"{file_prefix}_trades.csv"
# Buffers
self.book_buffer = []
self.trades_buffer = []
self.buffer_limit = 10
# Ensure dir exists
if not os.path.exists(MARKET_DATA_DIR):
os.makedirs(MARKET_DATA_DIR)
# Init Book CSV
if not os.path.exists(self.book_file):
with open(self.book_file, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['timestamp_ms', 'local_time', 'bid_px', 'bid_sz', 'ask_px', 'ask_sz', 'mid_price'])
# Init Trades CSV
if not os.path.exists(self.trades_file):
with open(self.trades_file, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['timestamp_ms', 'local_time', 'price', 'size', 'side', 'hash'])
def on_message(self, ws, message):
try:
recv_ts = time.time()
msg = json.loads(message)
channel = msg.get('channel')
data = msg.get('data', {})
if channel == 'l2Book':
self.process_book(data, recv_ts)
elif channel == 'trades':
self.process_trades(data, recv_ts)
except Exception as e:
print(f"[{datetime.now()}] Error processing: {e}")
def process_book(self, data, recv_ts):
if data.get('coin') != self.coin:
return
levels = data.get('levels', [])
if levels and len(levels) >= 2:
bids = levels[0]
asks = levels[1]
if bids and asks:
# Hyperliquid L2 format: {px: float, sz: float, n: int}
best_bid = bids[0]
best_ask = asks[0]
bid_px = float(best_bid['px'])
bid_sz = float(best_bid['sz'])
ask_px = float(best_ask['px'])
ask_sz = float(best_ask['sz'])
mid = (bid_px + ask_px) / 2
row = [
int(recv_ts * 1000),
datetime.fromtimestamp(recv_ts).strftime('%H:%M:%S.%f')[:-3],
bid_px, bid_sz,
ask_px, ask_sz,
mid
]
self.book_buffer.append(row)
if len(self.book_buffer) >= self.buffer_limit:
self.flush_book()
def process_trades(self, data, recv_ts):
# Data is a list of trades
for trade in data:
if trade.get('coin') != self.coin:
continue
# trade format: {coin, side, px, sz, time, hash}
row = [
int(trade.get('time', int(recv_ts * 1000))),
datetime.fromtimestamp(trade.get('time', 0)/1000 or recv_ts).strftime('%H:%M:%S.%f')[:-3],
float(trade['px']),
float(trade['sz']),
trade['side'],
trade.get('hash', '')
]
self.trades_buffer.append(row)
if len(self.trades_buffer) >= self.buffer_limit:
self.flush_trades()
def flush_book(self):
try:
with open(self.book_file, 'a', newline='') as f:
writer = csv.writer(f)
writer.writerows(self.book_buffer)
self.book_buffer = []
except Exception as e:
print(f"Error writing book: {e}")
def flush_trades(self):
try:
with open(self.trades_file, 'a', newline='') as f:
writer = csv.writer(f)
writer.writerows(self.trades_buffer)
# Console Feedback
last_trade = self.trades_buffer[-1] if self.trades_buffer else "N/A"
if last_trade != "N/A":
print(f"[{datetime.now().strftime('%H:%M:%S')}] 🔫 Trade: {last_trade[2]} (x{last_trade[3]}) {last_trade[4]}")
self.trades_buffer = []
except Exception as e:
print(f"Error writing trades: {e}")
def on_open(self, ws):
print(f"[{datetime.now()}] Connected! Subscribing to l2Book & trades for {self.coin}...")
# Subscribe to Book
ws.send(json.dumps({
"method": "subscribe",
"subscription": {"type": "l2Book", "coin": self.coin}
}))
# Subscribe to Trades
ws.send(json.dumps({
"method": "subscribe",
"subscription": {"type": "trades", "coin": self.coin}
}))
def on_error(self, ws, error):
print(f"WebSocket Error: {error}")
def on_close(self, ws, close_status_code, close_msg):
print(f"WebSocket Closed: {close_status_code}")
self.flush_book()
self.flush_trades()
def start(self):
print(f"🔴 RECORDING RAW DATA for {self.coin}")
print(f"📘 Book Data: {self.book_file}")
print(f"🔫 Trades Data: {self.trades_file}")
print("Press Ctrl+C to stop.")
while self.running:
try:
self.ws = websocket.WebSocketApp(
WS_URL,
on_open=self.on_open,
on_message=self.on_message,
on_error=self.on_error,
on_close=self.on_close
)
self.ws.run_forever(ping_interval=15, ping_timeout=5)
except Exception as e:
print(f"Critical error: {e}")
if self.running:
print("Reconnecting in 1s...")
time.sleep(1)
def signal_handler(sig, frame):
print("\nStopping recorder...")
sys.exit(0)
if __name__ == "__main__":
signal.signal(signal.SIGINT, signal_handler)
parser = argparse.ArgumentParser(description="Record RAW Book & Trades from Hyperliquid")
parser.add_argument("--coin", type=str, default="ETH", help="Coin symbol")
parser.add_argument("--output", type=str, help="Base filename (will append _book.csv and _trades.csv)")
args = parser.parse_args()
# Generate filename prefix
if args.output:
# Strip extension if user provided one like "data.csv" -> "data"
base = os.path.splitext(args.output)[0]
else:
date_str = datetime.now().strftime("%Y%m%d")
base = os.path.join(MARKET_DATA_DIR, f"{args.coin}_raw_{date_str}")
recorder = MarketDataRecorder(args.coin, base)
recorder.start()