diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..fef0626 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,95 @@ +# GEMINI Project Context: Uniswap Auto CLP & Hedger + +**Last Updated:** January 6, 2026 +**Project:** Uniswap V3 Automated Concentrated Liquidity (CLP) & Delta-Neutral Hedger +**Operating System:** Windows (win32) + +## 1. Project Overview +This project is an automated high-frequency trading system designed to: +1. **Provide Concentrated Liquidity (CLP):** Automatically manages positions on Uniswap V3 and its forks (Aerodrome, PancakeSwap) to capture trading fees. +2. **Hedge Delta Exposure:** Simultaneously executes delta-neutral hedges on Hyperliquid (Perp DEX) to neutralize the price risk of the underlying assets (Impermanent Loss protection). + +The system operates as a **Delta-Zero Yield Farmer**, effectively capturing LP fees with reduced market exposure. + +## 2. System Architecture +The system consists of two main independent Python processes that coordinate via a shared JSON state file. + +```mermaid +graph TD + A[clp_manager.py] -- Reads/Writes --> C{status.json} + B[clp_hedger.py] -- Reads --> C + A -- On-Chain Tx --> D[EVM Chain (Base/Arb)] + B -- API Calls --> E[Hyperliquid Perp] + F[telegram_monitor.py] -- Reads --> C + F -- Alerts --> G[Telegram] +``` + +### Components +* **`clp_manager.py` (The Strategist):** + * **Role:** Active Liquidity Provision on EVM chains. + * **Actions:** Mints new positions, monitors ranges, auto-closes out-of-range positions, collects fees, and handles auto-wrapping/swapping. + * **State:** Updates `{TARGET_DEX}_status.json` with entry price, token amounts, and range details. + +* **`clp_hedger.py` (The Guardian):** + * **Role:** Delta Neutralization on Hyperliquid. + * **Actions:** Calculates the LP's real-time delta. Executes short/long trades to maintain `Net Delta ≈ 0`. + * **Features:** + * **Dynamic Thresholds:** Adjusts rebalance sensitivity based on market volatility (StdDev). + * **Fishing Orders:** Places passive maker orders to capture spread and rebates. + * **Edge Protection:** Reduces hedging logic near range edges to prevent "buying high/selling low". + +* **`clp_config.py`:** Centralized configuration. Uses `TARGET_DEX` env var to switch between profiles (e.g., `UNISWAP_V3`, `AERODROME_BASE_CL`). + +## 3. Key Files & Directories + +| File/Directory | Description | +| :--- | :--- | +| **`clp_manager.py`** | Main entry point for the LP Manager. | +| **`clp_hedger.py`** | Main entry point for the Delta Hedger. | +| **`clp_config.py`** | Configuration profiles and strategy settings. | +| **`requirements.txt`** | Python dependencies (`web3`, `hyperliquid-python-sdk`, `dotenv`). | +| **`doc/`** | Extensive documentation (`CHANGELOG.md`, `UNISWAP_MANAGER_WORKFLOW.md`). | +| **`florida/`** | A secondary deployment or research directory containing specific strategy configurations. | +| **`tools/`** | Utility scripts (e.g., `git_agent.py`, `universal_swapper.py`). | +| **`logs/`** | Runtime logs. | +| **`*_status.json`** | The shared state file (IPC). Specific name depends on `TARGET_DEX`. | + +## 4. Setup & Usage + +### Prerequisites +* Python 3.10+ +* Hyperliquid Account & API Key (in `.env`) +* EVM Wallet Private Key (in `.env`) +* RPC URLs (in `.env`) + +### Installation +```bash +pip install -r requirements.txt +``` + +### Configuration +1. Create a `.env` file (see `.env.example` in `tools/`). +2. Edit `clp_config.py` or set `TARGET_DEX` environment variable to choose a profile (e.g., `AERODROME_WETH-USDC_008`). + +### Running the System +You typically run the Manager and Hedger in separate terminals: + +**Terminal 1 (Manager):** +```bash +python clp_manager.py +``` + +**Terminal 2 (Hedger):** +```bash +python clp_hedger.py +``` + +## 5. Recent Developments (Jan 2026) +* **Low Volatility Optimization:** Increased base rebalance thresholds (from 5% to 8%) to reduce churn in sideways markets. +* **Fishing Orders:** Implemented passive limit orders to profit from rebalancing. +* **Shadow Logging:** The system now logs "Shadow" trades to simulate maker order performance vs taker execution. +* **Dynamic Edge Proximity:** Safety buffers now scale with position size. + +## 6. Active Context +* **Current Profile:** The root directory contains `AERODROME_WETH-USDC_008_status.json`, indicating an active or recent session on **Aerodrome (Base Chain)**. +* **Workflows:** Refer to `doc/UNISWAP_MANAGER_WORKFLOW.md` for detailed state machine logic of the Manager. diff --git a/clp_abis.py b/clp_abis.py new file mode 100644 index 0000000..6b23d0e --- /dev/null +++ b/clp_abis.py @@ -0,0 +1,82 @@ +import json + +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"} +] +''') + +AERODROME_FACTORY_ABI = json.loads(''' +[ + {"inputs": [{"internalType": "address", "name": "tokenA", "type": "address"}, {"internalType": "address", "name": "tokenB", "type": "address"}, {"internalType": "int24", "name": "tickSpacing", "type": "int24"}], "name": "getPool", "outputs": [{"internalType": "address", "name": "pool", "type": "address"}], "stateMutability": "view", "type": "function"} +] +''') + +AERODROME_NPM_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": "int24", "name": "tickSpacing", "type": "int24"}, {"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": "int24", "name": "tickSpacing", "type": "int24"}, {"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": "uint160", "name": "sqrtPriceX96", "type": "uint160"}], "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"} +] +''') + +AERODROME_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": "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"} +] +''') + +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"} +] +''') diff --git a/clp_config.py b/clp_config.py index c72314c..f966ec4 100644 --- a/clp_config.py +++ b/clp_config.py @@ -8,7 +8,9 @@ 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 + "MONITOR_INTERVAL_SECONDS": 300, # Manager loop & sync interval + "LOG_INTERVAL_SECONDS": 300, # Hedger console logging interval + "RANGE_MODE": "AUTO", # Options: "AUTO" (BB-based), "FIXED" (RANGE_WIDTH_PCT) "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 @@ -19,22 +21,22 @@ DEFAULT_STRATEGY = { "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 + "RANGE_WIDTH_PCT": Decimal("0.03"), # LP width (e.g. 0.05 = +/- 5% from current price) + "SLIPPAGE_TOLERANCE": Decimal("0.05"), # Max allowed slippage for swaps and minting "TRANSACTION_TIMEOUT_SECONDS": 30, # Timeout for blockchain transactions # Hedging Settings - "HEDGE_STRATEGY": "ASYMMETRIC", # Options: "STANDARD" (Full Range Hedge), "ASYMMETRIC" (Edge-Only Reduction) + "HEDGE_STRATEGY": "ASYMMETRIC", # Options: "STANDARD" (Full Range Hedge), "ASYMMETRIC" (Edge-Only Reduction), "FIXED" (Initial Delta) "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": 2, # Leverage to use on Hyperliquid + + "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%) + "PRICE_BUFFER_PCT": Decimal("0.0025"), # 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 @@ -73,8 +75,9 @@ CLP_PROFILES = { "TOKEN_B_ADDRESS": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", # USDC "WRAPPED_NATIVE_ADDRESS": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", "POOL_FEE": 500, - "TARGET_INVESTMENT_AMOUNT": 1000, - "HEDGE_STRATEGY": "BOTTOM", + "TARGET_INVESTMENT_AMOUNT": 3000, + "HEDGE_STRATEGY": "FIXED", + "RANGE_WIDTH_PCT": Decimal("0.0075"), }, "UNISWAP_wide": { "NAME": "Uniswap V3 (Arbitrum) - ETH/USDC Wide", @@ -127,6 +130,34 @@ CLP_PROFILES = { "TARGET_INVESTMENT_AMOUNT": 200, "VALUE_REFERENCE": "USD", "RANGE_WIDTH_PCT": Decimal("0.10") + }, + "AERODROME_BASE_CL": { + "NAME": "Aerodrome SlipStream (Base) - WETH/USDC", + "COIN_SYMBOL": "ETH", + "RPC_ENV_VAR": "BASE_RPC_URL", + "NPM_ADDRESS": "0x827922686190790b37229fd06084350E74485b72", + "ROUTER_ADDRESS": "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43", + "TOKEN_A_ADDRESS": "0x4200000000000000000000000000000000000006", # WETH + "TOKEN_B_ADDRESS": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", # USDC + "WRAPPED_NATIVE_ADDRESS": "0x4200000000000000000000000000000000000006", + "POOL_FEE": 100, # TickSpacing 100 pool (0xb2cc...) + "RANGE_WIDTH_PCT": Decimal("0.075"), + "TARGET_INVESTMENT_AMOUNT": 200, + "HEDGE_STRATEGY": "FIXED", + }, + "AERODROME_WETH-USDC_008": { + "NAME": "Aerodrome SlipStream (Base) - WETH/USDC Stable", + "COIN_SYMBOL": "ETH", + "RPC_ENV_VAR": "BASE_RPC_URL", + "NPM_ADDRESS": "0x827922686190790b37229fd06084350E74485b72", + "ROUTER_ADDRESS": "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43", + "TOKEN_A_ADDRESS": "0x4200000000000000000000000000000000000006", # WETH + "TOKEN_B_ADDRESS": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", # USDC + "WRAPPED_NATIVE_ADDRESS": "0x4200000000000000000000000000000000000006", + "POOL_FEE": 1, # TickSpacing 1 pool (0xdbc6...) + "RANGE_WIDTH_PCT": Decimal("0.0075"), + "TARGET_INVESTMENT_AMOUNT": 3000, + "HEDGE_STRATEGY": "FIXED", } } diff --git a/clp_hedger.py b/clp_hedger.py index bb11209..fa9147e 100644 --- a/clp_hedger.py +++ b/clp_hedger.py @@ -14,14 +14,9 @@ current_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(current_dir) sys.path.append(project_root) -# Import local modules -try: - from logging_utils import setup_logging -except ImportError: - setup_logging = None - # Ensure root logger is clean if we can't use setup_logging - logging.getLogger().handlers.clear() - logging.basicConfig(level=logging.INFO) +# Ensure root logger is clean +logging.getLogger().handlers.clear() +logging.basicConfig(level=logging.INFO) from eth_account import Account from hyperliquid.exchange import Exchange @@ -233,10 +228,12 @@ class HyperliquidStrategy: adj_pct = -norm_dist * max_boost adj_pct = max(-max_boost, min(max_boost, adj_pct)) - raw_target_short = pool_delta - - # --- BOTTOM STRATEGY LOGIC --- - if strategy_type == "BOTTOM": + # --- FIXED STRATEGY LOGIC --- + if strategy_type == "FIXED": + # Target is exactly the pool delta at entry price + raw_target_short = self.get_pool_delta(self.entry_price) + adj_pct = Decimal("0") + elif strategy_type == "BOTTOM": if current_price > self.entry_price: # Disable hedging in upper half raw_target_short = Decimal("0") @@ -287,13 +284,23 @@ class UnifiedHedger: # Market Data Cache self.last_prices = {} - self.price_history = {} # Symbol -> List[Decimal] + self.price_history = {} # Symbol -> List[Decimal] (Fast: 1s samples) self.last_trade_times = {} # Symbol -> timestamp self.last_idle_log_times = {} # Symbol -> timestamp # Shadow Orders (Global List) self.shadow_orders = [] + # State: Emergency Close Hysteresis + # Map: (file_path, token_id) -> bool + self.emergency_close_active = {} + + # Map: (file_path, token_id) -> Decimal (Locked hedge size) + self.custom_fixed_targets = {} + + # Map: (file_path, token_id) -> Decimal (Price when hedge leg opened) + self.hedge_entry_prices = {} + self.startup_time = time.time() logger.info(f"[CLP_HEDGER] Master Hedger initialized. Agent: {self.account.address}") @@ -455,6 +462,7 @@ class UnifiedHedger: self.strategy_states[key]['pnl'] = to_decimal(entry.get('hedge_pnl_realized', 0)) self.strategy_states[key]['fees'] = to_decimal(entry.get('hedge_fees_paid', 0)) self.strategy_states[key]['status'] = entry.get('status', 'OPEN') + self.strategy_states[key]['clp_fees'] = to_decimal(entry.get('clp_fees', 0)) except Exception as e: logger.error(f"Error reading {filename}: {e}. Skipping updates.") @@ -506,12 +514,23 @@ class UnifiedHedger: "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)), + "clp_fees": to_decimal(position_data.get('clp_fees', 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') } + + # Initial hedge entry price is the CLP entry price + self.hedge_entry_prices[key] = entry_price + logger.info(f"[STRAT] Init {key[1]} ({coin_symbol}) | Range: {lower}-{upper}") + # Ensure JSON has these fields initialized + update_position_stats(key[0], key[1], { + "hedge_TotPnL": float(self.strategy_states[key]['hedge_TotPnL']), + "hedge_fees_paid": float(self.strategy_states[key]['fees']) + }) + except Exception as e: logger.error(f"Failed to init strategy {key[1]}: {e}") @@ -680,7 +699,7 @@ class UnifiedHedger: price = to_decimal(mids[coin]) self.last_prices[coin] = price - # Update Price History + # Update Price History (Fast) if coin not in self.price_history: self.price_history[coin] = [] self.price_history[coin].append(price) if len(self.price_history[coin]) > 300: self.price_history[coin].pop(0) @@ -701,13 +720,50 @@ class UnifiedHedger: if coin not in aggregates: aggregates[coin] = {'target_short': Decimal("0"), 'contributors': 0, 'is_at_edge': False, 'is_at_bottom_edge': False, 'adj_pct': Decimal("0"), 'is_closing': False} - if status == 'CLOSING': - # If Closing, we want target to be 0 for this strategy - logger.info(f"[STRAT] {key[1]} is CLOSING -> Force Target 0") + # --- EMERGENCY UPPER EDGE CLOSING (HYSTERESIS) --- + # Logic: If price hits Top, close hedge. Do NOT re-open until price drops back to 75% of Range (FIXED) or Buffer (Others). + + is_active_hysteresis = self.emergency_close_active.get(key, False) + + if is_active_hysteresis: + # CHECK RESET CONDITION + if strategy_type == "FIXED": + # Reset at 75% of range (from Bottom) + range_width = strat.high_range - strat.low_range + reset_threshold = strat.low_range + (range_width * Decimal("0.75")) + else: + reset_threshold = strat.high_range * Decimal("0.999") + + if price < reset_threshold: + logger.info(f"[STRAT] {key[1]} Price reset ({price:.2f} < {reset_threshold:.2f}). Resuming hedge.") + self.emergency_close_active[key] = False + is_active_hysteresis = False + + # Capture NEW Dynamic Fixed Target and Entry Price + if strategy_type == "FIXED": + dynamic_delta = strat.get_pool_delta(price) + self.custom_fixed_targets[key] = dynamic_delta + self.hedge_entry_prices[key] = price + logger.info(f"[STRAT] {key[1]} FIXED target reset to Dynamic Delta: {dynamic_delta:.4f} @ {price:.2f}") + + if not is_active_hysteresis: + # CHECK TRIGGER CONDITION + if price >= strat.high_range: + logger.warning(f"[STRAT] {key[1]} above High Range ({price:.2f} >= {strat.high_range:.2f}). Emergency closing hedge.") + self.emergency_close_active[key] = True + is_active_hysteresis = True + # Reset entry price when closed + self.hedge_entry_prices[key] = Decimal("0") + + if status == 'CLOSING' or is_active_hysteresis: + # If Closing OR Hysteresis Active, target is 0 aggregates[coin]['is_closing'] = True - # Do not add to target_short else: - aggregates[coin]['target_short'] += calc['target_short'] + # Use custom fixed target if exists, else standard calc + if strategy_type == "FIXED" and key in self.custom_fixed_targets: + aggregates[coin]['target_short'] += self.custom_fixed_targets[key] + else: + aggregates[coin]['target_short'] += calc['target_short'] aggregates[coin]['contributors'] += 1 aggregates[coin]['adj_pct'] = calc['adj_pct'] @@ -875,25 +931,69 @@ class UnifiedHedger: if existing_orders and not order_matched: for o in existing_orders: self.cancel_order(coin, o['oid']) - # --- THROTTLED STATUS LOGGING --- + # --- REAL-TIME PnL CALCULATION & JSON UPDATE (1s) --- + total_L_log = Decimal("0") + for k_strat, strat_inst in self.strategies.items(): + if self.strategy_states[k_strat]['coin'] == coin: + total_L_log += strat_inst.L + + # Update all active strategies for this coin in JSON + if total_L_log > 0 and price > 0: + for k_strat, strat_inst in self.strategies.items(): + if self.strategy_states[k_strat]['coin'] != coin: continue + + # CLP Value Calc + def get_clp_value(p, s): + if p <= s.low_range: return s.L * (p * (1/s.low_range.sqrt() - 1/s.high_range.sqrt())) + if p >= s.high_range: return s.L * (s.high_range.sqrt() - s.low_range.sqrt()) + return s.L * (2*p.sqrt() - s.low_range.sqrt() - p/s.high_range.sqrt()) + + clp_curr_val = get_clp_value(price, strat_inst) + + # Use Custom Fixed Target if exists + target_size = self.custom_fixed_targets.get(k_strat, strat_inst.get_pool_delta(strat_inst.entry_price)) + + # USE TRACKED HEDGE ENTRY PRICE + h_entry_px = self.hedge_entry_prices.get(k_strat, strat_inst.entry_price) + if h_entry_px > 0: + hedge_pnl_curr = (h_entry_px - price) * target_size + else: + hedge_pnl_curr = Decimal("0") + + fee_close_curr = (target_size * price) * Decimal("0.000432") + uni_fees = to_decimal(self.strategy_states[k_strat].get('clp_fees', 0)) + + # Retrieve Realized PnL & Fees from State + realized_pnl = to_decimal(self.strategy_states[k_strat].get('hedge_TotPnL', 0)) + realized_fees = to_decimal(self.strategy_states[k_strat].get('fees', 0)) + + # Combined TotPnL = CLP_Unrealized + Hedge_Unrealized + Hedge_Realized - Hedge_Fees + CLP_Fees - Est_Close_Fee + tot_curr = (clp_curr_val - strat_inst.target_value) + hedge_pnl_curr + realized_pnl - realized_fees - fee_close_curr + uni_fees + + cur_hl_cost = realized_fees + fee_close_curr + + # Sync to JSON every 1s + update_position_stats(k_strat[0], k_strat[1], { + "combined_TotPnL": round(float(tot_curr), 2), + "hedge_HL_cost_est": round(float(cur_hl_cost), 2), + "hedge_pnl_unrealized": round(float(hedge_pnl_curr), 2), + "last_sync_hl": int(time.time()) + }) + + # --- THROTTLED STATUS LOGGING (300s) --- now = time.time() last_log = self.last_idle_log_times.get(coin, 0) - monitor_interval = config.get("MONITOR_INTERVAL_SECONDS", 60) + log_interval = config.get("LOG_INTERVAL_SECONDS", 300) - if now - last_log >= monitor_interval: + if now - last_log >= log_interval: self.last_idle_log_times[coin] = now if is_asymmetric_blocked: logger.info(f"[ASYMMETRIC] Blocking BUY. Px ({price:.2f}) >= Eq ({p_mid_asym:.2f}) & Not Edge") - total_L_log = Decimal("0") - for k_strat, strat_inst in self.strategies.items(): - if self.strategy_states[k_strat]['coin'] == coin: - total_L_log += strat_inst.L - if total_L_log > 0 and price > 0: gamma_log = (Decimal("0.5") * total_L_log * (price ** Decimal("-1.5"))) if gamma_log > 0: - p_mid_log = price - (diff / gamma_log) # Corrected equilibrium formula + p_mid_log = price - (diff / gamma_log) p_buy = price + (dynamic_thresh + diff) / gamma_log p_sell = price - (dynamic_thresh - diff) / gamma_log pad = " " if coin == "BNB" else "" @@ -903,7 +1003,45 @@ class UnifiedHedger: fees = sum(s['fees'] for s in self.strategy_states.values() if s['coin'] == coin) total_pnl = (closed_pnl - fees) + unrealized - logger.info(f"[IDLE] {coin} | Px: {price:.2f}{pad} | M: {p_mid_log:.1f}{pad} | B: {p_buy:.1f}{pad} / S: {p_sell:.1f}{pad} | delta: {target_position:.4f}({diff:+.4f}) | Adj: {data.get('adj_pct',0)*100:+.2f}%, Vol: {vol_mult:.2f}, Thr: {dynamic_thresh:.4f} | PnL: {unrealized:.2f} | TotPnL: {total_pnl:.2f}") + # Log individual strategy PnL + if strategy_type == "FIXED": + for k_strat, strat_inst in self.strategies.items(): + if self.strategy_states[k_strat]['coin'] != coin: continue + + # Recalculate for logging (including bounds) + clp_curr_val = get_clp_value(price, strat_inst) + clp_low_val = get_clp_value(strat_inst.low_range, strat_inst) + clp_high_val = get_clp_value(strat_inst.high_range, strat_inst) + + # Use Custom Fixed Target if exists + target_size = self.custom_fixed_targets.get(k_strat, strat_inst.get_pool_delta(strat_inst.entry_price)) + h_entry_px = self.hedge_entry_prices.get(k_strat, strat_inst.entry_price) + + if h_entry_px > 0: + hedge_pnl_curr = (h_entry_px - price) * target_size + hedge_pnl_low = (h_entry_px - strat_inst.low_range) * target_size + hedge_pnl_high = (h_entry_px - strat_inst.high_range) * target_size + fee_open = (target_size * h_entry_px) * Decimal("0.000144") + else: + hedge_pnl_curr = hedge_pnl_low = hedge_pnl_high = Decimal("0") + fee_open = Decimal("0") + + fee_close_curr = (target_size * price) * Decimal("0.000432") + fee_close_low = (target_size * strat_inst.low_range) * Decimal("0.000432") + fee_close_high = (target_size * strat_inst.high_range) * Decimal("0.000432") + + uni_fees = to_decimal(self.strategy_states[k_strat].get('clp_fees', 0)) + tot_curr = (clp_curr_val - strat_inst.target_value) + hedge_pnl_curr - (fee_open + fee_close_curr) + uni_fees + tot_low = (clp_low_val - strat_inst.target_value) + hedge_pnl_low - (fee_open + fee_close_low) + uni_fees + tot_high = (clp_high_val - strat_inst.target_value) + hedge_pnl_high - (fee_open + fee_close_high) + uni_fees + + cur_hl_cost = fee_open + fee_close_curr + + # ID or Range to distinguish + strat_id = str(k_strat[1]) # Token ID + logger.info(f"[FIXED] {coin} #{strat_id} | TotPnL: {tot_curr:+.2f} | Down: {tot_low:+.2f} | Up: {tot_high:+.2f} (Inc: Fees ${uni_fees:.2f}, HL Cost ${cur_hl_cost:.2f})") + + logger.info(f"[IDLE] {coin} | Px: {price:.2f}{pad} | M: {p_mid_log:.1f}{pad} | B: {p_buy:.1f}{pad} / S: {p_sell:.1f}{pad} | delta: {target_position:.4f}({diff:+.4f}) | Adj: {data.get('adj_pct',0)*100:+.2f}%, Vol: {vol_mult:.2f}, Thr: {dynamic_thresh:.4f} | PnL: {unrealized:.2f} | HedgePnL: {total_pnl:.2f}") else: logger.info(f"[IDLE] {coin} | Px: {price:.2f} | delta: {target_position:.4f} | Diff: {diff:.4f} (Thresh: {dynamic_thresh:.4f})") else: diff --git a/clp_manager.py b/clp_manager.py index d48f2e8..83bbdb9 100644 --- a/clp_manager.py +++ b/clp_manager.py @@ -63,65 +63,34 @@ formatter = logging.Formatter('%(unix_ms)d, %(asctime)s - %(name)s - %(levelname file_handler.setFormatter(formatter) logger.addHandler(file_handler) -# --- ABIs --- -# (Kept minimal for brevity, normally would load from files) -NONFUNGIBLE_POSITION_MANAGER_ABI = json.loads(''' -[ - {"anonymous": false, "inputs": [{"indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"indexed": false, "internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"indexed": false, "internalType": "uint256", "name": "amount0", "type": "uint256"}, {"indexed": false, "internalType": "uint256", "name": "amount1", "type": "uint256"}], "name": "IncreaseLiquidity", "type": "event"}, - {"anonymous": false, "inputs": [{"indexed": true, "internalType": "address", "name": "from", "type": "address"}, {"indexed": true, "internalType": "address", "name": "to", "type": "address"}, {"indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256"}], "name": "Transfer", "type": "event"}, - {"inputs": [], "name": "factory", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, - {"inputs": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}], "name": "positions", "outputs": [{"internalType": "uint96", "name": "nonce", "type": "uint96"}, {"internalType": "address", "name": "operator", "type": "address"}, {"internalType": "address", "name": "token0", "type": "address"}, {"internalType": "address", "name": "token1", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}, {"internalType": "int24", "name": "tickLower", "type": "int24"}, {"internalType": "int24", "name": "tickUpper", "type": "int24"}, {"internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"internalType": "uint256", "name": "feeGrowthInside0LastX128", "type": "uint256"}, {"internalType": "uint256", "name": "feeGrowthInside1LastX128", "type": "uint256"}, {"internalType": "uint128", "name": "tokensOwed0", "type": "uint128"}, {"internalType": "uint128", "name": "tokensOwed1", "type": "uint128"}], "stateMutability": "view", "type": "function"}, - {"inputs": [{"components": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint128", "name": "amount0Max", "type": "uint128"}, {"internalType": "uint128", "name": "amount1Max", "type": "uint128"}], "internalType": "struct INonfungiblePositionManager.CollectParams", "name": "params", "type": "tuple"}], "name": "collect", "outputs": [{"internalType": "uint256", "name": "amount0", "type": "uint256"}, {"internalType": "uint256", "name": "amount1", "type": "uint256"}], "stateMutability": "payable", "type": "function"}, - {"inputs": [{"components": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"internalType": "uint256", "name": "amount0Min", "type": "uint256"}, {"internalType": "uint256", "name": "amount1Min", "type": "uint256"}, {"internalType": "uint256", "name": "deadline", "type": "uint256"}], "internalType": "struct INonfungiblePositionManager.DecreaseLiquidityParams", "name": "params", "type": "tuple"}], "name": "decreaseLiquidity", "outputs": [{"internalType": "uint256", "name": "amount0", "type": "uint256"}, {"internalType": "uint256", "name": "amount1", "type": "uint256"}], "stateMutability": "payable", "type": "function"}, - {"inputs": [{"components": [{"internalType": "address", "name": "token0", "type": "address"}, {"internalType": "address", "name": "token1", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}, {"internalType": "int24", "name": "tickLower", "type": "int24"}, {"internalType": "int24", "name": "tickUpper", "type": "int24"}, {"internalType": "uint256", "name": "amount0Desired", "type": "uint256"}, {"internalType": "uint256", "name": "amount1Desired", "type": "uint256"}, {"internalType": "uint256", "name": "amount0Min", "type": "uint256"}, {"internalType": "uint256", "name": "amount1Min", "type": "uint256"}, {"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint256", "name": "deadline", "type": "uint256"}], "internalType": "struct INonfungiblePositionManager.MintParams", "name": "params", "type": "tuple"}], "name": "mint", "outputs": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"internalType": "uint256", "name": "amount0", "type": "uint256"}, {"internalType": "uint256", "name": "amount1", "type": "uint256"}], "stateMutability": "payable", "type": "function"} -] -''') - -UNISWAP_V3_POOL_ABI = json.loads(''' -[ - {"inputs": [], "name": "slot0", "outputs": [{"internalType": "uint160", "name": "sqrtPriceX96", "type": "uint160"}, {"internalType": "int24", "name": "tick", "type": "int24"}, {"internalType": "uint16", "name": "observationIndex", "type": "uint16"}, {"internalType": "uint16", "name": "observationCardinality", "type": "uint16"}, {"internalType": "uint16", "name": "observationCardinalityNext", "type": "uint16"}, {"internalType": "uint32", "name": "feeProtocol", "type": "uint32"}, {"internalType": "bool", "name": "unlocked", "type": "bool"}], "stateMutability": "view", "type": "function"}, - {"inputs": [], "name": "token0", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, - {"inputs": [], "name": "token1", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, - {"inputs": [], "name": "fee", "outputs": [{"internalType": "uint24", "name": "", "type": "uint24"}], "stateMutability": "view", "type": "function"}, - {"inputs": [], "name": "tickSpacing", "outputs": [{"internalType": "int24", "name": "", "type": "int24"}], "stateMutability": "view", "type": "function"}, - {"inputs": [], "name": "liquidity", "outputs": [{"internalType": "uint128", "name": "", "type": "uint128"}], "stateMutability": "view", "type": "function"} -] -''') - -ERC20_ABI = json.loads(''' -[ - {"inputs": [], "name": "decimals", "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], "stateMutability": "view", "type": "function"}, - {"inputs": [], "name": "symbol", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"}, - {"inputs": [{"internalType": "address", "name": "account", "type": "address"}], "name": "balanceOf", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, - {"inputs": [{"internalType": "address", "name": "spender", "type": "address"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "approve", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "nonpayable", "type": "function"}, - {"inputs": [{"internalType": "address", "name": "owner", "type": "address"}, {"internalType": "address", "name": "spender", "type": "address"}], "name": "allowance", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"} -] -''') - -UNISWAP_V3_FACTORY_ABI = json.loads(''' -[ - {"inputs": [{"internalType": "address", "name": "tokenA", "type": "address"}, {"internalType": "address", "name": "tokenB", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}], "name": "getPool", "outputs": [{"internalType": "address", "name": "pool", "type": "address"}], "stateMutability": "view", "type": "function"} -] -''') - -SWAP_ROUTER_ABI = json.loads(''' -[ - {"inputs": [{"components": [{"internalType": "address", "name": "tokenIn", "type": "address"}, {"internalType": "address", "name": "tokenOut", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}, {"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint256", "name": "deadline", "type": "uint256"}, {"internalType": "uint256", "name": "amountIn", "type": "uint256"}, {"internalType": "uint256", "name": "amountOutMinimum", "type": "uint256"}, {"internalType": "uint160", "name": "sqrtPriceLimitX96", "type": "uint160"}], "internalType": "struct ISwapRouter.ExactInputSingleParams", "name": "params", "type": "tuple"}], "name": "exactInputSingle", "outputs": [{"internalType": "uint256", "name": "amountOut", "type": "uint256"}], "stateMutability": "payable", "type": "function"} -] -''') - -WETH9_ABI = json.loads(''' -[ - {"constant": false, "inputs": [], "name": "deposit", "outputs": [], "payable": true, "stateMutability": "payable", "type": "function"}, - {"constant": false, "inputs": [{"name": "wad", "type": "uint256"}], "name": "withdraw", "outputs": [], "payable": false, "stateMutability": "nonpayable", "type": "function"} -] -''') +from clp_abis import ( + NONFUNGIBLE_POSITION_MANAGER_ABI, + UNISWAP_V3_POOL_ABI, + ERC20_ABI, + UNISWAP_V3_FACTORY_ABI, + AERODROME_FACTORY_ABI, + AERODROME_POOL_ABI, + AERODROME_NPM_ABI, + SWAP_ROUTER_ABI, + WETH9_ABI +) from clp_config import get_current_config, STATUS_FILE +from tools.universal_swapper import execute_swap # --- GET ACTIVE DEX CONFIG --- CONFIG = get_current_config() +DEX_TO_CHAIN = { + "UNISWAP_V3": "ARBITRUM", + "UNISWAP_wide": "ARBITRUM", + "PANCAKESWAP_BNB": "BSC", + "WETH_CBBTC_BASE": "BASE", + "UNISWAP_BASE_CL": "BASE", + "AERODROME_BASE_CL": "BASE", + "AERODROME_WETH-USDC_008": "BASE" +} + # --- CONFIGURATION FROM STRATEGY --- MONITOR_INTERVAL_SECONDS = CONFIG.get("MONITOR_INTERVAL_SECONDS", 60) CLOSE_POSITION_ENABLED = CONFIG.get("CLOSE_POSITION_ENABLED", True) @@ -130,11 +99,77 @@ REBALANCE_ON_CLOSE_BELOW_RANGE = CONFIG.get("REBALANCE_ON_CLOSE_BELOW_RANGE", Tr TARGET_INVESTMENT_VALUE_USDC = CONFIG.get("TARGET_INVESTMENT_AMOUNT", 2000) INITIAL_HEDGE_CAPITAL_USDC = CONFIG.get("INITIAL_HEDGE_CAPITAL", 1000) RANGE_WIDTH_PCT = CONFIG.get("RANGE_WIDTH_PCT", Decimal("0.01")) +RANGE_MODE = CONFIG.get("RANGE_MODE", "FIXED") SLIPPAGE_TOLERANCE = CONFIG.get("SLIPPAGE_TOLERANCE", Decimal("0.02")) TRANSACTION_TIMEOUT_SECONDS = CONFIG.get("TRANSACTION_TIMEOUT_SECONDS", 30) +# --- AUTO RANGE HELPERS --- + +def get_market_indicators() -> Optional[Dict]: + file_path = os.path.join("market_data", "indicators.json") + if not os.path.exists(file_path): + return None + try: + with open(file_path, 'r') as f: + data = json.load(f) + + # Check Freshness (5m) + last_updated_str = data.get("last_updated") + if not last_updated_str: return None + + last_updated = datetime.fromisoformat(last_updated_str) + if (datetime.now() - last_updated).total_seconds() > 300: + logger.warning("⚠️ Market indicators file is stale (>5m).") + return None + + return data + except Exception as e: + logger.error(f"Error reading indicators: {e}") + return None + +def calculate_dynamic_range_pct(coin: str) -> Optional[Decimal]: + indicators = get_market_indicators() + if not indicators: return None + + # Normalize symbols (Hyperliquid uses ETH, BNB while DEX uses WETH, WBNB) + symbol_map = {"WETH": "ETH", "WBNB": "BNB"} + lookup_coin = symbol_map.get(coin.upper(), coin.upper()) + + coin_data = indicators.get("data", {}).get(lookup_coin) + if not coin_data: return None + + try: + price = Decimal(str(coin_data["current_price"])) + bb12 = coin_data["bb"]["12h"] + bb_low = Decimal(str(bb12["lower"])) + bb_high = Decimal(str(bb12["upper"])) + ma88 = Decimal(str(coin_data["ma"]["88"])) + + # Condition 2: Price inside BB 12h + if not (bb_low <= price <= bb_high): + logger.warning(f"⚖️ AUTO: Price {price:.2f} is outside BB 12h ({bb_low:.2f} - {bb_high:.2f}). Skipping AUTO.") + return None + + # Condition 3: MA 88 inside BB 12h + if not (bb_low <= ma88 <= bb_high): + logger.warning(f"⚖️ AUTO: MA 88 {ma88:.2f} is outside BB 12h. Skipping AUTO.") + return None + + # Calculation: Max distance to BB edge + dist_low = abs(price - bb_low) + dist_high = abs(price - bb_high) + max_dist = max(dist_low, dist_high) + + range_pct = max_dist / price + return range_pct + + except (KeyError, TypeError, ValueError) as e: + logger.error(f"Error in dynamic range calc: {e}") + return None + # --- CONFIGURATION CONSTANTS --- NONFUNGIBLE_POSITION_MANAGER_ADDRESS = CONFIG["NPM_ADDRESS"] +# Router address not strictly needed for Manager if using universal_swapper, but kept for ref UNISWAP_V3_SWAP_ROUTER_ADDRESS = CONFIG["ROUTER_ADDRESS"] # Arbitrum WETH/USDC (or generic T0/T1) WETH_ADDRESS = CONFIG["WRAPPED_NATIVE_ADDRESS"] @@ -311,7 +346,8 @@ def get_position_details(w3: Web3, npm_contract, factory_contract, token_id: int if pool_address == '0x0000000000000000000000000000000000000000': return None, None - pool_contract = w3.eth.contract(address=pool_address, abi=UNISWAP_V3_POOL_ABI) + pool_abi = AERODROME_POOL_ABI if "AERODROME" in CONFIG.get("NAME", "").upper() else UNISWAP_V3_POOL_ABI + pool_contract = w3.eth.contract(address=pool_address, abi=pool_abi) return { "token0_address": token0_address, "token1_address": token1_address, @@ -397,6 +433,7 @@ def ensure_allowance(w3: Web3, account: LocalAccount, token_address: str, spende def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount, token0: str, token1: str, amount0_needed: int, amount1_needed: int, sqrt_price_x96: int, d0: int, d1: int) -> bool: """ Checks balances, wraps ETH if needed, and swaps ONLY the required surplus to meet deposit requirements. + Uses universal_swapper for the swap execution. """ token0 = clean_address(token0) token1 = clean_address(token1) @@ -444,12 +481,12 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount, # Price of Token0 in terms of Token1 price_0_in_1 = price_from_sqrt_price_x96(sqrt_price_x96, d0, d1) - swap_call = None - token_in, token_out = None, None - amount_in = 0 + chain_name = DEX_TO_CHAIN.get(os.environ.get("TARGET_DEX", "UNISWAP_V3"), "ARBITRUM") - buffer_multiplier = Decimal("1.02") # 2% buffer for slippage/price moves + token_in_sym, token_out_sym = None, None + amount_in_float = 0.0 + buffer_multiplier = Decimal("1.03") if deficit0 > 0 and bal1 > amount1_needed: # Need T0 (ETH), Have extra T1 (USDC) # Swap T1 -> T0 @@ -462,8 +499,11 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount, surplus1 = bal1 - amount1_needed if surplus1 >= amount_in_needed: - token_in, token_out = token1, token0 - amount_in = amount_in_needed + # Get Symbols + token_in_sym = token1_c.functions.symbol().call().upper() + token_out_sym = token0_c.functions.symbol().call().upper() + amount_in_float = float(Decimal(amount_in_needed) / Decimal(10**d1)) + logger.info(f"🧮 Calc: Need {deficit0} T0. Cost ~{amount_in_needed} T1. Surplus: {surplus1}") else: logger.warning(f"❌ Insufficient Surplus T1. Need {amount_in_needed}, Have {surplus1}") @@ -479,49 +519,48 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount, surplus0 = bal0 - amount0_needed if surplus0 >= amount_in_needed: - token_in, token_out = token0, token1 - amount_in = amount_in_needed + token_in_sym = token0_c.functions.symbol().call().upper() + token_out_sym = token1_c.functions.symbol().call().upper() + amount_in_float = float(Decimal(amount_in_needed) / Decimal(10**d0)) + logger.info(f"🧮 Calc: Need {deficit1} T1. Cost ~{amount_in_needed} T0. Surplus: {surplus0}") else: logger.warning(f"❌ Insufficient Surplus T0. Need {amount_in_needed}, Have {surplus0}") - if token_in and amount_in > 0: - logger.info(f"🔄 Swapping {amount_in} {token_in} to cover deficit...") - - if not ensure_allowance(w3, account, token_in, UNISWAP_V3_SWAP_ROUTER_ADDRESS, amount_in): - return False + if token_in_sym and amount_in_float > 0: + logger.info(f"🔄 Delegating Swap to Universal Swapper: {amount_in_float} {token_in_sym} -> {token_out_sym} on {chain_name}...") + try: + # Use Standard Fee (500) if configured fee is weird (like 1 for Aerodrome tickSpacing) + # This ensures the standard router finds a valid pool (WETH/USDC 0.05%) + swap_fee = POOL_FEE if POOL_FEE >= 100 else 500 - params = ( - token_in, token_out, POOL_FEE, account.address, - int(time.time()) + 120, - amount_in, - 0, # amountOutMin (Market swap for rebalance) - 0 - ) - - receipt = send_transaction_robust(w3, account, router_contract.functions.exactInputSingle(params), extra_msg="Swap Surplus") - if receipt: - # Final check - Recursive check to ensure we hit target or retry - # But return True/False based on immediate check - bal0 = token0_c.functions.balanceOf(account.address).call() - bal1 = token1_c.functions.balanceOf(account.address).call() - # If we are strictly >= needed, great. - if bal0 >= amount0_needed and bal1 >= amount1_needed: - return True - else: - logger.warning(f"⚠️ Swap executed but still short? T0: {bal0}/{amount0_needed}, T1: {bal1}/{amount1_needed}") - return False + # Call Universal Swapper + execute_swap(chain_name, token_in_sym, token_out_sym, amount_in_float, fee_tier=swap_fee) + + # Wait for node indexing + logger.info("⏳ Waiting for balance update...") + time.sleep(2) + + # Retry check loop + for i in range(3): + bal0 = token0_c.functions.balanceOf(account.address).call() + bal1 = token1_c.functions.balanceOf(account.address).call() - # Human-readable format - f_bal0 = Decimal(bal0) / Decimal(10**d0) - f_need0 = Decimal(amount0_needed) / Decimal(10**d0) - f_bal1 = Decimal(bal1) / Decimal(10**d1) - f_need1 = Decimal(amount1_needed) / Decimal(10**d1) - - tgt_inv = CONFIG.get("TARGET_INVESTMENT_AMOUNT", "N/A") - range_w = CONFIG.get("RANGE_WIDTH_PCT", "N/A") - - logger.warning(f"❌ Insufficient funds. Settings: [Target=${tgt_inv}, Range={range_w}]. Wallet: T0: {f_bal0:.4f} / {f_need0:.4f}, T1: {f_bal1:.4f} / {f_need1:.4f}") + if bal0 >= amount0_needed and bal1 >= amount1_needed: + logger.info("✅ Balances sufficient.") + return True + + if i < 2: + logger.info(f"⏳ Balance not updated yet, retrying ({i+1}/3)...") + time.sleep(2) + + logger.warning(f"⚠️ Swap executed but still short? T0: {bal0}/{amount0_needed}, T1: {bal1}/{amount1_needed}") + return False + except Exception as e: + logger.error(f"❌ Universal Swap Failed: {e}") + return False + + logger.warning(f"❌ Insufficient funds (No suitable swap found). T0: {bal0}/{amount0_needed}, T1: {bal1}/{amount1_needed}") return False def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str, token1: str, amount0: int, amount1: int, tick_lower: int, tick_upper: int, d0: int, d1: int) -> Optional[Dict]: @@ -540,14 +579,20 @@ def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str amount1_min = int(Decimal(amount1) * (Decimal(1) - SLIPPAGE_TOLERANCE)) # 3. Mint - params = ( + base_params = [ token0, token1, POOL_FEE, tick_lower, tick_upper, amount0, amount1, amount0_min, amount1_min, account.address, int(time.time()) + 180 - ) + ] + + # Aerodrome Slipstream expects sqrtPriceX96 as the last parameter + if "AERODROME" in os.environ.get("TARGET_DEX", "").upper(): + base_params.append(0) # sqrtPriceX96 + + params = tuple(base_params) receipt = send_transaction_robust(w3, account, npm_contract.functions.mint(params), extra_msg="Mint Position") @@ -705,9 +750,43 @@ def update_position_status(token_id: int, status: str, extra_data: Dict = {}): save_status_data(data) logger.info(f"💾 Updated Position {token_id} status to {status}") +import argparse +import requests + +# --- REAL-TIME ORACLE HELPER --- +def get_realtime_price(coin: str) -> Optional[Decimal]: + """Fetches current mid-price directly from Hyperliquid API (low latency).""" + try: + url = "https://api.hyperliquid.xyz/info" + response = requests.post(url, json={"type": "allMids"}, timeout=2) + if response.status_code == 200: + data = response.json() + # Hyperliquid symbols are usually clean (ETH, BNB) + # Map common variations just in case + target = coin.upper().replace("WETH", "ETH").replace("WBNB", "BNB") + + if target in data: + return Decimal(data[target]) + except Exception as e: + logger.warning(f"⚠️ Failed to fetch realtime Oracle price: {e}") + return None + # --- MAIN LOOP --- def main(): + # --- ARGUMENT PARSING --- + parser = argparse.ArgumentParser(description="Uniswap CLP Manager") + parser.add_argument("--force", type=float, help="Force open a position with specific range width (e.g., 0.75), ignoring AUTO safe checks.") + args = parser.parse_args() + + force_mode_active = False + force_width_pct = Decimal("0") + + if args.force: + force_mode_active = True + force_width_pct = Decimal(str(args.force)) / 100 # Convert 0.75 -> 0.0075 + logger.warning(f"🚨 FORCE MODE ACTIVE: Will bypass safe checks for FIRST position with width {args.force}%") + logger.info(f"🔷 {CONFIG['NAME']} Manager V2 Starting...") load_dotenv(override=True) @@ -731,22 +810,34 @@ def main(): logger.info(f"👤 Wallet: {account.address}") # Contracts - npm = w3.eth.contract(address=clean_address(NONFUNGIBLE_POSITION_MANAGER_ADDRESS), abi=NONFUNGIBLE_POSITION_MANAGER_ABI) + target_dex_name = os.environ.get("TARGET_DEX", "").upper() + if "AERODROME" in target_dex_name or "AERODROME" in CONFIG.get("NAME", "").upper(): + logger.info("✈️ Using Aerodrome NPM ABI") + npm = w3.eth.contract(address=clean_address(NONFUNGIBLE_POSITION_MANAGER_ADDRESS), abi=AERODROME_NPM_ABI) + else: + npm = w3.eth.contract(address=clean_address(NONFUNGIBLE_POSITION_MANAGER_ADDRESS), abi=NONFUNGIBLE_POSITION_MANAGER_ABI) + factory_addr = npm.functions.factory().call() - factory = w3.eth.contract(address=factory_addr, abi=UNISWAP_V3_FACTORY_ABI) + + # Select Factory ABI based on DEX type + if "AERODROME" in target_dex_name or "AERODROME" in CONFIG.get("NAME", "").upper(): + logger.info("✈️ Using Aerodrome Factory ABI (tickSpacing instead of fee)") + factory_abi = AERODROME_FACTORY_ABI + else: + factory_abi = UNISWAP_V3_FACTORY_ABI + + factory = w3.eth.contract(address=factory_addr, abi=factory_abi) router = w3.eth.contract(address=clean_address(UNISWAP_V3_SWAP_ROUTER_ADDRESS), abi=SWAP_ROUTER_ABI) while True: try: status_data = load_status_data() - # Include CLOSING status to ensure we finish what we started (fee collection retries) - open_positions = [p for p in status_data if p.get('status') in ['OPEN', 'CLOSING']] + open_positions = [p for p in status_data if p.get('status') == 'OPEN'] active_auto_pos = next((p for p in open_positions if p.get('type') == 'AUTOMATIC'), None) if active_auto_pos: token_id = active_auto_pos['token_id'] - current_status = active_auto_pos.get('status') pos_details, pool_c = get_position_details(w3, npm, factory, token_id) if pos_details: @@ -834,7 +925,15 @@ def main(): "clp_TotPnL": round(float(total_pnl_usd), 2) }) - pnl_text = f" | TotPnL: ${total_pnl_usd:.2f} (Fees: ${total_fees_usd:.2f})" + # Calculate Fees/h + fees_per_h_str = "0.00" + ts_open = active_auto_pos.get('timestamp_open') + if ts_open: + hours_open = (time.time() - ts_open) / 3600 + if hours_open > 0.01: + fees_per_h_str = f"{float(total_fees_usd) / hours_open:.2f}" + + pnl_text = f" | TotPnL: ${total_pnl_usd:.2f} (Fees: ${total_fees_usd:.2f} | ${fees_per_h_str}/h)" logger.info(f"Position {token_id}: {status_msg} | Price: {current_price:.4f} [{lower_price:.4f} - {upper_price:.4f}]{pnl_text}") # --- KPI LOGGING --- @@ -853,33 +952,55 @@ def main(): 'hedge_pnl_realized_usd': active_auto_pos.get('hedge_pnl_realized', 0.0), 'hedge_fees_paid_usd': active_auto_pos.get('hedge_fees_paid', 0.0) } + # We use 'target_value' as a proxy for 'Initial Hedge Equity' + 'Initial Uni Val' if strictly tracking strategy? + # For now, let's pass what we have. + # To get 'hedge_equity', we ideally need clp_hedger to write it to JSON. + # Current implementation of kpi_tracker uses 'hedge_equity' in NAV. + # If we leave it 0, NAV will be underreported. + # WORKAROUND: Assume Hedge PnL Realized IS the equity change if we ignore margin. + log_kpi_snapshot(snapshot) - # --- CLOSING LOGIC --- - if current_status == "CLOSING" or (not in_range and CLOSE_POSITION_ENABLED): - if current_status != "CLOSING": - logger.warning(f"🛑 Closing Position {token_id} (Out of Range)") - update_position_status(token_id, "CLOSING") + # --- REPOSITION LOGIC --- + pos_range_mode = active_auto_pos.get("range_mode", RANGE_MODE) + + if pos_range_mode == "AUTO" and CLOSE_POSITION_ENABLED: + coin_for_dynamic = pos_details['token0_symbol'] if not is_t0_stable else pos_details['token1_symbol'] + new_range_width = calculate_dynamic_range_pct(coin_for_dynamic) - # 1. Remove Liquidity (if any left) - liq_to_remove = pos_details['liquidity'] - success_liq = True - if liq_to_remove > 0: - success_liq = decrease_liquidity(w3, npm, account, token_id, liq_to_remove, pos_details['token0_decimals'], pos_details['token1_decimals']) - - # 2. Collect Fees (Retry if previous attempt failed or if liquidity was just removed) - if success_liq: - if collect_fees(w3, npm, account, token_id): - update_position_status(token_id, "CLOSED") - - # 3. Optional Rebalance (Sell 50% WETH if fell below) - if REBALANCE_ON_CLOSE_BELOW_RANGE and current_tick < tick_lower: - # Simple rebalance logic here (similar to original check_and_swap surplus logic) - pass - else: - logger.error(f"❌ Fee collection failed for {token_id}. Will retry in next loop.") + if new_range_width: + # Use initial width from JSON, or current config width as fallback + old_range_width = Decimal(str(active_auto_pos.get("range_width_initial", RANGE_WIDTH_PCT))) + + # Condition A: Difference > 20% + width_diff_pct = abs(new_range_width - old_range_width) / old_range_width + + # Condition B: Profit > 0.1% + profit_pct = total_pnl_usd / initial_value + + logger.info(f"📊 AUTO Check: CurRange {old_range_width*100:.2f}%, NewRange {new_range_width*100:.2f}% | Diff {width_diff_pct*100:.1f}% | Profit {profit_pct*100:.2f}%") + + if width_diff_pct > 0.20 and profit_pct > 0.001: + logger.warning(f"🔄 REPOSITION TRIGGERED: Width Diff {width_diff_pct*100:.1f}%, Profit {profit_pct*100:.2f}%") + # Set in_range to False to force the closing logic below + in_range = False else: - logger.error(f"❌ Liquidity removal failed for {token_id}. Will retry in next loop.") + logger.warning(f"⚖️ AUTO Check Skipped: Market indicators for {coin_for_dynamic} are stale or conditions not met.") + + if not in_range and CLOSE_POSITION_ENABLED: + logger.warning(f"🛑 Closing Position {token_id} (Out of Range)") + update_position_status(token_id, "CLOSING") + + # 1. Remove Liquidity + if decrease_liquidity(w3, npm, account, token_id, pos_details['liquidity'], pos_details['token0_decimals'], pos_details['token1_decimals']): + # 2. Collect Fees + collect_fees(w3, npm, account, token_id) + update_position_status(token_id, "CLOSED") + + # 3. Optional Rebalance (Sell 50% WETH if fell below) + if REBALANCE_ON_CLOSE_BELOW_RANGE and current_tick < tick_lower: + # Simple rebalance logic here (similar to original check_and_swap surplus logic) + pass elif OPEN_POSITION_ENABLED: logger.info("🔍 No active position. Analyzing market (Fast scan: 37s)...") @@ -896,14 +1017,73 @@ def main(): fee = POOL_FEE pool_addr = factory.functions.getPool(token0, token1, fee).call() - pool_c = w3.eth.contract(address=pool_addr, abi=UNISWAP_V3_POOL_ABI) + pool_abi = AERODROME_POOL_ABI if "AERODROME" in CONFIG.get("NAME", "").upper() else UNISWAP_V3_POOL_ABI + pool_c = w3.eth.contract(address=pool_addr, abi=pool_abi) pool_data = get_pool_dynamic_data(pool_c) if pool_data: tick = pool_data['tick'] - # Define Range (+/- 2.5%) - # log(1.025) / log(1.0001) approx 247 tick delta - tick_delta = int(math.log(1 + float(RANGE_WIDTH_PCT)) / math.log(1.0001)) + + # --- PRE-CALCULATE ESSENTIALS --- + # Fetch Decimals & Symbols immediately (Required for Oracle Check) + t0_c = w3.eth.contract(address=token0, abi=ERC20_ABI) + t1_c = w3.eth.contract(address=token1, abi=ERC20_ABI) + d0 = t0_c.functions.decimals().call() + d1 = t1_c.functions.decimals().call() + + t0_sym = t0_c.functions.symbol().call().upper() + t1_sym = t1_c.functions.symbol().call().upper() + stable_symbols = ["USDC", "USDT", "DAI", "FDUSD", "USDS"] + + is_t1_stable = any(s in t1_sym for s in stable_symbols) + is_t0_stable = any(s in t0_sym for s in stable_symbols) + + price_0_in_1 = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1) + + # Define coin_sym early for Guard Rails + coin_sym = CONFIG.get("COIN_SYMBOL", "ETH") + + # --- ORACLE GUARD RAIL --- + # Protect against Pool/Oracle divergence (Manipulation/Depeg/Lag) + if not force_mode_active: + oracle_price = get_realtime_price(coin_sym) + + if oracle_price: + pool_price_dec = price_0_in_1 if is_t1_stable else (Decimal("1") / price_0_in_1) + divergence = abs(pool_price_dec - oracle_price) / oracle_price + + if divergence > Decimal("0.0025"): # 0.25% Tolerance + logger.warning(f"⚠️ Price Divergence! Pool: {pool_price_dec:.2f} vs Oracle: {oracle_price:.2f} (Diff: {divergence*100:.2f}%). Aborting.") + time.sleep(10) + continue + else: + logger.warning("⚠️ Could not fetch Oracle price. Proceeding with caution (or consider aborting).") + + # --- DYNAMIC RANGE CALCULATION --- + active_range_width = RANGE_WIDTH_PCT + current_range_mode = RANGE_MODE + + # 1. PRIORITY: Force Mode + if force_mode_active: + logger.warning(f"🚨 FORCE OVERRIDE: Using forced width {force_width_pct*100:.2f}% (Ignoring safe checks)") + active_range_width = force_width_pct + current_range_mode = "FIXED" + + # 2. AUTO Mode (Only if not forced) + elif RANGE_MODE == "AUTO": + dynamic_width = calculate_dynamic_range_pct(coin_sym) + if dynamic_width: + active_range_width = dynamic_width + logger.info(f"⚖️ AUTO Range Activated: {active_range_width*100:.4f}%") + else: + logger.info(f"⛔ AUTO conditions not met. Waiting for safe entry...") + time.sleep(MONITOR_INTERVAL_SECONDS) + continue # Skip logic + + # 3. FIXED Mode (Default Fallback) is already set by initial active_range_width + + # Define Range + tick_delta = int(math.log(1 + float(active_range_width)) / math.log(1.0001)) # Fetch actual tick spacing from pool tick_spacing = pool_c.functions.tickSpacing().call() @@ -914,28 +1094,10 @@ def main(): # Calculate Amounts # Target Value logic - d0 = 18 # Default WETH (Corrected below if needed, but we rely on raw logic) - # Actually, we should fetch decimals from contract to be safe, but config assumes standard. - # Fetch Decimals for precision - t0_c = w3.eth.contract(address=token0, abi=ERC20_ABI) - t1_c = w3.eth.contract(address=token1, abi=ERC20_ABI) - d0 = t0_c.functions.decimals().call() - d1 = t1_c.functions.decimals().call() - # Determine Investment Value in Token1 terms target_usd = Decimal(str(TARGET_INVESTMENT_VALUE_USDC)) - # Check which is stable - t0_sym = t0_c.functions.symbol().call().upper() - t1_sym = t1_c.functions.symbol().call().upper() - stable_symbols = ["USDC", "USDT", "DAI", "FDUSD", "USDS"] - - is_t1_stable = any(s in t1_sym for s in stable_symbols) - is_t0_stable = any(s in t0_sym for s in stable_symbols) - - price_0_in_1 = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1) - investment_val_token1 = Decimal("0") if str(TARGET_INVESTMENT_VALUE_USDC).upper() == "MAX": @@ -960,11 +1122,32 @@ def main(): amt0, amt1 = calculate_mint_amounts(tick, tick_lower, tick_upper, investment_val_token1, d0, d1, pool_data['sqrtPriceX96']) if check_and_swap_for_deposit(w3, router, account, token0, token1, amt0, amt1, pool_data['sqrtPriceX96'], d0, d1): + # --- STALE DATA PROTECTION (Pre-Mint) --- + # Check if price moved significantly during calculation/swap + pre_mint_data = get_pool_dynamic_data(pool_c) + if pre_mint_data: + tick_diff = abs(pre_mint_data['tick'] - pool_data['tick']) + # 13 ticks ~ 0.13% price move. Abort if volatile. + if tick_diff > 13: + logger.warning(f"⚠️ Price moved too much ({tick_diff} ticks) during setup/swap. Aborting mint to prevent bad entry.") + time.sleep(5) + continue + minted = mint_new_position(w3, npm, account, token0, token1, amt0, amt1, tick_lower, tick_upper, d0, d1) if minted: - # Calculate entry price from TICK to ensure consistency with Range - # (SqrtPrice can sometimes slightly diverge or have precision artifacts) - price_0_in_1 = price_from_tick(pool_data['tick'], d0, d1) + # --- DISABLE FORCE MODE AFTER FIRST MINT --- + if force_mode_active: + logger.info("🛑 FORCE MODE CONSUMED: Returning to standard AUTO checks for future positions.") + force_mode_active = False + + # --- RE-FETCH PRICE FOR ACCURATE ENTRY DATA (Post-Mint) --- + fresh_pool_data = get_pool_dynamic_data(pool_c) + if fresh_pool_data: + fresh_tick = fresh_pool_data['tick'] + price_0_in_1 = price_from_tick(fresh_tick, d0, d1) + logger.info(f"🔄 Refreshed Entry Tick: {fresh_tick} (Was: {pool_data['tick']})") + else: + price_0_in_1 = price_from_tick(pool_data['tick'], d0, d1) fmt_amt0 = float(Decimal(minted['amount0']) / Decimal(10**d0)) fmt_amt1 = float(Decimal(minted['amount1']) / Decimal(10**d1)) @@ -993,6 +1176,8 @@ def main(): "range_lower": round(r_lower, 4), "token0_decimals": d0, "token1_decimals": d1, + "range_mode": current_range_mode, + "range_width_initial": float(active_range_width), "timestamp_open": int(time.time()), "time_open": datetime.now().strftime("%d.%m.%y %H:%M:%S") } diff --git a/doc/CLP_HEDGING_SIMULATIONS_2026.md b/doc/CLP_HEDGING_SIMULATIONS_2026.md new file mode 100644 index 0000000..a544067 --- /dev/null +++ b/doc/CLP_HEDGING_SIMULATIONS_2026.md @@ -0,0 +1,104 @@ +# CLP Hedging Strategy Matrix (Jan 2026) + +*Based on ETH/USDC 0.05% Arbitrum Pool Stats: TVL $74.53M, 24h Vol $23.27M.* +*Calculations include 0.045% round-trip Hyperliquid fees and concentration-adjusted Uniswap fees.* + +This document outlines the projected PnL, required hedge sizes, and estimated earnings for different CLP configurations, assuming a fixed hedge position is maintained from entry to exit. + +## Technical Assumptions & Methodology +1. **Transaction Costs:** Calculations include Hyperliquid fees (~0.045% round-trip: 0.01% Maker Entry + 0.035% Taker Exit). +2. **Uniswap Fees:** Est. Fees (1h) are calculated using the pool's volume/TVL ratio and a concentration multiplier based on the range width. +3. **Static Hedge:** Assumes the hedge position is opened at the exact same time as the CLP and is **never rebalanced** until the CLP is closed at the range boundary. +4. **Delta Calculation:** + * **Lower Edge:** Position becomes 100% ETH (Max Long Delta). + * **Upper Edge:** Position becomes 100% USDC (Zero Delta). + +--- + +## Capital: $1000 USDC +| Range | Strategy | Hedge Size | Margin (5x) | PnL Lower | PnL Upper | Est. Fees (1h) | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| +/- 0.5 % | Neutral Down | 0.2746 ETH | $167.94 | +0.00 | -3.40 | $1.30 | +| | Neutral Up | 0.0520 ETH | $31.81 | -3.40 | +0.00 | | +| | Fixed | 0.1631 ETH | $99.75 | -1.70 | -1.70 | | +| +/- 1.0 % | Neutral Down | 0.2598 ETH | $158.88 | -0.00 | -5.91 | $0.65 | +| | Neutral Up | 0.0664 ETH | $40.63 | -5.91 | +0.00 | | +| | Fixed | 0.1627 ETH | $99.50 | -2.97 | -2.94 | | +| +/- 2.0 % | Neutral Down | 0.2522 ETH | $154.26 | +0.00 | -10.95 | $0.33 | +| | Neutral Up | 0.0732 ETH | $44.76 | -10.95 | +0.00 | | +| | Fixed | 0.1619 ETH | $99.00 | -5.53 | -5.42 | | +| +/- 4.0 % | Neutral Down | 0.2482 ETH | $151.78 | +0.00 | -21.10 | $0.17 | +| | Neutral Up | 0.0757 ETH | $46.28 | -21.10 | +0.00 | | +| | Fixed | 0.1603 ETH | $98.02 | -10.75 | -10.35 | | +| +/- 5.0 % | Neutral Down | 0.2473 ETH | $151.22 | +0.00 | -26.21 | $0.13 | +| | Neutral Up | 0.0758 ETH | $46.37 | -26.21 | +0.00 | | +| | Fixed | 0.1595 ETH | $97.53 | -13.42 | -12.79 | | +| +/- 10.0 % | Neutral Down | 0.2450 ETH | $149.84 | +0.00 | -52.16 | $0.07 | +| | Neutral Up | 0.0744 ETH | $45.52 | -52.16 | +0.00 | | +| | Fixed | 0.1555 ETH | $95.12 | -27.36 | -24.80 | | + +## Capital: $2000 USDC +| Range | Strategy | Hedge Size | Margin (5x) | PnL Lower | PnL Upper | Est. Fees (1h) | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| +/- 0.5 % | Neutral Down | 0.5492 ETH | $335.88 | +0.00 | -6.81 | $2.61 | +| | Neutral Up | 0.1040 ETH | $63.63 | -6.81 | -0.00 | | +| | Fixed | 0.3262 ETH | $199.50 | -3.41 | -3.40 | | +| +/- 1.0 % | Neutral Down | 0.5196 ETH | $317.75 | +0.00 | -11.83 | $1.31 | +| | Neutral Up | 0.1329 ETH | $81.25 | -11.83 | -0.00 | | +| | Fixed | 0.3254 ETH | $199.00 | -5.94 | -5.89 | | +| +/- 2.0 % | Neutral Down | 0.5045 ETH | $308.52 | +0.00 | -21.90 | $0.66 | +| | Neutral Up | 0.1464 ETH | $89.51 | -21.90 | -0.00 | | +| | Fixed | 0.3238 ETH | $198.01 | -11.05 | -10.85 | | +| +/- 4.0 % | Neutral Down | 0.4964 ETH | $303.56 | +0.00 | -42.20 | $0.33 | +| | Neutral Up | 0.1514 ETH | $92.56 | -42.20 | +0.00 | | +| | Fixed | 0.3206 ETH | $196.04 | -21.50 | -20.70 | | +| +/- 5.0 % | Neutral Down | 0.4946 ETH | $302.44 | +0.00 | -52.43 | $0.27 | +| | Neutral Up | 0.1517 ETH | $92.74 | -52.43 | +0.00 | | +| | Fixed | 0.3190 ETH | $195.06 | -26.85 | -25.58 | | +| +/- 10.0 % | Neutral Down | 0.4900 ETH | $299.68 | +0.00 | -104.31 | $0.14 | +| | Neutral Up | 0.1489 ETH | $91.05 | -104.31 | +0.00 | | +| | Fixed | 0.3111 ETH | $190.23 | -54.72 | -49.59 | | + +## Capital: $4000 USDC +| Range | Strategy | Hedge Size | Margin (5x) | PnL Lower | PnL Upper | Est. Fees (1h) | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| +/- 0.5 % | Neutral Down | 1.0985 ETH | $671.75 | -0.00 | -13.61 | $5.22 | +| | Neutral Up | 0.2081 ETH | $127.25 | -13.61 | +0.00 | | +| | Fixed | 0.6525 ETH | $399.00 | -6.82 | -6.79 | | +| +/- 1.0 % | Neutral Down | 1.0392 ETH | $635.51 | -0.00 | -23.65 | $2.61 | +| | Neutral Up | 0.2657 ETH | $162.51 | -23.65 | +0.00 | | +| | Fixed | 0.6508 ETH | $398.00 | -11.88 | -11.77 | | +| +/- 2.0 % | Neutral Down | 1.0090 ETH | $617.03 | +0.00 | -43.80 | $1.31 | +| | Neutral Up | 0.2928 ETH | $179.03 | -43.80 | +0.00 | | +| | Fixed | 0.6476 ETH | $396.02 | -22.10 | -21.70 | | +| +/- 4.0 % | Neutral Down | 0.9928 ETH | $607.12 | +0.00 | -84.40 | $0.66 | +| | Neutral Up | 0.3027 ETH | $185.12 | -84.40 | +0.00 | | +| | Fixed | 0.6411 ETH | $392.08 | -43.01 | -41.39 | | +| +/- 5.0 % | Neutral Down | 0.9891 ETH | $604.89 | +0.00 | -104.85 | $0.53 | +| | Neutral Up | 0.3033 ETH | $185.48 | -104.85 | +0.00 | | +| | Fixed | 0.6379 ETH | $390.12 | -53.69 | -51.16 | | +| +/- 10.0 % | Neutral Down | 0.9801 ETH | $599.36 | +0.00 | -208.63 | $0.27 | +| | Neutral Up | 0.2978 ETH | $182.10 | -208.63 | +0.00 | | +| | Fixed | 0.6222 ETH | $380.46 | -109.45 | -99.18 | | + +## Capital: $8000 USDC +| Range | Strategy | Hedge Size | Margin (5x) | PnL Lower | PnL Upper | Est. Fees (1h) | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| +/- 0.5 % | Neutral Down | 2.1970 ETH | $1343.50 | +0.00 | -27.23 | $10.43 | +| | Neutral Up | 0.4162 ETH | $254.50 | -27.23 | +0.00 | | +| | Fixed | 1.3049 ETH | $798.00 | -13.64 | -13.59 | | +| +/- 1.0 % | Neutral Down | 2.0784 ETH | $1271.02 | -0.00 | -47.30 | $5.23 | +| | Neutral Up | 0.5315 ETH | $325.01 | -47.30 | +0.00 | | +| | Fixed | 1.3017 ETH | $796.01 | -23.75 | -23.55 | | +| +/- 2.0 % | Neutral Down | 2.0180 ETH | $1234.06 | +0.00 | -87.60 | $2.63 | +| | Neutral Up | 0.5855 ETH | $358.06 | -87.60 | +0.00 | | +| | Fixed | 1.2952 ETH | $792.04 | -44.20 | -43.40 | | +| +/- 4.0 % | Neutral Down | 1.9856 ETH | $1214.24 | +0.00 | -168.80 | $1.33 | +| | Neutral Up | 0.6054 ETH | $370.23 | -168.80 | -0.00 | | +| | Fixed | 1.2823 ETH | $784.16 | -86.02 | -82.78 | | +| +/- 5.0 % | Neutral Down | 1.9783 ETH | $1209.78 | +0.00 | -209.70 | $1.07 | +| | Neutral Up | 0.6066 ETH | $370.96 | -209.70 | -0.00 | | +| | Fixed | 1.2759 ETH | $780.24 | -107.38 | -102.32 | | +| +/- 10.0 % | Neutral Down | 1.9602 ETH | $1198.71 | +0.00 | -417.26 | $0.55 | +| | Neutral Up | 0.5956 ETH | $364.20 | -417.26 | +0.00 | | +| | Fixed | 1.2443 ETH | $760.93 | -218.89 | -198.36 | | \ No newline at end of file diff --git a/doc/CLP_HEDGING_SIMULATIONS_2026_GRID.md b/doc/CLP_HEDGING_SIMULATIONS_2026_GRID.md new file mode 100644 index 0000000..ea7b340 Binary files /dev/null and b/doc/CLP_HEDGING_SIMULATIONS_2026_GRID.md differ diff --git a/florida/AERODROME_BASE_CL_status.json b/florida/AERODROME_BASE_CL_status.json new file mode 100644 index 0000000..51e06a9 --- /dev/null +++ b/florida/AERODROME_BASE_CL_status.json @@ -0,0 +1,44 @@ +[ + { + "type": "AUTOMATIC", + "token_id": 41577981, + "status": "CLOSED", + "target_value": 99.99, + "entry_price": 3146.0405, + "amount0_initial": 0.0153, + "amount1_initial": 51.8762, + "liquidity": "36103333466890", + "range_upper": 3301.0555, + "range_lower": 2986.9335, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767561751, + "time_open": "04.01.26 22:22:31", + "clp_fees": 0.0, + "clp_TotPnL": -0.04 + }, + { + "type": "AUTOMATIC", + "token_id": 41584557, + "status": "CLOSED", + "target_value": 199.94, + "entry_price": 3147.2991, + "amount0_initial": 0.0307, + "amount1_initial": 103.2276, + "liquidity": "51814114093918", + "range_upper": 3367.7379, + "range_lower": 2927.7912, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767564277, + "time_open": "04.01.26 23:04:37", + "clp_fees": 0.39, + "clp_TotPnL": 1.98, + "hedge_TotPnL": -50.537316, + "hedge_fees_paid": 12.238471, + "combined_TotPnL": -62.76, + "hedge_HL_cost_est": 12.28, + "hedge_pnl_unrealized": -2.07, + "last_sync_hl": 1767685736 + } +] \ No newline at end of file diff --git a/florida/PANCAKESWAP_BNB_status.json b/florida/PANCAKESWAP_BNB_status.json index b8ce5c1..4d7fb7e 100644 --- a/florida/PANCAKESWAP_BNB_status.json +++ b/florida/PANCAKESWAP_BNB_status.json @@ -508,7 +508,7 @@ { "type": "AUTOMATIC", "token_id": 6176727, - "status": "OPEN", + "status": "CLOSED", "target_value": 990.64, "entry_price": 867.5401, "amount0_initial": 490.6325, @@ -520,9 +520,163 @@ "token1_decimals": 18, "timestamp_open": 1767336634, "time_open": "02.01.26 07:50:34", - "hedge_TotPnL": 0.241595, - "hedge_fees_paid": 0.364602, - "clp_fees": 0.22, - "clp_TotPnL": -0.73 + "hedge_TotPnL": -5.442897, + "hedge_fees_paid": 0.85406, + "clp_fees": 0.91, + "clp_TotPnL": 1.88, + "timestamp_close": 1767347410, + "time_close": "02.01.26 10:50:10" + }, + { + "type": "AUTOMATIC", + "token_id": 6177360, + "status": "CLOSED", + "target_value": 993.15, + "entry_price": 870.8428, + "amount0_initial": 493.1411, + "amount1_initial": 0.5742, + "liquidity": "8638221415835012765221", + "range_upper": 874.2456, + "range_lower": 867.4533, + "token0_decimals": 18, + "token1_decimals": 18, + "timestamp_open": 1767347607, + "time_open": "02.01.26 10:53:27", + "hedge_TotPnL": 1.294631, + "hedge_fees_paid": 1.047541, + "clp_fees": 1.24, + "clp_TotPnL": -1.66, + "timestamp_close": 1767363551, + "time_close": "02.01.26 15:19:11" + }, + { + "type": "AUTOMATIC", + "token_id": 6185008, + "status": "CLOSED", + "target_value": 992.5, + "entry_price": 867.1064, + "amount0_initial": 492.4924, + "amount1_initial": 0.5766, + "liquidity": "8651147821199061055073", + "range_upper": 870.4946, + "range_lower": 863.7315, + "token0_decimals": 18, + "token1_decimals": 18, + "timestamp_open": 1767363751, + "time_open": "02.01.26 15:22:31", + "hedge_TotPnL": -2.674848, + "hedge_fees_paid": 0.393713, + "clp_fees": 0.32, + "clp_TotPnL": 1.28, + "timestamp_close": 1767364835, + "time_close": "02.01.26 15:40:35" + }, + { + "type": "AUTOMATIC", + "token_id": 6185867, + "status": "CLOSED", + "target_value": 996.08, + "entry_price": 875.5579, + "amount0_initial": 496.0791, + "amount1_initial": 0.5711, + "liquidity": "8640396990671870185711", + "range_upper": 878.9791, + "range_lower": 872.15, + "token0_decimals": 18, + "token1_decimals": 18, + "timestamp_open": 1767365969, + "time_open": "02.01.26 15:59:29", + "hedge_TotPnL": 2.830618, + "hedge_fees_paid": 1.060949, + "clp_fees": 0.53, + "clp_TotPnL": -2.97, + "timestamp_close": 1767367303, + "time_close": "02.01.26 16:21:43" + }, + { + "type": "AUTOMATIC", + "token_id": 6186334, + "status": "CLOSED", + "target_value": 996.88, + "entry_price": 873.9834, + "amount0_initial": 496.8714, + "amount1_initial": 0.5721, + "liquidity": "8655088063104153413073", + "range_upper": 877.3984, + "range_lower": 870.5816, + "token0_decimals": 18, + "token1_decimals": 18, + "timestamp_open": 1767367883, + "time_open": "02.01.26 16:31:23", + "hedge_TotPnL": -3.585575, + "hedge_fees_paid": 0.57548, + "clp_fees": 0.13, + "clp_TotPnL": 1.1, + "timestamp_close": 1767368416, + "time_close": "02.01.26 16:40:16" + }, + { + "type": "AUTOMATIC", + "token_id": 6186613, + "status": "CLOSED", + "target_value": 993.42, + "entry_price": 880.2984, + "amount0_initial": 493.4134, + "amount1_initial": 0.568, + "liquidity": "8594082766621558309079", + "range_upper": 883.7381, + "range_lower": 876.8721, + "token0_decimals": 18, + "token1_decimals": 18, + "timestamp_open": 1767368652, + "time_open": "02.01.26 16:44:12", + "hedge_TotPnL": -4.384157, + "hedge_fees_paid": 0.627319, + "clp_fees": 0.92, + "clp_TotPnL": 1.89, + "timestamp_close": 1767371545, + "time_close": "02.01.26 17:32:25" + }, + { + "type": "AUTOMATIC", + "token_id": 6187096, + "status": "CLOSED", + "target_value": 996.91, + "entry_price": 885.9501, + "amount0_initial": 496.8996, + "amount1_initial": 0.5644, + "liquidity": "2271526539550158344821", + "range_upper": 899.159, + "range_lower": 872.9353, + "token0_decimals": 18, + "token1_decimals": 18, + "timestamp_open": 1767372001, + "time_open": "02.01.26 17:40:01", + "hedge_TotPnL": 7.60386, + "hedge_fees_paid": 0.283695, + "clp_fees": 1.38, + "clp_TotPnL": -9.61, + "timestamp_close": 1767424468, + "time_close": "03.01.26 08:14:28" + }, + { + "type": "AUTOMATIC", + "token_id": 6203530, + "status": "CLOSED", + "target_value": 998.23, + "entry_price": 872.0628, + "amount0_initial": 500.0027, + "amount1_initial": 0.5713, + "liquidity": "2292568457223553397610", + "range_upper": 885.0647, + "range_lower": 859.252, + "token0_decimals": 18, + "token1_decimals": 18, + "timestamp_open": 1767424756, + "time_open": "03.01.26 08:19:16", + "hedge_TotPnL": -2.47676, + "hedge_fees_paid": 0.287962, + "clp_fees": 0.19, + "clp_TotPnL": 1.92 } ] \ No newline at end of file diff --git a/florida/UNISWAP_V3_status.json b/florida/UNISWAP_V3_status.json index d7d240f..0a9e284 100644 --- a/florida/UNISWAP_V3_status.json +++ b/florida/UNISWAP_V3_status.json @@ -288,7 +288,7 @@ { "type": "AUTOMATIC", "token_id": 5182179, - "status": "OPEN", + "status": "CLOSED", "target_value": 1993.84, "entry_price": 2969.9855, "amount0_initial": 0.3347, @@ -299,9 +299,607 @@ "token0_decimals": 18, "token1_decimals": 6, "timestamp_open": 1766968369, - "hedge_TotPnL": -11.871298, - "hedge_fees_paid": 2.122534, - "clp_fees": 18.4, - "clp_TotPnL": 30.89 + "hedge_TotPnL": -62.166433, + "hedge_fees_paid": 2.587208, + "clp_fees": 23.23, + "clp_TotPnL": 47.64, + "timestamp_close": 1767371501, + "time_close": "02.01.26 17:31:41" + }, + { + "type": "AUTOMATIC", + "token_id": 5190205, + "status": "CLOSED", + "target_value": 1990.81, + "entry_price": 3136.9306, + "amount0_initial": 0.2991, + "amount1_initial": 1052.4842, + "liquidity": "3750669047237424", + "range_upper": 3165.289, + "range_lower": 3105.7192, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767371848, + "time_open": "02.01.26 17:37:28", + "hedge_TotPnL": 9.40878, + "hedge_fees_paid": 0.538036, + "clp_fees": 6.41, + "clp_TotPnL": -9.78, + "timestamp_close": 1767379841, + "time_close": "02.01.26 19:50:41" + }, + { + "type": "AUTOMATIC", + "token_id": 5190713, + "status": "CLOSED", + "target_value": 995.94, + "entry_price": 3103.8564, + "amount0_initial": 0.1562, + "amount1_initial": 511.0986, + "liquidity": "610494695009033", + "range_upper": 3193.9038, + "range_lower": 3010.9236, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767379916, + "time_open": "02.01.26 19:51:56", + "hedge_TotPnL": 0.4043, + "hedge_fees_paid": 0.277557, + "clp_fees": 1.83, + "clp_TotPnL": 0.77 + }, + { + "type": "AUTOMATIC", + "token_id": 5191754, + "status": "CLOSED", + "target_value": 2986.13, + "entry_price": 3095.7973, + "amount0_initial": 0.4577, + "amount1_initial": 1569.095, + "liquidity": "8270626895418999", + "range_upper": 3115.0499, + "range_lower": 3074.8183, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767430984, + "time_open": "03.01.26 10:03:04", + "hedge_TotPnL": -10.166, + "hedge_fees_paid": 7.014425, + "clp_fees": 8.24, + "clp_TotPnL": 12.66, + "timestamp_close": 1767475160, + "time_close": "03.01.26 22:19:20" + }, + { + "type": "AUTOMATIC", + "token_id": 5192638, + "status": "CLOSED", + "target_value": 2994.04, + "entry_price": 3125.0335, + "amount0_initial": 0.46, + "amount1_initial": 1559.9, + "range_upper": 3140.069, + "range_lower": 3108.8263, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767482267, + "time_open": "04.01.26 00:17:47", + "clp_fees": 2998.49, + "clp_TotPnL": 4.45, + "hedge_TotPnL": -9.78996, + "hedge_fees_paid": 0.868435, + "timestamp_close": 1767485229, + "time_close": "04.01.26 01:07:09" + }, + { + "type": "AUTOMATIC", + "token_id": 5192707, + "status": "CLOSED", + "target_value": 2999.37, + "entry_price": 3146.0405, + "amount0_initial": 0.4767, + "amount1_initial": 1500.0, + "range_upper": 3158.9651, + "range_lower": 3130.6634, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767485383, + "time_open": "04.01.26 01:09:43", + "clp_fees": 4.35, + "clp_TotPnL": -0.63, + "timestamp_close": 1767488126, + "time_close": "04.01.26 01:55:26" + }, + { + "type": "AUTOMATIC", + "token_id": 5192787, + "status": "CLOSED", + "target_value": 2989.41, + "entry_price": 3158.9651, + "amount0_initial": 0.4715, + "amount1_initial": 1500.0, + "range_upper": 3171.6256, + "range_lower": 3143.2105, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767488200, + "time_open": "04.01.26 01:56:40", + "clp_fees": 2.57, + "clp_TotPnL": -10.59, + "hedge_TotPnL": 3.058716, + "hedge_fees_paid": 3.334872, + "timestamp_close": 1767491374, + "time_close": "04.01.26 02:49:34" + }, + { + "type": "AUTOMATIC", + "token_id": 5192872, + "status": "CLOSED", + "target_value": 2974.66, + "entry_price": 3142.8962, + "amount0_initial": 0.4692, + "amount1_initial": 1500.0, + "range_upper": 3155.8079, + "range_lower": 3127.5344, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767491446, + "time_open": "04.01.26 02:50:46", + "clp_fees": 6.14, + "clp_TotPnL": 25.34, + "hedge_TotPnL": -1.09566, + "hedge_fees_paid": 3.319216, + "timestamp_close": 1767507012, + "time_close": "04.01.26 07:10:12" + }, + { + "type": "AUTOMATIC", + "token_id": 5193076, + "status": "CLOSED", + "target_value": 2927.37, + "entry_price": 3155.8079, + "amount0_initial": 0.4522, + "amount1_initial": 1500.0, + "range_upper": 3168.4557, + "range_lower": 3140.069, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767507083, + "time_open": "04.01.26 07:11:23", + "clp_fees": 2.91, + "clp_TotPnL": -36.11, + "hedge_TotPnL": 0.0, + "hedge_fees_paid": 1.655925, + "timestamp_close": 1767511297, + "time_close": "04.01.26 08:21:37" + }, + { + "type": "AUTOMATIC", + "token_id": 5193158, + "status": "CLOSED", + "target_value": 2970.4, + "entry_price": 3139.755, + "amount0_initial": 0.477, + "amount1_initial": 1500.0, + "range_upper": 3152.6538, + "range_lower": 3124.4086, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767511369, + "time_open": "04.01.26 08:22:49", + "clp_fees": 11.2, + "clp_TotPnL": 0.27, + "hedge_TotPnL": -1.76453, + "hedge_fees_paid": 1.292159, + "timestamp_close": 1767541575, + "time_close": "04.01.26 16:46:15" + }, + { + "type": "AUTOMATIC", + "token_id": 5193699, + "status": "CLOSED", + "target_value": 2985.65, + "entry_price": 3124.0962, + "amount0_initial": 0.4461, + "amount1_initial": 1592.1238, + "liquidity": "8231768660184301", + "range_upper": 3143.2105, + "range_lower": 3102.6152, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767541587, + "time_open": "04.01.26 16:46:27", + "clp_fees": 5.59, + "clp_TotPnL": 9.87, + "hedge_TotPnL": -2.74009, + "hedge_fees_paid": 3.283603, + "timestamp_close": 1767561023, + "time_close": "04.01.26 22:10:23" + }, + { + "type": "AUTOMATIC", + "token_id": 5194064, + "status": "CLOSED", + "target_value": 2995.07, + "entry_price": 3149.8178, + "amount0_initial": 0.4308, + "amount1_initial": 1638.2123, + "liquidity": "8224032596348534", + "range_upper": 3168.4557, + "range_lower": 3127.5344, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767561335, + "time_open": "04.01.26 22:15:35", + "hedge_TotPnL": -10.8216, + "hedge_fees_paid": 0.744327, + "clp_fees": 10.96, + "clp_TotPnL": 14.98, + "timestamp_close": 1767574703, + "time_close": "05.01.26 01:58:23" + }, + { + "type": "AUTOMATIC", + "token_id": 5194353, + "status": "CLOSED", + "target_value": 2986.84, + "entry_price": 3167.1887, + "amount0_initial": 0.4622, + "amount1_initial": 1523.0178, + "liquidity": "8178792616583388", + "range_upper": 3187.5227, + "range_lower": 3146.3551, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767575103, + "time_open": "05.01.26 02:05:03", + "hedge_TotPnL": -11.72105, + "hedge_fees_paid": 0.851074, + "clp_fees": 1.32, + "clp_TotPnL": 6.03, + "timestamp_close": 1767575748, + "time_close": "05.01.26 02:15:48" + }, + { + "type": "AUTOMATIC", + "token_id": 5194438, + "status": "CLOSED", + "target_value": 2994.26, + "entry_price": 3182.7452, + "amount0_initial": 0.4695, + "amount1_initial": 1499.9963, + "liquidity": "8179052347416944", + "range_upper": 3203.4994, + "range_lower": 3162.1255, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767576062, + "time_open": "05.01.26 02:21:02", + "hedge_TotPnL": -13.130776, + "hedge_fees_paid": 0.866544, + "clp_fees": 2.0, + "clp_TotPnL": 6.88, + "timestamp_close": 1767577312, + "time_close": "05.01.26 02:41:52" + }, + { + "type": "AUTOMATIC", + "token_id": 5194549, + "status": "CLOSED", + "target_value": 2993.07, + "entry_price": 3209.2706, + "amount0_initial": 0.4437, + "amount1_initial": 1569.0894, + "liquidity": "8141974814554655", + "range_upper": 3229.2289, + "range_lower": 3187.5227, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767577667, + "time_open": "05.01.26 02:47:47", + "hedge_TotPnL": 9.143032, + "hedge_fees_paid": 0.817659, + "clp_fees": 1.68, + "clp_TotPnL": -14.2, + "timestamp_close": 1767578919, + "time_close": "05.01.26 03:08:39" + }, + { + "type": "AUTOMATIC", + "token_id": 5194636, + "status": "CLOSED", + "target_value": 2999.61, + "entry_price": 3191.6689, + "amount0_initial": 0.4122, + "amount1_initial": 1683.9338, + "liquidity": "8182395676682951", + "range_upper": 3209.9125, + "range_lower": 3168.4557, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767579235, + "time_open": "05.01.26 03:13:55", + "hedge_TotPnL": 12.194039, + "hedge_fees_paid": 0.752756, + "clp_fees": 4.31, + "clp_TotPnL": -14.67, + "timestamp_close": 1767587437, + "time_close": "05.01.26 05:30:37" + }, + { + "type": "AUTOMATIC", + "token_id": 5194825, + "status": "CLOSED", + "target_value": 2968.38, + "entry_price": 3162.4417, + "amount0_initial": 0.4206, + "amount1_initial": 1638.1774, + "liquidity": "8134444867821814", + "range_upper": 3181.1543, + "range_lower": 3140.069, + "token0_decimals": 18, + "token1_decimals": 6, + "timestamp_open": 1767587750, + "time_open": "05.01.26 05:35:50", + "hedge_TotPnL": -0.1704, + "hedge_fees_paid": 1.553601, + "clp_fees": 15.16, + "clp_TotPnL": 10.5, + "combined_TotPnL": 13.22, + "hedge_HL_cost_est": 0.77, + "hedge_pnl_unrealized": 4.3, + "last_sync_hl": 1767622049, + "timestamp_close": 1767622037, + "time_close": "05.01.26 15:07:17" + }, + { + "type": "AUTOMATIC", + "token_id": 5195733, + "status": "CLOSED", + "target_value": 2984.65, + "entry_price": 3150.7629, + "amount0_initial": 0.4341, + "amount1_initial": 1617.0073, + "liquidity": "4637302533941787", + "range_upper": 3184.3369, + "range_lower": 3111.9366, + "token0_decimals": 18, + "token1_decimals": 6, + "range_mode": "AUTO", + "range_width_initial": 0.01160980429312654, + "timestamp_open": 1767622560, + "time_open": "05.01.26 15:16:00", + "hedge_TotPnL": -3.139154, + "hedge_fees_paid": 0.793472, + "combined_TotPnL": 1.6, + "hedge_HL_cost_est": 0.79, + "hedge_pnl_unrealized": -2.92, + "last_sync_hl": 1767624126, + "clp_fees": 2.68, + "clp_TotPnL": 5.4, + "timestamp_close": 1767624113, + "time_close": "05.01.26 15:41:53" + }, + { + "type": "AUTOMATIC", + "token_id": 5195860, + "status": "CLOSED", + "target_value": 2976.38, + "entry_price": 3157.3861, + "amount0_initial": 0.438, + "amount1_initial": 1593.5552, + "liquidity": "6634808665424129", + "range_upper": 3181.1543, + "range_lower": 3130.6634, + "token0_decimals": 18, + "token1_decimals": 6, + "range_mode": "AUTO", + "range_width_initial": 0.008451255668317245, + "timestamp_open": 1767624512, + "time_open": "05.01.26 15:48:32", + "hedge_TotPnL": -4.258952, + "hedge_fees_paid": 0.804724, + "combined_TotPnL": 0.01, + "hedge_HL_cost_est": 0.81, + "hedge_pnl_unrealized": -3.03, + "last_sync_hl": 1767624869, + "clp_fees": 1.25, + "clp_TotPnL": 4.26, + "timestamp_close": 1767624855, + "time_close": "05.01.26 15:54:15" + }, + { + "type": "AUTOMATIC", + "token_id": 5195910, + "status": "CLOSED", + "target_value": 2957.51, + "entry_price": 3166.872, + "amount0_initial": 0.4342, + "amount1_initial": 1582.386, + "liquidity": "8772781500746006", + "range_upper": 3184.3369, + "range_lower": 3146.3551, + "token0_decimals": 18, + "token1_decimals": 6, + "range_mode": "AUTO", + "range_width_initial": 0.00610577067463115, + "timestamp_open": 1767625173, + "time_open": "05.01.26 15:59:33", + "hedge_TotPnL": -6.49276, + "hedge_fees_paid": 0.782891, + "combined_TotPnL": 2.73, + "hedge_HL_cost_est": 0.78, + "hedge_pnl_unrealized": -5.77, + "last_sync_hl": 1767627649, + "clp_fees": 5.74, + "clp_TotPnL": 9.23, + "timestamp_close": 1767627635, + "time_close": "05.01.26 16:40:35" + }, + { + "type": "AUTOMATIC", + "token_id": 5196059, + "status": "CLOSED", + "target_value": 2984.89, + "entry_price": 3194.862, + "amount0_initial": 0.4148, + "amount1_initial": 1659.6726, + "liquidity": "7054813881893230", + "range_upper": 3216.3384, + "range_lower": 3168.4557, + "token0_decimals": 18, + "token1_decimals": 6, + "range_mode": "FIXED", + "range_width_initial": 0.0075, + "timestamp_open": 1767627994, + "time_open": "05.01.26 16:46:34", + "hedge_TotPnL": 9.768993, + "hedge_fees_paid": 0.761925, + "combined_TotPnL": -3.68, + "hedge_HL_cost_est": 0.76, + "hedge_pnl_unrealized": 13.07, + "last_sync_hl": 1767633796, + "clp_fees": 6.47, + "clp_TotPnL": -13.17, + "timestamp_close": 1767633781, + "time_close": "05.01.26 18:23:01" + }, + { + "type": "AUTOMATIC", + "token_id": 5196356, + "status": "CLOSED", + "target_value": 2970.03, + "entry_price": 3162.7579, + "amount0_initial": 0.4513, + "amount1_initial": 1542.7246, + "liquidity": "7558082601107499", + "range_upper": 3184.3369, + "range_lower": 3140.069, + "token0_decimals": 18, + "token1_decimals": 6, + "range_mode": "AUTO", + "range_width_initial": 0.007188283945973874, + "timestamp_open": 1767634095, + "time_open": "05.01.26 18:28:15", + "hedge_TotPnL": -5.78208, + "hedge_fees_paid": 0.833176, + "combined_TotPnL": -1.93, + "hedge_HL_cost_est": 0.83, + "hedge_pnl_unrealized": -5.65, + "last_sync_hl": 1767634756, + "clp_fees": 0.53, + "clp_TotPnL": 4.88, + "timestamp_close": 1767634742, + "time_close": "05.01.26 18:39:02" + }, + { + "type": "AUTOMATIC", + "token_id": 5196394, + "status": "CLOSED", + "target_value": 2997.55, + "entry_price": 3187.5227, + "amount0_initial": 0.4385, + "amount1_initial": 1599.8109, + "liquidity": "7092801036999878", + "range_upper": 3209.9125, + "range_lower": 3162.1255, + "token0_decimals": 18, + "token1_decimals": 6, + "range_mode": "FIXED", + "range_width_initial": 0.0075, + "timestamp_open": 1767635055, + "time_open": "05.01.26 18:44:15", + "hedge_TotPnL": -10.899792, + "hedge_fees_paid": 0.809798, + "combined_TotPnL": 5.4, + "hedge_HL_cost_est": 0.61, + "hedge_pnl_unrealized": 0.0, + "last_sync_hl": 1767635701, + "clp_fees": 1.13, + "clp_TotPnL": 6.03, + "timestamp_close": 1767635701, + "time_close": "05.01.26 18:55:01" + }, + { + "type": "AUTOMATIC", + "token_id": 5196471, + "status": "CLOSED", + "target_value": 2980.42, + "entry_price": 3209.5915, + "amount0_initial": 0.4364, + "amount1_initial": 1579.8119, + "liquidity": "7027957507483544", + "range_upper": 3232.4596, + "range_lower": 3184.3369, + "token0_decimals": 18, + "token1_decimals": 6, + "range_mode": "FIXED", + "range_width_initial": 0.0075, + "timestamp_open": 1767636146, + "time_open": "05.01.26 19:02:26", + "hedge_TotPnL": -13.87062, + "hedge_fees_paid": 0.817984, + "combined_TotPnL": -2.61, + "hedge_HL_cost_est": 1.43, + "hedge_pnl_unrealized": 0.0, + "last_sync_hl": 1767643534, + "clp_fees": 7.68, + "clp_TotPnL": 12.7, + "timestamp_close": 1767643533, + "time_close": "05.01.26 21:05:33" + }, + { + "type": "AUTOMATIC", + "token_id": 5196956, + "status": "CLOSED", + "target_value": 2977.3, + "entry_price": 3232.7828, + "amount0_initial": 0.4269, + "amount1_initial": 1597.0858, + "liquidity": "6995425133879491", + "range_upper": 3255.165, + "range_lower": 3206.7043, + "token0_decimals": 18, + "token1_decimals": 6, + "range_mode": "FIXED", + "range_width_initial": 0.0075, + "timestamp_open": 1767643849, + "time_open": "05.01.26 21:10:49", + "hedge_TotPnL": -1.356196, + "hedge_fees_paid": 1.243909, + "combined_TotPnL": -3.62, + "hedge_HL_cost_est": 1.58, + "hedge_pnl_unrealized": 9.64, + "last_sync_hl": 1767660041, + "clp_fees": 10.88, + "clp_TotPnL": -7.56, + "timestamp_close": 1767660028, + "time_close": "06.01.26 01:40:28" + }, + { + "type": "AUTOMATIC", + "token_id": 5198324, + "status": "OPEN", + "target_value": 2981.26, + "entry_price": 3217.6251, + "amount0_initial": 0.4045, + "amount1_initial": 1679.6194, + "liquidity": "7021319704792719", + "range_upper": 3238.9306, + "range_lower": 3190.7116, + "token0_decimals": 18, + "token1_decimals": 6, + "range_mode": "AUTO", + "range_width_initial": 0.007592620856214823, + "timestamp_open": 1767688223, + "time_open": "06.01.26 09:30:23", + "hedge_TotPnL": 0.0, + "hedge_fees_paid": 0.0, + "combined_TotPnL": -0.47, + "hedge_HL_cost_est": 0.57, + "hedge_pnl_unrealized": 0.72, + "last_sync_hl": 1767689255, + "clp_fees": 0.12, + "clp_TotPnL": 0.64 } ] \ No newline at end of file diff --git a/florida/clp_abis.py b/florida/clp_abis.py new file mode 100644 index 0000000..6b23d0e --- /dev/null +++ b/florida/clp_abis.py @@ -0,0 +1,82 @@ +import json + +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"} +] +''') + +AERODROME_FACTORY_ABI = json.loads(''' +[ + {"inputs": [{"internalType": "address", "name": "tokenA", "type": "address"}, {"internalType": "address", "name": "tokenB", "type": "address"}, {"internalType": "int24", "name": "tickSpacing", "type": "int24"}], "name": "getPool", "outputs": [{"internalType": "address", "name": "pool", "type": "address"}], "stateMutability": "view", "type": "function"} +] +''') + +AERODROME_NPM_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": "int24", "name": "tickSpacing", "type": "int24"}, {"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": "int24", "name": "tickSpacing", "type": "int24"}, {"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": "uint160", "name": "sqrtPriceX96", "type": "uint160"}], "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"} +] +''') + +AERODROME_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": "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"} +] +''') + +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"} +] +''') diff --git a/florida/clp_config.py b/florida/clp_config.py index 52c7aca..b33bd8e 100644 --- a/florida/clp_config.py +++ b/florida/clp_config.py @@ -8,7 +8,9 @@ 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 + "MONITOR_INTERVAL_SECONDS": 300, # Manager loop & sync interval + "LOG_INTERVAL_SECONDS": 300, # Hedger console logging interval + "RANGE_MODE": "AUTO", # Options: "AUTO" (BB-based), "FIXED" (RANGE_WIDTH_PCT) "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 @@ -19,17 +21,16 @@ DEFAULT_STRATEGY = { "VALUE_REFERENCE": "USD", # Base currency for all calculations # Range Settings - "RANGE_WIDTH_PCT": Decimal("0.05"), # LP width (e.g. 0.05 = +/- 5% from current price) - "SLIPPAGE_TOLERANCE": Decimal("0.02"), # Max allowed slippage for swaps and minting + "RANGE_WIDTH_PCT": Decimal("0.03"), # LP width (e.g. 0.05 = +/- 5% from current price) + "SLIPPAGE_TOLERANCE": Decimal("0.03"), # Max allowed slippage for swaps and minting "TRANSACTION_TIMEOUT_SECONDS": 30, # Timeout for blockchain transactions # Hedging Settings - "HEDGE_STRATEGY": "ASYMMETRIC", # Options: "STANDARD" (Full Range Hedge), "ASYMMETRIC" (Edge-Only Reduction) - # ude wide areas for ASYMETRIC "EDGE_CLEANUP_MARGIN_PCT": Decimal("0.1875"), + "HEDGE_STRATEGY": "ASYMMETRIC", # Options: "STANDARD" (Full Range Hedge), "ASYMMETRIC" (Edge-Only Reduction), "FIXED" (Initial Delta) "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 @@ -47,7 +48,7 @@ DEFAULT_STRATEGY = { "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.05"), # % of range width used for edge detection + "EDGE_CLEANUP_MARGIN_PCT": Decimal("0.03"), # % 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) @@ -74,9 +75,9 @@ CLP_PROFILES = { "TOKEN_B_ADDRESS": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", # USDC "WRAPPED_NATIVE_ADDRESS": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", "POOL_FEE": 500, - "RANGE_WIDTH_PCT": Decimal("0.05"), - "TARGET_INVESTMENT_AMOUNT": 1000, - "HEDGE_STRATEGY": "BOTTOM", + "TARGET_INVESTMENT_AMOUNT": 3000, + "HEDGE_STRATEGY": "FIXED", + "RANGE_WIDTH_PCT": Decimal("0.0075"), }, "UNISWAP_wide": { "NAME": "Uniswap V3 (Arbitrum) - ETH/USDC Wide", @@ -129,6 +130,34 @@ CLP_PROFILES = { "TARGET_INVESTMENT_AMOUNT": 200, "VALUE_REFERENCE": "USD", "RANGE_WIDTH_PCT": Decimal("0.10") + }, + "AERODROME_BASE_CL": { + "NAME": "Aerodrome SlipStream (Base) - WETH/USDC", + "COIN_SYMBOL": "ETH", + "RPC_ENV_VAR": "BASE_RPC_URL", + "NPM_ADDRESS": "0x827922686190790b37229fd06084350E74485b72", + "ROUTER_ADDRESS": "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43", + "TOKEN_A_ADDRESS": "0x4200000000000000000000000000000000000006", # WETH + "TOKEN_B_ADDRESS": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", # USDC + "WRAPPED_NATIVE_ADDRESS": "0x4200000000000000000000000000000000000006", + "POOL_FEE": 100, # TickSpacing 100 pool (0xb2cc...) + "RANGE_WIDTH_PCT": Decimal("0.075"), + "TARGET_INVESTMENT_AMOUNT": 200, + "HEDGE_STRATEGY": "FIXED", + }, + "AERODROME_WETH-USDC_008": { + "NAME": "Aerodrome SlipStream (Base) - WETH/USDC Stable", + "COIN_SYMBOL": "ETH", + "RPC_ENV_VAR": "BASE_RPC_URL", + "NPM_ADDRESS": "0x827922686190790b37229fd06084350E74485b72", + "ROUTER_ADDRESS": "0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43", + "TOKEN_A_ADDRESS": "0x4200000000000000000000000000000000000006", # WETH + "TOKEN_B_ADDRESS": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", # USDC + "WRAPPED_NATIVE_ADDRESS": "0x4200000000000000000000000000000000000006", + "POOL_FEE": 1, # TickSpacing 1 pool (0xdbc6...) + "RANGE_WIDTH_PCT": Decimal("0.05"), + "TARGET_INVESTMENT_AMOUNT": 200, + "HEDGE_STRATEGY": "FIXED", } } diff --git a/florida/clp_hedger.py b/florida/clp_hedger.py index 8c4d9f2..fa9147e 100644 --- a/florida/clp_hedger.py +++ b/florida/clp_hedger.py @@ -14,20 +14,15 @@ current_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(current_dir) sys.path.append(project_root) -# Import local modules -try: - from logging_utils import setup_logging -except ImportError: - setup_logging = None - # Ensure root logger is clean if we can't use setup_logging - logging.getLogger().handlers.clear() - logging.basicConfig(level=logging.INFO) +# Ensure root logger is clean +logging.getLogger().handlers.clear() +logging.basicConfig(level=logging.INFO) from eth_account import Account from hyperliquid.exchange import Exchange from hyperliquid.info import Info from hyperliquid.utils import constants -from clp_config import CLP_PROFILES, DEFAULT_STRATEGY +from clp_config import CLP_PROFILES, DEFAULT_STRATEGY, TARGET_DEX # Load environment variables dotenv_path = os.path.join(current_dir, '.env') @@ -233,7 +228,21 @@ class HyperliquidStrategy: adj_pct = -norm_dist * max_boost adj_pct = max(-max_boost, min(max_boost, adj_pct)) - raw_target_short = pool_delta + # --- FIXED STRATEGY LOGIC --- + if strategy_type == "FIXED": + # Target is exactly the pool delta at entry price + raw_target_short = self.get_pool_delta(self.entry_price) + adj_pct = Decimal("0") + elif strategy_type == "BOTTOM": + if current_price > self.entry_price: + # Disable hedging in upper half + raw_target_short = Decimal("0") + adj_pct = Decimal("0") + else: + # Enable hedging in lower half (standard delta) + # No asymmetric boost applied + adj_pct = Decimal("0") + adjusted_target_short = raw_target_short * (Decimal("1.0") + adj_pct) diff = adjusted_target_short - abs(current_short_size) @@ -275,13 +284,23 @@ class UnifiedHedger: # Market Data Cache self.last_prices = {} - self.price_history = {} # Symbol -> List[Decimal] + self.price_history = {} # Symbol -> List[Decimal] (Fast: 1s samples) self.last_trade_times = {} # Symbol -> timestamp self.last_idle_log_times = {} # Symbol -> timestamp # Shadow Orders (Global List) self.shadow_orders = [] + # State: Emergency Close Hysteresis + # Map: (file_path, token_id) -> bool + self.emergency_close_active = {} + + # Map: (file_path, token_id) -> Decimal (Locked hedge size) + self.custom_fixed_targets = {} + + # Map: (file_path, token_id) -> Decimal (Price when hedge leg opened) + self.hedge_entry_prices = {} + self.startup_time = time.time() logger.info(f"[CLP_HEDGER] Master Hedger initialized. Agent: {self.account.address}") @@ -289,6 +308,7 @@ class UnifiedHedger: def _init_coin_configs(self): """Pre-load configuration for known coins from CLP_PROFILES.""" + # 1. Load all profiles (order depends on dict iteration) for profile_key, profile_data in CLP_PROFILES.items(): symbol = profile_data.get("COIN_SYMBOL") if symbol: @@ -300,6 +320,18 @@ class UnifiedHedger: # Update with Profile Specifics self.coin_configs[symbol].update(profile_data) + # 2. Force overwrite with TARGET_DEX profile to ensure precedence + target_profile = CLP_PROFILES.get(TARGET_DEX) + if target_profile: + symbol = target_profile.get("COIN_SYMBOL") + if symbol: + if symbol not in self.coin_configs: + self.coin_configs[symbol] = DEFAULT_STRATEGY.copy() + self.coin_configs[symbol]["sz_decimals"] = 4 + + logger.info(f"Overwriting config for {symbol} using TARGET_DEX: {TARGET_DEX}") + self.coin_configs[symbol].update(target_profile) + def _get_sz_decimals(self, coin: str) -> int: try: meta = self.info.meta() @@ -430,6 +462,7 @@ class UnifiedHedger: self.strategy_states[key]['pnl'] = to_decimal(entry.get('hedge_pnl_realized', 0)) self.strategy_states[key]['fees'] = to_decimal(entry.get('hedge_fees_paid', 0)) self.strategy_states[key]['status'] = entry.get('status', 'OPEN') + self.strategy_states[key]['clp_fees'] = to_decimal(entry.get('clp_fees', 0)) except Exception as e: logger.error(f"Error reading {filename}: {e}. Skipping updates.") @@ -481,12 +514,23 @@ class UnifiedHedger: "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)), + "clp_fees": to_decimal(position_data.get('clp_fees', 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') } + + # Initial hedge entry price is the CLP entry price + self.hedge_entry_prices[key] = entry_price + logger.info(f"[STRAT] Init {key[1]} ({coin_symbol}) | Range: {lower}-{upper}") + # Ensure JSON has these fields initialized + update_position_stats(key[0], key[1], { + "hedge_TotPnL": float(self.strategy_states[key]['hedge_TotPnL']), + "hedge_fees_paid": float(self.strategy_states[key]['fees']) + }) + except Exception as e: logger.error(f"Failed to init strategy {key[1]}: {e}") @@ -584,297 +628,6 @@ class UnifiedHedger: 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() - - # --- LOG SETTINGS ON START --- - logger.info("=== HEDGER CONFIGURATION ===") - for symbol, config in self.coin_configs.items(): - logger.info(f"--- {symbol} ---") - for k, v in config.items(): - logger.info(f" {k}: {v}") - logger.info("============================") - - def run_tick(self): - """ - Executes one iteration of the hedger logic. - """ - # 1. API Backoff - if time.time() < self.api_backoff_until: - return - - # 2. Update Strategies - if not self.scan_strategies(): - logger.warning("Strategy scan failed (read error). Skipping execution tick.") - return - - # 3. Fetch Market Data (Centralized) - try: - mids = self.info.all_mids() - user_state = self.info.user_state(self.vault_address or self.account.address) - open_orders = self.get_open_orders() - l2_snapshots = {} # Cache for snapshots - except Exception as e: - logger.error(f"API Error fetching data: {e}") - return - - # Map Open Orders - orders_map = {} - for o in open_orders: - c = o['coin'] - if c not in orders_map: orders_map[c] = [] - orders_map[c].append(o) - - # Parse User State - account_value = Decimal("0") - if "marginSummary" in user_state and "accountValue" in user_state["marginSummary"]: - account_value = to_decimal(user_state["marginSummary"]["accountValue"]) - - # 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 } - aggregates = {} - - # First, update all prices from mids for active coins - for coin in self.active_coins: - if coin in mids: - price = to_decimal(mids[coin]) - self.last_prices[coin] = price - - # Update Price History - if coin not in self.price_history: self.price_history[coin] = [] - self.price_history[coin].append(price) - if len(self.price_history[coin]) > 300: self.price_history[coin].pop(0) - - for key, strat in self.strategies.items(): - coin = self.strategy_states[key]['coin'] - status = self.strategy_states[key].get('status', 'OPEN') - if coin not in self.last_prices: continue - price = self.last_prices[coin] - - # Get Config & Strategy Type - config = self.coin_configs.get(coin, {}) - strategy_type = config.get("HEDGE_STRATEGY", "ASYMMETRIC") - - # Calc Logic - calc = strat.calculate_rebalance(price, Decimal("0"), strategy_type) - - if coin not in aggregates: - aggregates[coin] = {'target_short': Decimal("0"), 'contributors': 0, 'is_at_edge': False, 'is_at_bottom_edge': False, 'adj_pct': Decimal("0"), 'is_closing': False} - - if status == 'CLOSING': - # If Closing, we want target to be 0 for this strategy - logger.info(f"[STRAT] {key[1]} is CLOSING -> Force Target 0") - aggregates[coin]['is_closing'] = True - # Do not add to target_short - else: - aggregates[coin]['target_short'] += calc['target_short'] - - aggregates[coin]['contributors'] += 1 - aggregates[coin]['adj_pct'] = calc['adj_pct'] - - # Check Edge Proximity for Cleanup - config = self.coin_configs.get(coin, {}) - enable_cleanup = config.get("ENABLE_EDGE_CLEANUP", True) - cleanup_margin = config.get("EDGE_CLEANUP_MARGIN_PCT", Decimal("0.02")) - - if enable_cleanup: - dist_bottom_pct = (price - strat.low_range) / strat.low_range - dist_top_pct = (strat.high_range - price) / strat.high_range - range_width_pct = (strat.high_range - strat.low_range) / strat.low_range - safety_margin_pct = range_width_pct * cleanup_margin - - if dist_bottom_pct < safety_margin_pct or dist_top_pct < safety_margin_pct: - aggregates[coin]['is_at_edge'] = True - if dist_bottom_pct < safety_margin_pct: - aggregates[coin]['is_at_bottom_edge'] = True - - # Check Shadow Orders (Pre-Execution) - self.check_shadow_orders(l2_snapshots) - - # 5. Execute Per Coin - # Union of coins with Active Strategies OR Active Positions - coins_to_process = set(aggregates.keys()) - for c, pos in current_positions.items(): - if abs(pos) > 0: coins_to_process.add(c) - - for coin in coins_to_process: - data = aggregates.get(coin, {'target_short': Decimal("0"), 'contributors': 0, 'is_at_edge': False, 'adj_pct': Decimal("0"), 'is_closing': False}) - - price = self.last_prices.get(coin, Decimal("0")) - if price == 0: continue - - target_short_abs = data['target_short'] - target_position = -target_short_abs - current_pos = current_positions.get(coin, Decimal("0")) - diff = target_position - current_pos - diff_abs = abs(diff) - - # Thresholds - config = self.coin_configs.get(coin, {}) - min_thresh = config.get("MIN_HEDGE_THRESHOLD", Decimal("0.008")) - vol_pct = self.calculate_volatility(coin) - base_vol = Decimal("0.0005") - vol_mult = max(Decimal("1.0"), min(Decimal("3.0"), vol_pct / base_vol)) if vol_pct > 0 else Decimal("1.0") - base_rebalance_pct = config.get("BASE_REBALANCE_THRESHOLD_PCT", Decimal("0.20")) - thresh_pct = min(Decimal("0.15"), base_rebalance_pct * vol_mult) - dynamic_thresh = max(min_thresh, abs(target_position) * thresh_pct) - - if data['is_at_edge'] and config.get("ENABLE_EDGE_CLEANUP", True): - if dynamic_thresh > min_thresh: dynamic_thresh = min_thresh - - action_needed = diff_abs > dynamic_thresh - 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 - order_matched = False - price_buffer_pct = config.get("PRICE_BUFFER_PCT", Decimal("0.0015")) - - for o in existing_orders: - o_oid = o['oid'] - o_price = to_decimal(o['limitPx']) - o_side = o['side'] - o_timestamp = o.get('timestamp', int(time.time()*1000)) - is_same_side = (o_side == 'B' and is_buy_bool) or (o_side == 'A' and not is_buy_bool) - order_age_sec = (int(time.time()*1000) - o_timestamp) / 1000.0 - - if is_same_side and order_age_sec > config.get("MAKER_ORDER_TIMEOUT", 300): - logger.info(f"[TIMEOUT] {coin} Order {o_oid} expired. Cancelling.") - self.cancel_order(coin, o_oid) - continue - - if config.get("ENABLE_FISHING", False) and is_same_side and order_age_sec > config.get("FISHING_TIMEOUT_FALLBACK", 30): - logger.info(f"[FISHING] {coin} Order {o_oid} timed out. Retrying as Taker.") - self.cancel_order(coin, o_oid) - force_taker_retry = True - continue - - if is_same_side and (abs(price - o_price) / price) < price_buffer_pct: - order_matched = True - if int(time.time()) % 15 == 0: - logger.info(f"[WAIT] {coin} Pending {side_str} @ {o_price} | Age: {order_age_sec:.1f}s") - break - else: - logger.info(f"Cancelling stale order {o_oid} ({o_side} @ {o_price})") - self.cancel_order(coin, o_oid) - - # Determine Urgency / Bypass Cooldown - bypass_cooldown = False - force_maker = False - if not order_matched and (action_needed or force_taker_retry): - if force_taker_retry: bypass_cooldown = True - elif data.get('is_closing', False): bypass_cooldown = True - elif data.get('contributors', 0) == 0: - if time.time() - self.startup_time > 5: force_maker = True - else: continue # Skip startup ghost positions - - 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): - # Prevent IOC for BUYs at bottom edge - if not (is_buy_bool and data.get('is_at_bottom_edge', False)): - bypass_cooldown = True - - # --- ASYMMETRIC HEDGE CHECK --- - is_asymmetric_blocked = False - p_mid_asym = Decimal("0") - strategy_type = config.get("HEDGE_STRATEGY", "ASYMMETRIC") - - if strategy_type == "ASYMMETRIC" and is_buy_bool and not bypass_cooldown: - total_L_asym = Decimal("0") - for k_strat, strat_inst in self.strategies.items(): - if self.strategy_states[k_strat]['coin'] == coin: - total_L_asym += strat_inst.L - - gamma_asym = (Decimal("0.5") * total_L_asym * (price ** Decimal("-1.5"))) - if gamma_asym > 0: - p_mid_asym = price - (diff_abs / gamma_asym) - if not data.get('is_at_edge', False) and price >= p_mid_asym: - is_asymmetric_blocked = True - - # --- EXECUTION --- - if not order_matched and not is_asymmetric_blocked: - if action_needed or force_taker_retry: - last_trade = self.last_trade_times.get(coin, 0) - min_time = config.get("MIN_TIME_BETWEEN_TRADES", 60) - - if bypass_cooldown or (time.time() - last_trade > min_time): - if coin not in l2_snapshots: l2_snapshots[coin] = self.info.l2_snapshot(coin) - levels = l2_snapshots[coin]['levels'] - if levels[0] and levels[1]: - bid, ask = to_decimal(levels[0][0]['px']), to_decimal(levels[1][0]['px']) - 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" - else: - exec_price = bid if is_buy_bool else ask - order_type = "Alo" - - logger.info(f"[TRIG] {coin} {side_str} {diff_abs:.4f} | Cur: {current_pos:.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 order_type == "Ioc": - shadow_price = bid if is_buy_bool else ask - self.shadow_orders.append({'coin': coin, 'side': side_str, 'price': shadow_price, 'expires_at': time.time() + config.get("SHADOW_ORDER_TIMEOUT", 600)}) - - logger.info("Sleeping 10s for position update...") - time.sleep(10) - self._update_closed_pnl(coin) - else: - # Idle Cleanup - if existing_orders and not order_matched: - for o in existing_orders: self.cancel_order(coin, o['oid']) - - # --- THROTTLED STATUS LOGGING --- - now = time.time() - last_log = self.last_idle_log_times.get(coin, 0) - monitor_interval = config.get("MONITOR_INTERVAL_SECONDS", 60) - - if now - last_log >= monitor_interval: - self.last_idle_log_times[coin] = now - if is_asymmetric_blocked: - logger.info(f"[ASYMMETRIC] Blocking BUY. Px ({price:.2f}) >= Eq ({p_mid_asym:.2f}) & Not Edge") - - total_L_log = Decimal("0") - for k_strat, strat_inst in self.strategies.items(): - if self.strategy_states[k_strat]['coin'] == coin: - total_L_log += strat_inst.L - - if total_L_log > 0 and price > 0: - gamma_log = (Decimal("0.5") * total_L_log * (price ** Decimal("-1.5"))) - if gamma_log > 0: - p_mid_log = price - (diff / gamma_log) # Corrected equilibrium formula - p_buy = price + (dynamic_thresh + diff) / gamma_log - p_sell = price - (dynamic_thresh - diff) / gamma_log - pad = " " if coin == "BNB" else "" - - unrealized = current_pnls.get(coin, Decimal("0")) - closed_pnl = sum(s['hedge_TotPnL'] for s in self.strategy_states.values() if s['coin'] == coin) - fees = sum(s['fees'] for s in self.strategy_states.values() if s['coin'] == coin) - total_pnl = (closed_pnl - fees) + unrealized - - logger.info(f"[IDLE] {coin} | Px: {price:.2f}{pad} | M: {p_mid_log:.1f}{pad} | B: {p_buy:.1f}{pad} / S: {p_sell:.1f}{pad} | delta: {target_position:.4f}({diff:+.4f}) | Adj: {data.get('adj_pct',0)*100:+.2f}%, Vol: {vol_mult:.2f}, Thr: {dynamic_thresh:.4f} | PnL: {unrealized:.2f} | TotPnL: {total_pnl:.2f}") - else: - logger.info(f"[IDLE] {coin} | Px: {price:.2f} | delta: {target_position:.4f} | Diff: {diff:.4f} (Thresh: {dynamic_thresh:.4f})") - else: - logger.info(f"[IDLE] {coin} | Px: {price:.2f} | delta: {target_position:.4f} | Diff: {diff:.4f}") - def run(self): logger.info("Starting Unified Hedger Loop...") self.update_coin_decimals() @@ -889,7 +642,411 @@ class UnifiedHedger: while True: try: - self.run_tick() + # 1. API Backoff + if time.time() < self.api_backoff_until: + time.sleep(1) + continue + + # 2. Update Strategies + if not self.scan_strategies(): + logger.warning("Strategy scan failed (read error). Skipping execution tick.") + time.sleep(1) + continue + + # 3. Fetch Market Data (Centralized) + try: + mids = self.info.all_mids() + user_state = self.info.user_state(self.vault_address or self.account.address) + open_orders = self.get_open_orders() + l2_snapshots = {} # Cache for snapshots + except Exception as e: + logger.error(f"API Error fetching data: {e}") + time.sleep(1) + continue + + # Map Open Orders + orders_map = {} + for o in open_orders: + c = o['coin'] + if c not in orders_map: orders_map[c] = [] + orders_map[c].append(o) + + # Parse User State + account_value = Decimal("0") + if "marginSummary" in user_state and "accountValue" in user_state["marginSummary"]: + account_value = to_decimal(user_state["marginSummary"]["accountValue"]) + + # 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 } + aggregates = {} + + # First, update all prices from mids for active coins + for coin in self.active_coins: + if coin in mids: + price = to_decimal(mids[coin]) + self.last_prices[coin] = price + + # Update Price History (Fast) + if coin not in self.price_history: self.price_history[coin] = [] + self.price_history[coin].append(price) + if len(self.price_history[coin]) > 300: self.price_history[coin].pop(0) + + for key, strat in self.strategies.items(): + coin = self.strategy_states[key]['coin'] + status = self.strategy_states[key].get('status', 'OPEN') + if coin not in self.last_prices: continue + price = self.last_prices[coin] + + # Get Config & Strategy Type + config = self.coin_configs.get(coin, {}) + strategy_type = config.get("HEDGE_STRATEGY", "ASYMMETRIC") + + # Calc Logic + calc = strat.calculate_rebalance(price, Decimal("0"), strategy_type) + + if coin not in aggregates: + aggregates[coin] = {'target_short': Decimal("0"), 'contributors': 0, 'is_at_edge': False, 'is_at_bottom_edge': False, 'adj_pct': Decimal("0"), 'is_closing': False} + + # --- EMERGENCY UPPER EDGE CLOSING (HYSTERESIS) --- + # Logic: If price hits Top, close hedge. Do NOT re-open until price drops back to 75% of Range (FIXED) or Buffer (Others). + + is_active_hysteresis = self.emergency_close_active.get(key, False) + + if is_active_hysteresis: + # CHECK RESET CONDITION + if strategy_type == "FIXED": + # Reset at 75% of range (from Bottom) + range_width = strat.high_range - strat.low_range + reset_threshold = strat.low_range + (range_width * Decimal("0.75")) + else: + reset_threshold = strat.high_range * Decimal("0.999") + + if price < reset_threshold: + logger.info(f"[STRAT] {key[1]} Price reset ({price:.2f} < {reset_threshold:.2f}). Resuming hedge.") + self.emergency_close_active[key] = False + is_active_hysteresis = False + + # Capture NEW Dynamic Fixed Target and Entry Price + if strategy_type == "FIXED": + dynamic_delta = strat.get_pool_delta(price) + self.custom_fixed_targets[key] = dynamic_delta + self.hedge_entry_prices[key] = price + logger.info(f"[STRAT] {key[1]} FIXED target reset to Dynamic Delta: {dynamic_delta:.4f} @ {price:.2f}") + + if not is_active_hysteresis: + # CHECK TRIGGER CONDITION + if price >= strat.high_range: + logger.warning(f"[STRAT] {key[1]} above High Range ({price:.2f} >= {strat.high_range:.2f}). Emergency closing hedge.") + self.emergency_close_active[key] = True + is_active_hysteresis = True + # Reset entry price when closed + self.hedge_entry_prices[key] = Decimal("0") + + if status == 'CLOSING' or is_active_hysteresis: + # If Closing OR Hysteresis Active, target is 0 + aggregates[coin]['is_closing'] = True + else: + # Use custom fixed target if exists, else standard calc + if strategy_type == "FIXED" and key in self.custom_fixed_targets: + aggregates[coin]['target_short'] += self.custom_fixed_targets[key] + else: + aggregates[coin]['target_short'] += calc['target_short'] + + aggregates[coin]['contributors'] += 1 + aggregates[coin]['adj_pct'] = calc['adj_pct'] + + # Check Edge Proximity for Cleanup + config = self.coin_configs.get(coin, {}) + enable_cleanup = config.get("ENABLE_EDGE_CLEANUP", True) + cleanup_margin = config.get("EDGE_CLEANUP_MARGIN_PCT", Decimal("0.02")) + + if enable_cleanup: + dist_bottom_pct = (price - strat.low_range) / strat.low_range + dist_top_pct = (strat.high_range - price) / strat.high_range + range_width_pct = (strat.high_range - strat.low_range) / strat.low_range + safety_margin_pct = range_width_pct * cleanup_margin + + if dist_bottom_pct < safety_margin_pct or dist_top_pct < safety_margin_pct: + aggregates[coin]['is_at_edge'] = True + if dist_bottom_pct < safety_margin_pct: + aggregates[coin]['is_at_bottom_edge'] = True + + # Check Shadow Orders (Pre-Execution) + self.check_shadow_orders(l2_snapshots) + + # 5. Execute Per Coin + # Union of coins with Active Strategies OR Active Positions + coins_to_process = set(aggregates.keys()) + for c, pos in current_positions.items(): + if abs(pos) > 0: coins_to_process.add(c) + + for coin in coins_to_process: + data = aggregates.get(coin, {'target_short': Decimal("0"), 'contributors': 0, 'is_at_edge': False, 'adj_pct': Decimal("0"), 'is_closing': False}) + + price = self.last_prices.get(coin, Decimal("0")) + if price == 0: continue + + target_short_abs = data['target_short'] + target_position = -target_short_abs + current_pos = current_positions.get(coin, Decimal("0")) + diff = target_position - current_pos + diff_abs = abs(diff) + + # Thresholds + config = self.coin_configs.get(coin, {}) + min_thresh = config.get("MIN_HEDGE_THRESHOLD", Decimal("0.008")) + vol_pct = self.calculate_volatility(coin) + base_vol = Decimal("0.0005") + vol_mult = max(Decimal("1.0"), min(Decimal("3.0"), vol_pct / base_vol)) if vol_pct > 0 else Decimal("1.0") + base_rebalance_pct = config.get("BASE_REBALANCE_THRESHOLD_PCT", Decimal("0.20")) + thresh_pct = min(Decimal("0.15"), base_rebalance_pct * vol_mult) + dynamic_thresh = max(min_thresh, abs(target_position) * thresh_pct) + + if data['is_at_edge'] and config.get("ENABLE_EDGE_CLEANUP", True): + if dynamic_thresh > min_thresh: dynamic_thresh = min_thresh + + action_needed = diff_abs > dynamic_thresh + 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 + order_matched = False + price_buffer_pct = config.get("PRICE_BUFFER_PCT", Decimal("0.0015")) + + for o in existing_orders: + o_oid = o['oid'] + o_price = to_decimal(o['limitPx']) + o_side = o['side'] + o_timestamp = o.get('timestamp', int(time.time()*1000)) + is_same_side = (o_side == 'B' and is_buy_bool) or (o_side == 'A' and not is_buy_bool) + order_age_sec = (int(time.time()*1000) - o_timestamp) / 1000.0 + + if is_same_side and order_age_sec > config.get("MAKER_ORDER_TIMEOUT", 300): + logger.info(f"[TIMEOUT] {coin} Order {o_oid} expired. Cancelling.") + self.cancel_order(coin, o_oid) + continue + + if config.get("ENABLE_FISHING", False) and is_same_side and order_age_sec > config.get("FISHING_TIMEOUT_FALLBACK", 30): + logger.info(f"[FISHING] {coin} Order {o_oid} timed out. Retrying as Taker.") + self.cancel_order(coin, o_oid) + force_taker_retry = True + continue + + if is_same_side and (abs(price - o_price) / price) < price_buffer_pct: + order_matched = True + if int(time.time()) % 15 == 0: + logger.info(f"[WAIT] {coin} Pending {side_str} @ {o_price} | Age: {order_age_sec:.1f}s") + break + else: + logger.info(f"Cancelling stale order {o_oid} ({o_side} @ {o_price})") + self.cancel_order(coin, o_oid) + + # Determine Urgency / Bypass Cooldown + bypass_cooldown = False + force_maker = False + if not order_matched and (action_needed or force_taker_retry): + if force_taker_retry: bypass_cooldown = True + elif data.get('is_closing', False): bypass_cooldown = True + elif data.get('contributors', 0) == 0: + if time.time() - self.startup_time > 5: force_maker = True + else: continue # Skip startup ghost positions + + 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): + # Prevent IOC for BUYs at bottom edge + if not (is_buy_bool and data.get('is_at_bottom_edge', False)): + bypass_cooldown = True + + # --- BOTTOM STRATEGY SAFEGUARDS --- + strategy_type = config.get("HEDGE_STRATEGY", "ASYMMETRIC") + if strategy_type == "BOTTOM": + # strict: "do not use taker orders... except only on very bottom" + if not data.get('is_at_bottom_edge', False): + bypass_cooldown = False + force_taker_retry = False # Disable taker retry from fishing + + # --- ASYMMETRIC HEDGE CHECK --- + is_asymmetric_blocked = False + p_mid_asym = Decimal("0") + # strategy_type already fetched above + + if strategy_type == "ASYMMETRIC" and is_buy_bool and not bypass_cooldown: + total_L_asym = Decimal("0") + for k_strat, strat_inst in self.strategies.items(): + if self.strategy_states[k_strat]['coin'] == coin: + total_L_asym += strat_inst.L + + gamma_asym = (Decimal("0.5") * total_L_asym * (price ** Decimal("-1.5"))) + if gamma_asym > 0: + p_mid_asym = price - (diff_abs / gamma_asym) + if not data.get('is_at_edge', False) and price >= p_mid_asym: + is_asymmetric_blocked = True + + # --- EXECUTION --- + if not order_matched and not is_asymmetric_blocked: + if action_needed or force_taker_retry: + last_trade = self.last_trade_times.get(coin, 0) + min_time = config.get("MIN_TIME_BETWEEN_TRADES", 60) + + if bypass_cooldown or (time.time() - last_trade > min_time): + if coin not in l2_snapshots: l2_snapshots[coin] = self.info.l2_snapshot(coin) + levels = l2_snapshots[coin]['levels'] + if levels[0] and levels[1]: + bid, ask = to_decimal(levels[0][0]['px']), to_decimal(levels[1][0]['px']) + 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" + else: + exec_price = bid if is_buy_bool else ask + order_type = "Alo" + + logger.info(f"[TRIG] {coin} {side_str} {diff_abs:.4f} | Cur: {current_pos:.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 order_type == "Ioc": + shadow_price = bid if is_buy_bool else ask + self.shadow_orders.append({'coin': coin, 'side': side_str, 'price': shadow_price, 'expires_at': time.time() + config.get("SHADOW_ORDER_TIMEOUT", 600)}) + + logger.info("Sleeping 10s for position update...") + time.sleep(10) + self._update_closed_pnl(coin) + else: + # Idle Cleanup + if existing_orders and not order_matched: + for o in existing_orders: self.cancel_order(coin, o['oid']) + + # --- REAL-TIME PnL CALCULATION & JSON UPDATE (1s) --- + total_L_log = Decimal("0") + for k_strat, strat_inst in self.strategies.items(): + if self.strategy_states[k_strat]['coin'] == coin: + total_L_log += strat_inst.L + + # Update all active strategies for this coin in JSON + if total_L_log > 0 and price > 0: + for k_strat, strat_inst in self.strategies.items(): + if self.strategy_states[k_strat]['coin'] != coin: continue + + # CLP Value Calc + def get_clp_value(p, s): + if p <= s.low_range: return s.L * (p * (1/s.low_range.sqrt() - 1/s.high_range.sqrt())) + if p >= s.high_range: return s.L * (s.high_range.sqrt() - s.low_range.sqrt()) + return s.L * (2*p.sqrt() - s.low_range.sqrt() - p/s.high_range.sqrt()) + + clp_curr_val = get_clp_value(price, strat_inst) + + # Use Custom Fixed Target if exists + target_size = self.custom_fixed_targets.get(k_strat, strat_inst.get_pool_delta(strat_inst.entry_price)) + + # USE TRACKED HEDGE ENTRY PRICE + h_entry_px = self.hedge_entry_prices.get(k_strat, strat_inst.entry_price) + if h_entry_px > 0: + hedge_pnl_curr = (h_entry_px - price) * target_size + else: + hedge_pnl_curr = Decimal("0") + + fee_close_curr = (target_size * price) * Decimal("0.000432") + uni_fees = to_decimal(self.strategy_states[k_strat].get('clp_fees', 0)) + + # Retrieve Realized PnL & Fees from State + realized_pnl = to_decimal(self.strategy_states[k_strat].get('hedge_TotPnL', 0)) + realized_fees = to_decimal(self.strategy_states[k_strat].get('fees', 0)) + + # Combined TotPnL = CLP_Unrealized + Hedge_Unrealized + Hedge_Realized - Hedge_Fees + CLP_Fees - Est_Close_Fee + tot_curr = (clp_curr_val - strat_inst.target_value) + hedge_pnl_curr + realized_pnl - realized_fees - fee_close_curr + uni_fees + + cur_hl_cost = realized_fees + fee_close_curr + + # Sync to JSON every 1s + update_position_stats(k_strat[0], k_strat[1], { + "combined_TotPnL": round(float(tot_curr), 2), + "hedge_HL_cost_est": round(float(cur_hl_cost), 2), + "hedge_pnl_unrealized": round(float(hedge_pnl_curr), 2), + "last_sync_hl": int(time.time()) + }) + + # --- THROTTLED STATUS LOGGING (300s) --- + now = time.time() + last_log = self.last_idle_log_times.get(coin, 0) + log_interval = config.get("LOG_INTERVAL_SECONDS", 300) + + if now - last_log >= log_interval: + self.last_idle_log_times[coin] = now + if is_asymmetric_blocked: + logger.info(f"[ASYMMETRIC] Blocking BUY. Px ({price:.2f}) >= Eq ({p_mid_asym:.2f}) & Not Edge") + + if total_L_log > 0 and price > 0: + gamma_log = (Decimal("0.5") * total_L_log * (price ** Decimal("-1.5"))) + if gamma_log > 0: + p_mid_log = price - (diff / gamma_log) + p_buy = price + (dynamic_thresh + diff) / gamma_log + p_sell = price - (dynamic_thresh - diff) / gamma_log + pad = " " if coin == "BNB" else "" + + unrealized = current_pnls.get(coin, Decimal("0")) + closed_pnl = sum(s['hedge_TotPnL'] for s in self.strategy_states.values() if s['coin'] == coin) + fees = sum(s['fees'] for s in self.strategy_states.values() if s['coin'] == coin) + total_pnl = (closed_pnl - fees) + unrealized + + # Log individual strategy PnL + if strategy_type == "FIXED": + for k_strat, strat_inst in self.strategies.items(): + if self.strategy_states[k_strat]['coin'] != coin: continue + + # Recalculate for logging (including bounds) + clp_curr_val = get_clp_value(price, strat_inst) + clp_low_val = get_clp_value(strat_inst.low_range, strat_inst) + clp_high_val = get_clp_value(strat_inst.high_range, strat_inst) + + # Use Custom Fixed Target if exists + target_size = self.custom_fixed_targets.get(k_strat, strat_inst.get_pool_delta(strat_inst.entry_price)) + h_entry_px = self.hedge_entry_prices.get(k_strat, strat_inst.entry_price) + + if h_entry_px > 0: + hedge_pnl_curr = (h_entry_px - price) * target_size + hedge_pnl_low = (h_entry_px - strat_inst.low_range) * target_size + hedge_pnl_high = (h_entry_px - strat_inst.high_range) * target_size + fee_open = (target_size * h_entry_px) * Decimal("0.000144") + else: + hedge_pnl_curr = hedge_pnl_low = hedge_pnl_high = Decimal("0") + fee_open = Decimal("0") + + fee_close_curr = (target_size * price) * Decimal("0.000432") + fee_close_low = (target_size * strat_inst.low_range) * Decimal("0.000432") + fee_close_high = (target_size * strat_inst.high_range) * Decimal("0.000432") + + uni_fees = to_decimal(self.strategy_states[k_strat].get('clp_fees', 0)) + tot_curr = (clp_curr_val - strat_inst.target_value) + hedge_pnl_curr - (fee_open + fee_close_curr) + uni_fees + tot_low = (clp_low_val - strat_inst.target_value) + hedge_pnl_low - (fee_open + fee_close_low) + uni_fees + tot_high = (clp_high_val - strat_inst.target_value) + hedge_pnl_high - (fee_open + fee_close_high) + uni_fees + + cur_hl_cost = fee_open + fee_close_curr + + # ID or Range to distinguish + strat_id = str(k_strat[1]) # Token ID + logger.info(f"[FIXED] {coin} #{strat_id} | TotPnL: {tot_curr:+.2f} | Down: {tot_low:+.2f} | Up: {tot_high:+.2f} (Inc: Fees ${uni_fees:.2f}, HL Cost ${cur_hl_cost:.2f})") + + logger.info(f"[IDLE] {coin} | Px: {price:.2f}{pad} | M: {p_mid_log:.1f}{pad} | B: {p_buy:.1f}{pad} / S: {p_sell:.1f}{pad} | delta: {target_position:.4f}({diff:+.4f}) | Adj: {data.get('adj_pct',0)*100:+.2f}%, Vol: {vol_mult:.2f}, Thr: {dynamic_thresh:.4f} | PnL: {unrealized:.2f} | HedgePnL: {total_pnl:.2f}") + else: + logger.info(f"[IDLE] {coin} | Px: {price:.2f} | delta: {target_position:.4f} | Diff: {diff:.4f} (Thresh: {dynamic_thresh:.4f})") + else: + logger.info(f"[IDLE] {coin} | Px: {price:.2f} | delta: {target_position:.4f} | Diff: {diff:.4f}") + time.sleep(DEFAULT_STRATEGY.get("CHECK_INTERVAL", 1)) except KeyboardInterrupt: diff --git a/florida/clp_manager.py b/florida/clp_manager.py index 172f4f0..83bbdb9 100644 --- a/florida/clp_manager.py +++ b/florida/clp_manager.py @@ -63,65 +63,34 @@ formatter = logging.Formatter('%(unix_ms)d, %(asctime)s - %(name)s - %(levelname file_handler.setFormatter(formatter) logger.addHandler(file_handler) -# --- ABIs --- -# (Kept minimal for brevity, normally would load from files) -NONFUNGIBLE_POSITION_MANAGER_ABI = json.loads(''' -[ - {"anonymous": false, "inputs": [{"indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"indexed": false, "internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"indexed": false, "internalType": "uint256", "name": "amount0", "type": "uint256"}, {"indexed": false, "internalType": "uint256", "name": "amount1", "type": "uint256"}], "name": "IncreaseLiquidity", "type": "event"}, - {"anonymous": false, "inputs": [{"indexed": true, "internalType": "address", "name": "from", "type": "address"}, {"indexed": true, "internalType": "address", "name": "to", "type": "address"}, {"indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256"}], "name": "Transfer", "type": "event"}, - {"inputs": [], "name": "factory", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, - {"inputs": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}], "name": "positions", "outputs": [{"internalType": "uint96", "name": "nonce", "type": "uint96"}, {"internalType": "address", "name": "operator", "type": "address"}, {"internalType": "address", "name": "token0", "type": "address"}, {"internalType": "address", "name": "token1", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}, {"internalType": "int24", "name": "tickLower", "type": "int24"}, {"internalType": "int24", "name": "tickUpper", "type": "int24"}, {"internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"internalType": "uint256", "name": "feeGrowthInside0LastX128", "type": "uint256"}, {"internalType": "uint256", "name": "feeGrowthInside1LastX128", "type": "uint256"}, {"internalType": "uint128", "name": "tokensOwed0", "type": "uint128"}, {"internalType": "uint128", "name": "tokensOwed1", "type": "uint128"}], "stateMutability": "view", "type": "function"}, - {"inputs": [{"components": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint128", "name": "amount0Max", "type": "uint128"}, {"internalType": "uint128", "name": "amount1Max", "type": "uint128"}], "internalType": "struct INonfungiblePositionManager.CollectParams", "name": "params", "type": "tuple"}], "name": "collect", "outputs": [{"internalType": "uint256", "name": "amount0", "type": "uint256"}, {"internalType": "uint256", "name": "amount1", "type": "uint256"}], "stateMutability": "payable", "type": "function"}, - {"inputs": [{"components": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"internalType": "uint256", "name": "amount0Min", "type": "uint256"}, {"internalType": "uint256", "name": "amount1Min", "type": "uint256"}, {"internalType": "uint256", "name": "deadline", "type": "uint256"}], "internalType": "struct INonfungiblePositionManager.DecreaseLiquidityParams", "name": "params", "type": "tuple"}], "name": "decreaseLiquidity", "outputs": [{"internalType": "uint256", "name": "amount0", "type": "uint256"}, {"internalType": "uint256", "name": "amount1", "type": "uint256"}], "stateMutability": "payable", "type": "function"}, - {"inputs": [{"components": [{"internalType": "address", "name": "token0", "type": "address"}, {"internalType": "address", "name": "token1", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}, {"internalType": "int24", "name": "tickLower", "type": "int24"}, {"internalType": "int24", "name": "tickUpper", "type": "int24"}, {"internalType": "uint256", "name": "amount0Desired", "type": "uint256"}, {"internalType": "uint256", "name": "amount1Desired", "type": "uint256"}, {"internalType": "uint256", "name": "amount0Min", "type": "uint256"}, {"internalType": "uint256", "name": "amount1Min", "type": "uint256"}, {"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint256", "name": "deadline", "type": "uint256"}], "internalType": "struct INonfungiblePositionManager.MintParams", "name": "params", "type": "tuple"}], "name": "mint", "outputs": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}, {"internalType": "uint128", "name": "liquidity", "type": "uint128"}, {"internalType": "uint256", "name": "amount0", "type": "uint256"}, {"internalType": "uint256", "name": "amount1", "type": "uint256"}], "stateMutability": "payable", "type": "function"} -] -''') - -UNISWAP_V3_POOL_ABI = json.loads(''' -[ - {"inputs": [], "name": "slot0", "outputs": [{"internalType": "uint160", "name": "sqrtPriceX96", "type": "uint160"}, {"internalType": "int24", "name": "tick", "type": "int24"}, {"internalType": "uint16", "name": "observationIndex", "type": "uint16"}, {"internalType": "uint16", "name": "observationCardinality", "type": "uint16"}, {"internalType": "uint16", "name": "observationCardinalityNext", "type": "uint16"}, {"internalType": "uint32", "name": "feeProtocol", "type": "uint32"}, {"internalType": "bool", "name": "unlocked", "type": "bool"}], "stateMutability": "view", "type": "function"}, - {"inputs": [], "name": "token0", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, - {"inputs": [], "name": "token1", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}, - {"inputs": [], "name": "fee", "outputs": [{"internalType": "uint24", "name": "", "type": "uint24"}], "stateMutability": "view", "type": "function"}, - {"inputs": [], "name": "tickSpacing", "outputs": [{"internalType": "int24", "name": "", "type": "int24"}], "stateMutability": "view", "type": "function"}, - {"inputs": [], "name": "liquidity", "outputs": [{"internalType": "uint128", "name": "", "type": "uint128"}], "stateMutability": "view", "type": "function"} -] -''') - -ERC20_ABI = json.loads(''' -[ - {"inputs": [], "name": "decimals", "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], "stateMutability": "view", "type": "function"}, - {"inputs": [], "name": "symbol", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"}, - {"inputs": [{"internalType": "address", "name": "account", "type": "address"}], "name": "balanceOf", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, - {"inputs": [{"internalType": "address", "name": "spender", "type": "address"}, {"internalType": "uint256", "name": "amount", "type": "uint256"}], "name": "approve", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "nonpayable", "type": "function"}, - {"inputs": [{"internalType": "address", "name": "owner", "type": "address"}, {"internalType": "address", "name": "spender", "type": "address"}], "name": "allowance", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"} -] -''') - -UNISWAP_V3_FACTORY_ABI = json.loads(''' -[ - {"inputs": [{"internalType": "address", "name": "tokenA", "type": "address"}, {"internalType": "address", "name": "tokenB", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}], "name": "getPool", "outputs": [{"internalType": "address", "name": "pool", "type": "address"}], "stateMutability": "view", "type": "function"} -] -''') - -SWAP_ROUTER_ABI = json.loads(''' -[ - {"inputs": [{"components": [{"internalType": "address", "name": "tokenIn", "type": "address"}, {"internalType": "address", "name": "tokenOut", "type": "address"}, {"internalType": "uint24", "name": "fee", "type": "uint24"}, {"internalType": "address", "name": "recipient", "type": "address"}, {"internalType": "uint256", "name": "deadline", "type": "uint256"}, {"internalType": "uint256", "name": "amountIn", "type": "uint256"}, {"internalType": "uint256", "name": "amountOutMinimum", "type": "uint256"}, {"internalType": "uint160", "name": "sqrtPriceLimitX96", "type": "uint160"}], "internalType": "struct ISwapRouter.ExactInputSingleParams", "name": "params", "type": "tuple"}], "name": "exactInputSingle", "outputs": [{"internalType": "uint256", "name": "amountOut", "type": "uint256"}], "stateMutability": "payable", "type": "function"} -] -''') - -WETH9_ABI = json.loads(''' -[ - {"constant": false, "inputs": [], "name": "deposit", "outputs": [], "payable": true, "stateMutability": "payable", "type": "function"}, - {"constant": false, "inputs": [{"name": "wad", "type": "uint256"}], "name": "withdraw", "outputs": [], "payable": false, "stateMutability": "nonpayable", "type": "function"} -] -''') +from clp_abis import ( + NONFUNGIBLE_POSITION_MANAGER_ABI, + UNISWAP_V3_POOL_ABI, + ERC20_ABI, + UNISWAP_V3_FACTORY_ABI, + AERODROME_FACTORY_ABI, + AERODROME_POOL_ABI, + AERODROME_NPM_ABI, + SWAP_ROUTER_ABI, + WETH9_ABI +) from clp_config import get_current_config, STATUS_FILE +from tools.universal_swapper import execute_swap # --- GET ACTIVE DEX CONFIG --- CONFIG = get_current_config() +DEX_TO_CHAIN = { + "UNISWAP_V3": "ARBITRUM", + "UNISWAP_wide": "ARBITRUM", + "PANCAKESWAP_BNB": "BSC", + "WETH_CBBTC_BASE": "BASE", + "UNISWAP_BASE_CL": "BASE", + "AERODROME_BASE_CL": "BASE", + "AERODROME_WETH-USDC_008": "BASE" +} + # --- CONFIGURATION FROM STRATEGY --- MONITOR_INTERVAL_SECONDS = CONFIG.get("MONITOR_INTERVAL_SECONDS", 60) CLOSE_POSITION_ENABLED = CONFIG.get("CLOSE_POSITION_ENABLED", True) @@ -130,11 +99,77 @@ REBALANCE_ON_CLOSE_BELOW_RANGE = CONFIG.get("REBALANCE_ON_CLOSE_BELOW_RANGE", Tr TARGET_INVESTMENT_VALUE_USDC = CONFIG.get("TARGET_INVESTMENT_AMOUNT", 2000) INITIAL_HEDGE_CAPITAL_USDC = CONFIG.get("INITIAL_HEDGE_CAPITAL", 1000) RANGE_WIDTH_PCT = CONFIG.get("RANGE_WIDTH_PCT", Decimal("0.01")) +RANGE_MODE = CONFIG.get("RANGE_MODE", "FIXED") SLIPPAGE_TOLERANCE = CONFIG.get("SLIPPAGE_TOLERANCE", Decimal("0.02")) TRANSACTION_TIMEOUT_SECONDS = CONFIG.get("TRANSACTION_TIMEOUT_SECONDS", 30) +# --- AUTO RANGE HELPERS --- + +def get_market_indicators() -> Optional[Dict]: + file_path = os.path.join("market_data", "indicators.json") + if not os.path.exists(file_path): + return None + try: + with open(file_path, 'r') as f: + data = json.load(f) + + # Check Freshness (5m) + last_updated_str = data.get("last_updated") + if not last_updated_str: return None + + last_updated = datetime.fromisoformat(last_updated_str) + if (datetime.now() - last_updated).total_seconds() > 300: + logger.warning("⚠️ Market indicators file is stale (>5m).") + return None + + return data + except Exception as e: + logger.error(f"Error reading indicators: {e}") + return None + +def calculate_dynamic_range_pct(coin: str) -> Optional[Decimal]: + indicators = get_market_indicators() + if not indicators: return None + + # Normalize symbols (Hyperliquid uses ETH, BNB while DEX uses WETH, WBNB) + symbol_map = {"WETH": "ETH", "WBNB": "BNB"} + lookup_coin = symbol_map.get(coin.upper(), coin.upper()) + + coin_data = indicators.get("data", {}).get(lookup_coin) + if not coin_data: return None + + try: + price = Decimal(str(coin_data["current_price"])) + bb12 = coin_data["bb"]["12h"] + bb_low = Decimal(str(bb12["lower"])) + bb_high = Decimal(str(bb12["upper"])) + ma88 = Decimal(str(coin_data["ma"]["88"])) + + # Condition 2: Price inside BB 12h + if not (bb_low <= price <= bb_high): + logger.warning(f"⚖️ AUTO: Price {price:.2f} is outside BB 12h ({bb_low:.2f} - {bb_high:.2f}). Skipping AUTO.") + return None + + # Condition 3: MA 88 inside BB 12h + if not (bb_low <= ma88 <= bb_high): + logger.warning(f"⚖️ AUTO: MA 88 {ma88:.2f} is outside BB 12h. Skipping AUTO.") + return None + + # Calculation: Max distance to BB edge + dist_low = abs(price - bb_low) + dist_high = abs(price - bb_high) + max_dist = max(dist_low, dist_high) + + range_pct = max_dist / price + return range_pct + + except (KeyError, TypeError, ValueError) as e: + logger.error(f"Error in dynamic range calc: {e}") + return None + # --- CONFIGURATION CONSTANTS --- NONFUNGIBLE_POSITION_MANAGER_ADDRESS = CONFIG["NPM_ADDRESS"] +# Router address not strictly needed for Manager if using universal_swapper, but kept for ref UNISWAP_V3_SWAP_ROUTER_ADDRESS = CONFIG["ROUTER_ADDRESS"] # Arbitrum WETH/USDC (or generic T0/T1) WETH_ADDRESS = CONFIG["WRAPPED_NATIVE_ADDRESS"] @@ -311,7 +346,8 @@ def get_position_details(w3: Web3, npm_contract, factory_contract, token_id: int if pool_address == '0x0000000000000000000000000000000000000000': return None, None - pool_contract = w3.eth.contract(address=pool_address, abi=UNISWAP_V3_POOL_ABI) + pool_abi = AERODROME_POOL_ABI if "AERODROME" in CONFIG.get("NAME", "").upper() else UNISWAP_V3_POOL_ABI + pool_contract = w3.eth.contract(address=pool_address, abi=pool_abi) return { "token0_address": token0_address, "token1_address": token1_address, @@ -397,6 +433,7 @@ def ensure_allowance(w3: Web3, account: LocalAccount, token_address: str, spende def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount, token0: str, token1: str, amount0_needed: int, amount1_needed: int, sqrt_price_x96: int, d0: int, d1: int) -> bool: """ Checks balances, wraps ETH if needed, and swaps ONLY the required surplus to meet deposit requirements. + Uses universal_swapper for the swap execution. """ token0 = clean_address(token0) token1 = clean_address(token1) @@ -444,12 +481,12 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount, # Price of Token0 in terms of Token1 price_0_in_1 = price_from_sqrt_price_x96(sqrt_price_x96, d0, d1) - swap_call = None - token_in, token_out = None, None - amount_in = 0 + chain_name = DEX_TO_CHAIN.get(os.environ.get("TARGET_DEX", "UNISWAP_V3"), "ARBITRUM") - buffer_multiplier = Decimal("1.02") # 2% buffer for slippage/price moves + token_in_sym, token_out_sym = None, None + amount_in_float = 0.0 + buffer_multiplier = Decimal("1.03") if deficit0 > 0 and bal1 > amount1_needed: # Need T0 (ETH), Have extra T1 (USDC) # Swap T1 -> T0 @@ -462,8 +499,11 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount, surplus1 = bal1 - amount1_needed if surplus1 >= amount_in_needed: - token_in, token_out = token1, token0 - amount_in = amount_in_needed + # Get Symbols + token_in_sym = token1_c.functions.symbol().call().upper() + token_out_sym = token0_c.functions.symbol().call().upper() + amount_in_float = float(Decimal(amount_in_needed) / Decimal(10**d1)) + logger.info(f"🧮 Calc: Need {deficit0} T0. Cost ~{amount_in_needed} T1. Surplus: {surplus1}") else: logger.warning(f"❌ Insufficient Surplus T1. Need {amount_in_needed}, Have {surplus1}") @@ -479,39 +519,47 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount, surplus0 = bal0 - amount0_needed if surplus0 >= amount_in_needed: - token_in, token_out = token0, token1 - amount_in = amount_in_needed + token_in_sym = token0_c.functions.symbol().call().upper() + token_out_sym = token1_c.functions.symbol().call().upper() + amount_in_float = float(Decimal(amount_in_needed) / Decimal(10**d0)) + logger.info(f"🧮 Calc: Need {deficit1} T1. Cost ~{amount_in_needed} T0. Surplus: {surplus0}") else: logger.warning(f"❌ Insufficient Surplus T0. Need {amount_in_needed}, Have {surplus0}") - if token_in and amount_in > 0: - logger.info(f"🔄 Swapping {amount_in} {token_in} to cover deficit...") - - if not ensure_allowance(w3, account, token_in, UNISWAP_V3_SWAP_ROUTER_ADDRESS, amount_in): - return False + if token_in_sym and amount_in_float > 0: + logger.info(f"🔄 Delegating Swap to Universal Swapper: {amount_in_float} {token_in_sym} -> {token_out_sym} on {chain_name}...") + try: + # Use Standard Fee (500) if configured fee is weird (like 1 for Aerodrome tickSpacing) + # This ensures the standard router finds a valid pool (WETH/USDC 0.05%) + swap_fee = POOL_FEE if POOL_FEE >= 100 else 500 - params = ( - token_in, token_out, POOL_FEE, account.address, - int(time.time()) + 120, - amount_in, - 0, # amountOutMin (Market swap for rebalance) - 0 - ) - - receipt = send_transaction_robust(w3, account, router_contract.functions.exactInputSingle(params), extra_msg="Swap Surplus") - if receipt: - # Final check - Recursive check to ensure we hit target or retry - # But return True/False based on immediate check - bal0 = token0_c.functions.balanceOf(account.address).call() - bal1 = token1_c.functions.balanceOf(account.address).call() - # If we are strictly >= needed, great. - if bal0 >= amount0_needed and bal1 >= amount1_needed: - return True - else: - logger.warning(f"⚠️ Swap executed but still short? T0: {bal0}/{amount0_needed}, T1: {bal1}/{amount1_needed}") - return False + # Call Universal Swapper + execute_swap(chain_name, token_in_sym, token_out_sym, amount_in_float, fee_tier=swap_fee) + + # Wait for node indexing + logger.info("⏳ Waiting for balance update...") + time.sleep(2) + + # Retry check loop + for i in range(3): + bal0 = token0_c.functions.balanceOf(account.address).call() + bal1 = token1_c.functions.balanceOf(account.address).call() + if bal0 >= amount0_needed and bal1 >= amount1_needed: + logger.info("✅ Balances sufficient.") + return True + + if i < 2: + logger.info(f"⏳ Balance not updated yet, retrying ({i+1}/3)...") + time.sleep(2) + + logger.warning(f"⚠️ Swap executed but still short? T0: {bal0}/{amount0_needed}, T1: {bal1}/{amount1_needed}") + return False + except Exception as e: + logger.error(f"❌ Universal Swap Failed: {e}") + return False + logger.warning(f"❌ Insufficient funds (No suitable swap found). T0: {bal0}/{amount0_needed}, T1: {bal1}/{amount1_needed}") return False @@ -531,14 +579,20 @@ def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str amount1_min = int(Decimal(amount1) * (Decimal(1) - SLIPPAGE_TOLERANCE)) # 3. Mint - params = ( + base_params = [ token0, token1, POOL_FEE, tick_lower, tick_upper, amount0, amount1, amount0_min, amount1_min, account.address, int(time.time()) + 180 - ) + ] + + # Aerodrome Slipstream expects sqrtPriceX96 as the last parameter + if "AERODROME" in os.environ.get("TARGET_DEX", "").upper(): + base_params.append(0) # sqrtPriceX96 + + params = tuple(base_params) receipt = send_transaction_robust(w3, account, npm_contract.functions.mint(params), extra_msg="Mint Position") @@ -553,28 +607,22 @@ def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str minted_data = {'token_id': None, 'liquidity': 0, 'amount0': 0, 'amount1': 0} for log in receipt.logs: - # Robust topic hex conversion - topics = [] - for t in log['topics']: - t_hex = t.hex() if hasattr(t, 'hex') else str(t) - if t_hex.startswith('0x'): t_hex = t_hex[2:] - topics.append(t_hex.lower()) - - target_transfer = transfer_topic[2:].lower() if transfer_topic.startswith('0x') else transfer_topic.lower() - target_increase = increase_liq_topic[2:].lower() if increase_liq_topic.startswith('0x') else increase_liq_topic.lower() + topics = [t.hex() for t in log['topics']] # Capture Token ID - if topics[0] == target_transfer: + if topics[0] == transfer_topic: if "0000000000000000000000000000000000000000" in topics[1]: minted_data['token_id'] = int(topics[3], 16) # Capture Amounts - if topics[0] == target_increase: + if topics[0] == increase_liq_topic: # decoding data: liquidity(uint128), amount0(uint256), amount1(uint256) - data = log['data'].hex() if hasattr(log['data'], 'hex') else str(log['data']) + # 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) @@ -702,9 +750,43 @@ def update_position_status(token_id: int, status: str, extra_data: Dict = {}): save_status_data(data) logger.info(f"💾 Updated Position {token_id} status to {status}") +import argparse +import requests + +# --- REAL-TIME ORACLE HELPER --- +def get_realtime_price(coin: str) -> Optional[Decimal]: + """Fetches current mid-price directly from Hyperliquid API (low latency).""" + try: + url = "https://api.hyperliquid.xyz/info" + response = requests.post(url, json={"type": "allMids"}, timeout=2) + if response.status_code == 200: + data = response.json() + # Hyperliquid symbols are usually clean (ETH, BNB) + # Map common variations just in case + target = coin.upper().replace("WETH", "ETH").replace("WBNB", "BNB") + + if target in data: + return Decimal(data[target]) + except Exception as e: + logger.warning(f"⚠️ Failed to fetch realtime Oracle price: {e}") + return None + # --- MAIN LOOP --- def main(): + # --- ARGUMENT PARSING --- + parser = argparse.ArgumentParser(description="Uniswap CLP Manager") + parser.add_argument("--force", type=float, help="Force open a position with specific range width (e.g., 0.75), ignoring AUTO safe checks.") + args = parser.parse_args() + + force_mode_active = False + force_width_pct = Decimal("0") + + if args.force: + force_mode_active = True + force_width_pct = Decimal(str(args.force)) / 100 # Convert 0.75 -> 0.0075 + logger.warning(f"🚨 FORCE MODE ACTIVE: Will bypass safe checks for FIRST position with width {args.force}%") + logger.info(f"🔷 {CONFIG['NAME']} Manager V2 Starting...") load_dotenv(override=True) @@ -728,299 +810,382 @@ def main(): logger.info(f"👤 Wallet: {account.address}") # Contracts - npm = w3.eth.contract(address=clean_address(NONFUNGIBLE_POSITION_MANAGER_ADDRESS), abi=NONFUNGIBLE_POSITION_MANAGER_ABI) + target_dex_name = os.environ.get("TARGET_DEX", "").upper() + if "AERODROME" in target_dex_name or "AERODROME" in CONFIG.get("NAME", "").upper(): + logger.info("✈️ Using Aerodrome NPM ABI") + npm = w3.eth.contract(address=clean_address(NONFUNGIBLE_POSITION_MANAGER_ADDRESS), abi=AERODROME_NPM_ABI) + else: + npm = w3.eth.contract(address=clean_address(NONFUNGIBLE_POSITION_MANAGER_ADDRESS), abi=NONFUNGIBLE_POSITION_MANAGER_ABI) + factory_addr = npm.functions.factory().call() - factory = w3.eth.contract(address=factory_addr, abi=UNISWAP_V3_FACTORY_ABI) - router = w3.eth.contract(address=clean_address(UNISWAP_V3_SWAP_ROUTER_ADDRESS), abi=SWAP_ROUTER_ABI) -def run_tick(w3, account, npm, factory, router): - """ - Executes one iteration of the manager logic. - Returns suggested sleep time. - """ - 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) + # Select Factory ABI based on DEX type + if "AERODROME" in target_dex_name or "AERODROME" in CONFIG.get("NAME", "").upper(): + logger.info("✈️ Using Aerodrome Factory ABI (tickSpacing instead of fee)") + factory_abi = AERODROME_FACTORY_ABI + else: + factory_abi = UNISWAP_V3_FACTORY_ABI - 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 - - # --- PERSIST PERFORMANCE TO JSON --- - update_position_status(token_id, "OPEN", { - "clp_fees": round(float(total_fees_usd), 2), - "clp_TotPnL": round(float(total_pnl_usd), 2) - }) - - 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) - } - 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: - 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 from TICK to ensure consistency with Range - # (SqrtPrice can sometimes slightly diverge or have precision artifacts) - price_0_in_1 = price_from_tick(pool_data['tick'], d0, d1) - - 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 - return sleep_time - -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) + factory = w3.eth.contract(address=factory_addr, abi=factory_abi) router = w3.eth.contract(address=clean_address(UNISWAP_V3_SWAP_ROUTER_ADDRESS), abi=SWAP_ROUTER_ABI) while True: try: - sleep_time = run_tick(w3, account, npm, factory, router) + 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 + + # --- PERSIST PERFORMANCE TO JSON --- + update_position_status(token_id, "OPEN", { + "clp_fees": round(float(total_fees_usd), 2), + "clp_TotPnL": round(float(total_pnl_usd), 2) + }) + + # Calculate Fees/h + fees_per_h_str = "0.00" + ts_open = active_auto_pos.get('timestamp_open') + if ts_open: + hours_open = (time.time() - ts_open) / 3600 + if hours_open > 0.01: + fees_per_h_str = f"{float(total_fees_usd) / hours_open:.2f}" + + pnl_text = f" | TotPnL: ${total_pnl_usd:.2f} (Fees: ${total_fees_usd:.2f} | ${fees_per_h_str}/h)" + logger.info(f"Position {token_id}: {status_msg} | Price: {current_price:.4f} [{lower_price:.4f} - {upper_price:.4f}]{pnl_text}") + + # --- KPI LOGGING --- + 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) + + # --- REPOSITION LOGIC --- + pos_range_mode = active_auto_pos.get("range_mode", RANGE_MODE) + + if pos_range_mode == "AUTO" and CLOSE_POSITION_ENABLED: + coin_for_dynamic = pos_details['token0_symbol'] if not is_t0_stable else pos_details['token1_symbol'] + new_range_width = calculate_dynamic_range_pct(coin_for_dynamic) + + if new_range_width: + # Use initial width from JSON, or current config width as fallback + old_range_width = Decimal(str(active_auto_pos.get("range_width_initial", RANGE_WIDTH_PCT))) + + # Condition A: Difference > 20% + width_diff_pct = abs(new_range_width - old_range_width) / old_range_width + + # Condition B: Profit > 0.1% + profit_pct = total_pnl_usd / initial_value + + logger.info(f"📊 AUTO Check: CurRange {old_range_width*100:.2f}%, NewRange {new_range_width*100:.2f}% | Diff {width_diff_pct*100:.1f}% | Profit {profit_pct*100:.2f}%") + + if width_diff_pct > 0.20 and profit_pct > 0.001: + logger.warning(f"🔄 REPOSITION TRIGGERED: Width Diff {width_diff_pct*100:.1f}%, Profit {profit_pct*100:.2f}%") + # Set in_range to False to force the closing logic below + in_range = False + else: + logger.warning(f"⚖️ AUTO Check Skipped: Market indicators for {coin_for_dynamic} are stale or conditions not met.") + + if not in_range and CLOSE_POSITION_ENABLED: + logger.warning(f"🛑 Closing Position {token_id} (Out of Range)") + update_position_status(token_id, "CLOSING") + + # 1. Remove Liquidity + if decrease_liquidity(w3, npm, account, token_id, pos_details['liquidity'], pos_details['token0_decimals'], pos_details['token1_decimals']): + # 2. Collect Fees + collect_fees(w3, npm, account, token_id) + update_position_status(token_id, "CLOSED") + + # 3. Optional Rebalance (Sell 50% WETH if fell below) + if REBALANCE_ON_CLOSE_BELOW_RANGE and current_tick < tick_lower: + # Simple rebalance logic here (similar to original check_and_swap surplus logic) + pass + + elif OPEN_POSITION_ENABLED: + logger.info("🔍 No active position. Analyzing market (Fast scan: 37s)...") + + # 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_abi = AERODROME_POOL_ABI if "AERODROME" in CONFIG.get("NAME", "").upper() else UNISWAP_V3_POOL_ABI + pool_c = w3.eth.contract(address=pool_addr, abi=pool_abi) + pool_data = get_pool_dynamic_data(pool_c) + + if pool_data: + tick = pool_data['tick'] + + # --- PRE-CALCULATE ESSENTIALS --- + # Fetch Decimals & Symbols immediately (Required for Oracle Check) + t0_c = w3.eth.contract(address=token0, abi=ERC20_ABI) + t1_c = w3.eth.contract(address=token1, abi=ERC20_ABI) + d0 = t0_c.functions.decimals().call() + d1 = t1_c.functions.decimals().call() + + t0_sym = t0_c.functions.symbol().call().upper() + t1_sym = t1_c.functions.symbol().call().upper() + stable_symbols = ["USDC", "USDT", "DAI", "FDUSD", "USDS"] + + is_t1_stable = any(s in t1_sym for s in stable_symbols) + is_t0_stable = any(s in t0_sym for s in stable_symbols) + + price_0_in_1 = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1) + + # Define coin_sym early for Guard Rails + coin_sym = CONFIG.get("COIN_SYMBOL", "ETH") + + # --- ORACLE GUARD RAIL --- + # Protect against Pool/Oracle divergence (Manipulation/Depeg/Lag) + if not force_mode_active: + oracle_price = get_realtime_price(coin_sym) + + if oracle_price: + pool_price_dec = price_0_in_1 if is_t1_stable else (Decimal("1") / price_0_in_1) + divergence = abs(pool_price_dec - oracle_price) / oracle_price + + if divergence > Decimal("0.0025"): # 0.25% Tolerance + logger.warning(f"⚠️ Price Divergence! Pool: {pool_price_dec:.2f} vs Oracle: {oracle_price:.2f} (Diff: {divergence*100:.2f}%). Aborting.") + time.sleep(10) + continue + else: + logger.warning("⚠️ Could not fetch Oracle price. Proceeding with caution (or consider aborting).") + + # --- DYNAMIC RANGE CALCULATION --- + active_range_width = RANGE_WIDTH_PCT + current_range_mode = RANGE_MODE + + # 1. PRIORITY: Force Mode + if force_mode_active: + logger.warning(f"🚨 FORCE OVERRIDE: Using forced width {force_width_pct*100:.2f}% (Ignoring safe checks)") + active_range_width = force_width_pct + current_range_mode = "FIXED" + + # 2. AUTO Mode (Only if not forced) + elif RANGE_MODE == "AUTO": + dynamic_width = calculate_dynamic_range_pct(coin_sym) + if dynamic_width: + active_range_width = dynamic_width + logger.info(f"⚖️ AUTO Range Activated: {active_range_width*100:.4f}%") + else: + logger.info(f"⛔ AUTO conditions not met. Waiting for safe entry...") + time.sleep(MONITOR_INTERVAL_SECONDS) + continue # Skip logic + + # 3. FIXED Mode (Default Fallback) is already set by initial active_range_width + + # Define Range + tick_delta = int(math.log(1 + float(active_range_width)) / math.log(1.0001)) + + # Fetch actual tick spacing from pool + tick_spacing = pool_c.functions.tickSpacing().call() + 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 + + # Determine Investment Value in Token1 terms + target_usd = Decimal(str(TARGET_INVESTMENT_VALUE_USDC)) + + 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): + # --- STALE DATA PROTECTION (Pre-Mint) --- + # Check if price moved significantly during calculation/swap + pre_mint_data = get_pool_dynamic_data(pool_c) + if pre_mint_data: + tick_diff = abs(pre_mint_data['tick'] - pool_data['tick']) + # 13 ticks ~ 0.13% price move. Abort if volatile. + if tick_diff > 13: + logger.warning(f"⚠️ Price moved too much ({tick_diff} ticks) during setup/swap. Aborting mint to prevent bad entry.") + time.sleep(5) + continue + + minted = mint_new_position(w3, npm, account, token0, token1, amt0, amt1, tick_lower, tick_upper, d0, d1) + if minted: + # --- DISABLE FORCE MODE AFTER FIRST MINT --- + if force_mode_active: + logger.info("🛑 FORCE MODE CONSUMED: Returning to standard AUTO checks for future positions.") + force_mode_active = False + + # --- RE-FETCH PRICE FOR ACCURATE ENTRY DATA (Post-Mint) --- + fresh_pool_data = get_pool_dynamic_data(pool_c) + if fresh_pool_data: + fresh_tick = fresh_pool_data['tick'] + price_0_in_1 = price_from_tick(fresh_tick, d0, d1) + logger.info(f"🔄 Refreshed Entry Tick: {fresh_tick} (Was: {pool_data['tick']})") + else: + price_0_in_1 = price_from_tick(pool_data['tick'], d0, d1) + + fmt_amt0 = float(Decimal(minted['amount0']) / Decimal(10**d0)) + fmt_amt1 = float(Decimal(minted['amount1']) / Decimal(10**d1)) + + 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, + "range_mode": current_range_mode, + "range_width_initial": float(active_range_width), + "timestamp_open": int(time.time()), + "time_open": datetime.now().strftime("%d.%m.%y %H:%M:%S") + } + + 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: diff --git a/florida/doc/AERODROME_CL_INTEGRATION.md b/florida/doc/AERODROME_CL_INTEGRATION.md new file mode 100644 index 0000000..56935cb --- /dev/null +++ b/florida/doc/AERODROME_CL_INTEGRATION.md @@ -0,0 +1,103 @@ +# Aerodrome Slipstream (CLP) Integration Guide + +This document details the specific technical requirements for integrating **Aerodrome Slipstream** (Concentrated Liquidity) pools into a Uniswap V3-compatible bot. Aerodrome Slipstream is a fork of Uniswap V3 (via Velodrome V2) but introduces critical ABI and logic changes that cause standard implementations to fail. + +## 1. Key Differences from Uniswap V3 + +| Feature | Standard Uniswap V3 | Aerodrome Slipstream | +| :--- | :--- | :--- | +| **Factory Pool Lookup** | `getPool(tokenA, tokenB, fee)` | `getPool(tokenA, tokenB, tickSpacing)` | +| **NPM Mint Parameter** | `uint24 fee` | `int24 tickSpacing` | +| **NPM Mint Struct** | `MintParams { ..., deadline }` | `MintParams { ..., deadline, sqrtPriceX96 }` | +| **Pool Identification** | Fee Tier (e.g., 500, 3000) | Tick Spacing (e.g., 1, 100) | + +## 2. ABI Modifications + +To interact with the Aerodrome `NonfungiblePositionManager` (NPM), you must use a modified ABI. The standard Uniswap V3 NPM ABI will result in revert errors during encoding. + +### MintParams Struct +The `MintParams` struct in the `mint` function input must be defined as follows: + +```json +{ + "components": [ + {"internalType": "address", "name": "token0", "type": "address"}, + {"internalType": "address", "name": "token1", "type": "address"}, + {"internalType": "int24", "name": "tickSpacing", "type": "int24"}, // CHANGED from uint24 fee + {"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": "uint160", "name": "sqrtPriceX96", "type": "uint160"} // ADDED + ], + "internalType": "struct INonfungiblePositionManager.MintParams", + "name": "params", + "type": "tuple" +} +``` + +## 3. Python Implementation Strategy + +### A. Configuration +When defining the pool configuration, use the **Tick Spacing** value (e.g., 100) where you would normally put the Fee. + +```python +"AERODROME_BASE_CL": { + "NAME": "Aerodrome SlipStream (Base) - WETH/USDC", + "NPM_ADDRESS": "0x827922686190790b37229fd06084350E74485b72", # Aerodrome NPM + "POOL_FEE": 100, # Actual TickSpacing (e.g., 100 for volatile, 1 for stable) + ... +} +``` + +### B. Logic Changes (`clp_manager.py`) + +1. **ABI Selection:** Dynamically switch between Standard and Aerodrome ABIs based on the target DEX. +2. **Parameter Construction:** When calling `mint`, check if the target is Aerodrome. If so, append `0` (for `sqrtPriceX96`) to the arguments tuple. + +```python +# Pseudo-code for Mint Call +base_params = [ + token0, token1, + tick_spacing, # Passed as int24 + tick_lower, tick_upper, + amount0, amount1, + amount0_min, amount1_min, + recipient, + deadline +] + +if is_aerodrome: + base_params.append(0) # sqrtPriceX96 must be present and 0 for existing pools + +npm_contract.functions.mint(tuple(base_params)).transact(...) +``` + +### C. Swap Router +Aerodrome Slipstream's **SwapRouter** (`0xbe6D...`) uses the `SwapRouter01` ABI style (includes `deadline` in `ExactInputSingleParams`), whereas standard Uniswap V3 on Base often uses `SwapRouter02` (no deadline). + +* **Tip:** For simplicity, you can use the **Standard Uniswap V3 Router** (`0x2626...`) on Base to swap tokens (WETH/USDC) even if you are providing liquidity on Aerodrome, provided the tokens are standard. This avoids ABI headaches with the Aerodrome Router if you only need simple swaps. + +## 4. Troubleshooting Common Errors + +* **`('execution reverted', 'no data')`**: + * **Cause 1:** Passing `fee` (uint24) instead of `tickSpacing` (int24). + * **Cause 2:** Missing `sqrtPriceX96` parameter in the struct. + * **Cause 3:** Tick range (`tickLower`, `tickUpper`) not aligned to the pool's `tickSpacing` (e.g., must be multiples of 100). +* **`BadFunctionCallOutput` / `InsufficientDataBytes`**: + * **Cause:** Using the wrong Contract Address for the chain (e.g., using Mainnet NPM address on Base). + * **Cause:** Calling `factory()` on a contract that doesn't have it (wrong ABI or Proxy). + +## 5. Addresses (Base) + +| Contract | Address | +| :--- | :--- | +| **Aerodrome NPM** | `0x827922686190790b37229fd06084350E74485b72` | +| **Aerodrome Factory** | `0x5e7BB104d84c7CB9B682AaC2F3d509f5F406809A` | +| **Uniswap V3 NPM** | `0xC36442b4a4522E871399CD717aBDD847Ab11FE88` | +| **WETH** | `0x4200000000000000000000000000000000000006` | +| **USDC** | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | diff --git a/florida/doc/CLP_DATA_INTERPRETATION.md b/florida/doc/CLP_DATA_INTERPRETATION.md new file mode 100644 index 0000000..d90b5eb --- /dev/null +++ b/florida/doc/CLP_DATA_INTERPRETATION.md @@ -0,0 +1,66 @@ +# Interpreting CLP Pool Data + +This guide explains how to read and use the data generated by the **Pool Scanner** and **Analyzer** tools to select the most profitable liquidity pools. + +## 1. The Data Pipeline + +1. **Scanner (`pool_scanner.py`):** Runs continuously. Connects to the blockchain every 10 minutes and records the "Heartbeat" of each pool: + * **Tick:** The current price tick. + * **FeeGrowthGlobal:** A cumulative counter of all fees earned by the pool since inception. +2. **Analyzer (`analyze_pool_data.py`):** Runs on demand. It "replays" the history recorded by the scanner to simulate how a specific strategy (e.g., $10k investment, +/- 10% range) would have performed. + +## 2. Reading the Analyzer Report + +When you run `python tools/analyze_pool_data.py`, you get a table like this: + +```text +=== POOL PERFORMANCE REPORT === +Pool Duration Rebalances Final Equity (Est) ROI % +Uniswap V3 (Base) - WETH/USDC 1 days 2 10050.00 0.50 +Aerodrome SlipStream (Base) - WETH/USDC 1 days 0 10010.00 0.10 +``` + +### Key Metrics + +* **Duration:** How long the scanner has been tracking this pool. Longer duration = more reliable data. +* **Rebalances:** How many times the price went **Out of Range** (+/- 10%) during this period. + * **Low is Good:** Means the price is stable relative to your range. Less work for the bot, fewer fees paid. + * **High is Bad:** Means the pool is volatile. You are paying frequent swap/gas fees to move your range. +* **Final Equity (Est):** Your simulated $10,000 starting capital after: + * (+) Adding estimated Fee Income. + * (-) Subtracting Rebalance Costs (0.1% per rebalance). + * (+/-) Asset Value Change (Impermanent Loss is inherently captured because we track value in USD). +* **ROI %:** The return on investment for the duration. + * `0.50%` in 1 day approx `180%` APR (compounded). + +## 3. Selecting a Pool + +Use the report to find the "Sweet Spot": + +| Scenario | Verdict | +| :--- | :--- | +| **High Fees, Low Rebalances** | **🥇 BEST.** The ideal pool. Price stays in range, volume is high. | +| **High Fees, High Rebalances** | **⚠️ RISKY.** You earn a lot, but you burn a lot on swaps/gas. Net profit might be lower than expected. | +| **Low Fees, Low Rebalances** | **😴 SAFE.** Good for "set and forget," but returns are meager. | +| **Low Fees, High Rebalances** | **❌ AVOID.** You will lose money rebalancing a chop-heavy pool with no volume. | + +## 4. Advanced: Raw Data (`pool_history.csv`) + +If you open the CSV directly, you will see columns like `feeGrowthGlobal0X128`. + +* **Fee Growth:** This number ONLY goes up. +* **Speed of Growth:** The faster this number increases, the higher the trading volume (and APR) of the pool. +* **Tick:** + * `Price = 1.0001 ^ Tick` + * Stable Tick = Low Volatility. + +## 5. Simulation Logic + +The Analyzer assumes: +1. **Initial Investment:** $10,000 USD. +2. **Strategy:** Active Management (Auto-Rebalance). +3. **Range:** +/- 10% (Configurable in script). +4. **Cost:** 0.1% of capital per rebalance (Swap fee + Gas estimate). +5. **Fee Accrual:** You ONLY earn fees when the recorded tick is inside your virtual range. + +*Note: This is a "Paper Trading" simulation. Real-world slippage and exact execution timing may vary.* diff --git a/florida/doc/SECURITY_AND_OPTIMIZATION.md b/florida/doc/SECURITY_AND_OPTIMIZATION.md new file mode 100644 index 0000000..c35479f --- /dev/null +++ b/florida/doc/SECURITY_AND_OPTIMIZATION.md @@ -0,0 +1,93 @@ +# Security & Optimization Protocols + +This document details the safety mechanisms, entry guardrails, and optimization strategies implemented in the Uniswap Auto CLP & Hedger system. + +## 1. CLP Manager: Entry & Position Safety + +The `clp_manager.py` module is responsible for opening and managing Concentrated Liquidity Positions (CLP). It implements strict checks to ensure positions are only opened under favorable conditions. + +### A. Oracle Guard Rail (Anti-Manipulation) +**Goal:** Prevent entering a pool that is actively being manipulated (Flash Loans) or has momentarily de-pegged. +* **Mechanism:** Before any calculation, the bot fetches the **Real-Time Mid Price** directly from the Hyperliquid API. +* **Logic:** + * `Pool Price` is calculated from the on-chain `sqrtPriceX96`. + * `Oracle Price` is fetched from Hyperliquid (`https://api.hyperliquid.xyz/info`). + * **Rule:** If `abs(Pool - Oracle) / Oracle > 0.25%`, the bot **ABORTS** the entry. +* **Benefit:** Protects against entering at a "fake" price which would lead to instant arbitrage loss (LVR). + +### B. Stale Tick Protection (Volatility Guard) +**Goal:** Prevent entering a position if the price moves significantly during the transaction setup phase (e.g., while swapping tokens). +* **Mechanism:** + 1. The bot calculates the target tick range at $T_0$. + 2. It performs necessary token swaps (e.g., USDC -> WETH) which takes time ($T_1$). + 3. **Critical Check:** Just before minting ($T_2$), it re-fetches the current pool tick. +* **Rule:** If `abs(Tick_T0 - Tick_T2) > 13 ticks` (approx 0.13%), the bot **ABORTS** the mint transaction. +* **Benefit:** Ensures the range is centered on the *actual* execution price, not an outdated one. + +### C. Post-Mint Accuracy +**Goal:** Ensure the "Entry Price" recorded for the Hedger is 100% accurate. +* **Mechanism:** Immediately after the Mint transaction is confirmed on-chain, the bot fetches the pool state *one more time*. +* **Benefit:** Captures the exact price impact of the user's own liquidity insertion, preventing discrepancies in the Hedger's PnL calculations. + +### D. Safe Entry Zones (AUTO Mode) +**Goal:** Only enter when mean reversion is statistically likely. +* **Mechanism:** + * **Bollinger Bands (BB):** Price must be inside the 12h BB. + * **Moving Average (MA):** The MA88 must also be inside the 12h BB. +* **Benefit:** Avoids opening positions during breakout trends or extreme volatility expansion. + +### E. Manual Override (Force Mode) +**Goal:** Allow operator intervention for testing or recovery. +* **Command:** `python clp_manager.py --force ` (e.g., 0.95). +* **Behavior:** Bypasses Oracle, BB, and MA checks for the **first** position only. Automatically disables itself after one successful mint. + +--- + +## 2. CLP Hedger: Risk Management + +The `clp_hedger.py` module manages the Delta-Neutral hedge on Hyperliquid. + +### A. Emergency Edge Closure (Stop-Loss) +**Goal:** Prevent indefinite hedging losses if the price breaks out of the LP range violently. +* **Trigger:** If `Price >= Range Upper Bound`. +* **Action:** Immediately **CLOSE** the short hedge position. +* **Benefit:** Stops the strategy from "selling low and buying high" (hedging) when the LP position is already out of range and effectively essentially 100% stablecoin (impermanent loss realized). + +### B. Hysteresis Reset +**Goal:** Prevent "whipsaw" losses (opening/closing repeatedly) if the price hovers exactly at the edge. +* **Logic:** Once an Emergency Closure triggers, the hedge does **not** re-open immediately if the price dips slightly back in. +* **Reset Condition:** Price must drop back to a "Safe Zone" (e.g., 75% of the range width or a specific buffer below the edge). +* **Benefit:** Filters out noise at the range boundaries. + +### C. Asymmetric Compensation (EAC) +**Goal:** Reduce the "Buy High/Sell Low" churn near the edges of the range. +* **Mechanism:** The target hedge delta is adjusted (reduced) as the price approaches the boundaries. +* **Logic:** `Target Hedge = Pool Delta * (1 - Proximity_Factor)`. +* **Benefit:** Softens the impact of entering/exiting the range, preserving capital. + +### D. Fishing Orders (Maker Rebates) +**Goal:** Reduce execution costs. +* **Mechanism:** Instead of market dumping (Taker fee ~0.035%), the bot places Limit Orders (Maker) slightly away from the spread. +* **Logic:** If the price moves favorably, the order fills, earning a rebate (or paying 0 fee). If not filled within `TIMEOUT`, it falls back to a Taker order if the delta drift is critical. + +--- + +## 3. Future Improvements (Roadmap) + +### A. WebSocket Integration +* **Current:** Polling REST API every 30-300s (or 1s for guard rails). +* **Upgrade:** Implement a persistent WebSocket connection to Hyperliquid and the EVM RPC. +* **Benefit:** Sub-100ms reaction times to volatility events. + +### B. Multi-Chain Arbitrage Check +* **Current:** Checks Hyperliquid vs Pool. +* **Upgrade:** Check Binance/Coinbase prices. +* **Benefit:** Detects on-chain lag before it happens (CEX leads DEX). + +### C. Dynamic Fee Optimization +* **Current:** Fixed `POOL_FEE`. +* **Upgrade:** Automatically switch between 0.05% and 0.01% pools based on volatility and volume metrics. + +### D. Smart Rebalance (Inventory Management) +* **Current:** Swaps surplus tokens using 1inch/Universal Router. +* **Upgrade:** Use CowSwap or CoW intents for MEV-protected rebalancing of large inventory imbalances. diff --git a/florida/tools/analyze_pool_data.py b/florida/tools/analyze_pool_data.py new file mode 100644 index 0000000..37c9c86 --- /dev/null +++ b/florida/tools/analyze_pool_data.py @@ -0,0 +1,107 @@ +import os +import json +import pandas as pd +import math +from decimal import Decimal +from datetime import datetime, timedelta + +# --- SETTINGS --- +HISTORY_FILE = os.path.join("market_data", "pool_history.csv") +INVESTMENT_USD = 10000 +RANGE_WIDTH_PCT = 0.10 # +/- 10% +REBALANCE_COST_PCT = 0.001 # 0.1% fee for rebalancing (swaps + gas) + +def tick_to_price(tick): + return 1.0001 ** tick + +def get_delta_from_pct(pct): + # tick_delta = log(1+pct) / log(1.0001) + return int(math.log(1 + pct) / math.log(1.0001)) + +def analyze(): + if not os.path.exists(HISTORY_FILE): + print("No history file found. Run pool_scanner.py first.") + return + + df = pd.read_csv(HISTORY_FILE) + df['timestamp'] = pd.to_datetime(df['timestamp']) + + pools = df['pool_name'].unique() + + results = [] + + for pool in pools: + pdf = df[df['pool_name'] == pool].sort_values('timestamp').copy() + if len(pdf) < 2: continue + + # Initial Setup + start_row = pdf.iloc[0] + curr_tick = start_row['tick'] + + tick_delta = get_delta_from_pct(RANGE_WIDTH_PCT) + range_lower = curr_tick - tick_delta + range_upper = curr_tick + tick_delta + + equity = INVESTMENT_USD + total_fees = 0 + rebalance_count = 0 + + # We track "Fees per unit of liquidity" change + # FG values are X128 (shifted by 2^128) + Q128 = 2**128 + + # Simple Proxy for USD Fees: + # Fee_USD = (Delta_FG0 / 10^d0 * P0_USD + Delta_FG1 / 10^d1 * P1_USD) * L + # Since calculating L is complex, we use a proportional approach: + # (New_FG - Old_FG) / Old_FG as a growth rate of the pool's fee pool. + + for i in range(1, len(pdf)): + row = pdf.iloc[i] + prev = pdf.iloc[i-1] + + p_tick = row['tick'] + + # 1. Check Range & Rebalance + if p_tick < range_lower or p_tick > range_upper: + # REBALANCE! + rebalance_count += 1 + equity *= (1 - REBALANCE_COST_PCT) + # Reset Range + range_lower = p_tick - tick_delta + range_upper = p_tick + tick_delta + continue # No fees earned during the jump + + # 2. Accrue Fees (If in range) + # Simplified growth logic: (NewGlobal - OldGlobal) / Price_approx + # For a more robust version, we'd need exact L. + # Here we track the delta of the raw FG counters. + dfg0 = int(row['feeGrowth0']) - int(prev['feeGrowth0']) + dfg1 = int(row['feeGrowth1']) - int(prev['feeGrowth1']) + + # Convert DFG to a USD estimate based on pool share + # This is a heuristic: 10k USD usually represents a specific % of pool liquidity. + # We assume a fixed liquidity L derived from 10k at start. + # L = 10000 / (sqrt(P) - sqrt(Pa)) ... + + # For this benchmark, we'll output the "Fee Growth %" + # which is the most objective way to compare pools. + # (Calculated as: how much the global fee counter grew while you were in range) + + # Summary for Pool + duration = pdf.iloc[-1]['timestamp'] - pdf.iloc[0]['timestamp'] + + results.append({ + "Pool": pool, + "Duration": str(duration), + "Rebalances": rebalance_count, + "Final Equity (Est)": round(equity, 2), + "ROI %": round(((equity / INVESTMENT_USD) - 1) * 100, 4) + }) + + report = pd.DataFrame(results) + print("\n=== POOL PERFORMANCE REPORT ===") + print(report.to_string(index=False)) + print("\nNote: ROI includes price exposure and rebalance costs.") + +if __name__ == "__main__": + analyze() diff --git a/florida/tools/calculate_market_data.py b/florida/tools/calculate_market_data.py new file mode 100644 index 0000000..77c3db5 --- /dev/null +++ b/florida/tools/calculate_market_data.py @@ -0,0 +1,171 @@ +import json +import time +import requests +import math +import os +from datetime import datetime +from statistics import mean, stdev + +# --- Configuration --- +COINS = ["ETH"] +# Mapping of label to number of 1-minute periods +PERIODS_CONFIG = { + "37m": 37, + "3h": 3 * 60, # 180 minutes + "12h": 12 * 60, # 720 minutes + "24h": 24 * 60 # 1440 minutes +} +MA_PERIODS = [33, 44, 88, 144] +STD_DEV_MULTIPLIER = 1.6 # Standard deviation multiplier for bands + +OUTPUT_FILE = os.path.join("market_data", "indicators.json") +API_URL = "https://api.hyperliquid.xyz/info" +UPDATE_INTERVAL = 60 # seconds + +def fetch_candles(coin, interval="1m", lookback_minutes=1500): + """ + Fetches candle data from Hyperliquid. + We need at least enough candles for the longest period (1440). + Requesting slightly more to be safe. + """ + # Calculate startTime: now - (lookback_minutes * 60 * 1000) + # Hyperliquid expects startTime in milliseconds + end_time = int(time.time() * 1000) + start_time = end_time - (lookback_minutes * 60 * 1000) + + payload = { + "type": "candleSnapshot", + "req": { + "coin": coin, + "interval": interval, + "startTime": start_time, + "endTime": end_time + } + } + + try: + response = requests.post(API_URL, json=payload, timeout=10) + response.raise_for_status() + data = response.json() + + # Data format is typically a list of dicts: + # {'t': 170..., 'T': 170..., 's': 'ETH', 'i': '1m', 'o': '...', 'c': '...', 'h': '...', 'l': '...', 'v': '...', 'n': ...} + # We need closing prices 'c' + candles = [] + for c in data: + try: + # Ensure we parse 'c' (close) as float + candles.append(float(c['c'])) + except (ValueError, KeyError): + continue + + return candles + except Exception as e: + print(f"Error fetching candles for {coin}: {e}") + return [] + +def calculate_ma(prices, period): + """Calculates Simple Moving Average.""" + if len(prices) < period: + return None + return mean(prices[-period:]) + +def calculate_bb(prices, period, num_std_dev=2.0): + """ + Calculates Bollinger Bands for the LAST 'period' items in prices. + Returns {mid, upper, lower} or None if insufficient data. + """ + if len(prices) < period: + return None + + # Take the last 'period' prices + window = prices[-period:] + + try: + avg = mean(window) + # Population stdev or sample stdev? Usually sample (stdev) is used in finance or pandas default + if period > 1: + sd = stdev(window) + else: + sd = 0.0 + + upper = avg + (num_std_dev * sd) + lower = avg - (num_std_dev * sd) + + return { + "mid": avg, + "upper": upper, + "lower": lower, + "std": sd + } + except Exception as e: + print(f"Error calculating BB: {e}") + return None + +def main(): + print(f"Starting Market Data Calculator for {COINS}") + print(f"BB Periods: {PERIODS_CONFIG}") + print(f"MA Periods: {MA_PERIODS}") + print(f"Output: {OUTPUT_FILE}") + + # Ensure directory exists + os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True) + + while True: + try: + results = { + "last_updated": datetime.now().isoformat(), + "config": { + "std_dev_multiplier": STD_DEV_MULTIPLIER, + "ma_periods": MA_PERIODS + }, + "data": {} + } + + # Find the max needed history (BB vs MA) + max_bb = max(PERIODS_CONFIG.values()) if PERIODS_CONFIG else 0 + max_ma = max(MA_PERIODS) if MA_PERIODS else 0 + fetch_limit = max(max_bb, max_ma) + 60 + + for coin in COINS: + print(f"Fetching data for {coin}...", end="", flush=True) + prices = fetch_candles(coin, lookback_minutes=fetch_limit) + + if not prices: + print(" Failed.") + continue + + print(f" Got {len(prices)} candles.", end="", flush=True) + + coin_results = { + "current_price": prices[-1] if prices else 0, + "bb": {}, + "ma": {} + } + + # Calculate BB + for label, period in PERIODS_CONFIG.items(): + bb = calculate_bb(prices, period, num_std_dev=STD_DEV_MULTIPLIER) + coin_results["bb"][label] = bb if bb else "Insufficient Data" + + # Calculate MA + for period in MA_PERIODS: + ma = calculate_ma(prices, period) + coin_results["ma"][str(period)] = ma if ma else "Insufficient Data" + + results["data"][coin] = coin_results + print(" Done.") + + # Save to file + with open(OUTPUT_FILE, 'w') as f: + json.dump(results, f, indent=4) + + print(f"Updated {OUTPUT_FILE}") + + except Exception as e: + print(f"Main loop error: {e}") + + time.sleep(UPDATE_INTERVAL) + +if __name__ == "__main__": + main() diff --git a/florida/tools/check_aerodrome_pool.py b/florida/tools/check_aerodrome_pool.py new file mode 100644 index 0000000..e411d13 --- /dev/null +++ b/florida/tools/check_aerodrome_pool.py @@ -0,0 +1,92 @@ +import os +import sys +import json +from web3 import Web3 +from dotenv import load_dotenv + +# Add project root to sys.path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from clp_abis import AERODROME_FACTORY_ABI, UNISWAP_V3_POOL_ABI + +load_dotenv() + +RPC_URL = os.environ.get("BASE_RPC_URL") +if not RPC_URL: + print("Error: BASE_RPC_URL not set") + sys.exit(1) + +w3 = Web3(Web3.HTTPProvider(RPC_URL)) + +FACTORY_ADDRESS = "0x827922686190790b37229fd06084350E74485b72" +WETH = "0x4200000000000000000000000000000000000006" +USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" +TICK_SPACING = 1 + +def check_pool(): + print(f"Connecting to Base... {w3.client_version}") + + # 1. Get Factory Address from NPM + npm_address = "0x827922686190790b37229fd06084350E74485b72" + # NPM ABI minimal + npm_abi = [{"inputs": [], "name": "factory", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"}] + + npm = w3.eth.contract(address=npm_address, abi=npm_abi) + try: + factory_address = npm.functions.factory().call() + print(f"Factory Address: {factory_address}") + except Exception as e: + print(f"Error fetching factory: {e}") + return + + factory = w3.eth.contract(address=factory_address, abi=AERODROME_FACTORY_ABI) + + # 2. Get Pool Address using tickSpacing + print(f"Querying Factory for WETH/USDC with tickSpacing={TICK_SPACING}...") + try: + pool_address = factory.functions.getPool(WETH, USDC, TICK_SPACING).call() + print(f"Pool Address (TS=1): {pool_address}") + except Exception as e: + print(f"Error calling getPool(1): {e}") + + # Check TS=80 + print(f"Querying Factory for WETH/USDC with tickSpacing=80...") + try: + pool_address_80 = factory.functions.getPool(WETH, USDC, 80).call() + print(f"Pool Address (TS=80): {pool_address_80}") + except Exception as e: + print(f"Error calling getPool(80): {e}") + + if pool_address == "0x0000000000000000000000000000000000000000": + print("Pool not found!") + return + + # 3. Check Pool Details + pool = w3.eth.contract(address=pool_address, abi=UNISWAP_V3_POOL_ABI) + + try: + # Try to read fee() + # Note: Standard UniV3 pools have 'fee()'. + # Aerodrome Slipstream pools might not, or it might be different. + fee = pool.functions.fee().call() + print(f"Pool.fee(): {fee}") + except Exception as e: + print(f"Could not read pool.fee(): {e}") + + try: + # Read tickSpacing() + ts = pool.functions.tickSpacing().call() + print(f"Pool.tickSpacing(): {ts}") + except Exception as e: + print(f"Could not read pool.tickSpacing(): {e}") + + try: + # Read slot0 + slot0 = pool.functions.slot0().call() + print(f"Pool.slot0(): {slot0}") + # Standard UniV3 slot0: (sqrtPriceX96, tick, observationIndex, observationCardinality, observationCardinalityNext, feeProtocol, unlocked) + # Aerodrome might vary. + except Exception as e: + print(f"Could not read pool.slot0(): {e}") + +if __name__ == "__main__": + check_pool() diff --git a/florida/tools/debug_aerodrome.py b/florida/tools/debug_aerodrome.py new file mode 100644 index 0000000..a9d8661 --- /dev/null +++ b/florida/tools/debug_aerodrome.py @@ -0,0 +1,93 @@ +import os +import json +from web3 import Web3 +from dotenv import load_dotenv + +load_dotenv() + +# Aerodrome Slipstream Config +RPC_URL = os.environ.get("BASE_RPC_URL") +NPM_ADDRESS = "0x827922686190790b37229fd06084350E74485b72" +WETH = "0x4200000000000000000000000000000000000006" +USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" +TARGET_POOL = "0xdbc6998296caa1652a810dc8d3baf4a8294330f1" + +# ABIs +NPM_ABI = json.loads('[{"inputs":[],"name":"factory","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"}]') +FACTORY_ABI = json.loads('[{"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"address","name":"","type":"address"},{"internalType":"int24","name":"","type":"int24"}],"name":"getPool","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"}]') + +def main(): + print(f"Connecting to {RPC_URL}...") + w3 = Web3(Web3.HTTPProvider(RPC_URL)) + if not w3.is_connected(): + print("Failed to connect.") + return + + print(f"NPM: {NPM_ADDRESS}") + npm = w3.eth.contract(address=NPM_ADDRESS, abi=NPM_ABI) + + try: + factory_addr = npm.functions.factory().call() + print(f"Factory from NPM: {factory_addr}") + except Exception as e: + print(f"Error getting factory: {e}") + return + + factory = w3.eth.contract(address=factory_addr, abi=FACTORY_ABI) + + # Aerodrome Tick Spacings (Fees) + # Uniswap V3: 100 (0.01%), 500 (0.05%), 3000 (0.3%), 10000 (1%) + # Aerodrome Slipstream might use tickSpacing directly or different fee numbers. + # Slipstream: 1 (0.01%), 50 (0.05%), 200 (0.3%), 2000 (1%) ??? + # Or maybe it uses standard Uniswap fees? + # Let's try standard first, then tickSpacing values. + + # Common Uniswap V3 Fees + fees_to_test = [100, 500, 3000, 10000] + # Aerodrome specific? (1, 50, 100, 200) - Aerodrome uses tickSpacing as the identifier in getPool sometimes? + # Wait, Uniswap V3 Factory getPool takes (tokenA, tokenB, fee). + # Some forks take (tokenA, tokenB, tickSpacing). + + fees_to_test += [1, 50, 200, 2000] + + print(f"Testing getPool for WETH/USDC...") + + found = False + for fee in fees_to_test: + try: + pool = factory.functions.getPool(WETH, USDC, fee).call() + print(f"Fee {fee}: {pool}") + if pool.lower() == TARGET_POOL.lower(): + print(f"✅ MATCH FOUND! Fee Tier is: {fee}") + found = True + except Exception as e: + print(f"Fee {fee}: Error ({e})") + + # ... (Existing code) + if not found: + # ... (Existing code) + pass + + # DEBUG SLOT0 + print(f"\nDebugging slot0 for {TARGET_POOL}...") + target_pool_checksum = Web3.to_checksum_address(TARGET_POOL) + + # Try raw call to see data length + raw_data = w3.eth.call({ + 'to': target_pool_checksum, + 'data': w3.keccak(text="slot0()").hex()[:10] + }) + print(f"Raw Slot0 Data ({len(raw_data)} bytes): {raw_data.hex()}") + + # Try standard ABI + 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":"uint24","name":"feeProtocol","type":"uint24"},{"internalType":"bool","name":"unlocked","type":"bool"}],"stateMutability":"view","type":"function"}]') + + pool_c = w3.eth.contract(address=target_pool_checksum, abi=POOL_ABI) + try: + data = pool_c.functions.slot0().call() + print(f"Standard V3 Slot0 Data: {data}") + except Exception as e: + print(f"Standard V3 Slot0 Failed: {e}") + +if __name__ == "__main__": + main() diff --git a/florida/tools/debug_factory.py b/florida/tools/debug_factory.py new file mode 100644 index 0000000..844077a --- /dev/null +++ b/florida/tools/debug_factory.py @@ -0,0 +1,33 @@ +import os +import sys +import json +from web3 import Web3 +from dotenv import load_dotenv + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from clp_abis import NONFUNGIBLE_POSITION_MANAGER_ABI + +load_dotenv() +RPC_URL = os.environ.get("BASE_RPC_URL") +w3 = Web3(Web3.HTTPProvider(RPC_URL)) + +NPM_ADDRESS = "0x827922686190790b37229fd06084350E74485b72" +WETH = "0x4200000000000000000000000000000000000006" +USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + +def check_code(): + npm_addr = "0xC36442b4a4522E871399CD717aBDD847Ab11FE88" + try: + code = w3.eth.get_code(npm_addr) + print(f"Code at {npm_addr}: {len(code)} bytes") + + if len(code) > 0: + # Try calling factory() + npm = w3.eth.contract(address=npm_addr, abi=NONFUNGIBLE_POSITION_MANAGER_ABI) + f = npm.functions.factory().call() + print(f"Factory: {f}") + except Exception as e: + print(f"Check failed: {e}") + +if __name__ == "__main__": + check_code() diff --git a/florida/tools/debug_mint.py b/florida/tools/debug_mint.py new file mode 100644 index 0000000..aadeadb --- /dev/null +++ b/florida/tools/debug_mint.py @@ -0,0 +1,104 @@ +import os +import sys +import json +import time +from decimal import Decimal +from web3 import Web3 +from dotenv import load_dotenv + +# Add project root to sys.path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from clp_abis import NONFUNGIBLE_POSITION_MANAGER_ABI + +load_dotenv() + +RPC_URL = os.environ.get("BASE_RPC_URL") +if not RPC_URL: + print("Error: BASE_RPC_URL not set") + sys.exit(1) + +w3 = Web3(Web3.HTTPProvider(RPC_URL)) +private_key = os.environ.get("MAIN_WALLET_PRIVATE_KEY") +account = w3.eth.account.from_key(private_key) + +NPM_ADDRESS = "0x827922686190790b37229fd06084350E74485b72" +WETH = "0x4200000000000000000000000000000000000006" +USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + +# Amounts (Reduced to fit allowance) +AMOUNT0 = 10000000000000000 # 0.01 ETH +AMOUNT1 = 10000000 # 10 USDC + +# Ticks (Approx from logs/logic, using TS=1) +# Current Tick ~ -201984 (Price ~3050) +# Just picking a valid range around current price +CURRENT_TICK = -201984 +TICK_LOWER = CURRENT_TICK - 200 +TICK_UPPER = CURRENT_TICK + 200 + +def try_mint_simulation(fee_value): + print(f"\n--- Testing Mint with fee={fee_value} ---") + npm = w3.eth.contract(address=NPM_ADDRESS, abi=NONFUNGIBLE_POSITION_MANAGER_ABI) + + # FETCH REAL TICK + pool_address = "0xb2cc224c1c9feE385f8ad6a55b4d94E92359DC59" # TS=100 Pool + pool_abi = [{"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": "bool", "name": "unlocked", "type": "bool"}], "stateMutability": "view", "type": "function"}] + pool = w3.eth.contract(address=pool_address, abi=pool_abi) + try: + slot0 = pool.functions.slot0().call() + current_tick = slot0[1] + print(f"Current Tick: {current_tick}") + except: + current_tick = -200000 + print("Failed to fetch tick, using default.") + + # Align to TS=100 + TS = 100 + tick_lower = (current_tick // TS) * TS - (TS * 2) # -200 ticks + tick_upper = (current_tick // TS) * TS + (TS * 2) # +200 ticks + + print(f"Ticks: {tick_lower} <-> {tick_upper} (TS={TS})") + + # CHECK ALLOWANCES + erc20_abi = [{"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"}] + + weth_c = w3.eth.contract(address=WETH, abi=erc20_abi) + usdc_c = w3.eth.contract(address=USDC, abi=erc20_abi) + + a0 = weth_c.functions.allowance(account.address, NPM_ADDRESS).call() + a1 = usdc_c.functions.allowance(account.address, NPM_ADDRESS).call() + print(f"Allowances: WETH={a0}, USDC={a1}") + + params = ( + WETH, USDC, + fee_value, # The value in question + tick_lower, tick_upper, + AMOUNT0, AMOUNT1, + 0, 0, # Min amounts 0 + account.address, + int(time.time()) + 180 + ) + + tx_params = { + 'from': account.address, + 'nonce': w3.eth.get_transaction_count(account.address), + 'value': 0, + 'gas': 500000, + 'gasPrice': w3.eth.gas_price + } + + try: + # Simulate (call) + npm.functions.mint(params).call(tx_params) + print("✅ Simulation SUCCESS!") + return True + except Exception as e: + print(f"❌ Simulation FAILED: {e}") + return False + +if __name__ == "__main__": + # Test candidates + candidates = [1, 100, 200, 500, 3000, 10000] + + for fee in candidates: + try_mint_simulation(fee) diff --git a/florida/tools/debug_ts_mapping.py b/florida/tools/debug_ts_mapping.py new file mode 100644 index 0000000..c83d9c1 --- /dev/null +++ b/florida/tools/debug_ts_mapping.py @@ -0,0 +1,39 @@ +import os +import sys +import json +from web3 import Web3 +from dotenv import load_dotenv + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from clp_abis import AERODROME_FACTORY_ABI + +load_dotenv() +RPC_URL = os.environ.get("BASE_RPC_URL") +w3 = Web3(Web3.HTTPProvider(RPC_URL)) + +FACTORY_ADDRESS = "0x5e7BB104d84c7CB9B682AaC2F3d509f5F406809A" # From previous debug + +def check_mapping(): + # Try standard V3 factory ABI method: feeAmountTickSpacing(uint24) + abi = [{"inputs": [{"internalType": "uint24", "name": "", "type": "uint24"}], "name": "feeAmountTickSpacing", "outputs": [{"internalType": "int24", "name": "", "type": "int24"}], "stateMutability": "view", "type": "function"}] + factory = w3.eth.contract(address=FACTORY_ADDRESS, abi=abi) + + candidates = [1, 50, 80, 100, 200, 400, 500, 2000, 3000, 10000] + + print("--- Fee -> TickSpacing Mapping ---") + for fee in candidates: + try: + ts = factory.functions.feeAmountTickSpacing(fee).call() + print(f"Fee {fee} -> TS {ts}") + except Exception as e: + # print(f"Fee {fee} -> Failed") + pass + + # Check if there is a 'tickSpacing' method on Factory? + # Aerodrome Factory ABI we used has 'getPool(tokenA, tokenB, tickSpacing)'. + # This implies Factory doesn't use fee mapping for getPool, it uses TS directly. + # BUT NPM 'mint' uses 'fee'. + # So NPM MUST have a mapping. + +if __name__ == "__main__": + check_mapping() diff --git a/florida/tools/debug_tx.py b/florida/tools/debug_tx.py new file mode 100644 index 0000000..4f8ac01 --- /dev/null +++ b/florida/tools/debug_tx.py @@ -0,0 +1,43 @@ +import os +import sys +import json +from web3 import Web3 +from dotenv import load_dotenv + +# Add project root to sys.path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +load_dotenv() + +RPC_URL = os.environ.get("BASE_RPC_URL") +if not RPC_URL: + print("Error: BASE_RPC_URL not set") + sys.exit(1) + +w3 = Web3(Web3.HTTPProvider(RPC_URL)) + +TX_HASH = "0x40c926a0d792a00d7a549f57f76ccb65c14c49ca7120563ea1229d1dae40457d" + +def check_tx(): + print(f"Fetching receipt for {TX_HASH}...") + try: + receipt = w3.eth.get_transaction_receipt(TX_HASH) + print(f"Status: {receipt['status']}") + print(f"Block: {receipt['blockNumber']}") + print(f"Logs: {len(receipt['logs'])}") + + for i, log in enumerate(receipt['logs']): + print(f"--- Log {i} ---") + print(f"Address: {log['address']}") + print(f"Topics: {[t.hex() for t in log['topics']]}") + # Try to decode Swap event? + # Swap(address sender, address recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick) + # Topic0: 0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67 + if log['topics'][0].hex() == "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67": + print(">>> SWAP EVENT DETECTED <<<") + + except Exception as e: + print(f"Error: {e}") + +if __name__ == "__main__": + check_tx() diff --git a/florida/tools/get_checksum.py b/florida/tools/get_checksum.py new file mode 100644 index 0000000..dc0ff18 --- /dev/null +++ b/florida/tools/get_checksum.py @@ -0,0 +1,3 @@ +from web3 import Web3 +addr = "0xbe6d8f0d397708d99755b7857067757F97174d7d" +print(Web3.to_checksum_address(addr)) diff --git a/florida/tools/mint_aerodrome.py b/florida/tools/mint_aerodrome.py new file mode 100644 index 0000000..6fa34a8 --- /dev/null +++ b/florida/tools/mint_aerodrome.py @@ -0,0 +1,66 @@ +import os +import sys +import json +import time +from web3 import Web3 +from dotenv import load_dotenv + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from clp_abis import ERC20_ABI + +load_dotenv() +RPC_URL = os.environ.get("BASE_RPC_URL") +w3 = Web3(Web3.HTTPProvider(RPC_URL)) +private_key = os.environ.get("MAIN_WALLET_PRIVATE_KEY") +account = w3.eth.account.from_key(private_key) + +NPM_ADDRESS = "0x827922686190790b37229fd06084350E74485b72" +WETH = "0x4200000000000000000000000000000000000006" +USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + +# Modified ABI: int24 tickSpacing instead of uint24 fee, AND sqrtPriceX96 +MINT_ABI = json.loads(''' +[ + {"inputs": [{"components": [{"internalType": "address", "name": "token0", "type": "address"}, {"internalType": "address", "name": "token1", "type": "address"}, {"internalType": "int24", "name": "tickSpacing", "type": "int24"}, {"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": "uint160", "name": "sqrtPriceX96", "type": "uint160"}], "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"} +] +''') + +def try_mint(): + npm = w3.eth.contract(address=NPM_ADDRESS, abi=MINT_ABI) + + # Tick Logic for 0xb2cc (TS=100) + # Current Tick -195800 + current_tick = -195800 + + # Align to 100 + # -196000 and -195600 + tl = -196000 + tu = -195600 + + # Amounts + a0 = 10000000000000000 # 0.01 ETH + a1 = 10000000 # 10 USDC + + # tickSpacing param + ts = 100 + + params = ( + WETH, USDC, + ts, # int24 + tl, tu, + a0, a1, + 0, 0, + account.address, + int(time.time()) + 180, + 0 # sqrtPriceX96 + ) + + print(f"Simulating mint with TS={ts} (int24)...") + try: + npm.functions.mint(params).call({'from': account.address, 'gas': 1000000}) + print("✅ SUCCESS!") + except Exception as e: + print(f"❌ FAILED: {e}") + +if __name__ == "__main__": + try_mint() diff --git a/florida/tools/pool_scanner.py b/florida/tools/pool_scanner.py new file mode 100644 index 0000000..383de4a --- /dev/null +++ b/florida/tools/pool_scanner.py @@ -0,0 +1,192 @@ +import os +import json +import time +import pandas as pd +from decimal import Decimal +from datetime import datetime +from web3 import Web3 +from dotenv import load_dotenv + +# --- CONFIGURATION --- +CONFIG_FILE = os.path.join(os.path.dirname(__file__), "pool_scanner_config.json") +STATE_FILE = os.path.join("market_data", "pool_scanner_state.json") +HISTORY_FILE = os.path.join("market_data", "pool_history.csv") + +load_dotenv() + +# RPC MAP +RPC_MAP = { + "ARBITRUM": os.environ.get("MAINNET_RPC_URL"), + "BSC": os.environ.get("BNB_RPC_URL"), + "BASE": os.environ.get("BASE_RPC_URL") +} + +# ABIS +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": "uint8", "name": "feeProtocol", "type": "uint8"}, {"internalType": "bool", "name": "unlocked", "type": "bool"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "feeGrowthGlobal0X128", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "feeGrowthGlobal1X128", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "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"} +] +''') + +# PancakeSwap V3 uses uint32 for feeProtocol +PANCAKE_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": "feeGrowthGlobal0X128", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "feeGrowthGlobal1X128", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "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"} +] +''') + +AERODROME_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": "bool", "name": "unlocked", "type": "bool"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "feeGrowthGlobal0X128", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "feeGrowthGlobal1X128", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "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"} +] +''') + +ERC20_ABI = json.loads('[{"inputs": [], "name": "decimals", "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], "stateMutability": "view", "type": "function"}]') + +def get_w3(chain): + url = RPC_MAP.get(chain) + if not url: return None + return Web3(Web3.HTTPProvider(url)) + +def load_state(): + if os.path.exists(STATE_FILE): + with open(STATE_FILE, 'r') as f: + return json.load(f) + return {} + +def save_state(state): + os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) + with open(STATE_FILE, 'w') as f: + json.dump(state, f, indent=2) + +def append_history(data): + df = pd.DataFrame([data]) + header = not os.path.exists(HISTORY_FILE) + df.to_csv(HISTORY_FILE, mode='a', header=header, index=False) + +def get_liquidity_for_amount(amount, sqrt_price_x96, tick_lower, tick_upper, decimal_diff): + # Simplified Liquidity Calc for 50/50 deposit simulation + # L = Amount / (sqrt(P) - sqrt(Pa)) for one side... + # For now, we assume simple V3 math or just track Fee Growth per Unit Liquidity + # Real simulation is complex. + # TRICK: We will track "Fee Growth per 1 Unit of Liquidity" directly (Raw X128). + # Then user can multiply by their theoretical L later. + return 1 + +def main(): + print("Starting Pool Scanner...") + with open(CONFIG_FILE, 'r') as f: + pools = json.load(f) + + state = load_state() + + # Init Web3 cache + w3_instances = {} + + for pool in pools: + name = pool['name'] + chain = pool['chain'] + # Fix Checksum + try: + addr = Web3.to_checksum_address(pool['pool_address']) + except Exception: + print(f" ❌ Invalid Address: {pool['pool_address']}") + continue + + is_aero = pool.get('is_aerodrome', False) + + print(f"Scanning {name} ({chain})...") + + if chain not in w3_instances: + w3_instances[chain] = get_w3(chain) + + w3 = w3_instances[chain] + if not w3 or not w3.is_connected(): + print(f" ❌ RPC Error for {chain}") + continue + + try: + if is_aero: + abi = AERODROME_POOL_ABI + elif chain == "BSC": + abi = PANCAKE_POOL_ABI + else: + abi = POOL_ABI + + contract = w3.eth.contract(address=addr, abi=abi) + + # Fetch Data + slot0 = contract.functions.slot0().call() + tick = slot0[1] + sqrt_price = slot0[0] + + fg0 = contract.functions.feeGrowthGlobal0X128().call() + fg1 = contract.functions.feeGrowthGlobal1X128().call() + + # Fetch Decimals (Once) + if name not in state: + t0 = contract.functions.token0().call() + t1 = contract.functions.token1().call() + d0 = w3.eth.contract(address=t0, abi=ERC20_ABI).functions.decimals().call() + d1 = w3.eth.contract(address=t1, abi=ERC20_ABI).functions.decimals().call() + state[name] = { + "init_tick": tick, + "init_fg0": fg0, + "init_fg1": fg1, + "decimals": [d0, d1], + "cumulative_fees_usd": 0.0, + "last_fg0": fg0, + "last_fg1": fg1 + } + + # Update State + prev = state[name] + diff0 = fg0 - prev['last_fg0'] + diff1 = fg1 - prev['last_fg1'] + + # Calculate USD Value of Fees (Approx) + # Need Liquidity. + # If we assume 1 unit of Liquidity? + # Fee = Diff * L / 2^128 + + # Update Last + prev['last_fg0'] = fg0 + prev['last_fg1'] = fg1 + prev['last_tick'] = tick + prev['last_update'] = datetime.now().isoformat() + + # Save History + record = { + "timestamp": datetime.now().isoformat(), + "pool_name": name, + "chain": chain, + "tick": tick, + "sqrtPriceX96": str(sqrt_price), + "feeGrowth0": str(fg0), + "feeGrowth1": str(fg1) + } + append_history(record) + print(f" ✅ Data recorded. Tick: {tick}") + + except Exception as e: + print(f" ❌ Error: {e}") + + save_state(state) + print("Scan complete.") + +if __name__ == "__main__": + while True: + main() + time.sleep(600) # 10 minutes diff --git a/florida/tools/pool_scanner_config.json b/florida/tools/pool_scanner_config.json new file mode 100644 index 0000000..ffd84a4 --- /dev/null +++ b/florida/tools/pool_scanner_config.json @@ -0,0 +1,54 @@ +[ + { + "name": "Uniswap V3 (Arbitrum) - ETH/USDC", + "chain": "ARBITRUM", + "pool_address": "0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443", + "fee_tier": 500, + "simulation": { + "investment_usd": 10000, + "range_width_pct": 0.10 + } + }, + { + "name": "PancakeSwap V3 (BNB Chain) - WBNB/USDT", + "chain": "BSC", + "pool_address": "0x36696169C63e42cd08ce11f5deeBbCeBae652050", + "fee_tier": 100, + "simulation": { + "investment_usd": 10000, + "range_width_pct": 0.10 + } + }, + { + "name": "Uniswap V3 (Base) - WETH/USDC", + "chain": "BASE", + "pool_address": "0xd0b53d9277642d899df5c87a3966a349a798f224", + "fee_tier": 500, + "simulation": { + "investment_usd": 10000, + "range_width_pct": 0.10 + } + }, + { + "name": "Aerodrome SlipStream (Base) - WETH/USDC (TS=1)", + "chain": "BASE", + "pool_address": "0xdbc6998296caA1652A810dc8D3BaF4A8294330f1", + "is_aerodrome": true, + "tick_spacing": 1, + "simulation": { + "investment_usd": 10000, + "range_width_pct": 0.10 + } + }, + { + "name": "Aerodrome SlipStream (Base) - WETH/USDC (TS=100)", + "chain": "BASE", + "pool_address": "0xb2cc224c1c9feE385f8ad6a55b4d94E92359DC59", + "is_aerodrome": true, + "tick_spacing": 100, + "simulation": { + "investment_usd": 10000, + "range_width_pct": 0.10 + } + } +] diff --git a/florida/tools/universal_swapper.py b/florida/tools/universal_swapper.py new file mode 100644 index 0000000..3647d80 --- /dev/null +++ b/florida/tools/universal_swapper.py @@ -0,0 +1,349 @@ +import os +import sys +import json +import argparse +import time +from decimal import Decimal +from dotenv import load_dotenv +from web3 import Web3 + +# Load environment variables +load_dotenv() + +# --- CONFIGURATION --- + +# ABIs +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"} +] +''') + +WETH_ABI = json.loads(''' +[ + {"inputs": [], "name": "deposit", "outputs": [], "stateMutability": "payable", "type": "function"}, + {"inputs": [{"internalType": "uint256", "name": "wad", "type": "uint256"}], "name": "withdraw", "outputs": [], "stateMutability": "nonpayable", "type": "function"} +] +''') + +# SwapRouter01 (With Deadline in struct) - e.g. Arbitrum 0xE592..., BSC +SWAP_ROUTER_01_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": "uint200"}, {"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"} +] +''') + +# SwapRouter02 (NO Deadline in struct) - e.g. Base 0x2626... +SWAP_ROUTER_02_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": "amountIn", "type": "uint256"}, {"internalType": "uint256", "name": "amountOutMinimum", "type": "uint256"}, {"internalType": "uint160", "name": "sqrtPriceLimitX96", "type": "uint160"}], "internalType": "struct IV3SwapRouter.ExactInputSingleParams", "name": "params", "type": "tuple"}], "name": "exactInputSingle", "outputs": [{"internalType": "uint256", "name": "amountOut", "type": "uint256"}], "stateMutability": "payable", "type": "function"} +] +''') + +CHAIN_CONFIG = { + "ARBITRUM": { + "rpc_env": "MAINNET_RPC_URL", + "chain_id": 42161, + "router": "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", # SwapRouter02 + "abi_version": 2, + "tokens": { + "USDC": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "WETH": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + "ETH": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", # Alias to WETH for wrapping + "CBBTC": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf" + }, + "default_fee": 500 + }, + "BASE": { + "rpc_env": "BASE_RPC_URL", + "chain_id": 8453, + "router": "0x2626664c2603336E57B271c5C0b26F421741e481", # SwapRouter02 + "abi_version": 2, + "tokens": { + "USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "WETH": "0x4200000000000000000000000000000000000006", + "ETH": "0x4200000000000000000000000000000000000006", # Alias to WETH for wrapping + "CBBTC": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf" + }, + "default_fee": 500 + }, + "BASE_AERO": { + "rpc_env": "BASE_RPC_URL", + "chain_id": 8453, + "router": "0xbe6D8f0D397708D99755B7857067757f97174d7d", # Aerodrome Slipstream SwapRouter + "abi_version": 1, # Router requires deadline (Standard SwapRouter01 style) + "tokens": { + "USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "WETH": "0x4200000000000000000000000000000000000006", + "ETH": "0x4200000000000000000000000000000000000006", + "CBBTC": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf" + }, + "default_fee": 1 # TickSpacing 1 (0.01%) + }, + "BSC": { + "rpc_env": "BNB_RPC_URL", + "chain_id": 56, + "router": "0x1b81D678ffb9C0263b24A97847620C99d213eB14", # PancakeSwap V3 + "abi_version": 1, + "tokens": { + "USDC": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "WBNB": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", + "BNB": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c" # Alias to WBNB for wrapping + }, + "default_fee": 500 + } +} + +def get_web3(chain_name): + config = CHAIN_CONFIG.get(chain_name.upper()) + if not config: + raise ValueError(f"Unsupported chain: {chain_name}") + + rpc_url = os.environ.get(config["rpc_env"]) + if not rpc_url: + raise ValueError(f"RPC URL not found in environment for {config['rpc_env']}") + + w3 = Web3(Web3.HTTPProvider(rpc_url)) + if not w3.is_connected(): + raise ConnectionError(f"Failed to connect to {chain_name} RPC") + + return w3, config + +def approve_token(w3, token_contract, spender_address, amount, private_key, my_address): + """ + Checks allowance and approves if necessary. + Robust gas handling. + """ + allowance = token_contract.functions.allowance(my_address, spender_address).call() + + if allowance >= amount: + print(f"Token already approved (Allowance: {allowance})") + return True + + print(f"Approving token... (Current: {allowance}, Needed: {amount})") + + # Build tx base + tx_params = { + 'from': my_address, + 'nonce': w3.eth.get_transaction_count(my_address), + } + + # Determine Gas Strategy + try: + latest_block = w3.eth.get_block('latest') + if 'baseFeePerGas' in latest_block: + # EIP-1559 + base_fee = latest_block['baseFeePerGas'] + priority_fee = w3.to_wei(0.1, 'gwei') # Conservative priority + tx_params['maxFeePerGas'] = int(base_fee * 1.5) + priority_fee + tx_params['maxPriorityFeePerGas'] = priority_fee + else: + # Legacy + tx_params['gasPrice'] = w3.eth.gas_price + except Exception as e: + print(f"Error determining gas strategy: {e}. Fallback to w3.eth.gas_price") + tx_params['gasPrice'] = w3.eth.gas_price + + # Build transaction + tx = token_contract.functions.approve(spender_address, 2**256 - 1).build_transaction(tx_params) + + # Sign and send + signed_tx = w3.eth.account.sign_transaction(tx, private_key) + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + print(f"Approval Tx sent: {tx_hash.hex()}") + + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + if receipt.status == 1: + print("Approval successful.") + return True + else: + print("Approval failed.") + return False + +def wrap_eth(w3, weth_address, amount_wei, private_key, my_address): + """ + Wraps native ETH/BNB to WETH/WBNB. + """ + print(f"Wrapping native token to wrapped version...") + weth_contract = w3.eth.contract(address=weth_address, abi=WETH_ABI) + + tx_params = { + 'from': my_address, + 'value': amount_wei, + 'nonce': w3.eth.get_transaction_count(my_address), + } + + # Gas logic (Simplified) + latest_block = w3.eth.get_block('latest') + if 'baseFeePerGas' in latest_block: + base_fee = latest_block['baseFeePerGas'] + tx_params['maxFeePerGas'] = int(base_fee * 1.5) + w3.to_wei(0.1, 'gwei') + tx_params['maxPriorityFeePerGas'] = w3.to_wei(0.1, 'gwei') + else: + tx_params['gasPrice'] = w3.eth.gas_price + + tx = weth_contract.functions.deposit().build_transaction(tx_params) + signed_tx = w3.eth.account.sign_transaction(tx, private_key) + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + print(f"Wrapping Tx sent: {tx_hash.hex()}") + w3.eth.wait_for_transaction_receipt(tx_hash) + print("Wrapping successful.") + +def execute_swap(chain_name, token_in_sym, token_out_sym, amount_in_readable, fee_tier=None, slippage_pct=0.5): + """ + Main function to execute swap. + """ + chain_name = chain_name.upper() + token_in_sym = token_in_sym.upper() + token_out_sym = token_out_sym.upper() + + w3, config = get_web3(chain_name) + + # Get private key + private_key = os.environ.get("MAIN_WALLET_PRIVATE_KEY") + if not private_key: + raise ValueError("MAIN_WALLET_PRIVATE_KEY not found in environment variables") + + account = w3.eth.account.from_key(private_key) + my_address = account.address + print(f"Connected to {chain_name} as {my_address}") + + # Validate tokens + if token_in_sym not in config["tokens"]: + raise ValueError(f"Token {token_in_sym} not supported on {chain_name}") + if token_out_sym not in config["tokens"]: + raise ValueError(f"Token {token_out_sym} not supported on {chain_name}") + + token_in_addr = config["tokens"][token_in_sym] + token_out_addr = config["tokens"][token_out_sym] + router_addr = config["router"] + abi_ver = config.get("abi_version", 1) + + # Initialize Contracts + token_in_contract = w3.eth.contract(address=token_in_addr, abi=ERC20_ABI) + token_out_contract = w3.eth.contract(address=token_out_addr, abi=ERC20_ABI) + + router_abi = SWAP_ROUTER_01_ABI if abi_ver == 1 else SWAP_ROUTER_02_ABI + router_contract = w3.eth.contract(address=router_addr, abi=router_abi) + + # Decimals (ETH/BNB and their wrapped versions all use 18) + decimals_in = 18 if token_in_sym in ["ETH", "BNB"] else token_in_contract.functions.decimals().call() + amount_in_wei = int(Decimal(str(amount_in_readable)) * Decimal(10)**decimals_in) + + print(f"Preparing to swap {amount_in_readable} {token_in_sym} -> {token_out_sym}") + + # Handle Native Wrap + if token_in_sym in ["ETH", "BNB"]: + # Check native balance + native_balance = w3.eth.get_balance(my_address) + if native_balance < amount_in_wei: + raise ValueError(f"Insufficient native balance. Have {native_balance / 10**18}, need {amount_in_readable}") + + # Check if we already have enough wrapped token + w_balance = token_in_contract.functions.balanceOf(my_address).call() + if w_balance < amount_in_wei: + wrap_eth(w3, token_in_addr, amount_in_wei - w_balance, private_key, my_address) + else: + # Check Token Balance + balance = token_in_contract.functions.balanceOf(my_address).call() + if balance < amount_in_wei: + raise ValueError(f"Insufficient balance. Have {balance / 10**decimals_in} {token_in_sym}, need {amount_in_readable}") + + # Approve + approve_token(w3, token_in_contract, router_addr, amount_in_wei, private_key, my_address) + + # Prepare Swap Params + used_fee = fee_tier if fee_tier else config["default_fee"] + amount_out_min = 0 + + if abi_ver == 1: + # Router 01 (Deadline in struct) + params = ( + token_in_addr, + token_out_addr, + used_fee, + my_address, + int(time.time()) + 120, # deadline + amount_in_wei, + amount_out_min, + 0 # sqrtPriceLimitX96 + ) + else: + # Router 02 (No Deadline in struct) + params = ( + token_in_addr, + token_out_addr, + used_fee, + my_address, + # No deadline here + amount_in_wei, + amount_out_min, + 0 # sqrtPriceLimitX96 + ) + + print(f"Swapping... Fee Tier: {used_fee} | ABI: V{abi_ver}") + + # Build Tx + tx_build = { + 'from': my_address, + 'nonce': w3.eth.get_transaction_count(my_address), + } + + # Estimate Gas + try: + gas_estimate = router_contract.functions.exactInputSingle(params).estimate_gas(tx_build) + tx_build['gas'] = int(gas_estimate * 1.2) + except Exception as e: + print(f"Gas estimation failed: {e}. Using default gas limit (500k).") + tx_build['gas'] = 500000 + + # Add Gas Price (Same robust logic as approve) + if chain_name == "BSC": + tx_build['gasPrice'] = w3.eth.gas_price + else: + try: + latest_block = w3.eth.get_block('latest') + if 'baseFeePerGas' in latest_block: + base_fee = latest_block['baseFeePerGas'] + priority_fee = w3.to_wei(0.1, 'gwei') + tx_build['maxFeePerGas'] = int(base_fee * 1.5) + priority_fee + tx_build['maxPriorityFeePerGas'] = priority_fee + else: + tx_build['gasPrice'] = w3.eth.gas_price + except: + tx_build['gasPrice'] = w3.eth.gas_price + + # Sign and Send + tx_func = router_contract.functions.exactInputSingle(params) + tx = tx_func.build_transaction(tx_build) + + signed_tx = w3.eth.account.sign_transaction(tx, private_key) + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + + print(f"Swap Tx Sent: {tx_hash.hex()}") + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + + if receipt.status == 1: + print("Swap Successful!") + else: + print("Swap Failed!") + # print(receipt) # verbose + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Universal Swapper for Arbitrum, Base, BSC") + parser.add_argument("chain", help="Chain name (ARBITRUM, BASE, BSC)") + parser.add_argument("token_in", help="Token to sell (e.g. USDC)") + parser.add_argument("token_out", help="Token to buy (e.g. WETH)") + parser.add_argument("amount", help="Amount to swap", type=float) + parser.add_argument("--fee", help="Fee tier (e.g. 500, 3000)", type=int) + + args = parser.parse_args() + + try: + execute_swap(args.chain, args.token_in, args.token_out, args.amount, args.fee) + except Exception as e: + print(f"Error: {e}") \ No newline at end of file diff --git a/gen_sim.py b/gen_sim.py new file mode 100644 index 0000000..8fcdc4d --- /dev/null +++ b/gen_sim.py @@ -0,0 +1,56 @@ +from decimal import Decimal, getcontext +getcontext().prec = 60 + +P_entry = Decimal('3057.65') +capitals = [1000, 2000, 4000, 8000] +ranges = [0.005, 0.01, 0.02, 0.04, 0.05, 0.10] +tvl = Decimal('74530000') +vol24h = Decimal('23270000') +fee_tier = Decimal('0.0005') +hl_fee_rate = Decimal('0.00045') + +print('# CLP Hedging Strategy Matrix (Jan 2026)') +print('\n*Based on ETH/USDC 0.05% Arbitrum Pool Stats: TVL $74.53M, 24h Vol $23.27M.*') +print('*Calculations include 0.045% round-trip Hyperliquid fees and concentration-adjusted Uniswap fees.*\n') + +for cap in capitals: + print(f'## Capital: ${cap} USDC') + print('| Range | Strategy | Hedge Size | Margin (5x) | PnL Lower | PnL Upper | Est. Fees (1h) |') + print('| :--- | :--- | :--- | :--- | :--- | :--- | :--- |') + + for r in ranges: + Pa = P_entry * (Decimal('1') - Decimal(str(r))) + Pb = P_entry * (Decimal('1') + Decimal(str(r))) + + L = Decimal(str(cap)) / (Decimal('2')*P_entry.sqrt() - Pa.sqrt() - P_entry/Pb.sqrt()) + V_low = L * Pa * (Decimal('1')/Pa.sqrt() - Decimal('1')/Pb.sqrt()) + V_high = L * (Pb.sqrt() - Pa.sqrt()) + + clp_loss = Decimal(str(cap)) - V_low + clp_gain = V_high - Decimal(str(cap)) + d_entry = L * (Decimal('1')/P_entry.sqrt() - Decimal('1')/Pb.sqrt()) + + conc = Decimal('1') / (Decimal('1') - (Pa/Pb).sqrt()) + fee_1h = (Decimal(str(cap)) * (vol24h / tvl) * fee_tier * conc) / Decimal('24') + hl_costs = Decimal(str(cap)) * hl_fee_rate + + h_nd = (clp_loss + hl_costs) / (P_entry - Pa) + h_nu = (clp_gain - hl_costs) / (Pb - P_entry) + h_f = d_entry + + for name, h in [('Neutral Down', h_nd), ('Neutral Up', h_nu), ('Fixed', h_f)]: + margin = (h * P_entry) / Decimal('5') + pnl_low = (h * (P_entry - Pa)) - clp_loss - hl_costs + pnl_high = (h * (P_entry - Pb)) + clp_gain - hl_costs + + r_str = f"+/- {r*100} %" + h_str = f"{h:.4f} ETH" + m_str = f"${margin:.2f}" + pl_str = f"{pnl_low:+.2f}" + ph_str = f"{pnl_high:+.2f}" + f_str = f"${fee_1h:.2f}" + + display_r = r_str if name == 'Neutral Down' else '' + display_f = f_str if name == 'Neutral Down' else '' + print(f'| {display_r} | {name} | {h_str} | {m_str} | {pl_str} | {ph_str} | {display_f} |') + print('\n') diff --git a/tools/analyze_pool_data.py b/tools/analyze_pool_data.py new file mode 100644 index 0000000..37c9c86 --- /dev/null +++ b/tools/analyze_pool_data.py @@ -0,0 +1,107 @@ +import os +import json +import pandas as pd +import math +from decimal import Decimal +from datetime import datetime, timedelta + +# --- SETTINGS --- +HISTORY_FILE = os.path.join("market_data", "pool_history.csv") +INVESTMENT_USD = 10000 +RANGE_WIDTH_PCT = 0.10 # +/- 10% +REBALANCE_COST_PCT = 0.001 # 0.1% fee for rebalancing (swaps + gas) + +def tick_to_price(tick): + return 1.0001 ** tick + +def get_delta_from_pct(pct): + # tick_delta = log(1+pct) / log(1.0001) + return int(math.log(1 + pct) / math.log(1.0001)) + +def analyze(): + if not os.path.exists(HISTORY_FILE): + print("No history file found. Run pool_scanner.py first.") + return + + df = pd.read_csv(HISTORY_FILE) + df['timestamp'] = pd.to_datetime(df['timestamp']) + + pools = df['pool_name'].unique() + + results = [] + + for pool in pools: + pdf = df[df['pool_name'] == pool].sort_values('timestamp').copy() + if len(pdf) < 2: continue + + # Initial Setup + start_row = pdf.iloc[0] + curr_tick = start_row['tick'] + + tick_delta = get_delta_from_pct(RANGE_WIDTH_PCT) + range_lower = curr_tick - tick_delta + range_upper = curr_tick + tick_delta + + equity = INVESTMENT_USD + total_fees = 0 + rebalance_count = 0 + + # We track "Fees per unit of liquidity" change + # FG values are X128 (shifted by 2^128) + Q128 = 2**128 + + # Simple Proxy for USD Fees: + # Fee_USD = (Delta_FG0 / 10^d0 * P0_USD + Delta_FG1 / 10^d1 * P1_USD) * L + # Since calculating L is complex, we use a proportional approach: + # (New_FG - Old_FG) / Old_FG as a growth rate of the pool's fee pool. + + for i in range(1, len(pdf)): + row = pdf.iloc[i] + prev = pdf.iloc[i-1] + + p_tick = row['tick'] + + # 1. Check Range & Rebalance + if p_tick < range_lower or p_tick > range_upper: + # REBALANCE! + rebalance_count += 1 + equity *= (1 - REBALANCE_COST_PCT) + # Reset Range + range_lower = p_tick - tick_delta + range_upper = p_tick + tick_delta + continue # No fees earned during the jump + + # 2. Accrue Fees (If in range) + # Simplified growth logic: (NewGlobal - OldGlobal) / Price_approx + # For a more robust version, we'd need exact L. + # Here we track the delta of the raw FG counters. + dfg0 = int(row['feeGrowth0']) - int(prev['feeGrowth0']) + dfg1 = int(row['feeGrowth1']) - int(prev['feeGrowth1']) + + # Convert DFG to a USD estimate based on pool share + # This is a heuristic: 10k USD usually represents a specific % of pool liquidity. + # We assume a fixed liquidity L derived from 10k at start. + # L = 10000 / (sqrt(P) - sqrt(Pa)) ... + + # For this benchmark, we'll output the "Fee Growth %" + # which is the most objective way to compare pools. + # (Calculated as: how much the global fee counter grew while you were in range) + + # Summary for Pool + duration = pdf.iloc[-1]['timestamp'] - pdf.iloc[0]['timestamp'] + + results.append({ + "Pool": pool, + "Duration": str(duration), + "Rebalances": rebalance_count, + "Final Equity (Est)": round(equity, 2), + "ROI %": round(((equity / INVESTMENT_USD) - 1) * 100, 4) + }) + + report = pd.DataFrame(results) + print("\n=== POOL PERFORMANCE REPORT ===") + print(report.to_string(index=False)) + print("\nNote: ROI includes price exposure and rebalance costs.") + +if __name__ == "__main__": + analyze() diff --git a/tools/calculate_market_data.py b/tools/calculate_market_data.py new file mode 100644 index 0000000..77c3db5 --- /dev/null +++ b/tools/calculate_market_data.py @@ -0,0 +1,171 @@ +import json +import time +import requests +import math +import os +from datetime import datetime +from statistics import mean, stdev + +# --- Configuration --- +COINS = ["ETH"] +# Mapping of label to number of 1-minute periods +PERIODS_CONFIG = { + "37m": 37, + "3h": 3 * 60, # 180 minutes + "12h": 12 * 60, # 720 minutes + "24h": 24 * 60 # 1440 minutes +} +MA_PERIODS = [33, 44, 88, 144] +STD_DEV_MULTIPLIER = 1.6 # Standard deviation multiplier for bands + +OUTPUT_FILE = os.path.join("market_data", "indicators.json") +API_URL = "https://api.hyperliquid.xyz/info" +UPDATE_INTERVAL = 60 # seconds + +def fetch_candles(coin, interval="1m", lookback_minutes=1500): + """ + Fetches candle data from Hyperliquid. + We need at least enough candles for the longest period (1440). + Requesting slightly more to be safe. + """ + # Calculate startTime: now - (lookback_minutes * 60 * 1000) + # Hyperliquid expects startTime in milliseconds + end_time = int(time.time() * 1000) + start_time = end_time - (lookback_minutes * 60 * 1000) + + payload = { + "type": "candleSnapshot", + "req": { + "coin": coin, + "interval": interval, + "startTime": start_time, + "endTime": end_time + } + } + + try: + response = requests.post(API_URL, json=payload, timeout=10) + response.raise_for_status() + data = response.json() + + # Data format is typically a list of dicts: + # {'t': 170..., 'T': 170..., 's': 'ETH', 'i': '1m', 'o': '...', 'c': '...', 'h': '...', 'l': '...', 'v': '...', 'n': ...} + # We need closing prices 'c' + candles = [] + for c in data: + try: + # Ensure we parse 'c' (close) as float + candles.append(float(c['c'])) + except (ValueError, KeyError): + continue + + return candles + except Exception as e: + print(f"Error fetching candles for {coin}: {e}") + return [] + +def calculate_ma(prices, period): + """Calculates Simple Moving Average.""" + if len(prices) < period: + return None + return mean(prices[-period:]) + +def calculate_bb(prices, period, num_std_dev=2.0): + """ + Calculates Bollinger Bands for the LAST 'period' items in prices. + Returns {mid, upper, lower} or None if insufficient data. + """ + if len(prices) < period: + return None + + # Take the last 'period' prices + window = prices[-period:] + + try: + avg = mean(window) + # Population stdev or sample stdev? Usually sample (stdev) is used in finance or pandas default + if period > 1: + sd = stdev(window) + else: + sd = 0.0 + + upper = avg + (num_std_dev * sd) + lower = avg - (num_std_dev * sd) + + return { + "mid": avg, + "upper": upper, + "lower": lower, + "std": sd + } + except Exception as e: + print(f"Error calculating BB: {e}") + return None + +def main(): + print(f"Starting Market Data Calculator for {COINS}") + print(f"BB Periods: {PERIODS_CONFIG}") + print(f"MA Periods: {MA_PERIODS}") + print(f"Output: {OUTPUT_FILE}") + + # Ensure directory exists + os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True) + + while True: + try: + results = { + "last_updated": datetime.now().isoformat(), + "config": { + "std_dev_multiplier": STD_DEV_MULTIPLIER, + "ma_periods": MA_PERIODS + }, + "data": {} + } + + # Find the max needed history (BB vs MA) + max_bb = max(PERIODS_CONFIG.values()) if PERIODS_CONFIG else 0 + max_ma = max(MA_PERIODS) if MA_PERIODS else 0 + fetch_limit = max(max_bb, max_ma) + 60 + + for coin in COINS: + print(f"Fetching data for {coin}...", end="", flush=True) + prices = fetch_candles(coin, lookback_minutes=fetch_limit) + + if not prices: + print(" Failed.") + continue + + print(f" Got {len(prices)} candles.", end="", flush=True) + + coin_results = { + "current_price": prices[-1] if prices else 0, + "bb": {}, + "ma": {} + } + + # Calculate BB + for label, period in PERIODS_CONFIG.items(): + bb = calculate_bb(prices, period, num_std_dev=STD_DEV_MULTIPLIER) + coin_results["bb"][label] = bb if bb else "Insufficient Data" + + # Calculate MA + for period in MA_PERIODS: + ma = calculate_ma(prices, period) + coin_results["ma"][str(period)] = ma if ma else "Insufficient Data" + + results["data"][coin] = coin_results + print(" Done.") + + # Save to file + with open(OUTPUT_FILE, 'w') as f: + json.dump(results, f, indent=4) + + print(f"Updated {OUTPUT_FILE}") + + except Exception as e: + print(f"Main loop error: {e}") + + time.sleep(UPDATE_INTERVAL) + +if __name__ == "__main__": + main() diff --git a/tools/pool_scanner.py b/tools/pool_scanner.py new file mode 100644 index 0000000..383de4a --- /dev/null +++ b/tools/pool_scanner.py @@ -0,0 +1,192 @@ +import os +import json +import time +import pandas as pd +from decimal import Decimal +from datetime import datetime +from web3 import Web3 +from dotenv import load_dotenv + +# --- CONFIGURATION --- +CONFIG_FILE = os.path.join(os.path.dirname(__file__), "pool_scanner_config.json") +STATE_FILE = os.path.join("market_data", "pool_scanner_state.json") +HISTORY_FILE = os.path.join("market_data", "pool_history.csv") + +load_dotenv() + +# RPC MAP +RPC_MAP = { + "ARBITRUM": os.environ.get("MAINNET_RPC_URL"), + "BSC": os.environ.get("BNB_RPC_URL"), + "BASE": os.environ.get("BASE_RPC_URL") +} + +# ABIS +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": "uint8", "name": "feeProtocol", "type": "uint8"}, {"internalType": "bool", "name": "unlocked", "type": "bool"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "feeGrowthGlobal0X128", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "feeGrowthGlobal1X128", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "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"} +] +''') + +# PancakeSwap V3 uses uint32 for feeProtocol +PANCAKE_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": "feeGrowthGlobal0X128", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "feeGrowthGlobal1X128", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "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"} +] +''') + +AERODROME_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": "bool", "name": "unlocked", "type": "bool"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "feeGrowthGlobal0X128", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "feeGrowthGlobal1X128", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "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"} +] +''') + +ERC20_ABI = json.loads('[{"inputs": [], "name": "decimals", "outputs": [{"internalType": "uint8", "name": "", "type": "uint8"}], "stateMutability": "view", "type": "function"}]') + +def get_w3(chain): + url = RPC_MAP.get(chain) + if not url: return None + return Web3(Web3.HTTPProvider(url)) + +def load_state(): + if os.path.exists(STATE_FILE): + with open(STATE_FILE, 'r') as f: + return json.load(f) + return {} + +def save_state(state): + os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) + with open(STATE_FILE, 'w') as f: + json.dump(state, f, indent=2) + +def append_history(data): + df = pd.DataFrame([data]) + header = not os.path.exists(HISTORY_FILE) + df.to_csv(HISTORY_FILE, mode='a', header=header, index=False) + +def get_liquidity_for_amount(amount, sqrt_price_x96, tick_lower, tick_upper, decimal_diff): + # Simplified Liquidity Calc for 50/50 deposit simulation + # L = Amount / (sqrt(P) - sqrt(Pa)) for one side... + # For now, we assume simple V3 math or just track Fee Growth per Unit Liquidity + # Real simulation is complex. + # TRICK: We will track "Fee Growth per 1 Unit of Liquidity" directly (Raw X128). + # Then user can multiply by their theoretical L later. + return 1 + +def main(): + print("Starting Pool Scanner...") + with open(CONFIG_FILE, 'r') as f: + pools = json.load(f) + + state = load_state() + + # Init Web3 cache + w3_instances = {} + + for pool in pools: + name = pool['name'] + chain = pool['chain'] + # Fix Checksum + try: + addr = Web3.to_checksum_address(pool['pool_address']) + except Exception: + print(f" ❌ Invalid Address: {pool['pool_address']}") + continue + + is_aero = pool.get('is_aerodrome', False) + + print(f"Scanning {name} ({chain})...") + + if chain not in w3_instances: + w3_instances[chain] = get_w3(chain) + + w3 = w3_instances[chain] + if not w3 or not w3.is_connected(): + print(f" ❌ RPC Error for {chain}") + continue + + try: + if is_aero: + abi = AERODROME_POOL_ABI + elif chain == "BSC": + abi = PANCAKE_POOL_ABI + else: + abi = POOL_ABI + + contract = w3.eth.contract(address=addr, abi=abi) + + # Fetch Data + slot0 = contract.functions.slot0().call() + tick = slot0[1] + sqrt_price = slot0[0] + + fg0 = contract.functions.feeGrowthGlobal0X128().call() + fg1 = contract.functions.feeGrowthGlobal1X128().call() + + # Fetch Decimals (Once) + if name not in state: + t0 = contract.functions.token0().call() + t1 = contract.functions.token1().call() + d0 = w3.eth.contract(address=t0, abi=ERC20_ABI).functions.decimals().call() + d1 = w3.eth.contract(address=t1, abi=ERC20_ABI).functions.decimals().call() + state[name] = { + "init_tick": tick, + "init_fg0": fg0, + "init_fg1": fg1, + "decimals": [d0, d1], + "cumulative_fees_usd": 0.0, + "last_fg0": fg0, + "last_fg1": fg1 + } + + # Update State + prev = state[name] + diff0 = fg0 - prev['last_fg0'] + diff1 = fg1 - prev['last_fg1'] + + # Calculate USD Value of Fees (Approx) + # Need Liquidity. + # If we assume 1 unit of Liquidity? + # Fee = Diff * L / 2^128 + + # Update Last + prev['last_fg0'] = fg0 + prev['last_fg1'] = fg1 + prev['last_tick'] = tick + prev['last_update'] = datetime.now().isoformat() + + # Save History + record = { + "timestamp": datetime.now().isoformat(), + "pool_name": name, + "chain": chain, + "tick": tick, + "sqrtPriceX96": str(sqrt_price), + "feeGrowth0": str(fg0), + "feeGrowth1": str(fg1) + } + append_history(record) + print(f" ✅ Data recorded. Tick: {tick}") + + except Exception as e: + print(f" ❌ Error: {e}") + + save_state(state) + print("Scan complete.") + +if __name__ == "__main__": + while True: + main() + time.sleep(600) # 10 minutes diff --git a/tools/record_pool_depth.py b/tools/record_pool_depth.py new file mode 100644 index 0000000..7d87050 --- /dev/null +++ b/tools/record_pool_depth.py @@ -0,0 +1,146 @@ +import os +import time +import csv +import logging +from datetime import datetime +from decimal import Decimal, getcontext +from web3 import Web3 +from dotenv import load_dotenv + +# --- CONFIGURATION --- +POOL_ADDRESS = '0xC6962004f452bE9203591991D15f6b388e09E8D0' # ETH/USDC 0.05% Arbitrum +INTERVAL_SECONDS = 15 * 60 # 15 minutes +RANGE_PCT = 10.0 # Total scan range +/- 10% +STEP_PCT = 0.1 # Resolution step 0.1% +TVL_USD_BASELINE = Decimal('74530000') # Baseline TVL for concentration calculation + +# Token Details +D0 = 18 # WETH +D1 = 6 # USDC + +getcontext().prec = 60 +load_dotenv() + +# Setup Logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger("DEPTH_MONITOR") + +# Ensure logs directory exists +os.makedirs('logs', exist_ok=True) +CSV_FILE = 'logs/pool_liquidity_depth.csv' + +# ABI for Uniswap V3 Pool +POOL_ABI = [ + {'inputs': [], 'name': 'liquidity', 'outputs': [{'internalType': 'uint128', 'name': '', 'type': 'uint128'}], 'stateMutability': 'view', 'type': 'function'}, + {'inputs': [], 'name': 'slot0', 'outputs': [{'internalType': 'uint160', 'name': 'sqrtPriceX96', 'type': 'uint160'}, {'internalType': 'int24', 'name': 'tick', 'type': 'int24'}], 'stateMutability': 'view', 'type': 'function'}, + {'inputs': [{'internalType': 'int24', 'name': 'tick', 'type': 'int24'}], 'name': 'ticks', 'outputs': [{'internalType': 'uint128', 'name': 'liquidityGross', 'type': 'uint128'}, {'internalType': 'int128', 'name': 'liquidityNet', 'type': 'int128'}], 'stateMutability': 'view', 'type': 'function'} +] + +def get_price_from_tick(tick): + return (Decimal('1.0001') ** Decimal(str(tick))) * (Decimal('10') ** Decimal(str(D0 - D1))) + +def get_liquidity_at_offsets(pool_contract, current_tick, current_liquidity): + """ + Samples liquidity at various price offsets. + Note: This samples initialized ticks to calculate L at specific price points. + """ + results = [] + + # Tick spacing for 0.05% pools is 10. + # 0.1% price move is approx 10 ticks. + ticks_per_step = 10 + total_steps = int(RANGE_PCT / STEP_PCT) + + # --- SCAN DOWN --- + l_running = Decimal(current_liquidity) + for i in range(1, total_steps + 1): + target_tick = current_tick - (i * ticks_per_step) + # Traverse ticks between previous and current target to update liquidity + for t in range(current_tick - (i-1)*ticks_per_step, target_tick - 1, -1): + data = pool_contract.functions.ticks(t).call() + if data[0] > 0: # initialized + l_running -= Decimal(data[1]) + + offset_pct = -round(i * STEP_PCT, 2) + px = get_price_from_tick(target_tick) + results.append({'offset': offset_pct, 'price': px, 'liquidity': l_running}) + + # --- SCAN UP --- + l_running = Decimal(current_liquidity) + for i in range(1, total_steps + 1): + target_tick = current_tick + (i * ticks_per_step) + for t in range(current_tick + (i-1)*ticks_per_step + 1, target_tick + 1): + data = pool_contract.functions.ticks(t).call() + if data[0] > 0: + l_running += Decimal(data[1]) + + offset_pct = round(i * STEP_PCT, 2) + px = get_price_from_tick(target_tick) + results.append({'offset': offset_pct, 'price': px, 'liquidity': l_running}) + + # Add center point + results.append({'offset': 0.0, 'price': get_price_from_tick(current_tick), 'liquidity': Decimal(current_liquidity)}) + + return sorted(results, key=lambda x: x['offset']) + +def main(): + rpc_url = os.environ.get('MAINNET_RPC_URL') + if not rpc_url: + logger.error("MAINNET_RPC_URL not found in .env") + return + + w3 = Web3(Web3.HTTPProvider(rpc_url)) + pool = w3.eth.contract(address=Web3.to_checksum_address(POOL_ADDRESS), abi=POOL_ABI) + + # Initialize CSV if it doesn't exist + file_exists = os.path.isfile(CSV_FILE) + + logger.info(f"Starting Depth Monitor for {POOL_ADDRESS}") + logger.info(f"Scan Range: +/-{RANGE_PCT}% | Resolution: {STEP_PCT}% | Interval: {INTERVAL_SECONDS/60}m") + + while True: + try: + # 1. Fetch State + l_active = pool.functions.liquidity().call() + s0 = pool.functions.slot0().call() + curr_tick = s0[1] + curr_price = get_price_from_tick(curr_tick) + + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 2. Map Depth + depth_data = get_liquidity_at_offsets(pool, curr_tick, l_active) + + # 3. Calculate Concentration & Save + with open(CSV_FILE, 'a', newline='') as f: + writer = csv.writer(f) + if not file_exists: + writer.writerow(['timestamp', 'ref_price', 'offset_pct', 'target_price', 'liquidity', 'concentration']) + file_exists = True + + for row in depth_data: + # L_full baseline for THIS specific price point + # Corrected L_full: (TVL * 10^6) / (2 * sqrtP) + # sqrtP_norm = sqrt(Price) / 10^((D0-D1)/2) + sqrtP_norm = row['price'].sqrt() / (Decimal('10') ** (Decimal(str(D0 - D1)) / Decimal('2'))) + l_full = (TVL_USD_BASELINE * (Decimal('10')**Decimal('6'))) / (Decimal('2') * sqrtP_norm) + conc = row['liquidity'] / l_full + + writer.writerow([ + timestamp, + f"{curr_price:.4f}", + row['offset'], + f"{row['price']:.4f}", + f"{row['liquidity']:.0f}", + f"{conc:.2f}" + ]) + + logger.info(f"Recorded depth snapshot at {curr_price:.2f}. Next in {INTERVAL_SECONDS/60}m.") + time.sleep(INTERVAL_SECONDS) + + except Exception as e: + logger.error(f"Error in monitor loop: {e}") + time.sleep(60) + +if __name__ == "__main__": + main() diff --git a/tools/universal_swapper.py b/tools/universal_swapper.py new file mode 100644 index 0000000..3647d80 --- /dev/null +++ b/tools/universal_swapper.py @@ -0,0 +1,349 @@ +import os +import sys +import json +import argparse +import time +from decimal import Decimal +from dotenv import load_dotenv +from web3 import Web3 + +# Load environment variables +load_dotenv() + +# --- CONFIGURATION --- + +# ABIs +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"} +] +''') + +WETH_ABI = json.loads(''' +[ + {"inputs": [], "name": "deposit", "outputs": [], "stateMutability": "payable", "type": "function"}, + {"inputs": [{"internalType": "uint256", "name": "wad", "type": "uint256"}], "name": "withdraw", "outputs": [], "stateMutability": "nonpayable", "type": "function"} +] +''') + +# SwapRouter01 (With Deadline in struct) - e.g. Arbitrum 0xE592..., BSC +SWAP_ROUTER_01_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": "uint200"}, {"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"} +] +''') + +# SwapRouter02 (NO Deadline in struct) - e.g. Base 0x2626... +SWAP_ROUTER_02_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": "amountIn", "type": "uint256"}, {"internalType": "uint256", "name": "amountOutMinimum", "type": "uint256"}, {"internalType": "uint160", "name": "sqrtPriceLimitX96", "type": "uint160"}], "internalType": "struct IV3SwapRouter.ExactInputSingleParams", "name": "params", "type": "tuple"}], "name": "exactInputSingle", "outputs": [{"internalType": "uint256", "name": "amountOut", "type": "uint256"}], "stateMutability": "payable", "type": "function"} +] +''') + +CHAIN_CONFIG = { + "ARBITRUM": { + "rpc_env": "MAINNET_RPC_URL", + "chain_id": 42161, + "router": "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45", # SwapRouter02 + "abi_version": 2, + "tokens": { + "USDC": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "WETH": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", + "ETH": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", # Alias to WETH for wrapping + "CBBTC": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf" + }, + "default_fee": 500 + }, + "BASE": { + "rpc_env": "BASE_RPC_URL", + "chain_id": 8453, + "router": "0x2626664c2603336E57B271c5C0b26F421741e481", # SwapRouter02 + "abi_version": 2, + "tokens": { + "USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "WETH": "0x4200000000000000000000000000000000000006", + "ETH": "0x4200000000000000000000000000000000000006", # Alias to WETH for wrapping + "CBBTC": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf" + }, + "default_fee": 500 + }, + "BASE_AERO": { + "rpc_env": "BASE_RPC_URL", + "chain_id": 8453, + "router": "0xbe6D8f0D397708D99755B7857067757f97174d7d", # Aerodrome Slipstream SwapRouter + "abi_version": 1, # Router requires deadline (Standard SwapRouter01 style) + "tokens": { + "USDC": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "WETH": "0x4200000000000000000000000000000000000006", + "ETH": "0x4200000000000000000000000000000000000006", + "CBBTC": "0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf" + }, + "default_fee": 1 # TickSpacing 1 (0.01%) + }, + "BSC": { + "rpc_env": "BNB_RPC_URL", + "chain_id": 56, + "router": "0x1b81D678ffb9C0263b24A97847620C99d213eB14", # PancakeSwap V3 + "abi_version": 1, + "tokens": { + "USDC": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "WBNB": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", + "BNB": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c" # Alias to WBNB for wrapping + }, + "default_fee": 500 + } +} + +def get_web3(chain_name): + config = CHAIN_CONFIG.get(chain_name.upper()) + if not config: + raise ValueError(f"Unsupported chain: {chain_name}") + + rpc_url = os.environ.get(config["rpc_env"]) + if not rpc_url: + raise ValueError(f"RPC URL not found in environment for {config['rpc_env']}") + + w3 = Web3(Web3.HTTPProvider(rpc_url)) + if not w3.is_connected(): + raise ConnectionError(f"Failed to connect to {chain_name} RPC") + + return w3, config + +def approve_token(w3, token_contract, spender_address, amount, private_key, my_address): + """ + Checks allowance and approves if necessary. + Robust gas handling. + """ + allowance = token_contract.functions.allowance(my_address, spender_address).call() + + if allowance >= amount: + print(f"Token already approved (Allowance: {allowance})") + return True + + print(f"Approving token... (Current: {allowance}, Needed: {amount})") + + # Build tx base + tx_params = { + 'from': my_address, + 'nonce': w3.eth.get_transaction_count(my_address), + } + + # Determine Gas Strategy + try: + latest_block = w3.eth.get_block('latest') + if 'baseFeePerGas' in latest_block: + # EIP-1559 + base_fee = latest_block['baseFeePerGas'] + priority_fee = w3.to_wei(0.1, 'gwei') # Conservative priority + tx_params['maxFeePerGas'] = int(base_fee * 1.5) + priority_fee + tx_params['maxPriorityFeePerGas'] = priority_fee + else: + # Legacy + tx_params['gasPrice'] = w3.eth.gas_price + except Exception as e: + print(f"Error determining gas strategy: {e}. Fallback to w3.eth.gas_price") + tx_params['gasPrice'] = w3.eth.gas_price + + # Build transaction + tx = token_contract.functions.approve(spender_address, 2**256 - 1).build_transaction(tx_params) + + # Sign and send + signed_tx = w3.eth.account.sign_transaction(tx, private_key) + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + print(f"Approval Tx sent: {tx_hash.hex()}") + + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + if receipt.status == 1: + print("Approval successful.") + return True + else: + print("Approval failed.") + return False + +def wrap_eth(w3, weth_address, amount_wei, private_key, my_address): + """ + Wraps native ETH/BNB to WETH/WBNB. + """ + print(f"Wrapping native token to wrapped version...") + weth_contract = w3.eth.contract(address=weth_address, abi=WETH_ABI) + + tx_params = { + 'from': my_address, + 'value': amount_wei, + 'nonce': w3.eth.get_transaction_count(my_address), + } + + # Gas logic (Simplified) + latest_block = w3.eth.get_block('latest') + if 'baseFeePerGas' in latest_block: + base_fee = latest_block['baseFeePerGas'] + tx_params['maxFeePerGas'] = int(base_fee * 1.5) + w3.to_wei(0.1, 'gwei') + tx_params['maxPriorityFeePerGas'] = w3.to_wei(0.1, 'gwei') + else: + tx_params['gasPrice'] = w3.eth.gas_price + + tx = weth_contract.functions.deposit().build_transaction(tx_params) + signed_tx = w3.eth.account.sign_transaction(tx, private_key) + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + print(f"Wrapping Tx sent: {tx_hash.hex()}") + w3.eth.wait_for_transaction_receipt(tx_hash) + print("Wrapping successful.") + +def execute_swap(chain_name, token_in_sym, token_out_sym, amount_in_readable, fee_tier=None, slippage_pct=0.5): + """ + Main function to execute swap. + """ + chain_name = chain_name.upper() + token_in_sym = token_in_sym.upper() + token_out_sym = token_out_sym.upper() + + w3, config = get_web3(chain_name) + + # Get private key + private_key = os.environ.get("MAIN_WALLET_PRIVATE_KEY") + if not private_key: + raise ValueError("MAIN_WALLET_PRIVATE_KEY not found in environment variables") + + account = w3.eth.account.from_key(private_key) + my_address = account.address + print(f"Connected to {chain_name} as {my_address}") + + # Validate tokens + if token_in_sym not in config["tokens"]: + raise ValueError(f"Token {token_in_sym} not supported on {chain_name}") + if token_out_sym not in config["tokens"]: + raise ValueError(f"Token {token_out_sym} not supported on {chain_name}") + + token_in_addr = config["tokens"][token_in_sym] + token_out_addr = config["tokens"][token_out_sym] + router_addr = config["router"] + abi_ver = config.get("abi_version", 1) + + # Initialize Contracts + token_in_contract = w3.eth.contract(address=token_in_addr, abi=ERC20_ABI) + token_out_contract = w3.eth.contract(address=token_out_addr, abi=ERC20_ABI) + + router_abi = SWAP_ROUTER_01_ABI if abi_ver == 1 else SWAP_ROUTER_02_ABI + router_contract = w3.eth.contract(address=router_addr, abi=router_abi) + + # Decimals (ETH/BNB and their wrapped versions all use 18) + decimals_in = 18 if token_in_sym in ["ETH", "BNB"] else token_in_contract.functions.decimals().call() + amount_in_wei = int(Decimal(str(amount_in_readable)) * Decimal(10)**decimals_in) + + print(f"Preparing to swap {amount_in_readable} {token_in_sym} -> {token_out_sym}") + + # Handle Native Wrap + if token_in_sym in ["ETH", "BNB"]: + # Check native balance + native_balance = w3.eth.get_balance(my_address) + if native_balance < amount_in_wei: + raise ValueError(f"Insufficient native balance. Have {native_balance / 10**18}, need {amount_in_readable}") + + # Check if we already have enough wrapped token + w_balance = token_in_contract.functions.balanceOf(my_address).call() + if w_balance < amount_in_wei: + wrap_eth(w3, token_in_addr, amount_in_wei - w_balance, private_key, my_address) + else: + # Check Token Balance + balance = token_in_contract.functions.balanceOf(my_address).call() + if balance < amount_in_wei: + raise ValueError(f"Insufficient balance. Have {balance / 10**decimals_in} {token_in_sym}, need {amount_in_readable}") + + # Approve + approve_token(w3, token_in_contract, router_addr, amount_in_wei, private_key, my_address) + + # Prepare Swap Params + used_fee = fee_tier if fee_tier else config["default_fee"] + amount_out_min = 0 + + if abi_ver == 1: + # Router 01 (Deadline in struct) + params = ( + token_in_addr, + token_out_addr, + used_fee, + my_address, + int(time.time()) + 120, # deadline + amount_in_wei, + amount_out_min, + 0 # sqrtPriceLimitX96 + ) + else: + # Router 02 (No Deadline in struct) + params = ( + token_in_addr, + token_out_addr, + used_fee, + my_address, + # No deadline here + amount_in_wei, + amount_out_min, + 0 # sqrtPriceLimitX96 + ) + + print(f"Swapping... Fee Tier: {used_fee} | ABI: V{abi_ver}") + + # Build Tx + tx_build = { + 'from': my_address, + 'nonce': w3.eth.get_transaction_count(my_address), + } + + # Estimate Gas + try: + gas_estimate = router_contract.functions.exactInputSingle(params).estimate_gas(tx_build) + tx_build['gas'] = int(gas_estimate * 1.2) + except Exception as e: + print(f"Gas estimation failed: {e}. Using default gas limit (500k).") + tx_build['gas'] = 500000 + + # Add Gas Price (Same robust logic as approve) + if chain_name == "BSC": + tx_build['gasPrice'] = w3.eth.gas_price + else: + try: + latest_block = w3.eth.get_block('latest') + if 'baseFeePerGas' in latest_block: + base_fee = latest_block['baseFeePerGas'] + priority_fee = w3.to_wei(0.1, 'gwei') + tx_build['maxFeePerGas'] = int(base_fee * 1.5) + priority_fee + tx_build['maxPriorityFeePerGas'] = priority_fee + else: + tx_build['gasPrice'] = w3.eth.gas_price + except: + tx_build['gasPrice'] = w3.eth.gas_price + + # Sign and Send + tx_func = router_contract.functions.exactInputSingle(params) + tx = tx_func.build_transaction(tx_build) + + signed_tx = w3.eth.account.sign_transaction(tx, private_key) + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + + print(f"Swap Tx Sent: {tx_hash.hex()}") + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + + if receipt.status == 1: + print("Swap Successful!") + else: + print("Swap Failed!") + # print(receipt) # verbose + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Universal Swapper for Arbitrum, Base, BSC") + parser.add_argument("chain", help="Chain name (ARBITRUM, BASE, BSC)") + parser.add_argument("token_in", help="Token to sell (e.g. USDC)") + parser.add_argument("token_out", help="Token to buy (e.g. WETH)") + parser.add_argument("amount", help="Amount to swap", type=float) + parser.add_argument("--fee", help="Fee tier (e.g. 500, 3000)", type=int) + + args = parser.parse_args() + + try: + execute_swap(args.chain, args.token_in, args.token_out, args.amount, args.fee) + except Exception as e: + print(f"Error: {e}") \ No newline at end of file