Compare commits
2 Commits
e6adbaffef
...
b22fdcf741
| Author | SHA1 | Date | |
|---|---|---|---|
| b22fdcf741 | |||
| 69fbf389c8 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -28,3 +28,7 @@ hedge_status.json
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# Data
|
||||
*.csv
|
||||
florida/market_data/
|
||||
|
||||
54
AGENTS.md
Normal file
54
AGENTS.md
Normal file
@ -0,0 +1,54 @@
|
||||
# AGENTS.md - Repository Guidelines for Agentic Coding
|
||||
|
||||
## Project Overview
|
||||
This is a Python blockchain trading system for Uniswap CLP (Concentrated Liquidity Pool) management and hedging on Hyperliquid. The system consists of CLP hedgers, Uniswap managers, and KPI tracking tools.
|
||||
|
||||
## Build/Lint/Test Commands
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run Python with type checking (recommended workflow)
|
||||
python -m mypy clp_hedger.py
|
||||
python -m mypy uniswap_manager.py
|
||||
|
||||
# Lint with flake8 (optional)
|
||||
flake8 --max-line-length=100 *.py
|
||||
|
||||
# Run specific modules
|
||||
python clp_hedger.py
|
||||
python uniswap_manager.py
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Imports
|
||||
- Standard library imports first, then third-party, then local imports
|
||||
- Use `from typing import Optional, Dict, Any, List, Union`
|
||||
- Import logging_utils with try/except fallback pattern
|
||||
|
||||
### Types & Precision
|
||||
- Use `Decimal` for all financial calculations with `getcontext().prec = 60`
|
||||
- Function signatures: `def func_name(param: Type) -> ReturnType:`
|
||||
- Convert to float only for SDK compatibility at the last moment
|
||||
|
||||
### Naming Conventions
|
||||
- Constants: `UPPER_SNAKE_CASE`
|
||||
- Functions/variables: `lower_snake_case`
|
||||
- Classes: `PascalCase` (rarely used)
|
||||
- Loggers: `logger = logging.getLogger("MODULE_NAME")`
|
||||
|
||||
### Error Handling
|
||||
- Use try/except blocks for external dependencies (web3, hyperliquid, logging_utils)
|
||||
- Log warnings for missing optional dependencies
|
||||
- Return Decimal("0") for failed conversions, not None
|
||||
|
||||
### Logging
|
||||
- Use structured logging with UnixMsLogFilter for timestamp consistency
|
||||
- Log to files in `logs/` directory (auto-created)
|
||||
- Logger names should be uppercase: "HEDGER", "UNISWAP_MANAGER", "KPI_TRACKER"
|
||||
|
||||
### Environment
|
||||
- Load `.env` files from current directory with fallback
|
||||
- Use absolute paths for cross-directory imports
|
||||
- Append project root to `sys.path` for local modules
|
||||
@ -1,70 +0,0 @@
|
||||
import os
|
||||
from eth_account import Account
|
||||
from hyperliquid.exchange import Exchange
|
||||
from hyperliquid.utils import constants
|
||||
from dotenv import load_dotenv
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
|
||||
# Load environment variables from a .env file if it exists
|
||||
load_dotenv()
|
||||
|
||||
def create_and_authorize_agent():
|
||||
"""
|
||||
Creates and authorizes a new agent key pair using your main wallet,
|
||||
following the correct SDK pattern.
|
||||
"""
|
||||
# --- STEP 1: Load your main wallet ---
|
||||
# This is the wallet that holds the funds and has been activated on Hyperliquid.
|
||||
main_wallet_private_key = os.environ.get("MAIN_WALLET_PRIVATE_KEY")
|
||||
if not main_wallet_private_key:
|
||||
main_wallet_private_key = input("Please enter the private key of your MAIN trading wallet: ")
|
||||
|
||||
try:
|
||||
main_account = Account.from_key(main_wallet_private_key)
|
||||
print(f"\n✅ Loaded main wallet: {main_account.address}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error: Invalid main wallet private key provided. Details: {e}")
|
||||
return
|
||||
|
||||
# --- STEP 2: Initialize the Exchange with your MAIN account ---
|
||||
# This object is used to send the authorization transaction.
|
||||
exchange = Exchange(main_account, constants.MAINNET_API_URL, account_address=main_account.address)
|
||||
|
||||
# --- STEP 3: Create and approve the agent with a specific name ---
|
||||
# agent name must be between 1 and 16 characters long
|
||||
agent_name = "my_new_agent"
|
||||
|
||||
print(f"\n🔗 Authorizing a new agent named '{agent_name}'...")
|
||||
try:
|
||||
# --- FIX: Pass only the agent name string to the function ---
|
||||
approve_result, agent_private_key = exchange.approve_agent(agent_name)
|
||||
|
||||
if approve_result.get("status") == "ok":
|
||||
# Derive the agent's public address from the key we received
|
||||
agent_account = Account.from_key(agent_private_key)
|
||||
|
||||
print("\n🎉 SUCCESS! Agent has been authorized on-chain.")
|
||||
print("="*50)
|
||||
print("SAVE THESE SECURELY. This is what your bot will use.")
|
||||
print(f" Name: {agent_name}")
|
||||
print(f" (Agent has a default long-term validity)")
|
||||
print(f"🔑 Agent Private Key: {agent_private_key}")
|
||||
print(f"🏠 Agent Address: {agent_account.address}")
|
||||
print("="*50)
|
||||
print("\nYou can now set this private key as the AGENT_PRIVATE_KEY environment variable.")
|
||||
else:
|
||||
print("\n❌ ERROR: Agent authorization failed.")
|
||||
print(" Response:", approve_result)
|
||||
if "Vault may not perform this action" in str(approve_result):
|
||||
print("\n ACTION REQUIRED: This error means your main wallet (vault) has not been activated. "
|
||||
"Please go to the Hyperliquid website, connect this wallet, and make a deposit to activate it.")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nAn unexpected error occurred during authorization: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_and_authorize_agent()
|
||||
|
||||
@ -1,134 +0,0 @@
|
||||
import os
|
||||
import csv
|
||||
import time
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Optional
|
||||
|
||||
# Setup Logger
|
||||
logger = logging.getLogger("KPI_TRACKER")
|
||||
logger.setLevel(logging.INFO)
|
||||
# Basic handler if not already handled by parent
|
||||
if not logger.handlers:
|
||||
ch = logging.StreamHandler()
|
||||
formatter = logging.Formatter('%(asctime)s - KPI - %(message)s')
|
||||
ch.setFormatter(formatter)
|
||||
logger.addHandler(ch)
|
||||
|
||||
KPI_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs', 'kpi_history.csv')
|
||||
|
||||
def initialize_kpi_csv():
|
||||
"""Creates the CSV with headers if it doesn't exist."""
|
||||
if not os.path.exists(os.path.dirname(KPI_FILE)):
|
||||
os.makedirs(os.path.dirname(KPI_FILE))
|
||||
|
||||
if not os.path.exists(KPI_FILE):
|
||||
with open(KPI_FILE, 'w', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
"Timestamp",
|
||||
"Date",
|
||||
"NAV_Total_USD",
|
||||
"Benchmark_HODL_USD",
|
||||
"Alpha_USD",
|
||||
"Uniswap_Val_USD",
|
||||
"Uniswap_Fees_Claimed_USD",
|
||||
"Uniswap_Fees_Unclaimed_USD",
|
||||
"Hedge_Equity_USD",
|
||||
"Hedge_PnL_Realized_USD",
|
||||
"Hedge_Fees_Paid_USD",
|
||||
"ETH_Price",
|
||||
"Fee_Coverage_Ratio"
|
||||
])
|
||||
|
||||
def calculate_hodl_benchmark(initial_eth: Decimal, initial_usdc: Decimal, initial_hedge_usdc: Decimal, current_eth_price: Decimal) -> Decimal:
|
||||
"""Calculates value if assets were just held (Wallet Assets + Hedge Account Cash)."""
|
||||
return (initial_eth * current_eth_price) + initial_usdc + initial_hedge_usdc
|
||||
|
||||
def log_kpi_snapshot(
|
||||
snapshot_data: Dict[str, float]
|
||||
):
|
||||
"""
|
||||
Logs a KPI snapshot to CSV.
|
||||
Expected keys in snapshot_data:
|
||||
- initial_eth, initial_usdc, initial_hedge_usdc
|
||||
- current_eth_price
|
||||
- uniswap_pos_value_usd
|
||||
- uniswap_fees_claimed_usd
|
||||
- uniswap_fees_unclaimed_usd
|
||||
- hedge_equity_usd
|
||||
- hedge_pnl_realized_usd
|
||||
- hedge_fees_paid_usd
|
||||
- wallet_eth_bal, wallet_usdc_bal (Optional, for full NAV)
|
||||
"""
|
||||
try:
|
||||
initialize_kpi_csv()
|
||||
|
||||
# Convert all inputs to Decimal for precision
|
||||
price = Decimal(str(snapshot_data.get('current_eth_price', 0)))
|
||||
|
||||
# 1. Benchmark (HODL)
|
||||
init_eth = Decimal(str(snapshot_data.get('initial_eth', 0)))
|
||||
init_usdc = Decimal(str(snapshot_data.get('initial_usdc', 0)))
|
||||
init_hedge = Decimal(str(snapshot_data.get('initial_hedge_usdc', 0)))
|
||||
benchmark_val = calculate_hodl_benchmark(init_eth, init_usdc, init_hedge, price)
|
||||
|
||||
# 2. Strategy NAV (Net Asset Value)
|
||||
# NAV = Uni Pos + Uni Fees (Claimed+Unclaimed) + Hedge Equity + (Wallet Surplus - Initial Wallet Surplus?)
|
||||
# For simplicity, we focus on the Strategy PnL components:
|
||||
# Strategy Val = (Current Uni Pos) + (Claimed Fees) + (Unclaimed Fees) + (Hedge PnL Realized) + (Hedge Unrealized?)
|
||||
# Note: Hedge Equity usually includes margin. We strictly want "Value Generated".
|
||||
|
||||
uni_val = Decimal(str(snapshot_data.get('uniswap_pos_value_usd', 0)))
|
||||
uni_fees_claimed = Decimal(str(snapshot_data.get('uniswap_fees_claimed_usd', 0)))
|
||||
uni_fees_unclaimed = Decimal(str(snapshot_data.get('uniswap_fees_unclaimed_usd', 0)))
|
||||
|
||||
# Hedge PnL (Realized + Unrealized) is better than Equity for PnL tracking,
|
||||
# but Equity represents actual redeemable cash. Let's use Equity if provided, or PnL components.
|
||||
hedge_equity = Decimal(str(snapshot_data.get('hedge_equity_usd', 0)))
|
||||
hedge_fees = Decimal(str(snapshot_data.get('hedge_fees_paid_usd', 0)))
|
||||
|
||||
# Simplified NAV for Strategy Comparison:
|
||||
# We assume 'hedge_equity' is the Liquidation Value of the hedge account.
|
||||
# But if we want strictly "Strategy Performance", we usually do:
|
||||
# Current Value = Uni_Val + Unclaimed + Hedge_Equity
|
||||
# (Assuming Hedge_Equity started at 0 or we track delta? No, usually Hedge Account has deposit).
|
||||
|
||||
# Let's define NAV as Total Current Liquidation Value of Strategy Components
|
||||
current_nav = uni_val + uni_fees_unclaimed + uni_fees_claimed + hedge_equity
|
||||
|
||||
# Alpha
|
||||
alpha = current_nav - benchmark_val
|
||||
|
||||
# Coverage Ratio
|
||||
total_hedge_cost = abs(hedge_fees) # + funding if available
|
||||
total_uni_earnings = uni_fees_claimed + uni_fees_unclaimed
|
||||
|
||||
if total_hedge_cost > 0:
|
||||
coverage_ratio = total_uni_earnings / total_hedge_cost
|
||||
else:
|
||||
coverage_ratio = Decimal("999.0") # Infinite/Good
|
||||
|
||||
# Write
|
||||
with open(KPI_FILE, 'a', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
int(time.time()),
|
||||
time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
f"{current_nav:.2f}",
|
||||
f"{benchmark_val:.2f}",
|
||||
f"{alpha:.2f}",
|
||||
f"{uni_val:.2f}",
|
||||
f"{uni_fees_claimed:.2f}",
|
||||
f"{uni_fees_unclaimed:.2f}",
|
||||
f"{hedge_equity:.2f}",
|
||||
f"{snapshot_data.get('hedge_pnl_realized_usd', 0):.2f}",
|
||||
f"{hedge_fees:.2f}",
|
||||
f"{price:.2f}",
|
||||
f"{coverage_ratio:.2f}"
|
||||
])
|
||||
|
||||
logger.info(f"📊 KPI Logged | NAV: ${current_nav:.2f} | Benchmark: ${benchmark_val:.2f} | Alpha: ${alpha:.2f}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log KPI: {e}")
|
||||
169
clp_config.py
169
clp_config.py
@ -3,74 +3,137 @@ from decimal import Decimal
|
||||
|
||||
# --- GLOBAL SETTINGS ---
|
||||
# Use environment variables to switch profiles
|
||||
# Example: TARGET_DEX="UNISWAP_V3"
|
||||
TARGET_DEX = os.environ.get("TARGET_DEX", "UNISWAP_V3")
|
||||
STATUS_FILE = os.environ.get("STATUS_FILE", "hedge_status.json")
|
||||
STATUS_FILE = os.environ.get("STATUS_FILE", f"{TARGET_DEX}_status.json")
|
||||
|
||||
# --- DEX PROFILES ---
|
||||
DEX_PROFILES = {
|
||||
# --- DEFAULT STRATEGY ---
|
||||
DEFAULT_STRATEGY = {
|
||||
"MONITOR_INTERVAL_SECONDS": 60, # How often the Manager checks for range status
|
||||
"CLOSE_POSITION_ENABLED": True, # Allow the bot to automatically close out-of-range positions
|
||||
"OPEN_POSITION_ENABLED": True, # Allow the bot to automatically open new positions
|
||||
"REBALANCE_ON_CLOSE_BELOW_RANGE": True, # Strategy flag for specific closing behavior
|
||||
|
||||
# Investment Settings
|
||||
"TARGET_INVESTMENT_AMOUNT": 2000, # Total USD value to deploy into the LP position
|
||||
"INITIAL_HEDGE_CAPITAL": 1000, # Capital reserved on Hyperliquid for hedging
|
||||
"VALUE_REFERENCE": "USD", # Base currency for all calculations
|
||||
|
||||
# Range Settings
|
||||
"RANGE_WIDTH_PCT": Decimal("0.01"), # LP width (e.g. 0.05 = +/- 5% from current price)
|
||||
"SLIPPAGE_TOLERANCE": Decimal("0.02"), # Max allowed slippage for swaps and minting
|
||||
"TRANSACTION_TIMEOUT_SECONDS": 30, # Timeout for blockchain transactions
|
||||
|
||||
# Hedging Settings
|
||||
"MIN_HEDGE_THRESHOLD": Decimal("0.012"), # Minimum delta change (in coins) required to trigger a trade
|
||||
|
||||
# Unified Hedger Settings
|
||||
"CHECK_INTERVAL": 1, # Loop speed for the hedger (seconds)
|
||||
"LEVERAGE": 5, # Leverage to use on Hyperliquid
|
||||
"ZONE_BOTTOM_HEDGE_LIMIT": Decimal("1.0"), # Multiplier limit at the bottom of the range
|
||||
"ZONE_CLOSE_START": Decimal("10.0"), # Distance (pct) from edge to start closing logic
|
||||
"ZONE_CLOSE_END": Decimal("11.0"), # Distance (pct) from edge to finish closing logic
|
||||
"ZONE_TOP_HEDGE_START": Decimal("10.0"), # Distance (pct) from top edge to adjust hedging
|
||||
"PRICE_BUFFER_PCT": Decimal("0.0015"), # Buffer for limit order pricing (0.15%)
|
||||
"MIN_ORDER_VALUE_USD": Decimal("10.0"), # Minimum order size allowed by Hyperliquid
|
||||
"DYNAMIC_THRESHOLD_MULTIPLIER": Decimal("1.2"), # Expansion factor for thresholds
|
||||
"MIN_TIME_BETWEEN_TRADES": 60, # Cooldown (seconds) between rebalance trades
|
||||
"MAX_HEDGE_MULTIPLIER": Decimal("1.25"), # Max allowed hedge size relative to calculated target
|
||||
"BASE_REBALANCE_THRESHOLD_PCT": Decimal("0.25"), # Base tolerance for delta drift (20%)
|
||||
"EDGE_PROXIMITY_PCT": Decimal("0.04"), # Distance to range edge where protection activates
|
||||
"VELOCITY_THRESHOLD_PCT": Decimal("0.0005"), # Minimum price velocity to trigger volatility logic
|
||||
"POSITION_OPEN_EDGE_PROXIMITY_PCT": Decimal("0.06"), # Safety margin when opening new positions
|
||||
"POSITION_CLOSED_EDGE_PROXIMITY_PCT": Decimal("0.025"), # Safety margin for closing positions
|
||||
"LARGE_HEDGE_MULTIPLIER": Decimal("5.0"), # Multiplier to bypass trade cooldown for big moves
|
||||
"ENABLE_EDGE_CLEANUP": True, # Force rebalances when price is at range boundaries
|
||||
"EDGE_CLEANUP_MARGIN_PCT": Decimal("0.02"), # % of range width used for edge detection
|
||||
"MAKER_ORDER_TIMEOUT": 600, # Timeout for resting Maker orders (seconds)
|
||||
"SHADOW_ORDER_TIMEOUT": 600, # Timeout for theoretical shadow order tracking
|
||||
"ENABLE_FISHING": False, # Use passive maker orders for rebalancing (advanced)
|
||||
"FISHING_ORDER_SIZE_PCT": Decimal("0.10"), # Size of individual fishing orders
|
||||
"FISHING_TIMEOUT_FALLBACK": 30, # Seconds before converting fishing order to taker
|
||||
|
||||
# EAC (Enhanced Asymmetric Compensation)
|
||||
"EAC_NARROW_RANGE_THRESHOLD": Decimal("0.02"), # <2% = narrow
|
||||
"EAC_MEDIUM_RANGE_THRESHOLD": Decimal("0.05"), # <5% = medium
|
||||
"EAC_NARROW_BOOST": Decimal("0.15"), # 15% boost
|
||||
"EAC_MEDIUM_BOOST": Decimal("0.10"), # 10% boost
|
||||
"EAC_WIDE_BOOST": Decimal("0.075"), # 7.5% boost
|
||||
}
|
||||
|
||||
# --- CLP PROFILES ---
|
||||
CLP_PROFILES = {
|
||||
"UNISWAP_V3": {
|
||||
"NAME": "Uniswap V3 (Arbitrum)",
|
||||
"COIN_SYMBOL": "ETH", # Asset to hedge on Hyperliquid
|
||||
"RPC_ENV_VAR": "MAINNET_RPC_URL", # Env var to read RPC from
|
||||
"NPM_ADDRESS": "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
|
||||
"ROUTER_ADDRESS": "0xE592427A0AEce92De3Edee1F18E0157C05861564",
|
||||
"WETH_ADDRESS": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", # WETH
|
||||
"USDC_ADDRESS": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", # USDC
|
||||
"POOL_FEE": 500, # 0.05%
|
||||
},
|
||||
"PANCAKESWAP_V3": {
|
||||
"NAME": "PancakeSwap V3 (Arbitrum)",
|
||||
"NAME": "Uniswap V3 (Arbitrum) - ETH/USDC",
|
||||
"COIN_SYMBOL": "ETH",
|
||||
"RPC_ENV_VAR": "MAINNET_RPC_URL",
|
||||
"NPM_ADDRESS": "0x46A15B0b27311cedF172AB29E4f4766fbE7F4364",
|
||||
"ROUTER_ADDRESS": "0x1b81D678ffb9C0263b24A97847620C99d213eB14",
|
||||
"WETH_ADDRESS": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
|
||||
"USDC_ADDRESS": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
||||
"NPM_ADDRESS": "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
|
||||
"ROUTER_ADDRESS": "0xE592427A0AEce92De3Edee1F18E0157C05861564",
|
||||
"TOKEN_A_ADDRESS": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", # WETH
|
||||
"TOKEN_B_ADDRESS": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", # USDC
|
||||
"WRAPPED_NATIVE_ADDRESS": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
|
||||
"POOL_FEE": 500,
|
||||
},
|
||||
"UNISWAP_BNB": {
|
||||
"NAME": "Uniswap V3 (BNB Chain)",
|
||||
"COIN_SYMBOL": "BNB", # Hedge BNB
|
||||
"RPC_ENV_VAR": "BNB_RPC_URL", # Needs a BSC RPC
|
||||
# Uniswap V3 Official Addresses on BNB Chain
|
||||
"NPM_ADDRESS": "0x7b8A01B39D58278b5DE7e48c8449c9f4F5170613",
|
||||
"ROUTER_ADDRESS": "0xB971eF87ede563556b2ED4b1C0b0019111Dd35d2",
|
||||
# Pool: 0x47a90a2d92a8367a91efa1906bfc8c1e05bf10c4
|
||||
# Tokens: WBNB / USDT
|
||||
"WETH_ADDRESS": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", # WBNB
|
||||
"USDC_ADDRESS": "0x55d398326f99059fF775485246999027B3197955", # USDT (BSC)
|
||||
"POOL_FEE": 500, # 0.05%
|
||||
"UNISWAP_wide": {
|
||||
"NAME": "Uniswap V3 (Arbitrum) - ETH/USDC Wide",
|
||||
"COIN_SYMBOL": "ETH",
|
||||
"RPC_ENV_VAR": "MAINNET_RPC_URL",
|
||||
"NPM_ADDRESS": "0xC36442b4a4522E871399CD717aBDD847Ab11FE88",
|
||||
"ROUTER_ADDRESS": "0xE592427A0AEce92De3Edee1F18E0157C05861564",
|
||||
"TOKEN_A_ADDRESS": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", # WETH
|
||||
"TOKEN_B_ADDRESS": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", # USDC
|
||||
"WRAPPED_NATIVE_ADDRESS": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
|
||||
"POOL_FEE": 500,
|
||||
"RANGE_WIDTH_PCT": Decimal("0.05"),
|
||||
"TARGET_INVESTMENT_AMOUNT": 2000,
|
||||
"MIN_HEDGE_THRESHOLD": Decimal("0.01"),
|
||||
"BASE_REBALANCE_THRESHOLD_PCT": Decimal("0.15"),
|
||||
},
|
||||
"PANCAKESWAP_BNB": {
|
||||
"NAME": "PancakeSwap V3 (BNB Chain)",
|
||||
"NAME": "PancakeSwap V3 (BNB Chain) - BNB/USDT",
|
||||
"COIN_SYMBOL": "BNB",
|
||||
"RPC_ENV_VAR": "BNB_RPC_URL",
|
||||
"NPM_ADDRESS": "0x46A15B0b27311cedF172AB29E4f4766fbE7F4364",
|
||||
"ROUTER_ADDRESS": "0x1b81D678ffb9C0263b24A97847620C99d213eB14", # Smart Router
|
||||
# Pool: 0x172fcD41E0913e95784454622d1c3724f546f849 (USDT/WBNB)
|
||||
"WETH_ADDRESS": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", # WBNB
|
||||
"USDC_ADDRESS": "0x55d398326f99059fF775485246999027B3197955", # USDT
|
||||
"ROUTER_ADDRESS": "0x1b81D678ffb9C0263b24A97847620C99d213eB14",
|
||||
"TOKEN_A_ADDRESS": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", # WBNB
|
||||
"TOKEN_B_ADDRESS": "0x55d398326f99059fF775485246999027B3197955", # USDT
|
||||
"WRAPPED_NATIVE_ADDRESS": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c",
|
||||
"POOL_FEE": 100,
|
||||
"RANGE_WIDTH_PCT": Decimal("0.004"),
|
||||
"TARGET_INVESTMENT_AMOUNT": 1000,
|
||||
"MIN_HEDGE_THRESHOLD": Decimal("0.015"),
|
||||
"BASE_REBALANCE_THRESHOLD_PCT": Decimal("0.10"),
|
||||
"EDGE_PROXIMITY_PCT": Decimal("0.015"),
|
||||
"DYNAMIC_THRESHOLD_MULTIPLIER": Decimal("1.1"),
|
||||
"MIN_TIME_BETWEEN_TRADES": 20,
|
||||
"ENABLE_FISHING": False,
|
||||
"FISHING_ORDER_SIZE_PCT": Decimal("0.05"),
|
||||
"MAKER_ORDER_TIMEOUT": 180,
|
||||
"FISHING_TIMEOUT_FALLBACK": 60,
|
||||
},
|
||||
"WETH_CBBTC_BASE": {
|
||||
"NAME": "Aerodrome/Uni (Base) - WETH/cbBTC",
|
||||
"COIN_SYMBOL": "ETH",
|
||||
"RPC_ENV_VAR": "BASE_RPC_URL",
|
||||
"NPM_ADDRESS": "0x0000000000000000000000000000000000000000", # Placeholder
|
||||
"ROUTER_ADDRESS": "0x0000000000000000000000000000000000000000", # Placeholder
|
||||
"TOKEN_A_ADDRESS": "0x4200000000000000000000000000000000000006", # WETH (Base)
|
||||
"TOKEN_B_ADDRESS": "0xcbB7C915AB58735a1391B9fE18541b4d8926D412", # cbBTC (Base)
|
||||
"WRAPPED_NATIVE_ADDRESS": "0x4200000000000000000000000000000000000006",
|
||||
"POOL_FEE": 3000,
|
||||
"TARGET_INVESTMENT_AMOUNT": 200,
|
||||
"VALUE_REFERENCE": "USD",
|
||||
"RANGE_WIDTH_PCT": Decimal("0.10")
|
||||
}
|
||||
}
|
||||
|
||||
# --- STRATEGY SETTINGS ---
|
||||
MONITOR_INTERVAL_SECONDS = 60
|
||||
CLOSE_POSITION_ENABLED = True
|
||||
OPEN_POSITION_ENABLED = True
|
||||
REBALANCE_ON_CLOSE_BELOW_RANGE = True
|
||||
|
||||
TARGET_INVESTMENT_VALUE_USDC = 2000
|
||||
INITIAL_HEDGE_CAPITAL_USDC = 1000
|
||||
|
||||
RANGE_WIDTH_PCT = Decimal("0.05") # +/- 5%
|
||||
SLIPPAGE_TOLERANCE = Decimal("0.02") # 2%
|
||||
TRANSACTION_TIMEOUT_SECONDS = 30
|
||||
|
||||
# --- HELPER TO GET ACTIVE CONFIG ---
|
||||
def get_current_config():
|
||||
conf = DEX_PROFILES.get(TARGET_DEX)
|
||||
if not conf:
|
||||
raise ValueError(f"Unknown DEX profile: {TARGET_DEX}")
|
||||
return conf
|
||||
profile = CLP_PROFILES.get(TARGET_DEX)
|
||||
if not profile:
|
||||
raise ValueError(f"Unknown CLP profile: {TARGET_DEX}")
|
||||
|
||||
# Merge Default Strategy with Profile (Profile wins)
|
||||
config = DEFAULT_STRATEGY.copy()
|
||||
config.update(profile)
|
||||
|
||||
return config
|
||||
|
||||
1367
clp_hedger.py
1367
clp_hedger.py
File diff suppressed because it is too large
Load Diff
@ -117,23 +117,28 @@ WETH9_ABI = json.loads('''
|
||||
]
|
||||
''')
|
||||
|
||||
from clp_config import (
|
||||
get_current_config, STATUS_FILE, MONITOR_INTERVAL_SECONDS,
|
||||
CLOSE_POSITION_ENABLED, OPEN_POSITION_ENABLED,
|
||||
REBALANCE_ON_CLOSE_BELOW_RANGE, TARGET_INVESTMENT_VALUE_USDC,
|
||||
INITIAL_HEDGE_CAPITAL_USDC, RANGE_WIDTH_PCT,
|
||||
SLIPPAGE_TOLERANCE, TRANSACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
from clp_config import get_current_config, STATUS_FILE
|
||||
|
||||
# --- GET ACTIVE DEX CONFIG ---
|
||||
CONFIG = get_current_config()
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
# --- CONFIGURATION FROM STRATEGY ---
|
||||
MONITOR_INTERVAL_SECONDS = CONFIG.get("MONITOR_INTERVAL_SECONDS", 60)
|
||||
CLOSE_POSITION_ENABLED = CONFIG.get("CLOSE_POSITION_ENABLED", True)
|
||||
OPEN_POSITION_ENABLED = CONFIG.get("OPEN_POSITION_ENABLED", True)
|
||||
REBALANCE_ON_CLOSE_BELOW_RANGE = CONFIG.get("REBALANCE_ON_CLOSE_BELOW_RANGE", True)
|
||||
TARGET_INVESTMENT_VALUE_USDC = CONFIG.get("TARGET_INVESTMENT_AMOUNT", 2000)
|
||||
INITIAL_HEDGE_CAPITAL_USDC = CONFIG.get("INITIAL_HEDGE_CAPITAL", 1000)
|
||||
RANGE_WIDTH_PCT = CONFIG.get("RANGE_WIDTH_PCT", Decimal("0.01"))
|
||||
SLIPPAGE_TOLERANCE = CONFIG.get("SLIPPAGE_TOLERANCE", Decimal("0.02"))
|
||||
TRANSACTION_TIMEOUT_SECONDS = CONFIG.get("TRANSACTION_TIMEOUT_SECONDS", 30)
|
||||
|
||||
# --- CONFIGURATION CONSTANTS ---
|
||||
NONFUNGIBLE_POSITION_MANAGER_ADDRESS = CONFIG["NPM_ADDRESS"]
|
||||
UNISWAP_V3_SWAP_ROUTER_ADDRESS = CONFIG["ROUTER_ADDRESS"]
|
||||
# Arbitrum WETH/USDC
|
||||
WETH_ADDRESS = CONFIG["WETH_ADDRESS"]
|
||||
USDC_ADDRESS = CONFIG["USDC_ADDRESS"]
|
||||
# Arbitrum WETH/USDC (or generic T0/T1)
|
||||
WETH_ADDRESS = CONFIG["WRAPPED_NATIVE_ADDRESS"]
|
||||
USDC_ADDRESS = CONFIG["TOKEN_B_ADDRESS"]
|
||||
POOL_FEE = CONFIG.get("POOL_FEE", 500)
|
||||
|
||||
# --- HELPER FUNCTIONS ---
|
||||
@ -510,7 +515,7 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount,
|
||||
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) -> Optional[Dict]:
|
||||
def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str, token1: str, amount0: int, amount1: int, tick_lower: int, tick_upper: int, d0: int, d1: int) -> Optional[Dict]:
|
||||
"""
|
||||
Approves tokens and mints a new V3 position.
|
||||
"""
|
||||
@ -569,16 +574,24 @@ def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str
|
||||
minted_data['amount1'] = int(data[128:192], 16)
|
||||
|
||||
if minted_data['token_id']:
|
||||
# Format for Log
|
||||
# Assuming Token0=WETH (18), Token1=USDC (6) - should use fetched decimals ideally
|
||||
# We fetched decimals in main(), but here we can assume or pass them.
|
||||
# For simplicity, I'll pass them or use defaults since this is specific to this pair
|
||||
d0, d1 = 18, 6
|
||||
|
||||
# Format for Log using actual decimals
|
||||
fmt_amt0 = Decimal(minted_data['amount0']) / Decimal(10**d0)
|
||||
fmt_amt1 = Decimal(minted_data['amount1']) / Decimal(10**d1)
|
||||
|
||||
logger.info(f"✅ POSITION OPENED | ID: {minted_data['token_id']} | Deposited: {fmt_amt0:.6f} WETH + {fmt_amt1:.2f} USDC")
|
||||
logger.info(f"✅ POSITION OPENED | ID: {minted_data['token_id']} | Deposited: {fmt_amt0:.6f} + {fmt_amt1:.6f}")
|
||||
|
||||
# --- VERIFY TICKS ON-CHAIN ---
|
||||
try:
|
||||
pos_data = npm_contract.functions.positions(minted_data['token_id']).call()
|
||||
# pos_data structure: nonce, operator, t0, t1, fee, tickLower, tickUpper, ...
|
||||
minted_data['tick_lower'] = pos_data[5]
|
||||
minted_data['tick_upper'] = pos_data[6]
|
||||
logger.info(f"🔗 Verified Ticks: {minted_data['tick_lower']} <-> {minted_data['tick_upper']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Could not verify ticks immediately: {e}")
|
||||
# Fallback to requested ticks if fetch fails
|
||||
minted_data['tick_lower'] = tick_lower
|
||||
minted_data['tick_upper'] = tick_upper
|
||||
|
||||
return minted_data
|
||||
|
||||
@ -587,7 +600,7 @@ def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str
|
||||
|
||||
return None
|
||||
|
||||
def decrease_liquidity(w3: Web3, npm_contract, account: LocalAccount, token_id: int, liquidity: int) -> bool:
|
||||
def decrease_liquidity(w3: Web3, npm_contract, account: LocalAccount, token_id: int, liquidity: int, d0: int, d1: int) -> bool:
|
||||
if liquidity == 0: return True
|
||||
|
||||
logger.info(f"📉 Decreasing Liquidity for {token_id}...")
|
||||
@ -619,11 +632,10 @@ def decrease_liquidity(w3: Web3, npm_contract, account: LocalAccount, token_id:
|
||||
amt1 = int(data[128:192], 16)
|
||||
break
|
||||
|
||||
d0, d1 = 18, 6 # Assuming WETH/USDC
|
||||
fmt_amt0 = Decimal(amt0) / Decimal(10**d0)
|
||||
fmt_amt1 = Decimal(amt1) / Decimal(10**d1)
|
||||
|
||||
logger.info(f"📉 POSITION CLOSED (Liquidity Removed) | ID: {token_id} | Withdrawn: {fmt_amt0:.6f} WETH + {fmt_amt1:.2f} USDC")
|
||||
logger.info(f"📉 POSITION CLOSED (Liquidity Removed) | ID: {token_id} | Withdrawn: {fmt_amt0:.6f} + {fmt_amt1:.6f}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Closed but failed to parse details: {e}")
|
||||
@ -677,7 +689,9 @@ def update_position_status(token_id: int, status: str, extra_data: Dict = {}):
|
||||
entry.update(extra_data)
|
||||
|
||||
if status == "CLOSED":
|
||||
entry['timestamp_close'] = int(time.time())
|
||||
now = datetime.now()
|
||||
entry['timestamp_close'] = int(now.timestamp())
|
||||
entry['time_close'] = now.strftime("%d.%m.%y %H:%M:%S")
|
||||
|
||||
save_status_data(data)
|
||||
logger.info(f"💾 Updated Position {token_id} status to {status}")
|
||||
@ -760,6 +774,11 @@ def main():
|
||||
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)
|
||||
@ -831,7 +850,7 @@ def main():
|
||||
update_position_status(token_id, "CLOSING")
|
||||
|
||||
# 1. Remove Liquidity
|
||||
if decrease_liquidity(w3, npm, account, token_id, pos_details['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")
|
||||
@ -920,27 +939,39 @@ 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):
|
||||
minted = mint_new_position(w3, npm, account, token0, token1, amt0, amt1, tick_lower, tick_upper)
|
||||
minted = mint_new_position(w3, npm, account, token0, token1, amt0, amt1, tick_lower, tick_upper, d0, d1)
|
||||
if minted:
|
||||
# Calculate entry price and amounts for JSON compatibility
|
||||
entry_price = float(price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1))
|
||||
price_0_in_1 = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1)
|
||||
fmt_amt0 = float(Decimal(minted['amount0']) / Decimal(10**d0))
|
||||
fmt_amt1 = float(Decimal(minted['amount1']) / Decimal(10**d1))
|
||||
|
||||
# Calculate actual initial value
|
||||
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", # Will be handled by update_position_status logic if new
|
||||
"type": "AUTOMATIC",
|
||||
"target_value": round(float(actual_value), 2),
|
||||
"entry_price": round(entry_price, 2),
|
||||
"entry_price": round(entry_price, 4),
|
||||
"amount0_initial": round(fmt_amt0, 4),
|
||||
"amount1_initial": round(fmt_amt1, 2),
|
||||
"amount1_initial": round(fmt_amt1, 4),
|
||||
"liquidity": str(minted['liquidity']),
|
||||
"range_upper": round(float(price_from_tick(tick_upper, d0, d1)), 2),
|
||||
"range_lower": round(float(price_from_tick(tick_lower, d0, d1)), 2),
|
||||
"timestamp_open": int(time.time())
|
||||
"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)
|
||||
34
doc/TELEGRAM_QUICKSTART.md
Normal file
34
doc/TELEGRAM_QUICKSTART.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Quick Start Guide
|
||||
|
||||
## 1. Test the Monitor
|
||||
```bash
|
||||
cd tools
|
||||
python test_telegram_simple.py
|
||||
```
|
||||
|
||||
## 2. Setup Telegram Bot (Optional)
|
||||
```bash
|
||||
python telegram_setup.py
|
||||
```
|
||||
|
||||
## 3. Configure Environment
|
||||
Copy `tools/.env.example` to `.env` and add:
|
||||
```bash
|
||||
TELEGRAM_MONITOR_ENABLED=True
|
||||
TELEGRAM_BOT_TOKEN=your_token_here
|
||||
TELEGRAM_CHAT_ID=your_chat_id_here
|
||||
```
|
||||
|
||||
## 4. Run Monitor
|
||||
```bash
|
||||
python tools/telegram_monitor.py
|
||||
```
|
||||
|
||||
## Files Created
|
||||
- `tools/telegram_monitor.py` - Main monitoring script
|
||||
- `tools/telegram_setup.py` - Setup helper
|
||||
- `tools/test_telegram_simple.py` - Test script
|
||||
- `tools/.env.example` - Environment template
|
||||
- `tools/README_TELEGRAM.md` - Full documentation
|
||||
|
||||
Monitor checks `hedge_status.json` every 60 seconds for new positions and sends formatted notifications to Telegram.
|
||||
@ -1,6 +1,8 @@
|
||||
{
|
||||
"ui": {
|
||||
"useAlternateBuffer": true
|
||||
"useAlternateBuffer": true,
|
||||
"incrementalRendering": true,
|
||||
"multiline": true
|
||||
},
|
||||
"tools": {
|
||||
"truncateToolOutputLines": 10000
|
||||
|
||||
@ -108,7 +108,7 @@
|
||||
{
|
||||
"type": "AUTOMATIC",
|
||||
"token_id": 6154897,
|
||||
"status": "OPEN",
|
||||
"status": "CLOSED",
|
||||
"target_value": 998.49,
|
||||
"entry_price": 849.3437,
|
||||
"amount0_initial": 498.5177,
|
||||
@ -118,6 +118,101 @@
|
||||
"range_lower": 836.9493,
|
||||
"token0_decimals": 18,
|
||||
"token1_decimals": 18,
|
||||
"timestamp_open": 1767001797
|
||||
"timestamp_open": 1767001797,
|
||||
"target_value_end": 1005.08,
|
||||
"timestamp_close": 1767102435
|
||||
},
|
||||
{
|
||||
"type": "AUTOMATIC",
|
||||
"token_id": 6161247,
|
||||
"status": "CLOSED",
|
||||
"target_value": 995.73,
|
||||
"entry_price": 862.6115,
|
||||
"amount0_initial": 495.7523,
|
||||
"amount1_initial": 0.5796,
|
||||
"liquidity": "2299317483414760958984",
|
||||
"range_upper": 875.5579,
|
||||
"range_lower": 850.0224,
|
||||
"token0_decimals": 18,
|
||||
"token1_decimals": 18,
|
||||
"timestamp_open": 1767102508,
|
||||
"hedge_TotPnL": 0.523778
|
||||
},
|
||||
{
|
||||
"type": "AUTOMATIC",
|
||||
"token_id": 6162814,
|
||||
"status": "CLOSED",
|
||||
"target_value": 995.27,
|
||||
"entry_price": 860.0,
|
||||
"amount0_initial": 496.4754,
|
||||
"amount1_initial": 0.58,
|
||||
"liquidity": "8300226074094182294178",
|
||||
"range_upper": 863.616,
|
||||
"range_lower": 856.384,
|
||||
"token0_decimals": 18,
|
||||
"token1_decimals": 18,
|
||||
"timestamp_open": 1767126772,
|
||||
"hedge_TotPnL": -3.412645,
|
||||
"hedge_fees_paid": 1.002278,
|
||||
"timestamp_close": 1767144919
|
||||
},
|
||||
{
|
||||
"type": "AUTOMATIC",
|
||||
"token_id": 6163606,
|
||||
"status": "CLOSED",
|
||||
"target_value": 1000.01,
|
||||
"entry_price": 862.0807,
|
||||
"amount0_initial": 500.0068,
|
||||
"amount1_initial": 0.58,
|
||||
"liquidity": "8283150435973737393211",
|
||||
"range_upper": 865.7014,
|
||||
"range_lower": 858.46,
|
||||
"timestamp_open": 1767145082,
|
||||
"timestamp_close": 1767151372
|
||||
},
|
||||
{
|
||||
"type": "AUTOMATIC",
|
||||
"token_id": 6163987,
|
||||
"status": "CLOSED",
|
||||
"target_value": 995.26,
|
||||
"entry_price": 857.9836,
|
||||
"amount0_initial": 497.6305,
|
||||
"amount1_initial": 0.58,
|
||||
"liquidity": "8313185309628073121633",
|
||||
"range_upper": 861.5872,
|
||||
"range_lower": 854.3801,
|
||||
"timestamp_open": 1767152045,
|
||||
"timestamp_close": 1767158799
|
||||
},
|
||||
{
|
||||
"type": "AUTOMATIC",
|
||||
"token_id": 6164411,
|
||||
"status": "CLOSED",
|
||||
"target_value": 991.83,
|
||||
"entry_price": 855.03,
|
||||
"amount0_initial": 495.9174,
|
||||
"amount1_initial": 0.58,
|
||||
"liquidity": "8280770348281556176465",
|
||||
"range_upper": 858.6211,
|
||||
"range_lower": 851.4389,
|
||||
"timestamp_open": 1767158967,
|
||||
"timestamp_close": 1767163852
|
||||
},
|
||||
{
|
||||
"type": "AUTOMATIC",
|
||||
"token_id": 6164702,
|
||||
"status": "OPEN",
|
||||
"target_value": 981.88,
|
||||
"entry_price": 846.4517,
|
||||
"amount0_initial": 490.942,
|
||||
"amount1_initial": 0.58,
|
||||
"liquidity": "8220443727732589279738",
|
||||
"range_upper": 869.8855,
|
||||
"range_lower": 862.782,
|
||||
"token0_decimals": 18,
|
||||
"token1_decimals": 18,
|
||||
"timestamp_open": 1767164052,
|
||||
"hedge_TotPnL": -0.026171,
|
||||
"hedge_fees_paid": 0.097756
|
||||
}
|
||||
]
|
||||
@ -298,6 +298,8 @@
|
||||
"range_lower": 2827.096,
|
||||
"token0_decimals": 18,
|
||||
"token1_decimals": 6,
|
||||
"timestamp_open": 1766968369
|
||||
"timestamp_open": 1766968369,
|
||||
"hedge_TotPnL": -5.078135,
|
||||
"hedge_fees_paid": 2.029157
|
||||
}
|
||||
]
|
||||
@ -17,7 +17,6 @@ DEFAULT_STRATEGY = {
|
||||
"TARGET_INVESTMENT_AMOUNT": 2000, # Total USD value to deploy into the LP position
|
||||
"INITIAL_HEDGE_CAPITAL": 1000, # Capital reserved on Hyperliquid for hedging
|
||||
"VALUE_REFERENCE": "USD", # Base currency for all calculations
|
||||
"WRAPPED_NATIVE_ADDRESS": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", # WETH/WBNB address
|
||||
|
||||
# Range Settings
|
||||
"RANGE_WIDTH_PCT": Decimal("0.01"), # LP width (e.g. 0.05 = +/- 5% from current price)
|
||||
@ -51,6 +50,14 @@ DEFAULT_STRATEGY = {
|
||||
"SHADOW_ORDER_TIMEOUT": 600, # Timeout for theoretical shadow order tracking
|
||||
"ENABLE_FISHING": False, # Use passive maker orders for rebalancing (advanced)
|
||||
"FISHING_ORDER_SIZE_PCT": Decimal("0.10"), # Size of individual fishing orders
|
||||
"FISHING_TIMEOUT_FALLBACK": 30, # Seconds before converting fishing order to taker
|
||||
|
||||
# EAC (Enhanced Asymmetric Compensation)
|
||||
"EAC_NARROW_RANGE_THRESHOLD": Decimal("0.02"), # <2% = narrow
|
||||
"EAC_MEDIUM_RANGE_THRESHOLD": Decimal("0.05"), # <5% = medium
|
||||
"EAC_NARROW_BOOST": Decimal("0.15"), # 15% boost
|
||||
"EAC_MEDIUM_BOOST": Decimal("0.10"), # 10% boost
|
||||
"EAC_WIDE_BOOST": Decimal("0.075"), # 7.5% boost
|
||||
}
|
||||
|
||||
# --- CLP PROFILES ---
|
||||
@ -91,9 +98,17 @@ CLP_PROFILES = {
|
||||
"TOKEN_B_ADDRESS": "0x55d398326f99059fF775485246999027B3197955", # USDT
|
||||
"WRAPPED_NATIVE_ADDRESS": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c",
|
||||
"POOL_FEE": 100,
|
||||
"RANGE_WIDTH_PCT": Decimal("0.015"),
|
||||
"RANGE_WIDTH_PCT": Decimal("0.004"),
|
||||
"TARGET_INVESTMENT_AMOUNT": 1000,
|
||||
"MIN_HEDGE_THRESHOLD": Decimal("0.05"), # ~$30 for BNB
|
||||
"MIN_HEDGE_THRESHOLD": Decimal("0.015"),
|
||||
"BASE_REBALANCE_THRESHOLD_PCT": Decimal("0.10"),
|
||||
"EDGE_PROXIMITY_PCT": Decimal("0.015"),
|
||||
"DYNAMIC_THRESHOLD_MULTIPLIER": Decimal("1.1"),
|
||||
"MIN_TIME_BETWEEN_TRADES": 20,
|
||||
"ENABLE_FISHING": False,
|
||||
"FISHING_ORDER_SIZE_PCT": Decimal("0.05"),
|
||||
"MAKER_ORDER_TIMEOUT": 180,
|
||||
"FISHING_TIMEOUT_FALLBACK": 60,
|
||||
},
|
||||
"WETH_CBBTC_BASE": {
|
||||
"NAME": "Aerodrome/Uni (Base) - WETH/cbBTC",
|
||||
|
||||
@ -45,7 +45,7 @@ class UnixMsLogFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
# Configure Logging
|
||||
logger = logging.getLogger("UNIFIED_HEDGER")
|
||||
logger = logging.getLogger("CLP_HEDGER")
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False # Prevent double logging from root logger
|
||||
logger.handlers.clear() # Clear existing handlers to prevent duplicates
|
||||
@ -58,7 +58,7 @@ console_handler.setFormatter(console_fmt)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# File Handler
|
||||
log_file = os.path.join(log_dir, 'unified_hedger.log')
|
||||
log_file = os.path.join(log_dir, 'clp_hedger.log')
|
||||
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.addFilter(UnixMsLogFilter())
|
||||
@ -195,7 +195,23 @@ class HyperliquidStrategy:
|
||||
|
||||
sqrt_P = current_price.sqrt()
|
||||
sqrt_Pb = self.high_range.sqrt()
|
||||
return self.L * ((Decimal("1")/sqrt_P) - (Decimal("1")/sqrt_Pb))
|
||||
return self.L * (sqrt_Pb - sqrt_P) / (sqrt_P * sqrt_Pb)
|
||||
|
||||
def get_compensation_boost(self) -> Decimal:
|
||||
if self.low_range <= 0: return Decimal("0.075")
|
||||
range_width_pct = (self.high_range - self.low_range) / self.low_range
|
||||
|
||||
# Use default strategy values if not available in instance context,
|
||||
# but typically these are constant. For now hardcode per plan or use safe defaults.
|
||||
# Since this is inside Strategy which doesn't know about global config easily,
|
||||
# we'll implement the logic defined in the plan directly.
|
||||
|
||||
if range_width_pct < Decimal("0.02"): # <2% range
|
||||
return Decimal("0.15") # Double protection for narrow ranges
|
||||
elif range_width_pct < Decimal("0.05"): # <5% range
|
||||
return Decimal("0.10") # Moderate for medium ranges
|
||||
else: # >=5% range
|
||||
return Decimal("0.075") # Standard for wide ranges
|
||||
|
||||
def calculate_rebalance(self, current_price: Decimal, current_short_size: Decimal) -> Dict:
|
||||
# Note: current_short_size here is virtual (just for this specific strategy),
|
||||
@ -211,7 +227,7 @@ class HyperliquidStrategy:
|
||||
dist = current_price - self.entry_price
|
||||
half_width = range_width / Decimal("2")
|
||||
norm_dist = dist / half_width
|
||||
max_boost = Decimal("0.075")
|
||||
max_boost = self.get_compensation_boost()
|
||||
adj_pct = -norm_dist * max_boost
|
||||
adj_pct = max(-max_boost, min(max_boost, adj_pct))
|
||||
|
||||
@ -265,7 +281,7 @@ class UnifiedHedger:
|
||||
|
||||
self.startup_time = time.time()
|
||||
|
||||
logger.info(f"[UNIFIED] Master Hedger initialized. Agent: {self.account.address}")
|
||||
logger.info(f"[CLP_HEDGER] Master Hedger initialized. Agent: {self.account.address}")
|
||||
self._init_coin_configs()
|
||||
|
||||
def _init_coin_configs(self):
|
||||
@ -440,8 +456,7 @@ class UnifiedHedger:
|
||||
|
||||
d0 = int(position_data.get('token0_decimals', 18))
|
||||
d1 = int(position_data.get('token1_decimals', 6))
|
||||
scale_exp = (d0 + d1) / 2
|
||||
liquidity_scale = Decimal("10") ** Decimal(str(-scale_exp))
|
||||
liquidity_scale = Decimal("10") ** Decimal(str(-(d0 + d1) / 2))
|
||||
|
||||
start_price = self.last_prices.get(coin_symbol)
|
||||
if start_price is None:
|
||||
@ -453,12 +468,17 @@ class UnifiedHedger:
|
||||
liquidity_val, liquidity_scale
|
||||
)
|
||||
|
||||
# Fix: Use persistent start time from JSON to track all fills
|
||||
ts_open = position_data.get('timestamp_open')
|
||||
start_time_ms = int(ts_open * 1000) if ts_open else int(time.time() * 1000)
|
||||
|
||||
self.strategies[key] = strat
|
||||
self.strategy_states[key] = {
|
||||
"coin": coin_symbol,
|
||||
"start_time": int(time.time() * 1000),
|
||||
"start_time": start_time_ms,
|
||||
"pnl": to_decimal(position_data.get('hedge_pnl_realized', 0)),
|
||||
"fees": to_decimal(position_data.get('hedge_fees_paid', 0)),
|
||||
"hedge_TotPnL": to_decimal(position_data.get('hedge_TotPnL', 0)), # NEW: Total Closed PnL
|
||||
"entry_price": entry_price, # Store for fishing logic
|
||||
"status": position_data.get('status', 'OPEN')
|
||||
}
|
||||
@ -521,6 +541,46 @@ class UnifiedHedger:
|
||||
except Exception as e:
|
||||
logger.error(f"Error cancelling order: {e}")
|
||||
|
||||
def _update_closed_pnl(self, coin: str):
|
||||
"""Fetch fills from API and sum closedPnl and fees for active strategies."""
|
||||
try:
|
||||
# 1. Identify relevant strategies for this coin
|
||||
active_strats = [k for k, v in self.strategy_states.items() if v['coin'] == coin]
|
||||
if not active_strats: return
|
||||
|
||||
# 2. Fetch all fills (This is heavy, maybe cache or limit?)
|
||||
# SDK user_fills returns recent fills.
|
||||
fills = self.info.user_fills(self.vault_address or self.account.address)
|
||||
|
||||
for key in active_strats:
|
||||
start_time = self.strategy_states[key]['start_time']
|
||||
total_closed_pnl = Decimal("0")
|
||||
total_fees = Decimal("0")
|
||||
|
||||
for fill in fills:
|
||||
if fill['coin'] == coin:
|
||||
# Check timestamp
|
||||
if fill['time'] >= start_time:
|
||||
# Sum closedPnl
|
||||
total_closed_pnl += to_decimal(fill.get('closedPnl', 0))
|
||||
# Sum fees
|
||||
total_fees += to_decimal(fill.get('fee', 0))
|
||||
|
||||
# Update State
|
||||
self.strategy_states[key]['hedge_TotPnL'] = total_closed_pnl
|
||||
self.strategy_states[key]['fees'] = total_fees
|
||||
|
||||
# Write to JSON
|
||||
file_path, token_id = key
|
||||
update_position_stats(file_path, token_id, {
|
||||
"hedge_TotPnL": float(total_closed_pnl),
|
||||
"hedge_fees_paid": float(total_fees)
|
||||
})
|
||||
logger.info(f"[PnL] Updated {coin} | Closed PnL: ${total_closed_pnl:.2f} | Fees: ${total_fees:.2f}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update closed PnL/Fees for {coin}: {e}")
|
||||
|
||||
def run(self):
|
||||
logger.info("Starting Unified Hedger Loop...")
|
||||
self.update_coin_decimals()
|
||||
@ -572,12 +632,15 @@ class UnifiedHedger:
|
||||
# Map current positions
|
||||
current_positions = {} # Coin -> Size
|
||||
current_pnls = {} # Coin -> Unrealized PnL
|
||||
current_entry_pxs = {} # Coin -> Entry Price (NEW)
|
||||
for pos in user_state["assetPositions"]:
|
||||
c = pos["position"]["coin"]
|
||||
s = to_decimal(pos["position"]["szi"])
|
||||
u = to_decimal(pos["position"]["unrealizedPnl"])
|
||||
e = to_decimal(pos["position"]["entryPx"])
|
||||
current_positions[c] = s
|
||||
current_pnls[c] = u
|
||||
current_entry_pxs[c] = e
|
||||
|
||||
# 4. Aggregate Targets
|
||||
# Coin -> { 'target_short': Decimal, 'contributors': int, 'is_at_edge': bool }
|
||||
@ -677,16 +740,71 @@ class UnifiedHedger:
|
||||
# Check Trigger
|
||||
action_needed = diff_abs > dynamic_thresh
|
||||
|
||||
# Determine Intent (Moved UP for Order Logic)
|
||||
is_buy_bool = diff > 0
|
||||
side_str = "BUY" if is_buy_bool else "SELL"
|
||||
|
||||
# Manage Existing Orders
|
||||
existing_orders = orders_map.get(coin, [])
|
||||
force_taker_retry = False
|
||||
|
||||
# Fishing Config
|
||||
enable_fishing = config.get("ENABLE_FISHING", False)
|
||||
fishing_timeout = config.get("FISHING_TIMEOUT_FALLBACK", 30)
|
||||
|
||||
# Check Existing Orders for compatibility
|
||||
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'] # 'B' or 'A'
|
||||
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)
|
||||
|
||||
# Price Check (within buffer)
|
||||
dist_pct = abs(price - o_price) / price
|
||||
|
||||
# Maker Timeout Check (General)
|
||||
maker_timeout = config.get("MAKER_ORDER_TIMEOUT", 300)
|
||||
order_age_sec = (int(time.time()*1000) - o_timestamp) / 1000.0
|
||||
|
||||
if is_same_side and order_age_sec > maker_timeout:
|
||||
logger.info(f"[TIMEOUT] {coin} Order {o_oid} expired ({order_age_sec:.1f}s > {maker_timeout}s). Cancelling to refresh.")
|
||||
self.cancel_order(coin, o_oid)
|
||||
continue
|
||||
|
||||
# Fishing Timeout Check
|
||||
if enable_fishing and is_same_side and order_age_sec > fishing_timeout:
|
||||
logger.info(f"[FISHING] {coin} Order {o_oid} timed out ({order_age_sec:.1f}s > {fishing_timeout}s). Cancelling for Taker retry.")
|
||||
self.cancel_order(coin, o_oid)
|
||||
force_taker_retry = True
|
||||
continue # Do not mark matched, let it flow to execution
|
||||
|
||||
if is_same_side and dist_pct < price_buffer_pct:
|
||||
order_matched = True
|
||||
if int(time.time()) % 10 == 0:
|
||||
logger.info(f"[WAIT] {coin} Pending {side_str} Order {o_oid} @ {o_price} (Dist: {dist_pct*100:.3f}%) | 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)
|
||||
|
||||
# --- EXECUTION LOGIC ---
|
||||
if action_needed:
|
||||
if not order_matched:
|
||||
if action_needed or force_taker_retry:
|
||||
bypass_cooldown = False
|
||||
force_maker = False
|
||||
|
||||
# 0. Forced Taker Retry (Fishing Timeout)
|
||||
if force_taker_retry:
|
||||
bypass_cooldown = True
|
||||
logger.info(f"[RETRY] {coin} Fishing Failed -> Force Taker")
|
||||
|
||||
# 1. Urgent Closing -> Taker
|
||||
if data.get('is_closing', False):
|
||||
elif data.get('is_closing', False):
|
||||
bypass_cooldown = True
|
||||
logger.info(f"[URGENT] {coin} Closing Strategy -> Force Taker Exit")
|
||||
|
||||
@ -699,43 +817,14 @@ class UnifiedHedger:
|
||||
logger.info(f"[STARTUP] Skipping Ghost Cleanup for {coin} (Grace Period)")
|
||||
continue # Skip execution for this coin
|
||||
|
||||
# Large Hedge Check
|
||||
# Large Hedge Check (Only Force Taker if AT EDGE)
|
||||
large_hedge_mult = config.get("LARGE_HEDGE_MULTIPLIER", Decimal("5.0"))
|
||||
if diff_abs > (dynamic_thresh * large_hedge_mult) and not force_maker:
|
||||
if diff_abs > (dynamic_thresh * large_hedge_mult) and not force_maker and data.get('is_at_edge', False):
|
||||
bypass_cooldown = True
|
||||
logger.info(f"[WARN] LARGE HEDGE: {diff_abs:.4f} > {dynamic_thresh:.4f} (x{large_hedge_mult})")
|
||||
|
||||
# Determine Intent
|
||||
is_buy_bool = diff > 0
|
||||
side_str = "BUY" if is_buy_bool else "SELL"
|
||||
|
||||
# Check Existing Orders for compatibility
|
||||
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'] # 'B' or 'A'
|
||||
|
||||
is_same_side = (o_side == 'B' and is_buy_bool) or (o_side == 'A' and not is_buy_bool)
|
||||
|
||||
# Price Check (within buffer)
|
||||
# If we are BUYING, we want order price close to Bid (or higher)
|
||||
# If we are SELLING, we want order price close to Ask (or lower)
|
||||
dist_pct = abs(price - o_price) / price
|
||||
|
||||
if is_same_side and dist_pct < price_buffer_pct:
|
||||
order_matched = True
|
||||
if int(time.time()) % 10 == 0:
|
||||
logger.info(f"[WAIT] {coin} Pending {side_str} Order {o_oid} @ {o_price} (Dist: {dist_pct*100:.3f}%)")
|
||||
break
|
||||
else:
|
||||
logger.info(f"Cancelling stale order {o_oid} ({o_side} @ {o_price})")
|
||||
self.cancel_order(coin, o_oid)
|
||||
|
||||
if order_matched:
|
||||
continue # Order exists, wait for it
|
||||
logger.info(f"[WARN] LARGE HEDGE (Edge Protection): {diff_abs:.4f} > {dynamic_thresh:.4f} (x{large_hedge_mult})")
|
||||
elif diff_abs > (dynamic_thresh * large_hedge_mult) and not force_maker:
|
||||
# Large hedge but safe zone -> Maker is fine, but maybe log it
|
||||
logger.info(f"[INFO] Large Hedge (Safe Zone): {diff_abs:.4f}. Using Standard Execution.")
|
||||
|
||||
last_trade = self.last_trade_times.get(coin, 0)
|
||||
|
||||
@ -759,15 +848,32 @@ class UnifiedHedger:
|
||||
|
||||
# Price logic
|
||||
create_shadow = False
|
||||
|
||||
# Decide Order Type: Taker (Ioc) or Maker (Alo)
|
||||
# Taker if: Urgent (bypass_cooldown) OR Fishing Disabled OR Force Maker is False (wait, Force Maker means Alo)
|
||||
|
||||
# Logic:
|
||||
# If Force Maker -> Alo
|
||||
# Else if Urgent -> Ioc
|
||||
# Else if Enable Fishing -> Alo
|
||||
# Else -> Alo (Default non-urgent behavior was Alo anyway?)
|
||||
|
||||
# Let's clarify:
|
||||
# Previous logic: if bypass_cooldown -> Ioc. Else -> Alo.
|
||||
# New logic:
|
||||
# If bypass_cooldown -> Ioc
|
||||
# Else -> Alo (Fishing)
|
||||
|
||||
if bypass_cooldown and not force_maker:
|
||||
exec_price = ask * Decimal("1.001") if is_buy_bool else bid * Decimal("0.999")
|
||||
order_type = "Ioc"
|
||||
create_shadow = True
|
||||
else:
|
||||
# Fishing / Standard Maker
|
||||
exec_price = bid if is_buy_bool else ask
|
||||
order_type = "Alo"
|
||||
|
||||
logger.info(f"[TRIG] Net {coin}: {side_str} {diff_abs:.4f} | Tgt: {target_position:.4f} / Cur: {current_pos:.4f} | Thresh: {dynamic_thresh:.4f}")
|
||||
logger.info(f"[TRIG] Net {coin}: {side_str} {diff_abs:.4f} | Tgt: {target_position:.4f} / Cur: {current_pos:.4f} | Thresh: {dynamic_thresh:.4f} | Type: {order_type}")
|
||||
|
||||
oid = self.place_limit_order(coin, is_buy_bool, diff_abs, exec_price, order_type)
|
||||
if oid:
|
||||
@ -785,9 +891,12 @@ class UnifiedHedger:
|
||||
})
|
||||
logger.info(f"[SHADOW] Created Maker {side_str} @ {shadow_price:.2f}")
|
||||
|
||||
# UPDATED: Sleep for API Lag
|
||||
logger.info("Sleeping 5s to allow position update...")
|
||||
time.sleep(5)
|
||||
# UPDATED: Sleep for API Lag (Phase 5.1)
|
||||
logger.info("Sleeping 10s to allow position update...")
|
||||
time.sleep(10)
|
||||
|
||||
# --- UPDATE CLOSED PnL FROM API ---
|
||||
self._update_closed_pnl(coin)
|
||||
else:
|
||||
# Cooldown log
|
||||
pass
|
||||
@ -827,11 +936,14 @@ class UnifiedHedger:
|
||||
|
||||
# PnL Calc
|
||||
unrealized = current_pnls.get(coin, Decimal("0"))
|
||||
realized = Decimal("0")
|
||||
closed_pnl_total = Decimal("0")
|
||||
fees_total = Decimal("0")
|
||||
for k, s_state in self.strategy_states.items():
|
||||
if s_state['coin'] == coin:
|
||||
realized += (s_state['pnl'] - s_state['fees'])
|
||||
total_pnl = realized + unrealized
|
||||
closed_pnl_total += s_state.get('hedge_TotPnL', Decimal("0"))
|
||||
fees_total += s_state.get('fees', Decimal("0"))
|
||||
|
||||
total_pnl = (closed_pnl_total - fees_total) + unrealized
|
||||
|
||||
pnl_pad = " " if unrealized >= 0 else ""
|
||||
tot_pnl_pad = " " if total_pnl >= 0 else ""
|
||||
@ -1,11 +1,3 @@
|
||||
# --- AERODROME MANAGER (BASE CHAIN) ---
|
||||
# Adapted from uniswap_manager.py for Aerodrome Slipstream on Base.
|
||||
# Key Differences:
|
||||
# 1. Base Chain RPC & Tokens (WETH/USDC)
|
||||
# 2. Aerodrome Slipstream Contract Addresses
|
||||
# 3. Dynamic Tick Spacing (vs hardcoded 10)
|
||||
# 4. Separate Status File (aerodrome_status.json)
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
@ -19,6 +11,7 @@ from typing import Optional, Dict, Tuple, Any, List
|
||||
|
||||
from web3 import Web3
|
||||
from web3.exceptions import TimeExhausted, ContractLogicError
|
||||
from web3.middleware import ExtraDataToPOAMiddleware # FIX for Web3.py v6+
|
||||
from eth_account import Account
|
||||
from eth_account.signers.local import LocalAccount
|
||||
from dotenv import load_dotenv
|
||||
@ -41,18 +34,19 @@ sys.path.append(current_dir)
|
||||
log_dir = os.path.join(current_dir, 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# Logger Name changed to avoid conflict
|
||||
LOGGER_NAME = "AERODROME_MANAGER"
|
||||
|
||||
try:
|
||||
from logging_utils import setup_logging
|
||||
logger = setup_logging("normal", LOGGER_NAME)
|
||||
# Assuming setup_logging might handle file logging if configured,
|
||||
# but to be safe and explicit as requested, we'll add a FileHandler here
|
||||
# or rely on setup_logging if it supports it.
|
||||
# Since I don't see setup_logging code, I will manually add a file handler to the logger.
|
||||
logger = setup_logging("normal", "UNISWAP_MANAGER")
|
||||
except ImportError:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(LOGGER_NAME)
|
||||
logger = logging.getLogger("UNISWAP_MANAGER")
|
||||
|
||||
# Custom Filter for Millisecond Unix Timestamp
|
||||
class UnixMsLogFilter(logging.Filter):
|
||||
@ -61,7 +55,7 @@ class UnixMsLogFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
# Add File Handler
|
||||
log_file = os.path.join(log_dir, 'aerodrome_manager.log')
|
||||
log_file = os.path.join(log_dir, 'uniswap_manager.log')
|
||||
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.addFilter(UnixMsLogFilter())
|
||||
@ -69,7 +63,8 @@ formatter = logging.Formatter('%(unix_ms)d, %(asctime)s - %(name)s - %(levelname
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# --- ABIs (Standard V3/Slipstream Compatible) ---
|
||||
# --- 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"},
|
||||
@ -84,12 +79,12 @@ NONFUNGIBLE_POSITION_MANAGER_ABI = json.loads('''
|
||||
|
||||
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": "uint8", "name": "feeProtocol", "type": "uint8"}, {"internalType": "bool", "name": "unlocked", "type": "bool"}], "stateMutability": "view", "type": "function"},
|
||||
{"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": "liquidity", "outputs": [{"internalType": "uint128", "name": "", "type": "uint128"}], "stateMutability": "view", "type": "function"},
|
||||
{"inputs": [], "name": "tickSpacing", "outputs": [{"internalType": "int24", "name": "", "type": "int24"}], "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"}
|
||||
]
|
||||
''')
|
||||
|
||||
@ -122,25 +117,29 @@ WETH9_ABI = json.loads('''
|
||||
]
|
||||
''')
|
||||
|
||||
# --- CONFIGURATION (BASE CHAIN / AERODROME) ---
|
||||
# Aerodrome Slipstream Addresses (Base)
|
||||
NONFUNGIBLE_POSITION_MANAGER_ADDRESS = "0x827922686190790b37229fd06084350e74485b72"
|
||||
AERODROME_SWAP_ROUTER_ADDRESS = "0xBE6D8f0d05027F14e266dCC1E844717068f6f296"
|
||||
from clp_config import get_current_config, STATUS_FILE
|
||||
|
||||
# Base Chain Tokens
|
||||
WETH_ADDRESS = "0x4200000000000000000000000000000000000006"
|
||||
USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
||||
# --- GET ACTIVE DEX CONFIG ---
|
||||
CONFIG = get_current_config()
|
||||
|
||||
STATUS_FILE = "aerodrome_status.json" # Separate status file for Base
|
||||
MONITOR_INTERVAL_SECONDS = 666
|
||||
CLOSE_POSITION_ENABLED = True
|
||||
OPEN_POSITION_ENABLED = True
|
||||
REBALANCE_ON_CLOSE_BELOW_RANGE = True
|
||||
TARGET_INVESTMENT_VALUE_USDC = 2000
|
||||
INITIAL_HEDGE_CAPITAL_USDC = 2000
|
||||
RANGE_WIDTH_PCT = Decimal("0.02") # +/- 1%
|
||||
SLIPPAGE_TOLERANCE = Decimal("0.02")
|
||||
TRANSACTION_TIMEOUT_SECONDS = 30
|
||||
# --- CONFIGURATION FROM STRATEGY ---
|
||||
MONITOR_INTERVAL_SECONDS = CONFIG.get("MONITOR_INTERVAL_SECONDS", 60)
|
||||
CLOSE_POSITION_ENABLED = CONFIG.get("CLOSE_POSITION_ENABLED", True)
|
||||
OPEN_POSITION_ENABLED = CONFIG.get("OPEN_POSITION_ENABLED", True)
|
||||
REBALANCE_ON_CLOSE_BELOW_RANGE = CONFIG.get("REBALANCE_ON_CLOSE_BELOW_RANGE", True)
|
||||
TARGET_INVESTMENT_VALUE_USDC = CONFIG.get("TARGET_INVESTMENT_AMOUNT", 2000)
|
||||
INITIAL_HEDGE_CAPITAL_USDC = CONFIG.get("INITIAL_HEDGE_CAPITAL", 1000)
|
||||
RANGE_WIDTH_PCT = CONFIG.get("RANGE_WIDTH_PCT", Decimal("0.01"))
|
||||
SLIPPAGE_TOLERANCE = CONFIG.get("SLIPPAGE_TOLERANCE", Decimal("0.02"))
|
||||
TRANSACTION_TIMEOUT_SECONDS = CONFIG.get("TRANSACTION_TIMEOUT_SECONDS", 30)
|
||||
|
||||
# --- CONFIGURATION CONSTANTS ---
|
||||
NONFUNGIBLE_POSITION_MANAGER_ADDRESS = CONFIG["NPM_ADDRESS"]
|
||||
UNISWAP_V3_SWAP_ROUTER_ADDRESS = CONFIG["ROUTER_ADDRESS"]
|
||||
# Arbitrum WETH/USDC (or generic T0/T1)
|
||||
WETH_ADDRESS = CONFIG["WRAPPED_NATIVE_ADDRESS"]
|
||||
USDC_ADDRESS = CONFIG["TOKEN_B_ADDRESS"]
|
||||
POOL_FEE = CONFIG.get("POOL_FEE", 500)
|
||||
|
||||
# --- HELPER FUNCTIONS ---
|
||||
|
||||
@ -161,13 +160,13 @@ def to_wei_int(value: Decimal, decimals: int) -> int:
|
||||
return int(value * (Decimal(10) ** decimals))
|
||||
|
||||
def get_gas_params(w3: Web3) -> Dict[str, int]:
|
||||
"""Get dynamic gas parameters for EIP-1559 (Base Chain)."""
|
||||
"""Get dynamic gas parameters for EIP-1559."""
|
||||
latest_block = w3.eth.get_block("latest")
|
||||
base_fee = latest_block['baseFeePerGas']
|
||||
# Base chain fees are low, but we prioritize
|
||||
max_priority_fee = w3.eth.max_priority_fee or Web3.to_wei(0.05, 'gwei')
|
||||
# Priority fee: 0.1 gwei or dynamic
|
||||
max_priority_fee = w3.eth.max_priority_fee or Web3.to_wei(0.1, 'gwei')
|
||||
|
||||
# Max Fee = Base Fee * 1.25 + Priority Fee
|
||||
# Max Fee = Base Fee * 1.5 + Priority Fee
|
||||
max_fee = int(base_fee * 1.25) + max_priority_fee
|
||||
|
||||
return {
|
||||
@ -183,26 +182,36 @@ def send_transaction_robust(
|
||||
gas_limit: Optional[int] = None,
|
||||
extra_msg: str = ""
|
||||
) -> Optional[Any]:
|
||||
"""
|
||||
Builds, signs, sends, and waits for a transaction with timeout and status check.
|
||||
"""
|
||||
try:
|
||||
# 1. Prepare Params
|
||||
# Use 'pending' to ensure we get the correct nonce if a tx was just sent/mined
|
||||
tx_params = {
|
||||
'from': account.address,
|
||||
'nonce': w3.eth.get_transaction_count(account.address),
|
||||
'nonce': w3.eth.get_transaction_count(account.address, 'pending'),
|
||||
'value': value,
|
||||
'chainId': w3.eth.chain_id,
|
||||
}
|
||||
|
||||
# 2. Add Gas Params
|
||||
gas_fees = get_gas_params(w3)
|
||||
tx_params.update(gas_fees)
|
||||
|
||||
# 3. Simulate (Call) & Estimate Gas
|
||||
try:
|
||||
# If function call object provided
|
||||
if hasattr(func_call, 'call'):
|
||||
func_call.call({'from': account.address, 'value': value})
|
||||
func_call.call({'from': account.address, 'value': value}) # Safety Dry-Run
|
||||
estimated_gas = func_call.estimate_gas({'from': account.address, 'value': value})
|
||||
else:
|
||||
# Raw transaction construction if func_call is just params dict (rare here)
|
||||
estimated_gas = 200000
|
||||
|
||||
tx_params['gas'] = gas_limit if gas_limit else int(estimated_gas * 1.2)
|
||||
tx_params['gas'] = gas_limit if gas_limit else int(estimated_gas * 1.2) # 20% buffer
|
||||
|
||||
# Build
|
||||
if hasattr(func_call, 'build_transaction'):
|
||||
tx = func_call.build_transaction(tx_params)
|
||||
else:
|
||||
@ -212,12 +221,17 @@ def send_transaction_robust(
|
||||
logger.error(f"❌ Simulation/Estimation failed for {extra_msg}: {e}")
|
||||
return None
|
||||
|
||||
# 4. Sign
|
||||
signed_tx = account.sign_transaction(tx)
|
||||
|
||||
# 5. Send
|
||||
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
|
||||
logger.info(f"📤 Sent {extra_msg} | Hash: {tx_hash.hex()}")
|
||||
|
||||
# 6. Wait for Receipt
|
||||
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=TRANSACTION_TIMEOUT_SECONDS)
|
||||
|
||||
# 7. Verify Status
|
||||
if receipt.status == 1:
|
||||
logger.info(f"✅ Executed {extra_msg} | Block: {receipt.blockNumber}")
|
||||
return receipt
|
||||
@ -227,15 +241,22 @@ def send_transaction_robust(
|
||||
|
||||
except TimeExhausted:
|
||||
logger.error(f"⌛ Transaction Timeout {extra_msg} - Check Mempool")
|
||||
# In a full production bot, we would implement gas bumping here.
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Transaction Error {extra_msg}: {e}")
|
||||
return None
|
||||
|
||||
def price_from_sqrt_price_x96(sqrt_price_x96: int, token0_decimals: int, token1_decimals: int) -> Decimal:
|
||||
"""
|
||||
Returns price of Token0 in terms of Token1.
|
||||
"""
|
||||
sqrt_price = Decimal(sqrt_price_x96)
|
||||
q96 = Decimal(2) ** 96
|
||||
price = (sqrt_price / q96) ** 2
|
||||
|
||||
# Adjust for decimals: Price = (T1 / 10^d1) / (T0 / 10^d0)
|
||||
# = (T1/T0) * (10^d0 / 10^d1)
|
||||
adjustment = Decimal(10) ** (token0_decimals - token1_decimals)
|
||||
return price * adjustment
|
||||
|
||||
@ -255,6 +276,7 @@ def get_amounts_for_liquidity(sqrt_ratio_current: int, sqrt_ratio_a: int, sqrt_r
|
||||
amount1 = 0
|
||||
Q96 = 1 << 96
|
||||
|
||||
# Calculations performed in high-precision integer math (EVM style)
|
||||
if sqrt_ratio_current <= sqrt_ratio_a:
|
||||
amount0 = (liquidity * Q96 // sqrt_ratio_a) - (liquidity * Q96 // sqrt_ratio_b)
|
||||
amount1 = 0
|
||||
@ -271,6 +293,7 @@ def get_amounts_for_liquidity(sqrt_ratio_current: int, sqrt_ratio_a: int, sqrt_r
|
||||
|
||||
def get_position_details(w3: Web3, npm_contract, factory_contract, token_id: int):
|
||||
try:
|
||||
# Check ownership first to avoid errors? positions() works regardless of owner usually.
|
||||
position_data = npm_contract.functions.positions(token_id).call()
|
||||
(nonce, operator, token0_address, token1_address, fee, tickLower, tickUpper, liquidity,
|
||||
feeGrowthInside0, feeGrowthInside1, tokensOwed0, tokensOwed1) = position_data
|
||||
@ -278,6 +301,7 @@ def get_position_details(w3: Web3, npm_contract, factory_contract, token_id: int
|
||||
token0_contract = w3.eth.contract(address=token0_address, abi=ERC20_ABI)
|
||||
token1_contract = w3.eth.contract(address=token1_address, abi=ERC20_ABI)
|
||||
|
||||
# Multi-call optimization could be used here, but keeping simple for now
|
||||
token0_symbol = token0_contract.functions.symbol().call()
|
||||
token1_symbol = token1_contract.functions.symbol().call()
|
||||
token0_decimals = token0_contract.functions.decimals().call()
|
||||
@ -309,18 +333,25 @@ def get_pool_dynamic_data(pool_contract) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
def calculate_mint_amounts(current_tick, tick_lower, tick_upper, investment_value_token1: Decimal, decimals0, decimals1, sqrt_price_current_x96) -> Tuple[int, int]:
|
||||
"""
|
||||
Calculates required token amounts for a target investment value.
|
||||
Uses precise Decimal math.
|
||||
"""
|
||||
sqrt_price_current = get_sqrt_ratio_at_tick(current_tick)
|
||||
sqrt_price_lower = get_sqrt_ratio_at_tick(tick_lower)
|
||||
sqrt_price_upper = get_sqrt_ratio_at_tick(tick_upper)
|
||||
|
||||
# Price of T0 in T1
|
||||
price_t0_in_t1 = price_from_sqrt_price_x96(sqrt_price_current_x96, decimals0, decimals1)
|
||||
|
||||
# Calculate amounts for a "Test" liquidity amount
|
||||
L_test = 1 << 128
|
||||
amt0_test_wei, amt1_test_wei = get_amounts_for_liquidity(sqrt_price_current, sqrt_price_lower, sqrt_price_upper, L_test)
|
||||
|
||||
amt0_test = Decimal(amt0_test_wei) / Decimal(10**decimals0)
|
||||
amt1_test = Decimal(amt1_test_wei) / Decimal(10**decimals1)
|
||||
|
||||
# Value in Token1 terms
|
||||
value_test = (amt0_test * price_t0_in_t1) + amt1_test
|
||||
|
||||
if value_test <= 0:
|
||||
@ -334,6 +365,9 @@ def calculate_mint_amounts(current_tick, tick_lower, tick_upper, investment_valu
|
||||
return final_amt0_wei, final_amt1_wei
|
||||
|
||||
def ensure_allowance(w3: Web3, account: LocalAccount, token_address: str, spender_address: str, amount_needed: int) -> bool:
|
||||
"""
|
||||
Checks if allowance is sufficient, approves if not.
|
||||
"""
|
||||
try:
|
||||
token_c = w3.eth.contract(address=token_address, abi=ERC20_ABI)
|
||||
allowance = token_c.functions.allowance(account.address, spender_address).call()
|
||||
@ -343,9 +377,12 @@ def ensure_allowance(w3: Web3, account: LocalAccount, token_address: str, spende
|
||||
|
||||
logger.info(f"🔓 Approving {token_address} for {spender_address}...")
|
||||
|
||||
# Some tokens (USDT) fail if approving from non-zero to non-zero.
|
||||
# Safe practice: Approve 0 first if allowance > 0, then new amount.
|
||||
if allowance > 0:
|
||||
send_transaction_robust(w3, account, token_c.functions.approve(spender_address, 0), extra_msg="Reset Allowance")
|
||||
|
||||
# Approve
|
||||
receipt = send_transaction_robust(
|
||||
w3, account,
|
||||
token_c.functions.approve(spender_address, amount_needed),
|
||||
@ -358,6 +395,9 @@ def ensure_allowance(w3: Web3, account: LocalAccount, token_address: str, spende
|
||||
return False
|
||||
|
||||
def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount, token0: str, token1: str, amount0_needed: int, amount1_needed: int, sqrt_price_x96: int, d0: int, d1: int) -> bool:
|
||||
"""
|
||||
Checks balances, wraps ETH if needed, and swaps ONLY the required surplus to meet deposit requirements.
|
||||
"""
|
||||
token0 = clean_address(token0)
|
||||
token1 = clean_address(token1)
|
||||
token0_c = w3.eth.contract(address=token0, abi=ERC20_ABI)
|
||||
@ -366,15 +406,17 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount,
|
||||
bal0 = token0_c.functions.balanceOf(account.address).call()
|
||||
bal1 = token1_c.functions.balanceOf(account.address).call()
|
||||
|
||||
# Calculate Deficits
|
||||
deficit0 = max(0, amount0_needed - bal0)
|
||||
deficit1 = max(0, amount1_needed - bal1)
|
||||
|
||||
weth_lower = WETH_ADDRESS.lower()
|
||||
|
||||
# Auto-wrap ETH on Base
|
||||
# --- AUTO WRAP ETH ---
|
||||
if (deficit0 > 0 and token0.lower() == weth_lower) or (deficit1 > 0 and token1.lower() == weth_lower):
|
||||
eth_bal = w3.eth.get_balance(account.address)
|
||||
gas_reserve = Web3.to_wei(0.005, 'ether') # Lower gas reserve for Base
|
||||
# Keep 0.01 ETH for gas
|
||||
gas_reserve = Web3.to_wei(0.01, 'ether')
|
||||
available_eth = max(0, eth_bal - gas_reserve)
|
||||
|
||||
wrap_needed = 0
|
||||
@ -388,6 +430,7 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount,
|
||||
weth_c = w3.eth.contract(address=WETH_ADDRESS, abi=WETH9_ABI)
|
||||
receipt = send_transaction_robust(w3, account, weth_c.functions.deposit(), value=amount_to_wrap, extra_msg="Wrap ETH")
|
||||
if receipt:
|
||||
# Refresh Balances
|
||||
bal0 = token0_c.functions.balanceOf(account.address).call()
|
||||
bal1 = token1_c.functions.balanceOf(account.address).call()
|
||||
deficit0 = max(0, amount0_needed - bal0)
|
||||
@ -396,16 +439,28 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount,
|
||||
if deficit0 == 0 and deficit1 == 0:
|
||||
return True
|
||||
|
||||
# --- SWAP SURPLUS ---
|
||||
# Smart Swap: Calculate exactly how much we need to swap
|
||||
# Price of Token0 in terms of Token1
|
||||
price_0_in_1 = price_from_sqrt_price_x96(sqrt_price_x96, d0, d1)
|
||||
|
||||
swap_call = None
|
||||
token_in, token_out = None, None
|
||||
amount_in = 0
|
||||
buffer_multiplier = Decimal("1.02")
|
||||
|
||||
buffer_multiplier = Decimal("1.02") # 2% buffer for slippage/price moves
|
||||
|
||||
if deficit0 > 0 and bal1 > amount1_needed:
|
||||
# Need T0 (ETH), Have extra T1 (USDC)
|
||||
# Swap T1 -> T0
|
||||
# Cost in T1 = Deficit0 * Price(T0 in T1)
|
||||
cost_in_t1 = Decimal(deficit0) / Decimal(10**d0) * price_0_in_1
|
||||
|
||||
# Convert back to T1 Wei and apply buffer
|
||||
amount_in_needed = int(cost_in_t1 * Decimal(10**d1) * buffer_multiplier)
|
||||
|
||||
surplus1 = bal1 - amount1_needed
|
||||
|
||||
if surplus1 >= amount_in_needed:
|
||||
token_in, token_out = token1, token0
|
||||
amount_in = amount_in_needed
|
||||
@ -414,10 +469,15 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount,
|
||||
logger.warning(f"❌ Insufficient Surplus T1. Need {amount_in_needed}, Have {surplus1}")
|
||||
|
||||
elif deficit1 > 0 and bal0 > amount0_needed:
|
||||
# Need T1 (USDC), Have extra T0 (ETH)
|
||||
# Swap T0 -> T1
|
||||
# Cost in T0 = Deficit1 / Price(T0 in T1)
|
||||
if price_0_in_1 > 0:
|
||||
cost_in_t0 = (Decimal(deficit1) / Decimal(10**d1)) / price_0_in_1
|
||||
|
||||
amount_in_needed = int(cost_in_t0 * Decimal(10**d0) * buffer_multiplier)
|
||||
surplus0 = bal0 - amount0_needed
|
||||
|
||||
if surplus0 >= amount_in_needed:
|
||||
token_in, token_out = token0, token1
|
||||
amount_in = amount_in_needed
|
||||
@ -428,41 +488,51 @@ def check_and_swap_for_deposit(w3: Web3, router_contract, account: LocalAccount,
|
||||
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, AERODROME_SWAP_ROUTER_ADDRESS, amount_in):
|
||||
if not ensure_allowance(w3, account, token_in, UNISWAP_V3_SWAP_ROUTER_ADDRESS, amount_in):
|
||||
return False
|
||||
|
||||
params = (
|
||||
token_in, token_out, 500, account.address,
|
||||
token_in, token_out, POOL_FEE, account.address,
|
||||
int(time.time()) + 120,
|
||||
amount_in,
|
||||
0,
|
||||
0, # amountOutMin (Market swap for rebalance)
|
||||
0
|
||||
)
|
||||
|
||||
receipt = send_transaction_robust(w3, account, router_contract.functions.exactInputSingle(params), extra_msg="Swap Surplus")
|
||||
if receipt:
|
||||
# Final check - Recursive check to ensure we hit target or retry
|
||||
# But return True/False based on immediate check
|
||||
bal0 = token0_c.functions.balanceOf(account.address).call()
|
||||
bal1 = token1_c.functions.balanceOf(account.address).call()
|
||||
# If we are strictly >= needed, great.
|
||||
if bal0 >= amount0_needed and bal1 >= amount1_needed:
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"⚠️ Swap executed but still short? T0: {bal0}/{amount0_needed}, T1: {bal1}/{amount1_needed}")
|
||||
return False
|
||||
|
||||
logger.warning(f"❌ Insufficient funds. T0: {bal0}/{amount0_needed}, T1: {bal1}/{amount1_needed}")
|
||||
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) -> Optional[Dict]:
|
||||
logger.info("🚀 Minting new position on Aerodrome...")
|
||||
def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str, token1: str, amount0: int, amount1: int, tick_lower: int, tick_upper: int, d0: int, d1: int) -> Optional[Dict]:
|
||||
"""
|
||||
Approves tokens and mints a new V3 position.
|
||||
"""
|
||||
logger.info("🚀 Minting new position...")
|
||||
|
||||
# 1. Approve
|
||||
if not ensure_allowance(w3, account, token0, NONFUNGIBLE_POSITION_MANAGER_ADDRESS, amount0): return None
|
||||
if not ensure_allowance(w3, account, token1, NONFUNGIBLE_POSITION_MANAGER_ADDRESS, amount1): return None
|
||||
|
||||
# 2. Calculate Min Amounts (Slippage Protection)
|
||||
# Using 0.5% slippage tolerance
|
||||
amount0_min = int(Decimal(amount0) * (Decimal(1) - SLIPPAGE_TOLERANCE))
|
||||
amount1_min = int(Decimal(amount1) * (Decimal(1) - SLIPPAGE_TOLERANCE))
|
||||
|
||||
# 3. Mint
|
||||
params = (
|
||||
token0, token1, 500,
|
||||
token0, token1, POOL_FEE,
|
||||
tick_lower, tick_upper,
|
||||
amount0, amount1,
|
||||
amount0_min, amount1_min,
|
||||
@ -473,29 +543,56 @@ def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str
|
||||
receipt = send_transaction_robust(w3, account, npm_contract.functions.mint(params), extra_msg="Mint Position")
|
||||
|
||||
if receipt and receipt.status == 1:
|
||||
# Parse Logs
|
||||
try:
|
||||
# Transfer Event (Topic0)
|
||||
transfer_topic = Web3.keccak(text="Transfer(address,address,uint256)").hex()
|
||||
# IncreaseLiquidity Event (Topic0)
|
||||
increase_liq_topic = Web3.keccak(text="IncreaseLiquidity(uint256,uint128,uint256,uint256)").hex()
|
||||
|
||||
minted_data = {'token_id': None, 'amount0': 0, 'amount1': 0}
|
||||
minted_data = {'token_id': None, 'liquidity': 0, 'amount0': 0, 'amount1': 0}
|
||||
|
||||
for log in receipt.logs:
|
||||
topics = [t.hex() for t in log['topics']]
|
||||
|
||||
# Capture Token ID
|
||||
if topics[0] == transfer_topic:
|
||||
if "0000000000000000000000000000000000000000" in topics[1]:
|
||||
minted_data['token_id'] = int(topics[3], 16)
|
||||
|
||||
# Capture Amounts
|
||||
if topics[0] == increase_liq_topic:
|
||||
# decoding data: liquidity(uint128), amount0(uint256), amount1(uint256)
|
||||
# data is a single hex string, we need to decode it
|
||||
data = log['data'].hex()
|
||||
if data.startswith('0x'): data = data[2:]
|
||||
if data.startswith('0x'):
|
||||
data = data[2:]
|
||||
|
||||
# liquidity is first 32 bytes (padded), amt0 next 32, amt1 next 32
|
||||
minted_data['liquidity'] = int(data[0:64], 16)
|
||||
minted_data['amount0'] = int(data[64:128], 16)
|
||||
minted_data['amount1'] = int(data[128:192], 16)
|
||||
|
||||
if minted_data['token_id']:
|
||||
d0, d1 = 18, 6
|
||||
# Format for Log using actual decimals
|
||||
fmt_amt0 = Decimal(minted_data['amount0']) / Decimal(10**d0)
|
||||
fmt_amt1 = Decimal(minted_data['amount1']) / Decimal(10**d1)
|
||||
|
||||
logger.info(f"✅ POSITION OPENED | ID: {minted_data['token_id']} | Deposited: {fmt_amt0:.6f} WETH + {fmt_amt1:.2f} USDC")
|
||||
logger.info(f"✅ POSITION OPENED | ID: {minted_data['token_id']} | Deposited: {fmt_amt0:.6f} + {fmt_amt1:.6f}")
|
||||
|
||||
# --- VERIFY TICKS ON-CHAIN ---
|
||||
try:
|
||||
pos_data = npm_contract.functions.positions(minted_data['token_id']).call()
|
||||
# pos_data structure: nonce, operator, t0, t1, fee, tickLower, tickUpper, ...
|
||||
minted_data['tick_lower'] = pos_data[5]
|
||||
minted_data['tick_upper'] = pos_data[6]
|
||||
logger.info(f"🔗 Verified Ticks: {minted_data['tick_lower']} <-> {minted_data['tick_upper']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Could not verify ticks immediately: {e}")
|
||||
# Fallback to requested ticks if fetch fails
|
||||
minted_data['tick_lower'] = tick_lower
|
||||
minted_data['tick_upper'] = tick_upper
|
||||
|
||||
return minted_data
|
||||
|
||||
except Exception as e:
|
||||
@ -503,7 +600,7 @@ def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str
|
||||
|
||||
return None
|
||||
|
||||
def decrease_liquidity(w3: Web3, npm_contract, account: LocalAccount, token_id: int, liquidity: int) -> bool:
|
||||
def decrease_liquidity(w3: Web3, npm_contract, account: LocalAccount, token_id: int, liquidity: int, d0: int, d1: int) -> bool:
|
||||
if liquidity == 0: return True
|
||||
|
||||
logger.info(f"📉 Decreasing Liquidity for {token_id}...")
|
||||
@ -511,7 +608,7 @@ def decrease_liquidity(w3: Web3, npm_contract, account: LocalAccount, token_id:
|
||||
params = (
|
||||
token_id,
|
||||
liquidity,
|
||||
0, 0,
|
||||
0, 0, # amountMin0, amountMin1
|
||||
int(time.time()) + 180
|
||||
)
|
||||
|
||||
@ -519,86 +616,122 @@ def decrease_liquidity(w3: Web3, npm_contract, account: LocalAccount, token_id:
|
||||
|
||||
if receipt and receipt.status == 1:
|
||||
try:
|
||||
# Parse DecreaseLiquidity Event
|
||||
decrease_topic = Web3.keccak(text="DecreaseLiquidity(uint256,uint128,uint256,uint256)").hex()
|
||||
|
||||
amt0, amt1 = 0, 0
|
||||
|
||||
for log in receipt.logs:
|
||||
topics = [t.hex() for t in log['topics']]
|
||||
if topics[0] == decrease_topic and int(topics[1], 16) == token_id:
|
||||
if topics[0] == decrease_topic:
|
||||
# Check tokenID (topic 1)
|
||||
if int(topics[1], 16) == token_id:
|
||||
data = log['data'].hex()[2:]
|
||||
# liquidity (32), amt0 (32), amt1 (32)
|
||||
amt0 = int(data[64:128], 16)
|
||||
amt1 = int(data[128:192], 16)
|
||||
break
|
||||
|
||||
d0, d1 = 18, 6
|
||||
fmt_amt0 = Decimal(amt0) / Decimal(10**d0)
|
||||
fmt_amt1 = Decimal(amt1) / Decimal(10**d1)
|
||||
logger.info(f"📉 POSITION CLOSED | ID: {token_id} | Withdrawn: {fmt_amt0:.6f} WETH + {fmt_amt1:.2f} USDC")
|
||||
|
||||
logger.info(f"📉 POSITION CLOSED (Liquidity Removed) | ID: {token_id} | Withdrawn: {fmt_amt0:.6f} + {fmt_amt1:.6f}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Closed but failed to parse details: {e}")
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def collect_fees(w3: Web3, npm_contract, account: LocalAccount, token_id: int) -> bool:
|
||||
logger.info(f"💰 Collecting Fees for {token_id}...")
|
||||
|
||||
max_val = 2**128 - 1
|
||||
params = (token_id, account.address, max_val, max_val)
|
||||
params = (
|
||||
token_id,
|
||||
account.address,
|
||||
max_val, max_val
|
||||
)
|
||||
|
||||
receipt = send_transaction_robust(w3, account, npm_contract.functions.collect(params), extra_msg=f"Collect Fees {token_id}")
|
||||
return receipt is not None
|
||||
|
||||
# --- STATE MANAGEMENT ---
|
||||
|
||||
def load_status_data() -> List[Dict]:
|
||||
if not os.path.exists(STATUS_FILE): return []
|
||||
if not os.path.exists(STATUS_FILE):
|
||||
return []
|
||||
try:
|
||||
with open(STATUS_FILE, 'r') as f: return json.load(f)
|
||||
except: return []
|
||||
with open(STATUS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
return []
|
||||
|
||||
def save_status_data(data: List[Dict]):
|
||||
with open(STATUS_FILE, 'w') as f: json.dump(data, f, indent=2)
|
||||
with open(STATUS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
def update_position_status(token_id: int, status: str, extra_data: Dict = {}):
|
||||
data = load_status_data()
|
||||
|
||||
# Find existing or create new
|
||||
entry = next((item for item in data if item.get('token_id') == token_id), None)
|
||||
|
||||
if not entry:
|
||||
if status in ["OPEN", "PENDING_HEDGE"]:
|
||||
entry = {"type": "AUTOMATIC", "token_id": token_id}
|
||||
data.append(entry)
|
||||
else: return
|
||||
else:
|
||||
return # Can't update non-existent position unless opening
|
||||
|
||||
entry['status'] = status
|
||||
entry.update(extra_data)
|
||||
|
||||
if status == "CLOSED":
|
||||
entry['timestamp_close'] = int(time.time())
|
||||
now = datetime.now()
|
||||
entry['timestamp_close'] = int(now.timestamp())
|
||||
entry['time_close'] = now.strftime("%d.%m.%y %H:%M:%S")
|
||||
|
||||
save_status_data(data)
|
||||
logger.info(f"💾 Updated Position {token_id} status to {status}")
|
||||
|
||||
# --- MAIN LOOP ---
|
||||
|
||||
def main():
|
||||
logger.info("🔷 Aerodrome Manager (Base Chain) Starting...")
|
||||
logger.info(f"🔷 {CONFIG['NAME']} Manager V2 Starting...")
|
||||
load_dotenv(override=True)
|
||||
|
||||
rpc_url = os.environ.get("BASE_RPC_URL") # UPDATED ENV VAR
|
||||
private_key = os.environ.get("MAIN_WALLET_PRIVATE_KEY")
|
||||
# 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 BASE_RPC_URL or MAIN_WALLET_PRIVATE_KEY in .env")
|
||||
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 Base RPC")
|
||||
logger.error("❌ Could not connect to RPC")
|
||||
return
|
||||
|
||||
# FIX: Inject POA middleware for BNB Chain/Polygon/etc. (Web3.py v6+)
|
||||
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
|
||||
|
||||
account = Account.from_key(private_key)
|
||||
logger.info(f"👤 Wallet: {account.address}")
|
||||
|
||||
# Contracts
|
||||
npm = w3.eth.contract(address=clean_address(NONFUNGIBLE_POSITION_MANAGER_ADDRESS), abi=NONFUNGIBLE_POSITION_MANAGER_ABI)
|
||||
factory_addr = npm.functions.factory().call()
|
||||
factory = w3.eth.contract(address=factory_addr, abi=UNISWAP_V3_FACTORY_ABI)
|
||||
router = w3.eth.contract(address=clean_address(AERODROME_SWAP_ROUTER_ADDRESS), abi=SWAP_ROUTER_ABI)
|
||||
router = w3.eth.contract(address=clean_address(UNISWAP_V3_SWAP_ROUTER_ADDRESS), abi=SWAP_ROUTER_ABI)
|
||||
|
||||
while True:
|
||||
try:
|
||||
status_data = load_status_data()
|
||||
open_positions = [p for p in status_data if p.get('status') == 'OPEN']
|
||||
|
||||
active_auto_pos = next((p for p in open_positions if p.get('type') == 'AUTOMATIC'), None)
|
||||
|
||||
if active_auto_pos:
|
||||
@ -608,27 +741,65 @@ def main():
|
||||
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
|
||||
|
||||
current_price = price_from_tick(current_tick, pos_details['token0_decimals'], pos_details['token1_decimals'])
|
||||
# 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"
|
||||
|
||||
# Simulated Fees
|
||||
# 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})
|
||||
unclaimed0 = to_decimal(fees_sim[0], pos_details['token0_decimals'])
|
||||
unclaimed1 = to_decimal(fees_sim[1], pos_details['token1_decimals'])
|
||||
total_fees_usd = (unclaimed0 * current_price) + unclaimed1
|
||||
except: pass
|
||||
u0 = to_decimal(fees_sim[0], pos_details['token0_decimals'])
|
||||
u1 = to_decimal(fees_sim[1], pos_details['token1_decimals'])
|
||||
|
||||
# PnL Calc
|
||||
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),
|
||||
@ -637,26 +808,71 @@ def main():
|
||||
)
|
||||
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
|
||||
|
||||
logger.info(f"Position {token_id}: {status_msg} | Price: {current_price:.4f} [{lower_price:.4f} - {upper_price:.4f}] | TotPnL: ${total_pnl_usd:.2f} (Fees: ${total_fees_usd:.2f})")
|
||||
pnl_text = f" | TotPnL: ${total_pnl_usd:.2f} (Fees: ${total_fees_usd:.2f})"
|
||||
logger.info(f"Position {token_id}: {status_msg} | Price: {current_price:.4f} [{lower_price:.4f} - {upper_price:.4f}]{pnl_text}")
|
||||
|
||||
# --- KPI LOGGING ---
|
||||
if log_kpi_snapshot:
|
||||
snapshot = {
|
||||
'initial_eth': active_auto_pos.get('amount0_initial', 0),
|
||||
'initial_usdc': active_auto_pos.get('amount1_initial', 0),
|
||||
'initial_hedge_usdc': INITIAL_HEDGE_CAPITAL_USDC,
|
||||
'current_eth_price': float(current_price),
|
||||
'uniswap_pos_value_usd': float(current_pos_value_usd),
|
||||
'uniswap_fees_claimed_usd': 0.0, # Not tracked accumulated yet in JSON, using Unclaimed mainly
|
||||
'uniswap_fees_unclaimed_usd': float(total_fees_usd),
|
||||
|
||||
# Hedge Data (from JSON updated by clp_hedger)
|
||||
'hedge_equity_usd': float(active_auto_pos.get('hedge_equity_usd', 0.0)),
|
||||
'hedge_pnl_realized_usd': active_auto_pos.get('hedge_pnl_realized', 0.0),
|
||||
'hedge_fees_paid_usd': active_auto_pos.get('hedge_fees_paid', 0.0)
|
||||
}
|
||||
# We use 'target_value' as a proxy for 'Initial Hedge Equity' + 'Initial Uni Val' if strictly tracking strategy?
|
||||
# For now, let's pass what we have.
|
||||
# To get 'hedge_equity', we ideally need clp_hedger to write it to JSON.
|
||||
# Current implementation of kpi_tracker uses 'hedge_equity' in NAV.
|
||||
# If we leave it 0, NAV will be underreported.
|
||||
# WORKAROUND: Assume Hedge PnL Realized IS the equity change if we ignore margin.
|
||||
|
||||
log_kpi_snapshot(snapshot)
|
||||
|
||||
if not in_range and CLOSE_POSITION_ENABLED:
|
||||
logger.warning(f"🛑 Closing Position {token_id} (Out of Range)")
|
||||
update_position_status(token_id, "CLOSING")
|
||||
if decrease_liquidity(w3, npm, account, token_id, pos_details['liquidity']):
|
||||
|
||||
# 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")
|
||||
|
||||
elif OPEN_POSITION_ENABLED:
|
||||
logger.info("🔍 No active position. Analyzing Aerodrome market...")
|
||||
# 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
|
||||
|
||||
token0 = clean_address(WETH_ADDRESS)
|
||||
token1 = clean_address(USDC_ADDRESS)
|
||||
fee = 500 # 0.05% fee tier on Aerodrome Slipstream WETH/USDC
|
||||
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)
|
||||
@ -664,39 +880,103 @@ def main():
|
||||
|
||||
if pool_data:
|
||||
tick = pool_data['tick']
|
||||
# Get Tick Spacing dynamically
|
||||
tick_spacing = pool_c.functions.tickSpacing().call()
|
||||
|
||||
# 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
|
||||
|
||||
d0, d1 = 18, 6
|
||||
investment_val_dec = Decimal(str(TARGET_INVESTMENT_VALUE_USDC))
|
||||
# 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.
|
||||
|
||||
amt0, amt1 = calculate_mint_amounts(tick, tick_lower, tick_upper, investment_val_dec, d0, d1, pool_data['sqrtPriceX96'])
|
||||
# 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)
|
||||
minted = mint_new_position(w3, npm, account, token0, token1, amt0, amt1, tick_lower, tick_upper, d0, d1)
|
||||
if minted:
|
||||
entry_price = float(price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1))
|
||||
# Calculate entry price and amounts for JSON compatibility
|
||||
price_0_in_1 = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1)
|
||||
fmt_amt0 = float(Decimal(minted['amount0']) / Decimal(10**d0))
|
||||
fmt_amt1 = float(Decimal(minted['amount1']) / Decimal(10**d1))
|
||||
actual_value = (fmt_amt0 * entry_price) + fmt_amt1
|
||||
|
||||
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, 2),
|
||||
"entry_price": round(entry_price, 4),
|
||||
"amount0_initial": round(fmt_amt0, 4),
|
||||
"amount1_initial": round(fmt_amt1, 2),
|
||||
"range_upper": round(float(price_from_tick(tick_upper, d0, d1)), 2),
|
||||
"range_lower": round(float(price_from_tick(tick_lower, d0, d1)), 2),
|
||||
"timestamp_open": int(time.time())
|
||||
"amount1_initial": round(fmt_amt1, 4),
|
||||
"liquidity": str(minted['liquidity']),
|
||||
"range_upper": round(r_upper, 4),
|
||||
"range_lower": round(r_lower, 4),
|
||||
"token0_decimals": d0,
|
||||
"token1_decimals": d1,
|
||||
"timestamp_open": int(time.time()),
|
||||
"time_open": datetime.now().strftime("%d.%m.%y %H:%M:%S")
|
||||
}
|
||||
|
||||
update_position_status(minted['token_id'], "OPEN", new_position_data)
|
||||
|
||||
# Dynamic Sleep: 37s if no position, else configured interval
|
||||
sleep_time = MONITOR_INTERVAL_SECONDS if active_auto_pos else 37
|
||||
time.sleep(sleep_time)
|
||||
|
||||
@ -705,7 +985,7 @@ def main():
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Main Loop Error: {e}")
|
||||
time.sleep(60)
|
||||
time.sleep(MONITOR_INTERVAL_SECONDS)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
36
florida/tools/debug_fills.py
Normal file
36
florida/tools/debug_fills.py
Normal file
@ -0,0 +1,36 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
from hyperliquid.info import Info
|
||||
from hyperliquid.utils import constants
|
||||
|
||||
# Load env
|
||||
load_dotenv()
|
||||
address = os.environ.get("MAIN_WALLET_ADDRESS")
|
||||
|
||||
if not address:
|
||||
print("No address found")
|
||||
sys.exit(1)
|
||||
|
||||
info = Info(constants.MAINNET_API_URL, skip_ws=True)
|
||||
|
||||
try:
|
||||
print(f"Fetching fills for {address}...")
|
||||
fills = info.user_fills(address)
|
||||
|
||||
if fills:
|
||||
print(f"Found {len(fills)} fills. Inspecting first one:")
|
||||
print(json.dumps(fills[0], indent=2))
|
||||
|
||||
# Check for closedPnl
|
||||
if 'closedPnl' in fills[0]:
|
||||
print("✅ 'closedPnl' field FOUND!")
|
||||
else:
|
||||
print("❌ 'closedPnl' field NOT FOUND.")
|
||||
|
||||
else:
|
||||
print("No fills found.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
74
florida/tools/debug_pnl_check.py
Normal file
74
florida/tools/debug_pnl_check.py
Normal file
@ -0,0 +1,74 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from dotenv import load_dotenv
|
||||
from hyperliquid.info import Info
|
||||
from hyperliquid.utils import constants
|
||||
|
||||
# Load env
|
||||
current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.append(current_dir)
|
||||
load_dotenv(os.path.join(current_dir, '.env'))
|
||||
|
||||
address = os.environ.get("MAIN_WALLET_ADDRESS")
|
||||
if not address:
|
||||
print("No address found")
|
||||
sys.exit(1)
|
||||
|
||||
info = Info(constants.MAINNET_API_URL, skip_ws=True)
|
||||
|
||||
# Target Start Time: 2025-12-30 21:32:52 (From user JSON)
|
||||
START_TIME_MS = 1767126772 * 1000
|
||||
COIN = "BNB"
|
||||
|
||||
print(f"--- DEBUG PnL CHECK ---")
|
||||
print(f"Address: {address}")
|
||||
print(f"Coin: {COIN}")
|
||||
print(f"Start Time: {START_TIME_MS}")
|
||||
|
||||
try:
|
||||
fills = info.user_fills(address)
|
||||
|
||||
valid_fills = []
|
||||
total_closed_pnl = Decimal("0")
|
||||
total_fees = Decimal("0")
|
||||
|
||||
print(f"\n--- FILLS FOUND ---")
|
||||
print(f"{'Time':<20} | {'Side':<5} | {'Sz':<8} | {'Px':<8} | {'Fee':<8} | {'ClosedPnL':<10}")
|
||||
print("-" * 80)
|
||||
|
||||
for fill in fills:
|
||||
if fill['coin'] == COIN and fill['time'] >= START_TIME_MS:
|
||||
valid_fills.append(fill)
|
||||
|
||||
fee = Decimal(str(fill['fee']))
|
||||
pnl = Decimal(str(fill['closedPnl']))
|
||||
|
||||
total_closed_pnl += pnl
|
||||
total_fees += fee
|
||||
|
||||
ts_str = time.strftime('%H:%M:%S', time.localtime(fill['time']/1000))
|
||||
print(f"{ts_str:<20} | {fill['side']:<5} | {fill['sz']:<8} | {fill['px']:<8} | {fee:<8.4f} | {pnl:<10.4f}")
|
||||
|
||||
print("-" * 80)
|
||||
print(f"Count: {len(valid_fills)}")
|
||||
print(f"Sum Closed PnL (Gross): {total_closed_pnl:.4f}")
|
||||
print(f"Sum Fees: {total_fees:.4f}")
|
||||
|
||||
net_realized = total_closed_pnl - total_fees
|
||||
print(f"NET REALIZED (Gross - Fees): {net_realized:.4f}")
|
||||
|
||||
# Check JSON
|
||||
json_path = os.path.join(current_dir, "PANCAKESWAP_BNB_status.json")
|
||||
if os.path.exists(json_path):
|
||||
with open(json_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
last_pos = data[-1]
|
||||
print(f"\n--- JSON STATE ---")
|
||||
print(f"hedge_TotPnL: {last_pos.get('hedge_TotPnL')}")
|
||||
print(f"hedge_fees_paid: {last_pos.get('hedge_fees_paid')}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
142
florida/tools/fetch_real_position_data.py
Normal file
142
florida/tools/fetch_real_position_data.py
Normal file
@ -0,0 +1,142 @@
|
||||
import json
|
||||
import os
|
||||
import math
|
||||
import sys
|
||||
from decimal import Decimal, getcontext
|
||||
from web3 import Web3
|
||||
from web3.middleware import ExtraDataToPOAMiddleware
|
||||
from eth_account import Account
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Add project root to path
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from clp_config import CLP_PROFILES
|
||||
|
||||
# Load Env
|
||||
load_dotenv()
|
||||
|
||||
# Config for PancakeSwap
|
||||
PROFILE = CLP_PROFILES["PANCAKESWAP_BNB"]
|
||||
RPC_URL = os.environ.get(PROFILE["RPC_ENV_VAR"])
|
||||
STATUS_FILE = "PANCAKESWAP_BNB_status.json"
|
||||
|
||||
# Minimal ABI for NPM
|
||||
NPM_ABI = [
|
||||
{
|
||||
"inputs": [{"internalType": "uint256", "name": "tokenId", "type": "uint256"}],
|
||||
"name": "positions",
|
||||
"outputs": [
|
||||
{"internalType": "uint96", "name": "nonce", "type": "uint96"},
|
||||
{"internalType": "address", "name": "operator", "type": "address"},
|
||||
{"internalType": "address", "name": "token0", "type": "address"},
|
||||
{"internalType": "address", "name": "token1", "type": "address"},
|
||||
{"internalType": "uint24", "name": "fee", "type": "uint24"},
|
||||
{"internalType": "int24", "name": "tickLower", "type": "int24"},
|
||||
{"internalType": "int24", "name": "tickUpper", "type": "int24"},
|
||||
{"internalType": "uint128", "name": "liquidity", "type": "uint128"},
|
||||
{"internalType": "uint256", "name": "feeGrowthInside0LastX128", "type": "uint256"},
|
||||
{"internalType": "uint256", "name": "feeGrowthInside1LastX128", "type": "uint256"},
|
||||
{"internalType": "uint128", "name": "tokensOwed0", "type": "uint128"},
|
||||
{"internalType": "uint128", "name": "tokensOwed1", "type": "uint128"}
|
||||
],
|
||||
"stateMutability": "view",
|
||||
"type": "function"
|
||||
}
|
||||
]
|
||||
|
||||
def get_price_at_tick(tick):
|
||||
return 1.0001 ** tick
|
||||
|
||||
def fetch_and_fix():
|
||||
if not RPC_URL:
|
||||
print("❌ Missing RPC URL in .env")
|
||||
return
|
||||
|
||||
print(f"Connecting to RPC: {RPC_URL}")
|
||||
w3 = Web3(Web3.HTTPProvider(RPC_URL))
|
||||
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
|
||||
|
||||
if not w3.is_connected():
|
||||
print("❌ Failed to connect to Web3")
|
||||
return
|
||||
|
||||
npm = w3.eth.contract(address=PROFILE["NPM_ADDRESS"], abi=NPM_ABI)
|
||||
|
||||
with open(STATUS_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
updated_count = 0
|
||||
|
||||
for entry in data:
|
||||
token_id = entry.get('token_id')
|
||||
status = entry.get('status')
|
||||
|
||||
# We check ALL positions to be safe, or just the problematic ones.
|
||||
# Let's check any that seem to have suspect data or just refresh all active/recently active.
|
||||
# The user mentioned 6164702 specifically.
|
||||
|
||||
print(f"🔍 Checking Token ID: {token_id} ({status})")
|
||||
|
||||
try:
|
||||
pos = npm.functions.positions(token_id).call()
|
||||
# Pos structure:
|
||||
# 0: nonce, 1: operator, 2: token0, 3: token1, 4: fee,
|
||||
# 5: tickLower, 6: tickUpper, 7: liquidity ...
|
||||
|
||||
tick_lower = pos[5]
|
||||
tick_upper = pos[6]
|
||||
liquidity = pos[7]
|
||||
|
||||
# Calculate Ranges
|
||||
price_lower = get_price_at_tick(tick_lower)
|
||||
price_upper = get_price_at_tick(tick_upper)
|
||||
|
||||
# Format to 4 decimals
|
||||
new_lower = round(price_lower, 4)
|
||||
new_upper = round(price_upper, 4)
|
||||
|
||||
old_lower = entry.get('range_lower', 0)
|
||||
old_upper = entry.get('range_upper', 0)
|
||||
|
||||
# Check deviation
|
||||
if abs(new_lower - old_lower) > 0.1 or abs(new_upper - old_upper) > 0.1:
|
||||
print(f" ⚠️ Mismatch Found!")
|
||||
print(f" Old: {old_lower} - {old_upper}")
|
||||
print(f" New: {new_lower} - {new_upper}")
|
||||
|
||||
entry['range_lower'] = new_lower
|
||||
entry['range_upper'] = new_upper
|
||||
entry['liquidity'] = str(liquidity)
|
||||
|
||||
# Fix Entry Price if it looks wrong (e.g. 0 or way off range)
|
||||
# If single sided (e.g. 862-869), and spot is 860.
|
||||
# If we provided only Token0 (BNB), we are selling BNB as it goes UP.
|
||||
# So we entered 'below' the range.
|
||||
# If we assume the user just opened it, the 'entry_price' should roughly match
|
||||
# the current market price or at least be consistent.
|
||||
# Since we don't know the exact historical price, we can't perfectly fix 'entry_price'
|
||||
# without event logs.
|
||||
# HOWEVER, for the bot's logic, 'range_lower' and 'range_upper' are critical for 'in_range' checks.
|
||||
# 'entry_price' is mostly for PnL est.
|
||||
|
||||
# If entry_price is wildly different from range (e.g. 844 vs 862-869), it's confusing.
|
||||
# Let's see if we can infer something.
|
||||
# For now, we update ranges as that's the request.
|
||||
|
||||
updated_count += 1
|
||||
else:
|
||||
print(f" ✅ Data looks solid.")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Error fetching chain data: {e}")
|
||||
|
||||
if updated_count > 0:
|
||||
with open(STATUS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
print(f"💾 Updated {updated_count} entries in {STATUS_FILE}")
|
||||
else:
|
||||
print("No updates needed.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
fetch_and_fix()
|
||||
@ -90,7 +90,7 @@ class CandleRecorder:
|
||||
print(f"WebSocket Error: {error}")
|
||||
|
||||
def on_close(self, ws, close_status_code, close_msg):
|
||||
print("WebSocket Closed")
|
||||
print(f"WebSocket Closed: {close_status_code} - {close_msg}")
|
||||
|
||||
def on_open(self, ws):
|
||||
print("WebSocket Connected. Subscribing to allMids...")
|
||||
@ -105,8 +105,8 @@ class CandleRecorder:
|
||||
print(f"📂 Output: {self.output_file}")
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
# Start WS in separate thread? No, run_forever is blocking usually.
|
||||
# But we need to handle Ctrl+C.
|
||||
while self.running:
|
||||
try:
|
||||
self.ws = websocket.WebSocketApp(
|
||||
WS_URL,
|
||||
on_open=self.on_open,
|
||||
@ -115,7 +115,15 @@ class CandleRecorder:
|
||||
on_close=self.on_close
|
||||
)
|
||||
|
||||
self.ws.run_forever()
|
||||
# run_forever blocks until connection is lost
|
||||
self.ws.run_forever(ping_interval=30, ping_timeout=10)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Critical Error in main loop: {e}")
|
||||
|
||||
if self.running:
|
||||
print("Connection lost. Reconnecting in 5 seconds...")
|
||||
time.sleep(5)
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
print("\nStopping recorder...")
|
||||
|
||||
207
florida/tools/record_raw_ticks.py
Normal file
207
florida/tools/record_raw_ticks.py
Normal file
@ -0,0 +1,207 @@
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import threading
|
||||
import signal
|
||||
import sys
|
||||
import websocket
|
||||
from datetime import datetime
|
||||
|
||||
# Setup
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
MARKET_DATA_DIR = os.path.join(PROJECT_ROOT, 'market_data')
|
||||
WS_URL = "wss://api.hyperliquid.xyz/ws"
|
||||
|
||||
class MarketDataRecorder:
|
||||
def __init__(self, coin, file_prefix):
|
||||
self.coin = coin
|
||||
self.running = True
|
||||
self.ws = None
|
||||
|
||||
# File paths
|
||||
self.book_file = f"{file_prefix}_book.csv"
|
||||
self.trades_file = f"{file_prefix}_trades.csv"
|
||||
|
||||
# Buffers
|
||||
self.book_buffer = []
|
||||
self.trades_buffer = []
|
||||
self.buffer_limit = 10
|
||||
|
||||
# Ensure dir exists
|
||||
if not os.path.exists(MARKET_DATA_DIR):
|
||||
os.makedirs(MARKET_DATA_DIR)
|
||||
|
||||
# Init Book CSV
|
||||
if not os.path.exists(self.book_file):
|
||||
with open(self.book_file, 'w', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(['timestamp_ms', 'local_time', 'bid_px', 'bid_sz', 'ask_px', 'ask_sz', 'mid_price'])
|
||||
|
||||
# Init Trades CSV
|
||||
if not os.path.exists(self.trades_file):
|
||||
with open(self.trades_file, 'w', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(['timestamp_ms', 'local_time', 'price', 'size', 'side', 'hash'])
|
||||
|
||||
def on_message(self, ws, message):
|
||||
try:
|
||||
recv_ts = time.time()
|
||||
msg = json.loads(message)
|
||||
channel = msg.get('channel')
|
||||
data = msg.get('data', {})
|
||||
|
||||
if channel == 'l2Book':
|
||||
self.process_book(data, recv_ts)
|
||||
elif channel == 'trades':
|
||||
self.process_trades(data, recv_ts)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[{datetime.now()}] Error processing: {e}")
|
||||
|
||||
def process_book(self, data, recv_ts):
|
||||
if data.get('coin') != self.coin:
|
||||
return
|
||||
|
||||
levels = data.get('levels', [])
|
||||
if levels and len(levels) >= 2:
|
||||
bids = levels[0]
|
||||
asks = levels[1]
|
||||
|
||||
if bids and asks:
|
||||
# Hyperliquid L2 format: {px: float, sz: float, n: int}
|
||||
best_bid = bids[0]
|
||||
best_ask = asks[0]
|
||||
|
||||
bid_px = float(best_bid['px'])
|
||||
bid_sz = float(best_bid['sz'])
|
||||
ask_px = float(best_ask['px'])
|
||||
ask_sz = float(best_ask['sz'])
|
||||
mid = (bid_px + ask_px) / 2
|
||||
|
||||
row = [
|
||||
int(recv_ts * 1000),
|
||||
datetime.fromtimestamp(recv_ts).strftime('%H:%M:%S.%f')[:-3],
|
||||
bid_px, bid_sz,
|
||||
ask_px, ask_sz,
|
||||
mid
|
||||
]
|
||||
self.book_buffer.append(row)
|
||||
|
||||
if len(self.book_buffer) >= self.buffer_limit:
|
||||
self.flush_book()
|
||||
|
||||
def process_trades(self, data, recv_ts):
|
||||
# Data is a list of trades
|
||||
for trade in data:
|
||||
if trade.get('coin') != self.coin:
|
||||
continue
|
||||
|
||||
# trade format: {coin, side, px, sz, time, hash}
|
||||
row = [
|
||||
int(trade.get('time', int(recv_ts * 1000))),
|
||||
datetime.fromtimestamp(trade.get('time', 0)/1000 or recv_ts).strftime('%H:%M:%S.%f')[:-3],
|
||||
float(trade['px']),
|
||||
float(trade['sz']),
|
||||
trade['side'],
|
||||
trade.get('hash', '')
|
||||
]
|
||||
self.trades_buffer.append(row)
|
||||
|
||||
if len(self.trades_buffer) >= self.buffer_limit:
|
||||
self.flush_trades()
|
||||
|
||||
def flush_book(self):
|
||||
try:
|
||||
with open(self.book_file, 'a', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerows(self.book_buffer)
|
||||
self.book_buffer = []
|
||||
except Exception as e:
|
||||
print(f"Error writing book: {e}")
|
||||
|
||||
def flush_trades(self):
|
||||
try:
|
||||
with open(self.trades_file, 'a', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerows(self.trades_buffer)
|
||||
|
||||
# Console Feedback
|
||||
last_trade = self.trades_buffer[-1] if self.trades_buffer else "N/A"
|
||||
if last_trade != "N/A":
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] 🔫 Trade: {last_trade[2]} (x{last_trade[3]}) {last_trade[4]}")
|
||||
|
||||
self.trades_buffer = []
|
||||
except Exception as e:
|
||||
print(f"Error writing trades: {e}")
|
||||
|
||||
def on_open(self, ws):
|
||||
print(f"[{datetime.now()}] Connected! Subscribing to l2Book & trades for {self.coin}...")
|
||||
|
||||
# Subscribe to Book
|
||||
ws.send(json.dumps({
|
||||
"method": "subscribe",
|
||||
"subscription": {"type": "l2Book", "coin": self.coin}
|
||||
}))
|
||||
|
||||
# Subscribe to Trades
|
||||
ws.send(json.dumps({
|
||||
"method": "subscribe",
|
||||
"subscription": {"type": "trades", "coin": self.coin}
|
||||
}))
|
||||
|
||||
def on_error(self, ws, error):
|
||||
print(f"WebSocket Error: {error}")
|
||||
|
||||
def on_close(self, ws, close_status_code, close_msg):
|
||||
print(f"WebSocket Closed: {close_status_code}")
|
||||
self.flush_book()
|
||||
self.flush_trades()
|
||||
|
||||
def start(self):
|
||||
print(f"🔴 RECORDING RAW DATA for {self.coin}")
|
||||
print(f"📘 Book Data: {self.book_file}")
|
||||
print(f"🔫 Trades Data: {self.trades_file}")
|
||||
print("Press Ctrl+C to stop.")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
self.ws = websocket.WebSocketApp(
|
||||
WS_URL,
|
||||
on_open=self.on_open,
|
||||
on_message=self.on_message,
|
||||
on_error=self.on_error,
|
||||
on_close=self.on_close
|
||||
)
|
||||
self.ws.run_forever(ping_interval=15, ping_timeout=5)
|
||||
except Exception as e:
|
||||
print(f"Critical error: {e}")
|
||||
|
||||
if self.running:
|
||||
print("Reconnecting in 1s...")
|
||||
time.sleep(1)
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
print("\nStopping recorder...")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
parser = argparse.ArgumentParser(description="Record RAW Book & Trades from Hyperliquid")
|
||||
parser.add_argument("--coin", type=str, default="ETH", help="Coin symbol")
|
||||
parser.add_argument("--output", type=str, help="Base filename (will append _book.csv and _trades.csv)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Generate filename prefix
|
||||
if args.output:
|
||||
# Strip extension if user provided one like "data.csv" -> "data"
|
||||
base = os.path.splitext(args.output)[0]
|
||||
else:
|
||||
date_str = datetime.now().strftime("%Y%m%d")
|
||||
base = os.path.join(MARKET_DATA_DIR, f"{args.coin}_raw_{date_str}")
|
||||
|
||||
recorder = MarketDataRecorder(args.coin, base)
|
||||
recorder.start()
|
||||
394
telegram_monitor.py
Normal file
394
telegram_monitor.py
Normal file
@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Telegram Monitor for CLP Position Notifications
|
||||
Monitors hedge_status.json file for new position openings and sends Telegram notifications
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import hashlib
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, List, Tuple, Any
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# --- SETUP PROJECT PATH ---
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(current_dir)
|
||||
sys.path.append(current_dir)
|
||||
|
||||
# --- LOGGING SETUP ---
|
||||
os.makedirs(os.path.join(current_dir, 'logs'), exist_ok=True)
|
||||
|
||||
class UnixMsLogFilter(logging.Filter):
|
||||
def filter(self, record):
|
||||
record.unix_ms = int(record.created * 1000)
|
||||
return True
|
||||
|
||||
logger = logging.getLogger("TELEGRAM_MONITOR")
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
logger.handlers.clear()
|
||||
|
||||
# Console handler
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# File handler
|
||||
file_handler = logging.FileHandler(os.path.join(current_dir, 'logs', 'telegram_monitor.log'), encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.addFilter(UnixMsLogFilter())
|
||||
file_formatter = logging.Formatter('%(unix_ms)d, %(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
file_handler.setFormatter(file_formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
load_dotenv(os.path.join(current_dir, '.env'))
|
||||
|
||||
TELEGRAM_ENABLED = os.getenv('TELEGRAM_MONITOR_ENABLED', 'False').lower() == 'true'
|
||||
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '')
|
||||
TELEGRAM_CHECK_INTERVAL = int(os.getenv('TELEGRAM_CHECK_INTERVAL_SECONDS', '60'))
|
||||
TELEGRAM_STATE_FILE = os.getenv('TELEGRAM_STATE_FILE', 'telegram_monitor_state.json')
|
||||
TELEGRAM_TIMEOUT = int(os.getenv('TELEGRAM_TIMEOUT_SECONDS', '10'))
|
||||
HEDGE_STATUS_FILE = os.getenv('HEDGE_STATUS_FILE', 'hedge_status.json')
|
||||
|
||||
class TelegramNotifier:
|
||||
"""Handles Telegram API communication"""
|
||||
|
||||
def __init__(self, bot_token: str, chat_id: str):
|
||||
self.bot_token = bot_token
|
||||
self.chat_id = chat_id
|
||||
self.base_url = f"https://api.telegram.org/bot{bot_token}"
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""Test Telegram bot connection"""
|
||||
try:
|
||||
url = f"{self.base_url}/getMe"
|
||||
response = requests.get(url, timeout=TELEGRAM_TIMEOUT)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram connection test failed: {e}")
|
||||
return False
|
||||
|
||||
def send_message(self, text: str) -> bool:
|
||||
"""Send message to Telegram chat"""
|
||||
if not TELEGRAM_ENABLED or not self.bot_token or not self.chat_id:
|
||||
logger.debug("Telegram notifications disabled or missing credentials")
|
||||
return False
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/sendMessage"
|
||||
payload = {
|
||||
'chat_id': self.chat_id,
|
||||
'text': text,
|
||||
'parse_mode': 'Markdown'
|
||||
}
|
||||
|
||||
response = requests.post(url, json=payload, timeout=TELEGRAM_TIMEOUT)
|
||||
result = response.json()
|
||||
|
||||
if result.get('ok'):
|
||||
logger.info("Telegram notification sent successfully")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Telegram API error: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram message: {e}")
|
||||
return False
|
||||
|
||||
def format_position_message(self, last_closed: Optional[Dict], current_open: Dict) -> str:
|
||||
"""Format position data into readable message"""
|
||||
lines = ["NEW CLP POSITION DETECTED\n"]
|
||||
|
||||
# Previous closed position section
|
||||
if last_closed:
|
||||
lines.append("LAST CLOSED POSITION:")
|
||||
lines.append(f"• Token ID: {last_closed.get('token_id', 'N/A')}")
|
||||
lines.append(f"• Entry: ${last_closed.get('entry_price', 0):.2f}")
|
||||
lines.append(f"• Target Value: ${last_closed.get('target_value', 0):.2f}")
|
||||
|
||||
# Add duration if timestamps available
|
||||
if last_closed.get('timestamp_open') and last_closed.get('timestamp_close'):
|
||||
duration = last_closed['timestamp_close'] - last_closed['timestamp_open']
|
||||
hours = duration // 3600
|
||||
minutes = (duration % 3600) // 60
|
||||
lines.append(f"• Duration: {hours}h {minutes}m")
|
||||
|
||||
# Add hedge performance if available
|
||||
hedge_pnl = last_closed.get('hedge_pnl_realized', 0)
|
||||
if hedge_pnl != 0:
|
||||
lines.append(f"• Hedge PnL: ${hedge_pnl:.2f}")
|
||||
|
||||
hedge_fees = last_closed.get('hedge_fees_paid', 0)
|
||||
if hedge_fees != 0:
|
||||
lines.append(f"• Hedge Fees: ${hedge_fees:.2f}")
|
||||
else:
|
||||
lines.append("LAST CLOSED POSITION: None")
|
||||
|
||||
lines.append("") # Empty line
|
||||
|
||||
# Current opened position section
|
||||
lines.append("CURRENTLY OPENED:")
|
||||
lines.append(f"• Token ID: {current_open.get('token_id', 'N/A')}")
|
||||
lines.append(f"• Entry: ${current_open.get('entry_price', 0):.2f}")
|
||||
lines.append(f"• Target Value: ${current_open.get('target_value', 0):.2f}")
|
||||
|
||||
# Range information
|
||||
range_lower = current_open.get('range_lower', 0)
|
||||
range_upper = current_open.get('range_upper', 0)
|
||||
if range_lower and range_upper:
|
||||
lines.append(f"• Range: ${range_lower:.2f} - ${range_upper:.2f}")
|
||||
|
||||
# Initial amounts
|
||||
amount0 = current_open.get('amount0_initial', 0)
|
||||
amount1 = current_open.get('amount1_initial', 0)
|
||||
if amount0 and amount1:
|
||||
lines.append(f"• Initial: {amount0:.4f} ETH + {amount1:.2f} USDC")
|
||||
|
||||
# Time since opening
|
||||
if current_open.get('timestamp_open'):
|
||||
age = int(time.time()) - current_open['timestamp_open']
|
||||
hours = age // 3600
|
||||
minutes = (age % 3600) // 60
|
||||
lines.append(f"• Time: {hours}h {minutes}m ago")
|
||||
|
||||
# Performance comparison if we have both positions
|
||||
if last_closed and current_open:
|
||||
lines.append("") # Empty line
|
||||
lines.append("PERFORMANCE COMPARISON:")
|
||||
|
||||
# Entry price change
|
||||
last_entry = Decimal(str(last_closed.get('entry_price', 0)))
|
||||
curr_entry = Decimal(str(current_open.get('entry_price', 0)))
|
||||
if last_entry > 0:
|
||||
entry_change = curr_entry - last_entry
|
||||
entry_change_pct = (entry_change / last_entry) * 100
|
||||
sign = "+" if entry_change >= 0 else ""
|
||||
lines.append(f"• Entry Change: {sign}${entry_change:.2f} ({sign}{entry_change_pct:.2f}%)")
|
||||
|
||||
# Value change
|
||||
last_value = Decimal(str(last_closed.get('target_value', 0)))
|
||||
curr_value = Decimal(str(current_open.get('target_value', 0)))
|
||||
if last_value > 0:
|
||||
value_change = curr_value - last_value
|
||||
value_change_pct = (value_change / last_value) * 100
|
||||
sign = "+" if value_change >= 0 else ""
|
||||
lines.append(f"• Value Change: {sign}${value_change:.2f} ({sign}{value_change_pct:.2f}%)")
|
||||
|
||||
# Hedge PnL trend
|
||||
last_hedge_pnl = last_closed.get('hedge_pnl_realized', 0)
|
||||
curr_hedge_equity = current_open.get('hedge_equity_usd', 0)
|
||||
if last_hedge_pnl != 0 and curr_hedge_equity != 0:
|
||||
hedge_trend = curr_hedge_equity - last_hedge_pnl
|
||||
sign = "+" if hedge_trend >= 0 else ""
|
||||
lines.append(f"• Hedge PnL Trend: ${last_hedge_pnl:.2f} -> ${curr_hedge_equity:.2f} ({sign}${hedge_trend:.2f})")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class PositionMonitor:
|
||||
"""Monitors hedge_status.json for changes"""
|
||||
|
||||
def __init__(self, json_file_path: str, state_file_path: str):
|
||||
self.json_file_path = json_file_path
|
||||
self.state_file_path = state_file_path
|
||||
self.last_known_data = []
|
||||
self.last_file_hash = ""
|
||||
self.state = self.load_state()
|
||||
|
||||
def load_state(self) -> Dict[str, Any]:
|
||||
"""Load monitor state from file"""
|
||||
try:
|
||||
if os.path.exists(self.state_file_path):
|
||||
with open(self.state_file_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load state file: {e}")
|
||||
|
||||
return {
|
||||
"last_known_open_positions": [],
|
||||
"last_processed_timestamp": 0,
|
||||
"last_file_hash": ""
|
||||
}
|
||||
|
||||
def save_state(self):
|
||||
"""Save monitor state to file"""
|
||||
try:
|
||||
with open(self.state_file_path, 'w') as f:
|
||||
json.dump(self.state, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Could not save state file: {e}")
|
||||
|
||||
def get_file_hash(self, data: List[Dict]) -> str:
|
||||
"""Generate hash of file content to detect changes"""
|
||||
content = json.dumps(data, sort_keys=True)
|
||||
return hashlib.md5(content.encode()).hexdigest()
|
||||
|
||||
def safe_read_json(self) -> List[Dict]:
|
||||
"""Safely read JSON file with retry logic"""
|
||||
attempts = 0
|
||||
while attempts < 3:
|
||||
try:
|
||||
if not os.path.exists(self.json_file_path):
|
||||
logger.warning(f"JSON file not found: {self.json_file_path}")
|
||||
return []
|
||||
|
||||
with open(self.json_file_path, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.warning(f"Attempt {attempts + 1}: Error reading JSON file: {e}")
|
||||
time.sleep(1)
|
||||
attempts += 1
|
||||
|
||||
logger.error("Failed to read JSON file after 3 attempts")
|
||||
return []
|
||||
|
||||
def extract_notification_data(self, data: List[Dict]) -> Tuple[Optional[Dict], Optional[Dict]]:
|
||||
"""Extract last closed and current open positions"""
|
||||
current_open = None
|
||||
last_closed = None
|
||||
|
||||
# Find current open position
|
||||
for item in data:
|
||||
if item.get('status') == 'OPEN':
|
||||
current_open = item
|
||||
break
|
||||
|
||||
# Find most recent closed position
|
||||
closed_positions = [item for item in data if item.get('status') == 'CLOSED']
|
||||
if closed_positions:
|
||||
# Sort by timestamp_open (descending) to get most recent
|
||||
closed_positions.sort(key=lambda x: x.get('timestamp_open', 0), reverse=True)
|
||||
last_closed = closed_positions[0]
|
||||
|
||||
return last_closed, current_open
|
||||
|
||||
def check_for_changes(self) -> bool:
|
||||
"""Check if there are changes requiring notification"""
|
||||
current_data = self.safe_read_json()
|
||||
|
||||
if not current_data:
|
||||
return False
|
||||
|
||||
# Check if file content actually changed
|
||||
current_hash = self.get_file_hash(current_data)
|
||||
if current_hash == self.state.get("last_file_hash", ""):
|
||||
return False
|
||||
|
||||
# Extract positions
|
||||
last_closed, current_open = self.extract_notification_data(current_data)
|
||||
|
||||
if not current_open:
|
||||
# No open position, nothing to notify about
|
||||
return False
|
||||
|
||||
current_open_id = current_open.get('token_id')
|
||||
last_known_opens = self.state.get("last_known_open_positions", [])
|
||||
|
||||
# Check if this is a new open position
|
||||
if current_open_id not in last_known_opens:
|
||||
# New position detected!
|
||||
self.last_known_data = current_data
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_notification_data(self) -> Tuple[Optional[Dict], Optional[Dict]]:
|
||||
"""Get data for notification"""
|
||||
current_data = self.safe_read_json()
|
||||
return self.extract_notification_data(current_data)
|
||||
|
||||
def update_state(self):
|
||||
"""Update internal state after notification"""
|
||||
current_data = self.safe_read_json()
|
||||
if current_data:
|
||||
_, current_open = self.extract_notification_data(current_data)
|
||||
|
||||
if current_open:
|
||||
# Update state with current open positions
|
||||
self.state["last_known_open_positions"] = [current_open.get('token_id')]
|
||||
self.state["last_processed_timestamp"] = int(time.time())
|
||||
self.state["last_file_hash"] = self.get_file_hash(current_data)
|
||||
self.save_state()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main monitoring loop"""
|
||||
logger.info("🤖 Telegram Monitor Starting...")
|
||||
|
||||
notifier = None
|
||||
if not TELEGRAM_ENABLED:
|
||||
logger.info("📵 Telegram notifications disabled (TELEGRAM_MONITOR_ENABLED=False)")
|
||||
else:
|
||||
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
|
||||
logger.error("❌ Telegram enabled but missing BOT_TOKEN or CHAT_ID")
|
||||
return
|
||||
|
||||
# Initialize notifier and test connection
|
||||
notifier = TelegramNotifier(TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID)
|
||||
if not notifier.test_connection():
|
||||
logger.error("❌ Telegram connection failed - check token and network")
|
||||
return
|
||||
|
||||
logger.info(f"✅ Telegram connection established to chat ID: {TELEGRAM_CHAT_ID}")
|
||||
|
||||
# Initialize monitor
|
||||
monitor = PositionMonitor(HEDGE_STATUS_FILE, TELEGRAM_STATE_FILE)
|
||||
|
||||
logger.info(f"Monitoring file: {HEDGE_STATUS_FILE}")
|
||||
logger.info(f"Check interval: {TELEGRAM_CHECK_INTERVAL} seconds")
|
||||
logger.info(f"State file: {TELEGRAM_STATE_FILE}")
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
if monitor.check_for_changes():
|
||||
logger.info("New position opening detected!")
|
||||
|
||||
if TELEGRAM_ENABLED and notifier:
|
||||
last_closed, current_open = monitor.get_notification_data()
|
||||
|
||||
if current_open:
|
||||
message = notifier.format_position_message(last_closed, current_open)
|
||||
success = notifier.send_message(message)
|
||||
|
||||
if success:
|
||||
monitor.update_state()
|
||||
logger.info(f"Notification sent for position {current_open.get('token_id')}")
|
||||
else:
|
||||
logger.error("Failed to send notification")
|
||||
else:
|
||||
logger.warning("Position change detected but no open position found")
|
||||
else:
|
||||
logger.info("Telegram disabled or notifier not available - skipping notification")
|
||||
monitor.update_state() # Still update state to avoid loops
|
||||
else:
|
||||
logger.debug("No changes detected")
|
||||
|
||||
time.sleep(TELEGRAM_CHECK_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in monitoring loop: {e}")
|
||||
time.sleep(TELEGRAM_CHECK_INTERVAL) # Continue after error
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down Telegram Monitor...")
|
||||
|
||||
logger.info("Telegram Monitor stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
7
telegram_monitor_state.json
Normal file
7
telegram_monitor_state.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"last_known_open_positions": [
|
||||
5173016
|
||||
],
|
||||
"last_processed_timestamp": 1766572620,
|
||||
"last_file_hash": "ebd0d929cd0b382919cfc9e5a9523006"
|
||||
}
|
||||
96
todo/fishing_orders.md
Normal file
96
todo/fishing_orders.md
Normal file
@ -0,0 +1,96 @@
|
||||
# [Feature Name / Analysis Topic] - fishing orders
|
||||
|
||||
**Status:** in progress
|
||||
**Started:** 2025/12/23
|
||||
**Last Updated:** `[YYYY-MM-DD]`
|
||||
**Tags:** `[Tag1, Tag2]`
|
||||
|
||||
---
|
||||
|
||||
## 1. Origin & Context (The "Why")
|
||||
*This section captures the inception of the idea. What was the problem, and what was the initial vision?*
|
||||
|
||||
* **Goal:** create additional reveniue, increase TotPnL,
|
||||
* **Initial State:** currently we are under the water in total (clp position+fees+ TotPnL if closed hedge) ~$10-20, after 24h (clp position 2000, range +/-1%, fees $5)
|
||||
* **Trigger:** to make a hedger real delta-zero to clp position
|
||||
* **Original Hypothesis:** make small reveniue will help with it
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 2. Chronological Evolution (The Journey)
|
||||
*A living diary of the process. Add new entries top-down as the feature evolves. This is the story of "How we got here".*
|
||||
|
||||
### 📅 Entry 1: 2025-12-23 - Initial Implementation
|
||||
* **Objective:** Implement a "Fishing" mechanism to capture Maker rebates and reduce hedge size at break-even prices.
|
||||
* **Investigation:**
|
||||
* *Findings:* Identified that when the price is in the "Safe Zone" (near entry), the bot sits idle. This is an opportunity to place resting Maker orders to "fish" for better entries/exits than standard Taker rebalances.
|
||||
* **Current Implementation:**
|
||||
* **Logic:** Places a Maker BUY order (`Alo` - Add Liquidity Only) at the `hedge_entry_price` when current market price is above entry.
|
||||
* **Size:** 10% of the target hedge size (`FISHING_ORDER_SIZE_PCT = 0.10`).
|
||||
* **Management:** The order is tracked via `self.fishing_oid` and is automatically cancelled before any standard rebalance trade to avoid size conflicts.
|
||||
* **Decision:** Start with a conservative 10% size and only for closing/reducing the hedge (Buy-to-Close when price is above entry) to validate the "Maker fill" probability without over-exposing.
|
||||
|
||||
### 📅 Entry 2: 2025-12-23 - Analysis of Symmetric Fishing (Bottom-Side)
|
||||
* **Objective:** Analyze the potential of adding a "Sell" fishing order when price is below entry (Symmetric Grid).
|
||||
* **Investigation:**
|
||||
* *Scenario:* Price is 1900 (Down). Entry is 2000. Hedger is Short.
|
||||
* *Logic:* A Limit SELL at 2000 is a valid Maker order (Above current price).
|
||||
* *Effect:* If filled, it increases the Short position at 2000. Since the neutral target at 2000 is fixed, this results in being slightly "Over-Hedged" (Short Delta) at Entry.
|
||||
* *Strategic Value:* This creates a "Mean Reversion" or "Grid" effect around the entry.
|
||||
* *Top Side (Buy at Entry):* Profitable if price drops to Entry and bounces up.
|
||||
* *Bottom Side (Sell at Entry):* Profitable if price rises to Entry and rejects down.
|
||||
* *Verdict:* This is beneficial. It helps offset hedging costs (which typically lose money in chop) by actively trading the chop around the entry price.
|
||||
* **Decision:** Implement the symmetric bottom-side fishing order.
|
||||
* If `Price < Entry`: Place Maker SELL at Entry.
|
||||
* If `Price > Entry`: Place Maker BUY at Entry.
|
||||
|
||||
### 📅 Entry 3: 2025-12-23 - Implementation of Symmetric Fishing
|
||||
* **Action Taken:** Modified `clp_hedger.py` to allow fishing orders on both sides of the entry price.
|
||||
* **Result:**
|
||||
* When `Price > Entry`: Places Maker **BUY** at Entry (Reduce Short).
|
||||
* When `Price < Entry`: Places Maker **SELL** at Entry (Increase Short).
|
||||
* **Safety Features:**
|
||||
* Added a 0.1% price buffer (`dist_pct > 0.001`) to prevent placing orders too close to the market price, which could result in unintended Taker trades or API rejections for `Alo` orders.
|
||||
* Maintains the existing 10% size restriction to ensure fishing doesn't drastically warp the delta profile.
|
||||
|
||||
---
|
||||
|
||||
## 3. Final Technical Solution (The "How")
|
||||
*The consolidated, final design documentation.*
|
||||
|
||||
### 3.1 Architecture & Logic
|
||||
* **Core Components:** `ScalperHedger` class in `clp_hedger.py`.
|
||||
* **Data Flow:**
|
||||
1. Bot checks for active standard rebalance orders. If none exist, it enters "Fishing Mode".
|
||||
2. Compares `current_mid_price` against the `hedge_entry_price` from the exchange.
|
||||
3. Determines side: BUY if price is above entry, SELL if price is below entry.
|
||||
4. Places an `Alo` (Add Liquidity Only) Limit order at the exact `hedge_entry_price`.
|
||||
* **Configuration:**
|
||||
* `FISHING_ORDER_SIZE_PCT = 0.10`: Size of the fishing order relative to the current target hedge.
|
||||
|
||||
### 3.2 Key Constraints & Edge Cases
|
||||
* **Safe Zone Only:** Only active when no other orders are pending.
|
||||
* **Rebalance Priority:** Automatically cancelled before any rebalance trade to avoid over-exposure.
|
||||
* **Maker Buffer:** Only placed if market price is >0.1% away from entry to ensure Maker status.
|
||||
|
||||
---
|
||||
|
||||
## 4. Retrospective (The "Cookbook" of Fails & Ups)
|
||||
*Summary of lessons learned. "Read this before touching this code again."*
|
||||
|
||||
### ❌ What Failed (Pitfalls to Avoid)
|
||||
* *Trap:* [Don't try doing X, because Y will happen.]
|
||||
* *False Assumption:* [We thought Z was true, but it wasn't.]
|
||||
|
||||
### ✅ What Worked (Success Factors)
|
||||
* *Key Insight:* [The breakthrough was...]
|
||||
* *Design Pattern:* [This specific pattern proved robust.]
|
||||
|
||||
---
|
||||
|
||||
## 5. Artifacts & References
|
||||
* **Affected Files:**
|
||||
* `[path/to/file.py]`
|
||||
* **Commits:** `[git hash or description]`
|
||||
* **External Links:** `[URLs]`
|
||||
18
tools/.env.example
Normal file
18
tools/.env.example
Normal file
@ -0,0 +1,18 @@
|
||||
# Base Chain Configuration
|
||||
BASE_RPC_URL=https://mainnet.base.org # or your preferred Base RPC provider
|
||||
|
||||
# Wallet Configuration
|
||||
MAIN_WALLET_PRIVATE_KEY=your_private_key_here
|
||||
# or
|
||||
PRIVATE_KEY=your_private_key_here
|
||||
|
||||
# Telegram Notifications (Optional)
|
||||
TELEGRAM_MONITOR_ENABLED=False
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
TELEGRAM_CHAT_ID=your_telegram_chat_id
|
||||
TELEGRAM_CHECK_INTERVAL_SECONDS=60
|
||||
TELEGRAM_TIMEOUT_SECONDS=10
|
||||
TELEGRAM_STATE_FILE=telegram_monitor_state.json
|
||||
|
||||
# Optional: Custom hedge status file path
|
||||
HEDGE_STATUS_FILE=hedge_status.json
|
||||
194
tools/README_TELEGRAM.md
Normal file
194
tools/README_TELEGRAM.md
Normal file
@ -0,0 +1,194 @@
|
||||
# CLP Telegram Notification Monitor
|
||||
|
||||
## Overview
|
||||
|
||||
Standalone Python script that monitors your CLP (Concentrated Liquidity Pool) positions and sends Telegram notifications when new positions are opened.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔍 **File-based Monitoring**: Watches `hedge_status.json` every 60 seconds
|
||||
- 📊 **Rich Notifications**: Shows last closed position vs currently opened position
|
||||
- 🔧 **Zero Integration**: No modifications to existing trading logic required
|
||||
- 🛡️ **Safe Operation**: Graceful error handling, won't affect trading system
|
||||
- 📈 **Performance Tracking**: Compares entry prices, values, and hedge PnL
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install requests python-dotenv
|
||||
```
|
||||
|
||||
### 2. Create Telegram Bot
|
||||
|
||||
1. Open Telegram and search for **@BotFather**
|
||||
2. Send: `/newbot`
|
||||
3. Choose a name for your bot (e.g., "CLP Position Monitor")
|
||||
4. Choose a username (must end with 'bot', e.g., "clp_monitor_bot")
|
||||
5. Copy the **HTTP API token** from BotFather
|
||||
|
||||
### 3. Get Your Chat ID
|
||||
|
||||
1. Send any message to your new bot first
|
||||
2. Run the setup script: `python tools/telegram_setup.py`
|
||||
3. Choose option "2. Get Chat ID" and enter your bot token
|
||||
4. Copy your Chat ID
|
||||
|
||||
### 4. Configure Environment
|
||||
|
||||
Add to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Enable Telegram notifications
|
||||
TELEGRAM_MONITOR_ENABLED=True
|
||||
|
||||
# Your bot credentials
|
||||
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||
TELEGRAM_CHAT_ID=123456789
|
||||
|
||||
# Optional settings
|
||||
TELEGRAM_CHECK_INTERVAL_SECONDS=60
|
||||
TELEGRAM_TIMEOUT_SECONDS=10
|
||||
TELEGRAM_STATE_FILE=telegram_monitor_state.json
|
||||
HEDGE_STATUS_FILE=hedge_status.json
|
||||
```
|
||||
|
||||
### 5. Run the Monitor
|
||||
|
||||
```bash
|
||||
python tools/telegram_monitor.py
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Running as Background Process
|
||||
|
||||
**Option 1: Simple Background**
|
||||
```bash
|
||||
nohup python tools/telegram_monitor.py > logs/telegram_monitor.log 2>&1 &
|
||||
```
|
||||
|
||||
**Option 2: Screen Session**
|
||||
```bash
|
||||
screen -S telegram_monitor
|
||||
python tools/telegram_monitor.py
|
||||
# Press Ctrl+A, D to detach
|
||||
```
|
||||
|
||||
**Option 3: Systemd Service** (Linux)
|
||||
Create `/etc/systemd/system/clp-telegram-monitor.service`:
|
||||
```ini
|
||||
[Unit]
|
||||
Description=CLP Telegram Monitor
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=your_username
|
||||
WorkingDirectory=/path/to/uniswap_auto_clp
|
||||
ExecStart=/usr/bin/python3 tools/telegram_monitor.py
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
```bash
|
||||
sudo systemctl enable clp-telegram-monitor
|
||||
sudo systemctl start clp-telegram-monitor
|
||||
```
|
||||
|
||||
## Message Format Example
|
||||
|
||||
When a new position opens, you'll receive:
|
||||
|
||||
```
|
||||
🚀 NEW CLP POSITION DETECTED
|
||||
|
||||
📊 LAST CLOSED POSITION:
|
||||
• Token ID: 5171687
|
||||
• Entry: $3043.86
|
||||
• Target Value: $1985.02
|
||||
• Duration: 2h 14m
|
||||
• Hedge PnL: -$20.99
|
||||
• Hedge Fees: $2.30
|
||||
|
||||
🔥 CURRENTLY OPENED:
|
||||
• Token ID: 5173016
|
||||
• Entry: $2993.72
|
||||
• Target Value: $1935.04
|
||||
• Range: $2849.80 - $3140.07
|
||||
• Initial: 0.3181 ETH + 982.71 USDC
|
||||
• Time: 2h 15m ago
|
||||
|
||||
📈 PERFORMANCE COMPARISON:
|
||||
• Entry Change: -$50.14 (-1.65%)
|
||||
• Value Change: -$49.98 (-2.52%)
|
||||
• Hedge PnL Trend: -$20.99 → $2.75 (+$23.74)
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|----------|-------------|
|
||||
| `TELEGRAM_MONITOR_ENABLED` | `False` | Enable/disable notifications |
|
||||
| `TELEGRAM_BOT_TOKEN` | Required | Your bot's HTTP API token |
|
||||
| `TELEGRAM_CHAT_ID` | Required | Your personal chat ID |
|
||||
| `TELEGRAM_CHECK_INTERVAL_SECONDS` | `60` | How often to check for changes |
|
||||
| `TELEGRAM_TIMEOUT_SECONDS` | `10` | Telegram API timeout |
|
||||
| `TELEGRAM_STATE_FILE` | `telegram_monitor_state.json` | Internal state tracking |
|
||||
| `HEDGE_STATUS_FILE` | `hedge_status.json` | File to monitor |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot Not Receiving Messages
|
||||
- Ensure you've sent a message to your bot first
|
||||
- Check that `TELEGRAM_CHAT_ID` is correct (no quotes)
|
||||
- Verify bot token has no extra spaces
|
||||
|
||||
### No Notifications When Position Opens
|
||||
- Check `TELEGRAM_MONITOR_ENABLED=True`
|
||||
- Verify the script is running: `ps aux | grep telegram_monitor`
|
||||
- Check logs: `tail -f logs/telegram_monitor.log`
|
||||
- Ensure `hedge_status.json` path is correct
|
||||
|
||||
### Telegram API Errors
|
||||
- Verify bot token format (should have colon: `123:ABC`)
|
||||
- Check internet connectivity
|
||||
- Try running setup script to test connection
|
||||
|
||||
### File Not Found Errors
|
||||
- Make sure you're running from project root directory
|
||||
- Verify `hedge_status.json` exists and has correct permissions
|
||||
- Check that your trading system is generating the status file
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Never share your bot token** - it's like a password
|
||||
- **Store credentials in `.env`** - never commit to git
|
||||
- **Limit bot permissions** - only send messages, no admin rights
|
||||
- **Monitor logs regularly** for any unusual activity
|
||||
|
||||
## Files Created
|
||||
|
||||
- `tools/telegram_monitor.py` - Main monitoring script
|
||||
- `tools/telegram_setup.py` - Setup helper script
|
||||
- `tools/.env.example` - Environment template
|
||||
- `telegram_monitor_state.json` - Internal state (auto-created)
|
||||
- `logs/telegram_monitor.log` - Monitor logs (auto-created)
|
||||
|
||||
## Integration Notes
|
||||
|
||||
This monitor is **completely standalone** and:
|
||||
|
||||
- ✅ Does not modify your trading logic
|
||||
- ✅ Does not interfere with uniswap_manager.py
|
||||
- ✅ Does not affect clp_hedger.py
|
||||
- ✅ Safe to run alongside existing processes
|
||||
- ✅ Can be stopped/started independently
|
||||
- ✅ Uses minimal system resources
|
||||
|
||||
The monitor simply reads the status file that your existing trading system already generates, making it safe and non-intrusive.
|
||||
394
tools/telegram_monitor.py
Normal file
394
tools/telegram_monitor.py
Normal file
@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Telegram Monitor for CLP Position Notifications
|
||||
Monitors hedge_status.json file for new position openings and sends Telegram notifications
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import hashlib
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, List, Tuple, Any
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# --- SETUP PROJECT PATH ---
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(current_dir)
|
||||
sys.path.append(current_dir)
|
||||
|
||||
# --- LOGGING SETUP ---
|
||||
os.makedirs(os.path.join(current_dir, 'logs'), exist_ok=True)
|
||||
|
||||
class UnixMsLogFilter(logging.Filter):
|
||||
def filter(self, record):
|
||||
record.unix_ms = int(record.created * 1000)
|
||||
return True
|
||||
|
||||
logger = logging.getLogger("TELEGRAM_MONITOR")
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
logger.handlers.clear()
|
||||
|
||||
# Console handler
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# File handler
|
||||
file_handler = logging.FileHandler(os.path.join(current_dir, 'logs', 'telegram_monitor.log'), encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.addFilter(UnixMsLogFilter())
|
||||
file_formatter = logging.Formatter('%(unix_ms)d, %(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
file_handler.setFormatter(file_formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
load_dotenv(os.path.join(current_dir, '.env'))
|
||||
|
||||
TELEGRAM_ENABLED = os.getenv('TELEGRAM_MONITOR_ENABLED', 'False').lower() == 'true'
|
||||
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID', '')
|
||||
TELEGRAM_CHECK_INTERVAL = int(os.getenv('TELEGRAM_CHECK_INTERVAL_SECONDS', '60'))
|
||||
TELEGRAM_STATE_FILE = os.getenv('TELEGRAM_STATE_FILE', 'telegram_monitor_state.json')
|
||||
TELEGRAM_TIMEOUT = int(os.getenv('TELEGRAM_TIMEOUT_SECONDS', '10'))
|
||||
HEDGE_STATUS_FILE = os.getenv('HEDGE_STATUS_FILE', 'hedge_status.json')
|
||||
|
||||
class TelegramNotifier:
|
||||
"""Handles Telegram API communication"""
|
||||
|
||||
def __init__(self, bot_token: str, chat_id: str):
|
||||
self.bot_token = bot_token
|
||||
self.chat_id = chat_id
|
||||
self.base_url = f"https://api.telegram.org/bot{bot_token}"
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""Test Telegram bot connection"""
|
||||
try:
|
||||
url = f"{self.base_url}/getMe"
|
||||
response = requests.get(url, timeout=TELEGRAM_TIMEOUT)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram connection test failed: {e}")
|
||||
return False
|
||||
|
||||
def send_message(self, text: str) -> bool:
|
||||
"""Send message to Telegram chat"""
|
||||
if not TELEGRAM_ENABLED or not self.bot_token or not self.chat_id:
|
||||
logger.debug("Telegram notifications disabled or missing credentials")
|
||||
return False
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/sendMessage"
|
||||
payload = {
|
||||
'chat_id': self.chat_id,
|
||||
'text': text,
|
||||
'parse_mode': 'Markdown'
|
||||
}
|
||||
|
||||
response = requests.post(url, json=payload, timeout=TELEGRAM_TIMEOUT)
|
||||
result = response.json()
|
||||
|
||||
if result.get('ok'):
|
||||
logger.info("Telegram notification sent successfully")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Telegram API error: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send Telegram message: {e}")
|
||||
return False
|
||||
|
||||
def format_position_message(self, last_closed: Optional[Dict], current_open: Dict) -> str:
|
||||
"""Format position data into readable message"""
|
||||
lines = ["NEW CLP POSITION DETECTED\n"]
|
||||
|
||||
# Previous closed position section
|
||||
if last_closed:
|
||||
lines.append("LAST CLOSED POSITION:")
|
||||
lines.append(f"• Token ID: {last_closed.get('token_id', 'N/A')}")
|
||||
lines.append(f"• Entry: ${last_closed.get('entry_price', 0):.2f}")
|
||||
lines.append(f"• Target Value: ${last_closed.get('target_value', 0):.2f}")
|
||||
|
||||
# Add duration if timestamps available
|
||||
if last_closed.get('timestamp_open') and last_closed.get('timestamp_close'):
|
||||
duration = last_closed['timestamp_close'] - last_closed['timestamp_open']
|
||||
hours = duration // 3600
|
||||
minutes = (duration % 3600) // 60
|
||||
lines.append(f"• Duration: {hours}h {minutes}m")
|
||||
|
||||
# Add hedge performance if available
|
||||
hedge_pnl = last_closed.get('hedge_pnl_realized', 0)
|
||||
if hedge_pnl != 0:
|
||||
lines.append(f"• Hedge PnL: ${hedge_pnl:.2f}")
|
||||
|
||||
hedge_fees = last_closed.get('hedge_fees_paid', 0)
|
||||
if hedge_fees != 0:
|
||||
lines.append(f"• Hedge Fees: ${hedge_fees:.2f}")
|
||||
else:
|
||||
lines.append("LAST CLOSED POSITION: None")
|
||||
|
||||
lines.append("") # Empty line
|
||||
|
||||
# Current opened position section
|
||||
lines.append("CURRENTLY OPENED:")
|
||||
lines.append(f"• Token ID: {current_open.get('token_id', 'N/A')}")
|
||||
lines.append(f"• Entry: ${current_open.get('entry_price', 0):.2f}")
|
||||
lines.append(f"• Target Value: ${current_open.get('target_value', 0):.2f}")
|
||||
|
||||
# Range information
|
||||
range_lower = current_open.get('range_lower', 0)
|
||||
range_upper = current_open.get('range_upper', 0)
|
||||
if range_lower and range_upper:
|
||||
lines.append(f"• Range: ${range_lower:.2f} - ${range_upper:.2f}")
|
||||
|
||||
# Initial amounts
|
||||
amount0 = current_open.get('amount0_initial', 0)
|
||||
amount1 = current_open.get('amount1_initial', 0)
|
||||
if amount0 and amount1:
|
||||
lines.append(f"• Initial: {amount0:.4f} ETH + {amount1:.2f} USDC")
|
||||
|
||||
# Time since opening
|
||||
if current_open.get('timestamp_open'):
|
||||
age = int(time.time()) - current_open['timestamp_open']
|
||||
hours = age // 3600
|
||||
minutes = (age % 3600) // 60
|
||||
lines.append(f"• Time: {hours}h {minutes}m ago")
|
||||
|
||||
# Performance comparison if we have both positions
|
||||
if last_closed and current_open:
|
||||
lines.append("") # Empty line
|
||||
lines.append("PERFORMANCE COMPARISON:")
|
||||
|
||||
# Entry price change
|
||||
last_entry = Decimal(str(last_closed.get('entry_price', 0)))
|
||||
curr_entry = Decimal(str(current_open.get('entry_price', 0)))
|
||||
if last_entry > 0:
|
||||
entry_change = curr_entry - last_entry
|
||||
entry_change_pct = (entry_change / last_entry) * 100
|
||||
sign = "+" if entry_change >= 0 else ""
|
||||
lines.append(f"• Entry Change: {sign}${entry_change:.2f} ({sign}{entry_change_pct:.2f}%)")
|
||||
|
||||
# Value change
|
||||
last_value = Decimal(str(last_closed.get('target_value', 0)))
|
||||
curr_value = Decimal(str(current_open.get('target_value', 0)))
|
||||
if last_value > 0:
|
||||
value_change = curr_value - last_value
|
||||
value_change_pct = (value_change / last_value) * 100
|
||||
sign = "+" if value_change >= 0 else ""
|
||||
lines.append(f"• Value Change: {sign}${value_change:.2f} ({sign}{value_change_pct:.2f}%)")
|
||||
|
||||
# Hedge PnL trend
|
||||
last_hedge_pnl = last_closed.get('hedge_pnl_realized', 0)
|
||||
curr_hedge_equity = current_open.get('hedge_equity_usd', 0)
|
||||
if last_hedge_pnl != 0 and curr_hedge_equity != 0:
|
||||
hedge_trend = curr_hedge_equity - last_hedge_pnl
|
||||
sign = "+" if hedge_trend >= 0 else ""
|
||||
lines.append(f"• Hedge PnL Trend: ${last_hedge_pnl:.2f} -> ${curr_hedge_equity:.2f} ({sign}${hedge_trend:.2f})")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class PositionMonitor:
|
||||
"""Monitors hedge_status.json for changes"""
|
||||
|
||||
def __init__(self, json_file_path: str, state_file_path: str):
|
||||
self.json_file_path = json_file_path
|
||||
self.state_file_path = state_file_path
|
||||
self.last_known_data = []
|
||||
self.last_file_hash = ""
|
||||
self.state = self.load_state()
|
||||
|
||||
def load_state(self) -> Dict[str, Any]:
|
||||
"""Load monitor state from file"""
|
||||
try:
|
||||
if os.path.exists(self.state_file_path):
|
||||
with open(self.state_file_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load state file: {e}")
|
||||
|
||||
return {
|
||||
"last_known_open_positions": [],
|
||||
"last_processed_timestamp": 0,
|
||||
"last_file_hash": ""
|
||||
}
|
||||
|
||||
def save_state(self):
|
||||
"""Save monitor state to file"""
|
||||
try:
|
||||
with open(self.state_file_path, 'w') as f:
|
||||
json.dump(self.state, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Could not save state file: {e}")
|
||||
|
||||
def get_file_hash(self, data: List[Dict]) -> str:
|
||||
"""Generate hash of file content to detect changes"""
|
||||
content = json.dumps(data, sort_keys=True)
|
||||
return hashlib.md5(content.encode()).hexdigest()
|
||||
|
||||
def safe_read_json(self) -> List[Dict]:
|
||||
"""Safely read JSON file with retry logic"""
|
||||
attempts = 0
|
||||
while attempts < 3:
|
||||
try:
|
||||
if not os.path.exists(self.json_file_path):
|
||||
logger.warning(f"JSON file not found: {self.json_file_path}")
|
||||
return []
|
||||
|
||||
with open(self.json_file_path, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.warning(f"Attempt {attempts + 1}: Error reading JSON file: {e}")
|
||||
time.sleep(1)
|
||||
attempts += 1
|
||||
|
||||
logger.error("Failed to read JSON file after 3 attempts")
|
||||
return []
|
||||
|
||||
def extract_notification_data(self, data: List[Dict]) -> Tuple[Optional[Dict], Optional[Dict]]:
|
||||
"""Extract last closed and current open positions"""
|
||||
current_open = None
|
||||
last_closed = None
|
||||
|
||||
# Find current open position
|
||||
for item in data:
|
||||
if item.get('status') == 'OPEN':
|
||||
current_open = item
|
||||
break
|
||||
|
||||
# Find most recent closed position
|
||||
closed_positions = [item for item in data if item.get('status') == 'CLOSED']
|
||||
if closed_positions:
|
||||
# Sort by timestamp_open (descending) to get most recent
|
||||
closed_positions.sort(key=lambda x: x.get('timestamp_open', 0), reverse=True)
|
||||
last_closed = closed_positions[0]
|
||||
|
||||
return last_closed, current_open
|
||||
|
||||
def check_for_changes(self) -> bool:
|
||||
"""Check if there are changes requiring notification"""
|
||||
current_data = self.safe_read_json()
|
||||
|
||||
if not current_data:
|
||||
return False
|
||||
|
||||
# Check if file content actually changed
|
||||
current_hash = self.get_file_hash(current_data)
|
||||
if current_hash == self.state.get("last_file_hash", ""):
|
||||
return False
|
||||
|
||||
# Extract positions
|
||||
last_closed, current_open = self.extract_notification_data(current_data)
|
||||
|
||||
if not current_open:
|
||||
# No open position, nothing to notify about
|
||||
return False
|
||||
|
||||
current_open_id = current_open.get('token_id')
|
||||
last_known_opens = self.state.get("last_known_open_positions", [])
|
||||
|
||||
# Check if this is a new open position
|
||||
if current_open_id not in last_known_opens:
|
||||
# New position detected!
|
||||
self.last_known_data = current_data
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_notification_data(self) -> Tuple[Optional[Dict], Optional[Dict]]:
|
||||
"""Get data for notification"""
|
||||
current_data = self.safe_read_json()
|
||||
return self.extract_notification_data(current_data)
|
||||
|
||||
def update_state(self):
|
||||
"""Update internal state after notification"""
|
||||
current_data = self.safe_read_json()
|
||||
if current_data:
|
||||
_, current_open = self.extract_notification_data(current_data)
|
||||
|
||||
if current_open:
|
||||
# Update state with current open positions
|
||||
self.state["last_known_open_positions"] = [current_open.get('token_id')]
|
||||
self.state["last_processed_timestamp"] = int(time.time())
|
||||
self.state["last_file_hash"] = self.get_file_hash(current_data)
|
||||
self.save_state()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main monitoring loop"""
|
||||
logger.info("🤖 Telegram Monitor Starting...")
|
||||
|
||||
notifier = None
|
||||
if not TELEGRAM_ENABLED:
|
||||
logger.info("📵 Telegram notifications disabled (TELEGRAM_MONITOR_ENABLED=False)")
|
||||
else:
|
||||
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
|
||||
logger.error("❌ Telegram enabled but missing BOT_TOKEN or CHAT_ID")
|
||||
return
|
||||
|
||||
# Initialize notifier and test connection
|
||||
notifier = TelegramNotifier(TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID)
|
||||
if not notifier.test_connection():
|
||||
logger.error("❌ Telegram connection failed - check token and network")
|
||||
return
|
||||
|
||||
logger.info(f"✅ Telegram connection established to chat ID: {TELEGRAM_CHAT_ID}")
|
||||
|
||||
# Initialize monitor
|
||||
monitor = PositionMonitor(HEDGE_STATUS_FILE, TELEGRAM_STATE_FILE)
|
||||
|
||||
logger.info(f"Monitoring file: {HEDGE_STATUS_FILE}")
|
||||
logger.info(f"Check interval: {TELEGRAM_CHECK_INTERVAL} seconds")
|
||||
logger.info(f"State file: {TELEGRAM_STATE_FILE}")
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
if monitor.check_for_changes():
|
||||
logger.info("New position opening detected!")
|
||||
|
||||
if TELEGRAM_ENABLED and notifier:
|
||||
last_closed, current_open = monitor.get_notification_data()
|
||||
|
||||
if current_open:
|
||||
message = notifier.format_position_message(last_closed, current_open)
|
||||
success = notifier.send_message(message)
|
||||
|
||||
if success:
|
||||
monitor.update_state()
|
||||
logger.info(f"Notification sent for position {current_open.get('token_id')}")
|
||||
else:
|
||||
logger.error("Failed to send notification")
|
||||
else:
|
||||
logger.warning("Position change detected but no open position found")
|
||||
else:
|
||||
logger.info("Telegram disabled or notifier not available - skipping notification")
|
||||
monitor.update_state() # Still update state to avoid loops
|
||||
else:
|
||||
logger.debug("No changes detected")
|
||||
|
||||
time.sleep(TELEGRAM_CHECK_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in monitoring loop: {e}")
|
||||
time.sleep(TELEGRAM_CHECK_INTERVAL) # Continue after error
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down Telegram Monitor...")
|
||||
|
||||
logger.info("Telegram Monitor stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
109
tools/telegram_setup.py
Normal file
109
tools/telegram_setup.py
Normal file
@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Setup script for Telegram Bot notifications
|
||||
Helps users create a Telegram bot and get required credentials
|
||||
"""
|
||||
|
||||
import webbrowser
|
||||
import time
|
||||
|
||||
def print_instructions():
|
||||
"""Print setup instructions"""
|
||||
print("🤖 TELEGRAM BOT SETUP INSTRUCTIONS")
|
||||
print("=" * 50)
|
||||
print()
|
||||
print("1️⃣ CREATE YOUR TELEGRAM BOT:")
|
||||
print(" • Open Telegram and search for @BotFather")
|
||||
print(" • Send: /newbot")
|
||||
print(" • Choose a name for your bot")
|
||||
print(" • Choose a username (must end with 'bot')")
|
||||
print(" • BotFather will give you a HTTP API token")
|
||||
print(" • Copy this token (it looks like: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz)")
|
||||
print()
|
||||
print("2️⃣ GET YOUR CHAT ID:")
|
||||
print(" • Send a message to your new bot first (any message)")
|
||||
print(" • Your bot will not work if you haven't messaged it first")
|
||||
print(" • Use the script below to get your Chat ID")
|
||||
print()
|
||||
print("3️⃣ CONFIGURE ENVIRONMENT:")
|
||||
print(" • Add to your .env file:")
|
||||
print(" TELEGRAM_MONITOR_ENABLED=True")
|
||||
print(" TELEGRAM_BOT_TOKEN=your_bot_token_here")
|
||||
print(" TELEGRAM_CHAT_ID=your_chat_id_here")
|
||||
print()
|
||||
print("4️⃣ RUN THE MONITOR:")
|
||||
print(" • python tools/telegram_monitor.py")
|
||||
print()
|
||||
print("=" * 50)
|
||||
|
||||
def get_chat_id():
|
||||
"""Get chat ID using bot token"""
|
||||
print("🔍 CHAT ID FINDER")
|
||||
print("-" * 20)
|
||||
|
||||
token = input("Enter your bot token: ").strip()
|
||||
if not token:
|
||||
print("❌ No token provided")
|
||||
return
|
||||
|
||||
try:
|
||||
import requests
|
||||
import json
|
||||
|
||||
url = f"https://api.telegram.org/bot{token}/getUpdates"
|
||||
response = requests.get(url, timeout=10)
|
||||
data = response.json()
|
||||
|
||||
if data.get('ok'):
|
||||
updates = data.get('result', [])
|
||||
if updates:
|
||||
chat_id = updates[-1]['message']['chat']['id']
|
||||
print(f"✅ Your Chat ID: {chat_id}")
|
||||
print()
|
||||
print("Add this to your .env file:")
|
||||
print(f"TELEGRAM_CHAT_ID={chat_id}")
|
||||
else:
|
||||
print("❌ No messages found")
|
||||
print("Send a message to your bot first, then try again")
|
||||
else:
|
||||
print(f"❌ Error: {data.get('description', 'Unknown error')}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error getting Chat ID: {e}")
|
||||
|
||||
def main():
|
||||
"""Main setup menu"""
|
||||
print("🤖 CLP TELEGRAM NOTIFICATION SETUP")
|
||||
print("=" * 40)
|
||||
print()
|
||||
|
||||
while True:
|
||||
print("Choose an option:")
|
||||
print("1. Show setup instructions")
|
||||
print("2. Get Chat ID")
|
||||
print("3. Open BotFather (to create bot)")
|
||||
print("4. Exit")
|
||||
print()
|
||||
|
||||
choice = input("Enter choice (1-4): ").strip()
|
||||
|
||||
if choice == '1':
|
||||
print_instructions()
|
||||
elif choice == '2':
|
||||
get_chat_id()
|
||||
elif choice == '3':
|
||||
print("Opening BotFather in Telegram...")
|
||||
try:
|
||||
webbrowser.open('https://t.me/BotFather')
|
||||
except:
|
||||
print("Could not open browser. Go to https://t.me/BotFather manually")
|
||||
elif choice == '4':
|
||||
print("Goodbye! 🚀")
|
||||
break
|
||||
else:
|
||||
print("Invalid choice. Try again.")
|
||||
|
||||
print("\n" + "=" * 40 + "\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
105
tools/test_telegram_monitor.py
Normal file
105
tools/test_telegram_monitor.py
Normal file
@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for Telegram Monitor functionality
|
||||
Validates JSON parsing and message formatting
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the tools directory to the path
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.append(current_dir)
|
||||
|
||||
from telegram_monitor import TelegramNotifier, PositionMonitor
|
||||
|
||||
def test_json_parsing():
|
||||
"""Test JSON parsing from hedge_status.json"""
|
||||
print("Testing JSON parsing...")
|
||||
|
||||
monitor = PositionMonitor("../hedge_status.json", "test_state.json")
|
||||
data = monitor.safe_read_json()
|
||||
|
||||
if not data:
|
||||
print("❌ Failed to read JSON data")
|
||||
return False
|
||||
|
||||
print(f"✅ Successfully read {len(data)} positions")
|
||||
|
||||
# Find open and closed positions
|
||||
last_closed, current_open = monitor.extract_notification_data(data)
|
||||
|
||||
if current_open:
|
||||
print(f"✅ Found open position: Token ID {current_open.get('token_id')}")
|
||||
else:
|
||||
print("ℹ️ No open position found")
|
||||
|
||||
if last_closed:
|
||||
print(f"✅ Found last closed: Token ID {last_closed.get('token_id')}")
|
||||
else:
|
||||
print("ℹ️ No closed positions found")
|
||||
|
||||
return True
|
||||
|
||||
def test_message_formatting():
|
||||
"""Test message formatting"""
|
||||
print("\n🧪 Testing message formatting...")
|
||||
|
||||
# Create test data
|
||||
last_closed = {
|
||||
"token_id": 1234567,
|
||||
"entry_price": 3000.0,
|
||||
"target_value": 2000.0,
|
||||
"hedge_pnl_realized": -15.50,
|
||||
"hedge_fees_paid": 2.25,
|
||||
"timestamp_open": 1766328197,
|
||||
"timestamp_close": 1766331797
|
||||
}
|
||||
|
||||
current_open = {
|
||||
"token_id": 1234568,
|
||||
"entry_price": 2980.0,
|
||||
"target_value": 1950.0,
|
||||
"range_lower": 2900.0,
|
||||
"range_upper": 3060.0,
|
||||
"amount0_initial": 0.3,
|
||||
"amount1_initial": 900.0,
|
||||
"timestamp_open": 1766335400
|
||||
}
|
||||
|
||||
notifier = TelegramNotifier("test_token", "test_chat")
|
||||
message = notifier.format_position_message(last_closed, current_open)
|
||||
|
||||
print("📱 Sample message:")
|
||||
print("-" * 40)
|
||||
print(message)
|
||||
print("-" * 40)
|
||||
|
||||
return True
|
||||
|
||||
def main():
|
||||
print("Telegram Monitor Test Suite")
|
||||
print("=" * 50)
|
||||
|
||||
success = True
|
||||
|
||||
try:
|
||||
if not test_json_parsing():
|
||||
success = False
|
||||
|
||||
if not test_message_formatting():
|
||||
success = False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Test failed with error: {e}")
|
||||
success = False
|
||||
|
||||
if success:
|
||||
print("\n✅ All tests passed! Telegram monitor is ready.")
|
||||
else:
|
||||
print("\n❌ Some tests failed. Check the errors above.")
|
||||
|
||||
return 0 if success else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
64
tools/test_telegram_simple.py
Normal file
64
tools/test_telegram_simple.py
Normal file
@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple test for Telegram Monitor functionality
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add tools directory to path
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.append(current_dir)
|
||||
|
||||
try:
|
||||
from telegram_monitor import TelegramNotifier, PositionMonitor
|
||||
|
||||
print("Testing Telegram Monitor components...")
|
||||
|
||||
# Test with sample data
|
||||
notifier = TelegramNotifier("test_token", "test_chat")
|
||||
|
||||
last_closed = {
|
||||
"token_id": 1234567,
|
||||
"entry_price": 3000.0,
|
||||
"target_value": 2000.0,
|
||||
"hedge_pnl_realized": -15.50,
|
||||
"hedge_fees_paid": 2.25,
|
||||
"timestamp_open": 1766328197,
|
||||
"timestamp_close": 1766331797
|
||||
}
|
||||
|
||||
current_open = {
|
||||
"token_id": 1234568,
|
||||
"entry_price": 2980.0,
|
||||
"target_value": 1950.0,
|
||||
"range_lower": 2900.0,
|
||||
"range_upper": 3060.0,
|
||||
"amount0_initial": 0.3,
|
||||
"amount1_initial": 900.0,
|
||||
"timestamp_open": 1766335400
|
||||
}
|
||||
|
||||
message = notifier.format_position_message(last_closed, current_open)
|
||||
print("Message formatting test PASSED")
|
||||
print("Sample message:")
|
||||
print("-" * 40)
|
||||
print(message)
|
||||
print("-" * 40)
|
||||
|
||||
# Test JSON reading if file exists
|
||||
if os.path.exists("../hedge_status.json"):
|
||||
monitor = PositionMonitor("../hedge_status.json", "test_state.json")
|
||||
data = monitor.safe_read_json()
|
||||
if data:
|
||||
print(f"JSON reading test PASSED - found {len(data)} positions")
|
||||
else:
|
||||
print("JSON reading test FAILED")
|
||||
|
||||
print("All tests completed successfully!")
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Import error: {e}")
|
||||
print("Make sure telegram_monitor.py is in the same directory")
|
||||
except Exception as e:
|
||||
print(f"Test error: {e}")
|
||||
Reference in New Issue
Block a user