Merge branch 'bottom-safe-type' into main

This commit is contained in:
2026-01-02 09:20:26 +01:00
14 changed files with 2163 additions and 708 deletions

View File

@ -29,7 +29,7 @@ DEFAULT_STRATEGY = {
# Unified Hedger Settings
"CHECK_INTERVAL": 1, # Loop speed for the hedger (seconds)
"LEVERAGE": 5, # Leverage to use on Hyperliquid
"LEVERAGE": 2, # 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
@ -74,6 +74,7 @@ CLP_PROFILES = {
"WRAPPED_NATIVE_ADDRESS": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
"POOL_FEE": 500,
"TARGET_INVESTMENT_AMOUNT": 1000,
"HEDGE_STRATEGY": "BOTTOM",
},
"UNISWAP_wide": {
"NAME": "Uniswap V3 (Arbitrum) - ETH/USDC Wide",

View File

@ -27,7 +27,7 @@ from eth_account import Account
from hyperliquid.exchange import Exchange
from hyperliquid.info import Info
from hyperliquid.utils import constants
from clp_config import CLP_PROFILES, DEFAULT_STRATEGY
from clp_config import CLP_PROFILES, DEFAULT_STRATEGY, TARGET_DEX
# Load environment variables
dotenv_path = os.path.join(current_dir, '.env')
@ -234,6 +234,18 @@ class HyperliquidStrategy:
adj_pct = max(-max_boost, min(max_boost, adj_pct))
raw_target_short = pool_delta
# --- BOTTOM STRATEGY LOGIC ---
if strategy_type == "BOTTOM":
if current_price > self.entry_price:
# Disable hedging in upper half
raw_target_short = Decimal("0")
adj_pct = Decimal("0")
else:
# Enable hedging in lower half (standard delta)
# No asymmetric boost applied
adj_pct = Decimal("0")
adjusted_target_short = raw_target_short * (Decimal("1.0") + adj_pct)
diff = adjusted_target_short - abs(current_short_size)
@ -289,6 +301,7 @@ class UnifiedHedger:
def _init_coin_configs(self):
"""Pre-load configuration for known coins from CLP_PROFILES."""
# 1. Load all profiles (order depends on dict iteration)
for profile_key, profile_data in CLP_PROFILES.items():
symbol = profile_data.get("COIN_SYMBOL")
if symbol:
@ -300,6 +313,18 @@ class UnifiedHedger:
# Update with Profile Specifics
self.coin_configs[symbol].update(profile_data)
# 2. Force overwrite with TARGET_DEX profile to ensure precedence
target_profile = CLP_PROFILES.get(TARGET_DEX)
if target_profile:
symbol = target_profile.get("COIN_SYMBOL")
if symbol:
if symbol not in self.coin_configs:
self.coin_configs[symbol] = DEFAULT_STRATEGY.copy()
self.coin_configs[symbol]["sz_decimals"] = 4
logger.info(f"Overwriting config for {symbol} using TARGET_DEX: {TARGET_DEX}")
self.coin_configs[symbol].update(target_profile)
def _get_sz_decimals(self, coin: str) -> int:
try:
meta = self.info.meta()
@ -791,10 +816,18 @@ class UnifiedHedger:
if not (is_buy_bool and data.get('is_at_bottom_edge', False)):
bypass_cooldown = True
# --- BOTTOM STRATEGY SAFEGUARDS ---
strategy_type = config.get("HEDGE_STRATEGY", "ASYMMETRIC")
if strategy_type == "BOTTOM":
# strict: "do not use taker orders... except only on very bottom"
if not data.get('is_at_bottom_edge', False):
bypass_cooldown = False
force_taker_retry = False # Disable taker retry from fishing
# --- ASYMMETRIC HEDGE CHECK ---
is_asymmetric_blocked = False
p_mid_asym = Decimal("0")
strategy_type = config.get("HEDGE_STRATEGY", "ASYMMETRIC")
# strategy_type already fetched above
if strategy_type == "ASYMMETRIC" and is_buy_bool and not bypass_cooldown:
total_L_asym = Decimal("0")

View File

@ -43,76 +43,29 @@ The system consists of three independent Python processes that coordinate via sh
| `telegram_monitor.py` | Telegram bot for notifications. |
| `{TARGET_DEX}_status.json` | **Critical:** Shared state file acting as the database between Manager and Hedger. |
| `.env` | Stores secrets (Private Keys, RPCs). **Do not commit.** |
| `tests/backtest/` | **New:** Professional Backtesting & Optimization Framework. |
| `tools/` | Utility scripts, including the Git Agent for auto-backups. |
| `logs/` | Detailed logs for all processes. |
## Configuration
## Backtesting Framework (Jan 2026 Update)
A robust simulation engine has been implemented to validate strategies before capital commitment.
### Environment Variables (`.env`)
Required variables for operation:
```env
# Blockchain
MAINNET_RPC_URL=... # Arbitrum
BNB_RPC_URL=... # BNB Chain
BASE_RPC_URL=... # Base
MAIN_WALLET_PRIVATE_KEY=...
MAIN_WALLET_ADDRESS=...
### Components
* **`tests/backtest/backtester.py`**: Event-driven engine mocking Web3/Hyperliquid interactions.
* **`tests/backtest/mocks.py`**: Stateful simulator handling balance tracking, V3 tick math, and fee accrual.
* **`tests/backtest/grid_search.py`**: Optimization runner to test parameter combinations (Range Width, Hedging Threshold).
* **`tests/backtest/analyze_results.py`**: Helper to interpret simulation CSV results.
# Hyperliquid
HEDGER_PRIVATE_KEY=... # Usually same as Main Wallet or specialized sub-account
# Telegram
TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHAT_ID=...
TELEGRAM_MONITOR_ENABLED=True
```
### Strategy Config (`clp_config.py`)
Key parameters controlling the bot's behavior:
* `TARGET_DEX`: Selects the active chain/profile (e.g., "UNISWAP_V3", "PANCAKESWAP_BNB").
* `RANGE_WIDTH_PCT`: Width of the LP position (e.g., `0.05` for +/- 5%).
* `TARGET_INVESTMENT_AMOUNT`: Notional size of the position in USD.
* `SLIPPAGE_TOLERANCE`: Max slippage for minting/swapping.
## Usage
The system is designed to run continuously. It is recommended to use a process manager like `pm2` or `systemd`, or simply run in separate terminal tabs.
1. **Start the Manager:**
```bash
python clp_manager.py
```
* *Action:* Will check for existing positions. If none, it prepares to open one based on config.
2. **Start the Hedger:**
```bash
python clp_hedger.py
```
* *Action:* Will read the position created by the Manager and open a corresponding short on Hyperliquid.
3. **Start Monitoring (Optional):**
```bash
python telegram_monitor.py
```
## Development & Git Agent
This project uses a custom **Git Agent** (`tools/git_agent.py`) for automated version control and backups.
* **Auto-Backup:** Runs hourly (if configured) to create backup branches (e.g., `backup-2025-01-01-12`).
* **Manual Commit:**
```bash
python tools/git_agent.py --backup
```
* **Status:**
```bash
python tools/git_agent.py --status
```
* **Restoration:**
To restore a file from a backup branch:
```bash
git checkout backup-BRANCH-NAME -- path/to/file.py
```
### Progress Status (Jan 1, 2026)
* **Completed:**
* Simulation loop runs end-to-end (Mint -> Accrue Fees -> Close).
* Fixed Mock Pricing logic (handling inverted T0/T1 pairs like USDT/WBNB).
* Implemented realistic Fee Accrual based on Trade Volume + Market Share.
* Verified "In Range" detection and position lifecycle.
* **Pending / Next Steps:**
* **Hedger PnL Verification:** Simulation showed 0.0 Hedging Fees because the price volatility in the 1-day sample was too low to trigger the 10% rebalance threshold. We are lowering thresholds to 1% to force activity and verify costs.
* **NAV Calculation:** Refine "Total PnL" to include Unrealized PnL from both LP and Hedge to handle Impermanent Loss correctly.
* **Final Optimization:** Run the `grid_search.py` with the corrected Market Share (0.02%) and lower thresholds to find the profitable "Sweet Spot".
## Logic Details
@ -134,4 +87,4 @@ Rebalancing is triggered when:
* **Logs:** Check `logs/clp_manager.log` and `logs/clp_hedger.log` first.
* **Stuck Position:** If a position is closed on-chain but `{TARGET_DEX}_status.json` says `OPEN`, manually edit the JSON status to `CLOSED` or delete the entry (with caution).
* **RPC Errors:** Ensure your RPC URLs in `.env` are active and have sufficient rate limits.
* **RPC Errors:** Ensure your RPC URLs in `.env` are active and have sufficient rate limits.

View File

@ -119,10 +119,6 @@
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767001797,
<<<<<<< HEAD
"timestamp_close": 1767175880,
"time_close": "31.12.25 11:11:20"
=======
"target_value_end": 1005.08,
"timestamp_close": 1767102435
},
@ -205,9 +201,9 @@
{
"type": "AUTOMATIC",
"token_id": 6164702,
"status": "OPEN",
"target_value": 981.88,
"entry_price": 846.4517,
"status": "CLOSED",
"target_value": 993.41,
"entry_price": 866.3337,
"amount0_initial": 490.942,
"amount1_initial": 0.58,
"liquidity": "8220443727732589279738",
@ -216,8 +212,317 @@
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767164052,
"hedge_TotPnL": -0.026171,
"hedge_fees_paid": 0.097756
>>>>>>> clp-optimalization
"hedge_TotPnL": -3.587319,
"hedge_fees_paid": 0.723066,
"clp_fees": 1.75,
"clp_TotPnL": 0.31,
"timestamp_close": 1767189814,
"time_close": "31.12.25 15:03:34"
},
{
"type": "AUTOMATIC",
"token_id": 6166625,
"status": "CLOSED",
"target_value": 996.7,
"entry_price": 873.896,
"amount0_initial": 496.6816,
"amount1_initial": 0.5722,
"liquidity": "8653989263919246133281",
"range_upper": 877.3107,
"range_lower": 870.4946,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767190229,
"time_open": "31.12.25 15:10:29",
"hedge_TotPnL": 4.004047,
"hedge_fees_paid": 0.807563,
"clp_fees": 0.34,
"clp_TotPnL": -3.96,
"timestamp_close": 1767191809,
"time_close": "31.12.25 15:36:49"
},
{
"type": "AUTOMATIC",
"token_id": 6166939,
"status": "CLOSED",
"target_value": 999.11,
"entry_price": 866.9331,
"amount0_initial": 500.0004,
"amount1_initial": 0.5757,
"liquidity": "8709690098157915483248",
"range_upper": 870.3205,
"range_lower": 863.5588,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767192966,
"time_open": "31.12.25 15:56:06",
"hedge_TotPnL": 1.064447,
"hedge_fees_paid": 0.927408,
"clp_fees": 0.3,
"clp_TotPnL": -2.71,
"timestamp_close": 1767193991,
"time_close": "31.12.25 16:13:11"
},
{
"type": "AUTOMATIC",
"token_id": 6167093,
"status": "CLOSED",
"target_value": 996.69,
"entry_price": 864.077,
"amount0_initial": 500.0128,
"amount1_initial": 0.5748,
"liquidity": "8702875143941291654654",
"range_upper": 867.4533,
"range_lower": 860.7139,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767194522,
"time_open": "31.12.25 16:22:02",
"hedge_TotPnL": -3.013382,
"hedge_fees_paid": 0.814047,
"clp_fees": 0.95,
"clp_TotPnL": -2.76,
"timestamp_close": 1767199352,
"time_close": "31.12.25 17:42:32"
},
{
"type": "AUTOMATIC",
"token_id": 6167590,
"status": "CLOSED",
"target_value": 998.37,
"entry_price": 861.6611,
"amount0_initial": 498.363,
"amount1_initial": 0.5803,
"liquidity": "8729751956580574272932",
"range_upper": 865.028,
"range_lower": 858.3074,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767200083,
"time_open": "31.12.25 17:54:43",
"hedge_TotPnL": -4.720271,
"hedge_fees_paid": 1.311938,
"clp_fees": 1.95,
"clp_TotPnL": 2.92,
"timestamp_close": 1767217535,
"time_close": "31.12.25 22:45:35"
},
{
"type": "AUTOMATIC",
"token_id": 6168553,
"status": "CLOSED",
"target_value": 991.55,
"entry_price": 865.4606,
"amount0_initial": 491.5385,
"amount1_initial": 0.5777,
"liquidity": "8651067937842123260294",
"range_upper": 868.8423,
"range_lower": 862.092,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767217854,
"time_open": "31.12.25 22:50:54",
"hedge_TotPnL": -3.016562,
"hedge_fees_paid": 0.460066,
"clp_fees": 0.58,
"clp_TotPnL": 1.55,
"timestamp_close": 1767229894,
"time_close": "01.01.26 02:11:34"
},
{
"type": "AUTOMATIC",
"token_id": 6169279,
"status": "CLOSED",
"target_value": 993.04,
"entry_price": 869.1899,
"amount0_initial": 493.031,
"amount1_initial": 0.5753,
"liquidity": "8645470844979366936741",
"range_upper": 872.5862,
"range_lower": 865.8068,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767230090,
"time_open": "01.01.26 02:14:50",
"hedge_TotPnL": -1.709208,
"hedge_fees_paid": 0.300063,
"clp_fees": 0.22,
"clp_TotPnL": 1.19,
"timestamp_close": 1767232654,
"time_close": "01.01.26 02:57:34"
},
{
"type": "AUTOMATIC",
"token_id": 6169469,
"status": "CLOSED",
"target_value": 996.5,
"entry_price": 873.4592,
"amount0_initial": 496.4932,
"amount1_initial": 0.5724,
"liquidity": "8654359631059929427298",
"range_upper": 876.8721,
"range_lower": 870.0595,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767233101,
"time_open": "01.01.26 03:05:01",
"hedge_TotPnL": 0.379026,
"hedge_fees_paid": 0.415621,
"clp_fees": 0.4,
"clp_TotPnL": -3.21,
"timestamp_close": 1767238291,
"time_close": "01.01.26 04:31:31"
},
{
"type": "AUTOMATIC",
"token_id": 6169789,
"status": "CLOSED",
"target_value": 996.05,
"entry_price": 869.103,
"amount0_initial": 500.0117,
"amount1_initial": 0.5707,
"liquidity": "8672126155624077647253",
"range_upper": 872.4989,
"range_lower": 865.7203,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767238369,
"time_open": "01.01.26 04:32:49",
"hedge_TotPnL": 3.954178,
"hedge_fees_paid": 0.765854,
"clp_fees": 0.21,
"clp_TotPnL": -2.7,
"timestamp_close": 1767242596,
"time_close": "01.01.26 05:43:16"
},
{
"type": "AUTOMATIC",
"token_id": 6170135,
"status": "CLOSED",
"target_value": 998.15,
"entry_price": 862.6094,
"amount0_initial": 500.001,
"amount1_initial": 0.5775,
"liquidity": "8723056935772169247603",
"range_upper": 865.98,
"range_lower": 859.252,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767243101,
"time_open": "01.01.26 05:51:41",
"hedge_TotPnL": 2.409614,
"hedge_fees_paid": 1.355821,
"clp_fees": 0.64,
"clp_TotPnL": -2.48,
"timestamp_close": 1767254432,
"time_close": "01.01.26 09:00:32"
},
{
"type": "AUTOMATIC",
"token_id": 6170841,
"status": "CLOSED",
"target_value": 998.62,
"entry_price": 859.8536,
"amount0_initial": 498.6144,
"amount1_initial": 0.5815,
"liquidity": "8741115554990437903852",
"range_upper": 863.2134,
"range_lower": 856.5069,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767254603,
"time_open": "01.01.26 09:03:23",
"hedge_TotPnL": -4.244326,
"hedge_fees_paid": 1.827099,
"clp_fees": 2.59,
"clp_TotPnL": 3.56,
"timestamp_close": 1767308203,
"time_close": "01.01.26 23:56:43"
},
{
"type": "AUTOMATIC",
"token_id": 6175190,
"status": "CLOSED",
"target_value": 998.54,
"entry_price": 863.8179,
"amount0_initial": 498.5396,
"amount1_initial": 0.5788,
"liquidity": "8720378230633469405596",
"range_upper": 867.1932,
"range_lower": 860.4557,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767308440,
"time_open": "02.01.26 00:00:40",
"hedge_TotPnL": 2.712563,
"hedge_fees_paid": 0.819224,
"clp_fees": 0.62,
"clp_TotPnL": -2.49,
"timestamp_close": 1767320335,
"time_close": "02.01.26 03:18:55"
},
{
"type": "AUTOMATIC",
"token_id": 6175868,
"status": "CLOSED",
"target_value": 991.53,
"entry_price": 860.2836,
"amount0_initial": 491.5252,
"amount1_initial": 0.5812,
"liquidity": "8676952736685102236300",
"range_upper": 863.6451,
"range_lower": 856.9352,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767320584,
"time_open": "02.01.26 03:23:04",
"hedge_TotPnL": -1.782405,
"hedge_fees_paid": 0.312615,
"clp_fees": 0.11,
"clp_TotPnL": 1.09,
"timestamp_close": 1767323453,
"time_close": "02.01.26 04:10:53"
},
{
"type": "AUTOMATIC",
"token_id": 6176051,
"status": "CLOSED",
"target_value": 997.7,
"entry_price": 863.7315,
"amount0_initial": 497.694,
"amount1_initial": 0.5789,
"liquidity": "8713457799891424871655",
"range_upper": 867.1064,
"range_lower": 860.3697,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767324323,
"time_open": "02.01.26 04:25:23",
"hedge_TotPnL": -3.840822,
"hedge_fees_paid": 0.892717,
"clp_fees": 0.65,
"clp_TotPnL": 1.63,
"timestamp_close": 1767335965,
"time_close": "02.01.26 07:39:25"
},
{
"type": "AUTOMATIC",
"token_id": 6176727,
"status": "OPEN",
"target_value": 990.64,
"entry_price": 867.5401,
"amount0_initial": 490.6325,
"amount1_initial": 0.5764,
"liquidity": "8632807640200638943476",
"range_upper": 870.9299,
"range_lower": 864.1634,
"token0_decimals": 18,
"token1_decimals": 18,
"timestamp_open": 1767336634,
"time_open": "02.01.26 07:50:34",
"hedge_TotPnL": 0.241595,
"hedge_fees_paid": 0.364602,
"clp_fees": 0.22,
"clp_TotPnL": -0.73
}
]

View File

@ -299,7 +299,9 @@
"token0_decimals": 18,
"token1_decimals": 6,
"timestamp_open": 1766968369,
"hedge_TotPnL": -5.078135,
"hedge_fees_paid": 2.029157
"hedge_TotPnL": -11.871298,
"hedge_fees_paid": 2.122534,
"clp_fees": 18.4,
"clp_TotPnL": 30.89
}
]

View File

@ -19,11 +19,13 @@ DEFAULT_STRATEGY = {
"VALUE_REFERENCE": "USD", # Base currency for all calculations
# Range Settings
"RANGE_WIDTH_PCT": Decimal("0.01"), # LP width (e.g. 0.05 = +/- 5% from current price)
"RANGE_WIDTH_PCT": Decimal("0.05"), # LP width (e.g. 0.05 = +/- 5% from current price)
"SLIPPAGE_TOLERANCE": Decimal("0.02"), # Max allowed slippage for swaps and minting
"TRANSACTION_TIMEOUT_SECONDS": 30, # Timeout for blockchain transactions
# Hedging Settings
"HEDGE_STRATEGY": "ASYMMETRIC", # Options: "STANDARD" (Full Range Hedge), "ASYMMETRIC" (Edge-Only Reduction)
# ude wide areas for ASYMETRIC "EDGE_CLEANUP_MARGIN_PCT": Decimal("0.1875"),
"MIN_HEDGE_THRESHOLD": Decimal("0.012"), # Minimum delta change (in coins) required to trigger a trade
# Unified Hedger Settings
@ -45,7 +47,7 @@ DEFAULT_STRATEGY = {
"POSITION_CLOSED_EDGE_PROXIMITY_PCT": Decimal("0.025"), # Safety margin for closing positions
"LARGE_HEDGE_MULTIPLIER": Decimal("5.0"), # Multiplier to bypass trade cooldown for big moves
"ENABLE_EDGE_CLEANUP": True, # Force rebalances when price is at range boundaries
"EDGE_CLEANUP_MARGIN_PCT": Decimal("0.02"), # % of range width used for edge detection
"EDGE_CLEANUP_MARGIN_PCT": Decimal("0.05"), # % 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)
@ -72,6 +74,9 @@ CLP_PROFILES = {
"TOKEN_B_ADDRESS": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", # USDC
"WRAPPED_NATIVE_ADDRESS": "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
"POOL_FEE": 500,
"RANGE_WIDTH_PCT": Decimal("0.05"),
"TARGET_INVESTMENT_AMOUNT": 1000,
"HEDGE_STRATEGY": "BOTTOM",
},
"UNISWAP_wide": {
"NAME": "Uniswap V3 (Arbitrum) - ETH/USDC Wide",
@ -98,6 +103,7 @@ CLP_PROFILES = {
"TOKEN_B_ADDRESS": "0x55d398326f99059fF775485246999027B3197955", # USDT
"WRAPPED_NATIVE_ADDRESS": "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c",
"POOL_FEE": 100,
"EDGE_CLEANUP_MARGIN_PCT": Decimal("0.1875"), # 0.1875 only for asymmetric shedge % of range width used for edge detection
"RANGE_WIDTH_PCT": Decimal("0.004"),
"TARGET_INVESTMENT_AMOUNT": 1000,
"MIN_HEDGE_THRESHOLD": Decimal("0.015"),

View File

@ -213,7 +213,7 @@ class HyperliquidStrategy:
else: # >=5% range
return Decimal("0.075") # Standard for wide ranges
def calculate_rebalance(self, current_price: Decimal, current_short_size: Decimal) -> Dict:
def calculate_rebalance(self, current_price: Decimal, current_short_size: Decimal, strategy_type: str = "ASYMMETRIC") -> Dict:
# Note: current_short_size here is virtual (just for this specific strategy),
# but the unified hedger will use the 'target_short' output primarily.
@ -221,15 +221,17 @@ class HyperliquidStrategy:
# --- ASYMMETRIC COMPENSATION ---
adj_pct = Decimal("0.0")
range_width = self.high_range - self.low_range
if range_width > 0:
dist = current_price - self.entry_price
half_width = range_width / Decimal("2")
norm_dist = dist / half_width
max_boost = self.get_compensation_boost()
adj_pct = -norm_dist * max_boost
adj_pct = max(-max_boost, min(max_boost, adj_pct))
if strategy_type == "ASYMMETRIC":
range_width = self.high_range - self.low_range
if range_width > 0:
dist = current_price - self.entry_price
half_width = range_width / Decimal("2")
norm_dist = dist / half_width
max_boost = self.get_compensation_boost()
adj_pct = -norm_dist * max_boost
adj_pct = max(-max_boost, min(max_boost, adj_pct))
raw_target_short = pool_delta
adjusted_target_short = raw_target_short * (Decimal("1.0") + adj_pct)
@ -275,6 +277,7 @@ class UnifiedHedger:
self.last_prices = {}
self.price_history = {} # Symbol -> List[Decimal]
self.last_trade_times = {} # Symbol -> timestamp
self.last_idle_log_times = {} # Symbol -> timestamp
# Shadow Orders (Global List)
self.shadow_orders = []
@ -581,6 +584,297 @@ class UnifiedHedger:
except Exception as e:
logger.error(f"Failed to update closed PnL/Fees for {coin}: {e}")
def run(self):
logger.info("Starting Unified Hedger Loop...")
self.update_coin_decimals()
# --- LOG SETTINGS ON START ---
logger.info("=== HEDGER CONFIGURATION ===")
for symbol, config in self.coin_configs.items():
logger.info(f"--- {symbol} ---")
for k, v in config.items():
logger.info(f" {k}: {v}")
logger.info("============================")
def run_tick(self):
"""
Executes one iteration of the hedger logic.
"""
# 1. API Backoff
if time.time() < self.api_backoff_until:
return
# 2. Update Strategies
if not self.scan_strategies():
logger.warning("Strategy scan failed (read error). Skipping execution tick.")
return
# 3. Fetch Market Data (Centralized)
try:
mids = self.info.all_mids()
user_state = self.info.user_state(self.vault_address or self.account.address)
open_orders = self.get_open_orders()
l2_snapshots = {} # Cache for snapshots
except Exception as e:
logger.error(f"API Error fetching data: {e}")
return
# Map Open Orders
orders_map = {}
for o in open_orders:
c = o['coin']
if c not in orders_map: orders_map[c] = []
orders_map[c].append(o)
# Parse User State
account_value = Decimal("0")
if "marginSummary" in user_state and "accountValue" in user_state["marginSummary"]:
account_value = to_decimal(user_state["marginSummary"]["accountValue"])
# Map current positions
current_positions = {} # Coin -> Size
current_pnls = {} # Coin -> Unrealized PnL
current_entry_pxs = {} # Coin -> Entry Price (NEW)
for pos in user_state["assetPositions"]:
c = pos["position"]["coin"]
s = to_decimal(pos["position"]["szi"])
u = to_decimal(pos["position"]["unrealizedPnl"])
e = to_decimal(pos["position"]["entryPx"])
current_positions[c] = s
current_pnls[c] = u
current_entry_pxs[c] = e
# 4. Aggregate Targets
# Coin -> { 'target_short': Decimal, 'contributors': int, 'is_at_edge': bool }
aggregates = {}
# First, update all prices from mids for active coins
for coin in self.active_coins:
if coin in mids:
price = to_decimal(mids[coin])
self.last_prices[coin] = price
# Update Price History
if coin not in self.price_history: self.price_history[coin] = []
self.price_history[coin].append(price)
if len(self.price_history[coin]) > 300: self.price_history[coin].pop(0)
for key, strat in self.strategies.items():
coin = self.strategy_states[key]['coin']
status = self.strategy_states[key].get('status', 'OPEN')
if coin not in self.last_prices: continue
price = self.last_prices[coin]
# Get Config & Strategy Type
config = self.coin_configs.get(coin, {})
strategy_type = config.get("HEDGE_STRATEGY", "ASYMMETRIC")
# Calc Logic
calc = strat.calculate_rebalance(price, Decimal("0"), strategy_type)
if coin not in aggregates:
aggregates[coin] = {'target_short': Decimal("0"), 'contributors': 0, 'is_at_edge': False, 'is_at_bottom_edge': False, 'adj_pct': Decimal("0"), 'is_closing': False}
if status == 'CLOSING':
# If Closing, we want target to be 0 for this strategy
logger.info(f"[STRAT] {key[1]} is CLOSING -> Force Target 0")
aggregates[coin]['is_closing'] = True
# Do not add to target_short
else:
aggregates[coin]['target_short'] += calc['target_short']
aggregates[coin]['contributors'] += 1
aggregates[coin]['adj_pct'] = calc['adj_pct']
# Check Edge Proximity for Cleanup
config = self.coin_configs.get(coin, {})
enable_cleanup = config.get("ENABLE_EDGE_CLEANUP", True)
cleanup_margin = config.get("EDGE_CLEANUP_MARGIN_PCT", Decimal("0.02"))
if enable_cleanup:
dist_bottom_pct = (price - strat.low_range) / strat.low_range
dist_top_pct = (strat.high_range - price) / strat.high_range
range_width_pct = (strat.high_range - strat.low_range) / strat.low_range
safety_margin_pct = range_width_pct * cleanup_margin
if dist_bottom_pct < safety_margin_pct or dist_top_pct < safety_margin_pct:
aggregates[coin]['is_at_edge'] = True
if dist_bottom_pct < safety_margin_pct:
aggregates[coin]['is_at_bottom_edge'] = True
# Check Shadow Orders (Pre-Execution)
self.check_shadow_orders(l2_snapshots)
# 5. Execute Per Coin
# Union of coins with Active Strategies OR Active Positions
coins_to_process = set(aggregates.keys())
for c, pos in current_positions.items():
if abs(pos) > 0: coins_to_process.add(c)
for coin in coins_to_process:
data = aggregates.get(coin, {'target_short': Decimal("0"), 'contributors': 0, 'is_at_edge': False, 'adj_pct': Decimal("0"), 'is_closing': False})
price = self.last_prices.get(coin, Decimal("0"))
if price == 0: continue
target_short_abs = data['target_short']
target_position = -target_short_abs
current_pos = current_positions.get(coin, Decimal("0"))
diff = target_position - current_pos
diff_abs = abs(diff)
# Thresholds
config = self.coin_configs.get(coin, {})
min_thresh = config.get("MIN_HEDGE_THRESHOLD", Decimal("0.008"))
vol_pct = self.calculate_volatility(coin)
base_vol = Decimal("0.0005")
vol_mult = max(Decimal("1.0"), min(Decimal("3.0"), vol_pct / base_vol)) if vol_pct > 0 else Decimal("1.0")
base_rebalance_pct = config.get("BASE_REBALANCE_THRESHOLD_PCT", Decimal("0.20"))
thresh_pct = min(Decimal("0.15"), base_rebalance_pct * vol_mult)
dynamic_thresh = max(min_thresh, abs(target_position) * thresh_pct)
if data['is_at_edge'] and config.get("ENABLE_EDGE_CLEANUP", True):
if dynamic_thresh > min_thresh: dynamic_thresh = min_thresh
action_needed = diff_abs > dynamic_thresh
is_buy_bool = diff > 0
side_str = "BUY" if is_buy_bool else "SELL"
# Manage Existing Orders
existing_orders = orders_map.get(coin, [])
force_taker_retry = False
order_matched = False
price_buffer_pct = config.get("PRICE_BUFFER_PCT", Decimal("0.0015"))
for o in existing_orders:
o_oid = o['oid']
o_price = to_decimal(o['limitPx'])
o_side = o['side']
o_timestamp = o.get('timestamp', int(time.time()*1000))
is_same_side = (o_side == 'B' and is_buy_bool) or (o_side == 'A' and not is_buy_bool)
order_age_sec = (int(time.time()*1000) - o_timestamp) / 1000.0
if is_same_side and order_age_sec > config.get("MAKER_ORDER_TIMEOUT", 300):
logger.info(f"[TIMEOUT] {coin} Order {o_oid} expired. Cancelling.")
self.cancel_order(coin, o_oid)
continue
if config.get("ENABLE_FISHING", False) and is_same_side and order_age_sec > config.get("FISHING_TIMEOUT_FALLBACK", 30):
logger.info(f"[FISHING] {coin} Order {o_oid} timed out. Retrying as Taker.")
self.cancel_order(coin, o_oid)
force_taker_retry = True
continue
if is_same_side and (abs(price - o_price) / price) < price_buffer_pct:
order_matched = True
if int(time.time()) % 15 == 0:
logger.info(f"[WAIT] {coin} Pending {side_str} @ {o_price} | Age: {order_age_sec:.1f}s")
break
else:
logger.info(f"Cancelling stale order {o_oid} ({o_side} @ {o_price})")
self.cancel_order(coin, o_oid)
# Determine Urgency / Bypass Cooldown
bypass_cooldown = False
force_maker = False
if not order_matched and (action_needed or force_taker_retry):
if force_taker_retry: bypass_cooldown = True
elif data.get('is_closing', False): bypass_cooldown = True
elif data.get('contributors', 0) == 0:
if time.time() - self.startup_time > 5: force_maker = True
else: continue # Skip startup ghost positions
large_hedge_mult = config.get("LARGE_HEDGE_MULTIPLIER", Decimal("5.0"))
if diff_abs > (dynamic_thresh * large_hedge_mult) and not force_maker and data.get('is_at_edge', False):
# Prevent IOC for BUYs at bottom edge
if not (is_buy_bool and data.get('is_at_bottom_edge', False)):
bypass_cooldown = True
# --- ASYMMETRIC HEDGE CHECK ---
is_asymmetric_blocked = False
p_mid_asym = Decimal("0")
strategy_type = config.get("HEDGE_STRATEGY", "ASYMMETRIC")
if strategy_type == "ASYMMETRIC" and is_buy_bool and not bypass_cooldown:
total_L_asym = Decimal("0")
for k_strat, strat_inst in self.strategies.items():
if self.strategy_states[k_strat]['coin'] == coin:
total_L_asym += strat_inst.L
gamma_asym = (Decimal("0.5") * total_L_asym * (price ** Decimal("-1.5")))
if gamma_asym > 0:
p_mid_asym = price - (diff_abs / gamma_asym)
if not data.get('is_at_edge', False) and price >= p_mid_asym:
is_asymmetric_blocked = True
# --- EXECUTION ---
if not order_matched and not is_asymmetric_blocked:
if action_needed or force_taker_retry:
last_trade = self.last_trade_times.get(coin, 0)
min_time = config.get("MIN_TIME_BETWEEN_TRADES", 60)
if bypass_cooldown or (time.time() - last_trade > min_time):
if coin not in l2_snapshots: l2_snapshots[coin] = self.info.l2_snapshot(coin)
levels = l2_snapshots[coin]['levels']
if levels[0] and levels[1]:
bid, ask = to_decimal(levels[0][0]['px']), to_decimal(levels[1][0]['px'])
if bypass_cooldown and not force_maker:
exec_price = ask * Decimal("1.001") if is_buy_bool else bid * Decimal("0.999")
order_type = "Ioc"
else:
exec_price = bid if is_buy_bool else ask
order_type = "Alo"
logger.info(f"[TRIG] {coin} {side_str} {diff_abs:.4f} | Cur: {current_pos:.4f} | Type: {order_type}")
oid = self.place_limit_order(coin, is_buy_bool, diff_abs, exec_price, order_type)
if oid:
self.last_trade_times[coin] = time.time()
if order_type == "Ioc":
shadow_price = bid if is_buy_bool else ask
self.shadow_orders.append({'coin': coin, 'side': side_str, 'price': shadow_price, 'expires_at': time.time() + config.get("SHADOW_ORDER_TIMEOUT", 600)})
logger.info("Sleeping 10s for position update...")
time.sleep(10)
self._update_closed_pnl(coin)
else:
# Idle Cleanup
if existing_orders and not order_matched:
for o in existing_orders: self.cancel_order(coin, o['oid'])
# --- THROTTLED STATUS LOGGING ---
now = time.time()
last_log = self.last_idle_log_times.get(coin, 0)
monitor_interval = config.get("MONITOR_INTERVAL_SECONDS", 60)
if now - last_log >= monitor_interval:
self.last_idle_log_times[coin] = now
if is_asymmetric_blocked:
logger.info(f"[ASYMMETRIC] Blocking BUY. Px ({price:.2f}) >= Eq ({p_mid_asym:.2f}) & Not Edge")
total_L_log = Decimal("0")
for k_strat, strat_inst in self.strategies.items():
if self.strategy_states[k_strat]['coin'] == coin:
total_L_log += strat_inst.L
if total_L_log > 0 and price > 0:
gamma_log = (Decimal("0.5") * total_L_log * (price ** Decimal("-1.5")))
if gamma_log > 0:
p_mid_log = price - (diff / gamma_log) # Corrected equilibrium formula
p_buy = price + (dynamic_thresh + diff) / gamma_log
p_sell = price - (dynamic_thresh - diff) / gamma_log
pad = " " if coin == "BNB" else ""
unrealized = current_pnls.get(coin, Decimal("0"))
closed_pnl = sum(s['hedge_TotPnL'] for s in self.strategy_states.values() if s['coin'] == coin)
fees = sum(s['fees'] for s in self.strategy_states.values() if s['coin'] == coin)
total_pnl = (closed_pnl - fees) + unrealized
logger.info(f"[IDLE] {coin} | Px: {price:.2f}{pad} | M: {p_mid_log:.1f}{pad} | B: {p_buy:.1f}{pad} / S: {p_sell:.1f}{pad} | delta: {target_position:.4f}({diff:+.4f}) | Adj: {data.get('adj_pct',0)*100:+.2f}%, Vol: {vol_mult:.2f}, Thr: {dynamic_thresh:.4f} | PnL: {unrealized:.2f} | TotPnL: {total_pnl:.2f}")
else:
logger.info(f"[IDLE] {coin} | Px: {price:.2f} | delta: {target_position:.4f} | Diff: {diff:.4f} (Thresh: {dynamic_thresh:.4f})")
else:
logger.info(f"[IDLE] {coin} | Px: {price:.2f} | delta: {target_position:.4f} | Diff: {diff:.4f}")
def run(self):
logger.info("Starting Unified Hedger Loop...")
self.update_coin_decimals()
@ -595,367 +889,7 @@ class UnifiedHedger:
while True:
try:
# 1. API Backoff
if time.time() < self.api_backoff_until:
time.sleep(1)
continue
# 2. Update Strategies
if not self.scan_strategies():
logger.warning("Strategy scan failed (read error). Skipping execution tick.")
time.sleep(1)
continue
# 3. Fetch Market Data (Centralized)
try:
mids = self.info.all_mids()
user_state = self.info.user_state(self.vault_address or self.account.address)
open_orders = self.get_open_orders()
l2_snapshots = {} # Cache for snapshots
except Exception as e:
logger.error(f"API Error fetching data: {e}")
time.sleep(1)
continue
# Map Open Orders
orders_map = {}
for o in open_orders:
c = o['coin']
if c not in orders_map: orders_map[c] = []
orders_map[c].append(o)
# Parse User State
account_value = Decimal("0")
if "marginSummary" in user_state and "accountValue" in user_state["marginSummary"]:
account_value = to_decimal(user_state["marginSummary"]["accountValue"])
# Map current positions
current_positions = {} # Coin -> Size
current_pnls = {} # Coin -> Unrealized PnL
current_entry_pxs = {} # Coin -> Entry Price (NEW)
for pos in user_state["assetPositions"]:
c = pos["position"]["coin"]
s = to_decimal(pos["position"]["szi"])
u = to_decimal(pos["position"]["unrealizedPnl"])
e = to_decimal(pos["position"]["entryPx"])
current_positions[c] = s
current_pnls[c] = u
current_entry_pxs[c] = e
# 4. Aggregate Targets
# Coin -> { 'target_short': Decimal, 'contributors': int, 'is_at_edge': bool }
aggregates = {}
# First, update all prices from mids for active coins
for coin in self.active_coins:
if coin in mids:
price = to_decimal(mids[coin])
self.last_prices[coin] = price
# Update Price History
if coin not in self.price_history: self.price_history[coin] = []
self.price_history[coin].append(price)
if len(self.price_history[coin]) > 300: self.price_history[coin].pop(0)
for key, strat in self.strategies.items():
coin = self.strategy_states[key]['coin']
status = self.strategy_states[key].get('status', 'OPEN')
if coin not in self.last_prices: continue
price = self.last_prices[coin]
# Calc Logic
calc = strat.calculate_rebalance(price, Decimal("0"))
if coin not in aggregates:
aggregates[coin] = {'target_short': Decimal("0"), 'contributors': 0, 'is_at_edge': False, 'adj_pct': Decimal("0"), 'is_closing': False}
if status == 'CLOSING':
# If Closing, we want target to be 0 for this strategy
logger.info(f"[STRAT] {key[1]} is CLOSING -> Force Target 0")
aggregates[coin]['is_closing'] = True
# Do not add to target_short
else:
aggregates[coin]['target_short'] += calc['target_short']
aggregates[coin]['contributors'] += 1
aggregates[coin]['adj_pct'] = calc['adj_pct']
# Check Edge Proximity for Cleanup
config = self.coin_configs.get(coin, {})
enable_cleanup = config.get("ENABLE_EDGE_CLEANUP", True)
cleanup_margin = config.get("EDGE_CLEANUP_MARGIN_PCT", Decimal("0.02"))
if enable_cleanup:
dist_bottom_pct = (price - strat.low_range) / strat.low_range
dist_top_pct = (strat.high_range - price) / strat.high_range
range_width_pct = (strat.high_range - strat.low_range) / strat.low_range
safety_margin_pct = range_width_pct * cleanup_margin
if dist_bottom_pct < safety_margin_pct or dist_top_pct < safety_margin_pct:
aggregates[coin]['is_at_edge'] = True
# Check Shadow Orders (Pre-Execution)
self.check_shadow_orders(l2_snapshots)
# 5. Execute Per Coin
# Union of coins with Active Strategies OR Active Positions
coins_to_process = set(aggregates.keys())
for c, pos in current_positions.items():
if abs(pos) > 0: coins_to_process.add(c)
for coin in coins_to_process:
data = aggregates.get(coin, {'target_short': Decimal("0"), 'contributors': 0, 'is_at_edge': False, 'adj_pct': Decimal("0"), 'is_closing': False})
price = self.last_prices.get(coin, Decimal("0")) # FIX: Explicitly get price for this coin
if price == 0: continue
target_short_abs = data['target_short'] # Always positive (it's a magnitude of short)
target_position = -target_short_abs # We want to be Short, so negative size
current_pos = current_positions.get(coin, Decimal("0"))
diff = target_position - current_pos # e.g. -1.0 - (-0.8) = -0.2 (Sell 0.2)
diff_abs = abs(diff)
# Thresholds
config = self.coin_configs.get(coin, {})
min_thresh = config.get("min_threshold", Decimal("0.008"))
# Volatility Multiplier
vol_pct = self.calculate_volatility(coin)
base_vol = Decimal("0.0005")
vol_mult = max(Decimal("1.0"), min(Decimal("3.0"), vol_pct / base_vol)) if vol_pct > 0 else Decimal("1.0")
base_rebalance_pct = config.get("BASE_REBALANCE_THRESHOLD_PCT", Decimal("0.20"))
thresh_pct = min(Decimal("0.15"), base_rebalance_pct * vol_mult)
dynamic_thresh = max(min_thresh, abs(target_position) * thresh_pct)
# FORCE EDGE CLEANUP
enable_edge_cleanup = config.get("ENABLE_EDGE_CLEANUP", True)
if data['is_at_edge'] and enable_edge_cleanup:
if dynamic_thresh > min_thresh:
# logger.info(f"[EDGE] {coin} forced to min threshold.")
dynamic_thresh = min_thresh
# 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 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
elif data.get('is_closing', False):
bypass_cooldown = True
logger.info(f"[URGENT] {coin} Closing Strategy -> Force Taker Exit")
# 2. Ghost/Cleanup -> Maker
elif data.get('contributors', 0) == 0:
if time.time() - self.startup_time > 5:
force_maker = True
logger.info(f"[CLEANUP] {coin} Ghost Position -> Force Maker Reduce")
else:
logger.info(f"[STARTUP] Skipping Ghost Cleanup for {coin} (Grace Period)")
continue # Skip execution for this coin
# Large Hedge Check (Only Force Taker if AT EDGE)
large_hedge_mult = config.get("LARGE_HEDGE_MULTIPLIER", Decimal("5.0"))
if diff_abs > (dynamic_thresh * large_hedge_mult) and not force_maker and data.get('is_at_edge', False):
bypass_cooldown = True
logger.info(f"[WARN] LARGE HEDGE (Edge Protection): {diff_abs:.4f} > {dynamic_thresh:.4f} (x{large_hedge_mult})")
elif diff_abs > (dynamic_thresh * large_hedge_mult) and not force_maker:
# Large hedge but safe zone -> Maker is fine, but maybe log it
logger.info(f"[INFO] Large Hedge (Safe Zone): {diff_abs:.4f}. Using Standard Execution.")
last_trade = self.last_trade_times.get(coin, 0)
min_time_trade = config.get("MIN_TIME_BETWEEN_TRADES", 60)
can_trade = False
if bypass_cooldown:
can_trade = True
elif time.time() - last_trade > min_time_trade:
can_trade = True
if can_trade:
# Get Orderbook for Price
if coin not in l2_snapshots:
l2_snapshots[coin] = self.info.l2_snapshot(coin)
levels = l2_snapshots[coin]['levels']
if not levels[0] or not levels[1]: continue
bid = to_decimal(levels[0][0]['px'])
ask = to_decimal(levels[1][0]['px'])
# Price logic
create_shadow = False
# Decide Order Type: Taker (Ioc) or Maker (Alo)
# Taker if: Urgent (bypass_cooldown) OR Fishing Disabled OR Force Maker is False (wait, Force Maker means Alo)
# Logic:
# If Force Maker -> Alo
# Else if Urgent -> Ioc
# Else if Enable Fishing -> Alo
# Else -> Alo (Default non-urgent behavior was Alo anyway?)
# Let's clarify:
# Previous logic: if bypass_cooldown -> Ioc. Else -> Alo.
# New logic:
# If bypass_cooldown -> Ioc
# Else -> Alo (Fishing)
if bypass_cooldown and not force_maker:
exec_price = ask * Decimal("1.001") if is_buy_bool else bid * Decimal("0.999")
order_type = "Ioc"
create_shadow = True
else:
# Fishing / Standard Maker
exec_price = bid if is_buy_bool else ask
order_type = "Alo"
logger.info(f"[TRIG] Net {coin}: {side_str} {diff_abs:.4f} | Tgt: {target_position:.4f} / Cur: {current_pos:.4f} | Thresh: {dynamic_thresh:.4f} | Type: {order_type}")
oid = self.place_limit_order(coin, is_buy_bool, diff_abs, exec_price, order_type)
if oid:
self.last_trade_times[coin] = time.time()
# Shadow Order
if create_shadow:
shadow_price = bid if is_buy_bool else ask
shadow_timeout = config.get("SHADOW_ORDER_TIMEOUT", 600)
self.shadow_orders.append({
'coin': coin,
'side': side_str,
'price': shadow_price,
'expires_at': time.time() + shadow_timeout
})
logger.info(f"[SHADOW] Created Maker {side_str} @ {shadow_price:.2f}")
# UPDATED: Sleep for API Lag (Phase 5.1)
logger.info("Sleeping 10s to allow position update...")
time.sleep(10)
# --- UPDATE CLOSED PnL FROM API ---
self._update_closed_pnl(coin)
else:
# Cooldown log
pass
else:
# Action NOT needed
# Cleanup any dangling orders
if existing_orders:
for o in existing_orders:
logger.info(f"Cancelling idle order {o['oid']} ({o['side']} @ {o['limitPx']})")
self.cancel_order(coin, o['oid'])
# --- IDLE LOGGING (Restored Format) ---
# Calculate aggregate Gamma to estimate triggers
# Gamma = 0.5 * Sum(L) * P^-1.5
# We need Sum(L) for this coin.
total_L = Decimal("0")
# We need to re-iterate or cache L.
# Simpler: Just re-sum L from active strats for this coin.
for key, strat in self.strategies.items():
if self.strategy_states[key]['coin'] == coin:
total_L += strat.L
if total_L > 0 and price > 0:
gamma = (Decimal("0.5") * total_L * (price ** Decimal("-1.5")))
if gamma > 0:
# Equilibrium Price (Diff = 0)
p_mid = price + (diff / gamma)
# Triggers
p_buy = price + (dynamic_thresh + diff) / gamma
p_sell = price - (dynamic_thresh - diff) / gamma
if int(time.time()) % 30 == 0:
pad = " " if coin == "BNB" else ""
adj_val = data.get('adj_pct', Decimal("0")) * 100
# PnL Calc
unrealized = current_pnls.get(coin, 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:
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 ""
logger.info(f"[IDLE] {coin} | Px: {price:.2f}{pad} | M: {p_mid:.1f}{pad} | B: {p_buy:.1f}{pad} / S: {p_sell:.1f}{pad} | delta: {target_position:.4f}({diff:+.4f}) | Adj: {adj_val:+.2f}%, Vol: {vol_mult:.2f}, Thr: {dynamic_thresh:.4f} | PnL: {unrealized:.2f}{pnl_pad} | TotPnL: {total_pnl:.2f}{tot_pnl_pad}")
else:
if int(time.time()) % 30 == 0:
logger.info(f"[IDLE] {coin} | Px: {price:.2f} | delta: {target_position:.4f} | Diff: {diff:.4f} (Thresh: {dynamic_thresh:.4f})")
else:
if int(time.time()) % 30 == 0:
logger.info(f"[IDLE] {coin} | Px: {price:.2f} | delta: {target_position:.4f} | Diff: {diff:.4f} (Thresh: {dynamic_thresh:.4f})")
self.run_tick()
time.sleep(DEFAULT_STRATEGY.get("CHECK_INTERVAL", 1))
except KeyboardInterrupt:

View File

@ -553,22 +553,28 @@ def mint_new_position(w3: Web3, npm_contract, account: LocalAccount, token0: str
minted_data = {'token_id': None, 'liquidity': 0, 'amount0': 0, 'amount1': 0}
for log in receipt.logs:
topics = [t.hex() for t in log['topics']]
# Robust topic hex conversion
topics = []
for t in log['topics']:
t_hex = t.hex() if hasattr(t, 'hex') else str(t)
if t_hex.startswith('0x'): t_hex = t_hex[2:]
topics.append(t_hex.lower())
target_transfer = transfer_topic[2:].lower() if transfer_topic.startswith('0x') else transfer_topic.lower()
target_increase = increase_liq_topic[2:].lower() if increase_liq_topic.startswith('0x') else increase_liq_topic.lower()
# Capture Token ID
if topics[0] == transfer_topic:
if topics[0] == target_transfer:
if "0000000000000000000000000000000000000000" in topics[1]:
minted_data['token_id'] = int(topics[3], 16)
# Capture Amounts
if topics[0] == increase_liq_topic:
if topics[0] == target_increase:
# decoding data: liquidity(uint128), amount0(uint256), amount1(uint256)
# data is a single hex string, we need to decode it
data = log['data'].hex()
data = log['data'].hex() if hasattr(log['data'], 'hex') else str(log['data'])
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)
@ -698,6 +704,291 @@ def update_position_status(token_id: int, status: str, extra_data: Dict = {}):
# --- MAIN LOOP ---
def main():
logger.info(f"🔷 {CONFIG['NAME']} Manager V2 Starting...")
load_dotenv(override=True)
# Dynamically load the RPC based on DEX Profile
rpc_url = os.environ.get(CONFIG["RPC_ENV_VAR"])
private_key = os.environ.get("MAIN_WALLET_PRIVATE_KEY") or os.environ.get("PRIVATE_KEY")
if not rpc_url or not private_key:
logger.error("❌ Missing RPC or Private Key in .env")
return
w3 = Web3(Web3.HTTPProvider(rpc_url))
if not w3.is_connected():
logger.error("❌ Could not connect to RPC")
return
# FIX: Inject POA middleware for BNB Chain/Polygon/etc. (Web3.py v6+)
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
account = Account.from_key(private_key)
logger.info(f"👤 Wallet: {account.address}")
# Contracts
npm = w3.eth.contract(address=clean_address(NONFUNGIBLE_POSITION_MANAGER_ADDRESS), abi=NONFUNGIBLE_POSITION_MANAGER_ABI)
factory_addr = npm.functions.factory().call()
factory = w3.eth.contract(address=factory_addr, abi=UNISWAP_V3_FACTORY_ABI)
router = w3.eth.contract(address=clean_address(UNISWAP_V3_SWAP_ROUTER_ADDRESS), abi=SWAP_ROUTER_ABI)
def run_tick(w3, account, npm, factory, router):
"""
Executes one iteration of the manager logic.
Returns suggested sleep time.
"""
status_data = load_status_data()
open_positions = [p for p in status_data if p.get('status') == 'OPEN']
active_auto_pos = next((p for p in open_positions if p.get('type') == 'AUTOMATIC'), None)
if active_auto_pos:
token_id = active_auto_pos['token_id']
pos_details, pool_c = get_position_details(w3, npm, factory, token_id)
if pos_details:
pool_data = get_pool_dynamic_data(pool_c)
current_tick = pool_data['tick']
# Check Range
tick_lower = pos_details['tickLower']
tick_upper = pos_details['tickUpper']
in_range = tick_lower <= current_tick < tick_upper
# Calculate Prices for logging
price_0_in_1 = price_from_tick(current_tick, pos_details['token0_decimals'], pos_details['token1_decimals'])
# --- SMART STABLE DETECTION ---
# Determine which token is the "Stable" side to anchor USD value
stable_symbols = ["USDC", "USDT", "DAI", "FDUSD", "USDS"]
is_t1_stable = any(s in pos_details['token1_symbol'].upper() for s in stable_symbols)
is_t0_stable = any(s in pos_details['token0_symbol'].upper() for s in stable_symbols)
if is_t1_stable:
# Standard: T0=Volatile, T1=Stable. Price = T1 per T0
current_price = price_0_in_1
lower_price = price_from_tick(tick_lower, pos_details['token0_decimals'], pos_details['token1_decimals'])
upper_price = price_from_tick(tick_upper, pos_details['token0_decimals'], pos_details['token1_decimals'])
elif is_t0_stable:
# Inverted: T0=Stable, T1=Volatile. Price = T0 per T1
# We want Price of T1 in terms of T0
current_price = Decimal("1") / price_0_in_1
lower_price = Decimal("1") / price_from_tick(tick_upper, pos_details['token0_decimals'], pos_details['token1_decimals'])
upper_price = Decimal("1") / price_from_tick(tick_lower, pos_details['token0_decimals'], pos_details['token1_decimals'])
else:
# Fallback to T1
current_price = price_0_in_1
lower_price = price_from_tick(tick_lower, pos_details['token0_decimals'], pos_details['token1_decimals'])
upper_price = price_from_tick(tick_upper, pos_details['token0_decimals'], pos_details['token1_decimals'])
# --- RANGE DISPLAY ---
# Calculate ranges from ticks for display purposes
real_range_lower = round(float(lower_price), 4)
real_range_upper = round(float(upper_price), 4)
status_msg = "✅ IN RANGE" if in_range else "⚠️ OUT OF RANGE"
# Calculate Unclaimed Fees (Simulation)
unclaimed0, unclaimed1, total_fees_usd = 0, 0, 0
try:
# Call collect with zero address to simulate fee estimation
fees_sim = npm.functions.collect((token_id, "0x0000000000000000000000000000000000000000", 2**128-1, 2**128-1)).call({'from': account.address})
u0 = to_decimal(fees_sim[0], pos_details['token0_decimals'])
u1 = to_decimal(fees_sim[1], pos_details['token1_decimals'])
if is_t1_stable:
total_fees_usd = (u0 * current_price) + u1
else:
total_fees_usd = u0 + (u1 * current_price)
except Exception as e:
logger.debug(f"Fee simulation failed for {token_id}: {e}")
# Calculate Total PnL (Fees + Price Appreciation/Depreciation)
# We need the initial investment value (target_value)
initial_value = Decimal(str(active_auto_pos.get('target_value', 0)))
curr_amt0_wei, curr_amt1_wei = get_amounts_for_liquidity(
pool_data['sqrtPriceX96'],
get_sqrt_ratio_at_tick(tick_lower),
get_sqrt_ratio_at_tick(tick_upper),
pos_details['liquidity']
)
curr_amt0 = Decimal(curr_amt0_wei) / Decimal(10**pos_details['token0_decimals'])
curr_amt1 = Decimal(curr_amt1_wei) / Decimal(10**pos_details['token1_decimals'])
if is_t1_stable:
current_pos_value_usd = (curr_amt0 * current_price) + curr_amt1
else:
current_pos_value_usd = curr_amt0 + (curr_amt1 * current_price)
pnl_unrealized = current_pos_value_usd - initial_value
total_pnl_usd = pnl_unrealized + total_fees_usd
# --- PERSIST PERFORMANCE TO JSON ---
update_position_status(token_id, "OPEN", {
"clp_fees": round(float(total_fees_usd), 2),
"clp_TotPnL": round(float(total_pnl_usd), 2)
})
pnl_text = f" | TotPnL: ${total_pnl_usd:.2f} (Fees: ${total_fees_usd:.2f})"
logger.info(f"Position {token_id}: {status_msg} | Price: {current_price:.4f} [{lower_price:.4f} - {upper_price:.4f}]{pnl_text}")
# --- KPI LOGGING ---
if log_kpi_snapshot:
snapshot = {
'initial_eth': active_auto_pos.get('amount0_initial', 0),
'initial_usdc': active_auto_pos.get('amount1_initial', 0),
'initial_hedge_usdc': INITIAL_HEDGE_CAPITAL_USDC,
'current_eth_price': float(current_price),
'uniswap_pos_value_usd': float(current_pos_value_usd),
'uniswap_fees_claimed_usd': 0.0, # Not tracked accumulated yet in JSON, using Unclaimed mainly
'uniswap_fees_unclaimed_usd': float(total_fees_usd),
# Hedge Data (from JSON updated by clp_hedger)
'hedge_equity_usd': float(active_auto_pos.get('hedge_equity_usd', 0.0)),
'hedge_pnl_realized_usd': active_auto_pos.get('hedge_pnl_realized', 0.0),
'hedge_fees_paid_usd': active_auto_pos.get('hedge_fees_paid', 0.0)
}
log_kpi_snapshot(snapshot)
if not in_range and CLOSE_POSITION_ENABLED:
logger.warning(f"🛑 Closing Position {token_id} (Out of Range)")
update_position_status(token_id, "CLOSING")
# 1. Remove Liquidity
if decrease_liquidity(w3, npm, account, token_id, pos_details['liquidity'], pos_details['token0_decimals'], pos_details['token1_decimals']):
# 2. Collect Fees
collect_fees(w3, npm, account, token_id)
update_position_status(token_id, "CLOSED")
# 3. Optional Rebalance (Sell 50% WETH if fell below)
if REBALANCE_ON_CLOSE_BELOW_RANGE and current_tick < tick_lower:
pass
elif OPEN_POSITION_ENABLED:
logger.info("🔍 No active position. Analyzing market (Fast scan: 37s)...")
# Setup logic for new position
tA = clean_address(WETH_ADDRESS)
tB = clean_address(USDC_ADDRESS)
if tA.lower() < tB.lower():
token0, token1 = tA, tB
else:
token0, token1 = tB, tA
fee = POOL_FEE
pool_addr = factory.functions.getPool(token0, token1, fee).call()
pool_c = w3.eth.contract(address=pool_addr, abi=UNISWAP_V3_POOL_ABI)
pool_data = get_pool_dynamic_data(pool_c)
if pool_data:
tick = pool_data['tick']
# Define Range (+/- 2.5%)
# log(1.025) / log(1.0001) approx 247 tick delta
tick_delta = int(math.log(1 + float(RANGE_WIDTH_PCT)) / math.log(1.0001))
# Fetch actual tick spacing from pool
tick_spacing = pool_c.functions.tickSpacing().call()
logger.info(f"📏 Tick Spacing: {tick_spacing}")
tick_lower = (tick - tick_delta) // tick_spacing * tick_spacing
tick_upper = (tick + tick_delta) // tick_spacing * tick_spacing
# Calculate Amounts
# Target Value logic
d0 = 18 # Default WETH (Corrected below if needed, but we rely on raw logic)
# Actually, we should fetch decimals from contract to be safe, but config assumes standard.
# Fetch Decimals for precision
t0_c = w3.eth.contract(address=token0, abi=ERC20_ABI)
t1_c = w3.eth.contract(address=token1, abi=ERC20_ABI)
d0 = t0_c.functions.decimals().call()
d1 = t1_c.functions.decimals().call()
# Determine Investment Value in Token1 terms
target_usd = Decimal(str(TARGET_INVESTMENT_VALUE_USDC))
# Check which is stable
t0_sym = t0_c.functions.symbol().call().upper()
t1_sym = t1_c.functions.symbol().call().upper()
stable_symbols = ["USDC", "USDT", "DAI", "FDUSD", "USDS"]
is_t1_stable = any(s in t1_sym for s in stable_symbols)
is_t0_stable = any(s in t0_sym for s in stable_symbols)
price_0_in_1 = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1)
investment_val_token1 = Decimal("0")
if str(TARGET_INVESTMENT_VALUE_USDC).upper() == "MAX":
# ... (Existing MAX logic needs update too, but skipping for brevity as user uses fixed amount)
pass
else:
if is_t1_stable:
# T1 is stable (e.g. ETH/USDC). Target 2000 USD = 2000 Token1.
investment_val_token1 = target_usd
elif is_t0_stable:
# T0 is stable (e.g. USDT/BNB). Target 2000 USD = 2000 Token0.
# We need value in Token1.
# Price 0 in 1 = (BNB per USDT) approx 0.0012
# Val T1 = Val T0 * Price(0 in 1)
investment_val_token1 = target_usd * price_0_in_1
logger.info(f"💱 Converted ${target_usd} -> {investment_val_token1:.4f} {t1_sym} (Price: {price_0_in_1:.6f})")
else:
# Fallback: Assume T1 is Stable (Dangerous but standard default)
logger.warning("⚠️ Could not detect Stable token. Assuming T1 is stable.")
investment_val_token1 = target_usd
amt0, amt1 = calculate_mint_amounts(tick, tick_lower, tick_upper, investment_val_token1, d0, d1, pool_data['sqrtPriceX96'])
if check_and_swap_for_deposit(w3, router, account, token0, token1, amt0, amt1, pool_data['sqrtPriceX96'], d0, d1):
minted = mint_new_position(w3, npm, account, token0, token1, amt0, amt1, tick_lower, tick_upper, d0, d1)
if minted:
# Calculate entry price from TICK to ensure consistency with Range
# (SqrtPrice can sometimes slightly diverge or have precision artifacts)
price_0_in_1 = price_from_tick(pool_data['tick'], d0, d1)
fmt_amt0 = float(Decimal(minted['amount0']) / Decimal(10**d0))
fmt_amt1 = float(Decimal(minted['amount1']) / Decimal(10**d1))
if is_t1_stable:
entry_price = float(price_0_in_1)
actual_value = (fmt_amt0 * entry_price) + fmt_amt1
r_upper = float(price_from_tick(minted['tick_upper'], d0, d1))
r_lower = float(price_from_tick(minted['tick_lower'], d0, d1))
else:
# Inverted (T0 is stable)
entry_price = float(Decimal("1") / price_0_in_1)
actual_value = fmt_amt0 + (fmt_amt1 * entry_price)
r_upper = float(Decimal("1") / price_from_tick(minted['tick_lower'], d0, d1))
r_lower = float(Decimal("1") / price_from_tick(minted['tick_upper'], d0, d1))
# Prepare ordered data with specific rounding
new_position_data = {
"type": "AUTOMATIC",
"target_value": round(float(actual_value), 2),
"entry_price": round(entry_price, 4),
"amount0_initial": round(fmt_amt0, 4),
"amount1_initial": round(fmt_amt1, 4),
"liquidity": str(minted['liquidity']),
"range_upper": round(r_upper, 4),
"range_lower": round(r_lower, 4),
"token0_decimals": d0,
"token1_decimals": d1,
"timestamp_open": int(time.time()),
"time_open": datetime.now().strftime("%d.%m.%y %H:%M:%S")
}
update_position_status(minted['token_id'], "OPEN", new_position_data)
# Dynamic Sleep: 37s if no position, else configured interval
sleep_time = MONITOR_INTERVAL_SECONDS if active_auto_pos else 37
return sleep_time
def main():
logger.info(f"🔷 {CONFIG['NAME']} Manager V2 Starting...")
load_dotenv(override=True)
@ -729,255 +1020,7 @@ def main():
while True:
try:
status_data = load_status_data()
open_positions = [p for p in status_data if p.get('status') == 'OPEN']
active_auto_pos = next((p for p in open_positions if p.get('type') == 'AUTOMATIC'), None)
if active_auto_pos:
token_id = active_auto_pos['token_id']
pos_details, pool_c = get_position_details(w3, npm, factory, token_id)
if pos_details:
pool_data = get_pool_dynamic_data(pool_c)
current_tick = pool_data['tick']
# Check Range
tick_lower = pos_details['tickLower']
tick_upper = pos_details['tickUpper']
in_range = tick_lower <= current_tick < tick_upper
# Calculate Prices for logging
price_0_in_1 = price_from_tick(current_tick, pos_details['token0_decimals'], pos_details['token1_decimals'])
# --- SMART STABLE DETECTION ---
# Determine which token is the "Stable" side to anchor USD value
stable_symbols = ["USDC", "USDT", "DAI", "FDUSD", "USDS"]
is_t1_stable = any(s in pos_details['token1_symbol'].upper() for s in stable_symbols)
is_t0_stable = any(s in pos_details['token0_symbol'].upper() for s in stable_symbols)
if is_t1_stable:
# Standard: T0=Volatile, T1=Stable. Price = T1 per T0
current_price = price_0_in_1
lower_price = price_from_tick(tick_lower, pos_details['token0_decimals'], pos_details['token1_decimals'])
upper_price = price_from_tick(tick_upper, pos_details['token0_decimals'], pos_details['token1_decimals'])
elif is_t0_stable:
# Inverted: T0=Stable, T1=Volatile. Price = T0 per T1
# We want Price of T1 in terms of T0
current_price = Decimal("1") / price_0_in_1
lower_price = Decimal("1") / price_from_tick(tick_upper, pos_details['token0_decimals'], pos_details['token1_decimals'])
upper_price = Decimal("1") / price_from_tick(tick_lower, pos_details['token0_decimals'], pos_details['token1_decimals'])
else:
# Fallback to T1
current_price = price_0_in_1
lower_price = price_from_tick(tick_lower, pos_details['token0_decimals'], pos_details['token1_decimals'])
upper_price = price_from_tick(tick_upper, pos_details['token0_decimals'], pos_details['token1_decimals'])
# --- RANGE DISPLAY ---
# Calculate ranges from ticks for display purposes
real_range_lower = round(float(lower_price), 4)
real_range_upper = round(float(upper_price), 4)
status_msg = "✅ IN RANGE" if in_range else "⚠️ OUT OF RANGE"
# Calculate Unclaimed Fees (Simulation)
unclaimed0, unclaimed1, total_fees_usd = 0, 0, 0
try:
# Call collect with zero address to simulate fee estimation
fees_sim = npm.functions.collect((token_id, "0x0000000000000000000000000000000000000000", 2**128-1, 2**128-1)).call({'from': account.address})
u0 = to_decimal(fees_sim[0], pos_details['token0_decimals'])
u1 = to_decimal(fees_sim[1], pos_details['token1_decimals'])
if is_t1_stable:
total_fees_usd = (u0 * current_price) + u1
else:
total_fees_usd = u0 + (u1 * current_price)
except Exception as e:
logger.debug(f"Fee simulation failed for {token_id}: {e}")
# Calculate Total PnL (Fees + Price Appreciation/Depreciation)
# We need the initial investment value (target_value)
initial_value = Decimal(str(active_auto_pos.get('target_value', 0)))
curr_amt0_wei, curr_amt1_wei = get_amounts_for_liquidity(
pool_data['sqrtPriceX96'],
get_sqrt_ratio_at_tick(tick_lower),
get_sqrt_ratio_at_tick(tick_upper),
pos_details['liquidity']
)
curr_amt0 = Decimal(curr_amt0_wei) / Decimal(10**pos_details['token0_decimals'])
curr_amt1 = Decimal(curr_amt1_wei) / Decimal(10**pos_details['token1_decimals'])
if is_t1_stable:
current_pos_value_usd = (curr_amt0 * current_price) + curr_amt1
else:
current_pos_value_usd = curr_amt0 + (curr_amt1 * current_price)
pnl_unrealized = current_pos_value_usd - initial_value
total_pnl_usd = pnl_unrealized + total_fees_usd
pnl_text = f" | TotPnL: ${total_pnl_usd:.2f} (Fees: ${total_fees_usd:.2f})"
logger.info(f"Position {token_id}: {status_msg} | Price: {current_price:.4f} [{lower_price:.4f} - {upper_price:.4f}]{pnl_text}")
# --- KPI LOGGING ---
if log_kpi_snapshot:
snapshot = {
'initial_eth': active_auto_pos.get('amount0_initial', 0),
'initial_usdc': active_auto_pos.get('amount1_initial', 0),
'initial_hedge_usdc': INITIAL_HEDGE_CAPITAL_USDC,
'current_eth_price': float(current_price),
'uniswap_pos_value_usd': float(current_pos_value_usd),
'uniswap_fees_claimed_usd': 0.0, # Not tracked accumulated yet in JSON, using Unclaimed mainly
'uniswap_fees_unclaimed_usd': float(total_fees_usd),
# Hedge Data (from JSON updated by clp_hedger)
'hedge_equity_usd': float(active_auto_pos.get('hedge_equity_usd', 0.0)),
'hedge_pnl_realized_usd': active_auto_pos.get('hedge_pnl_realized', 0.0),
'hedge_fees_paid_usd': active_auto_pos.get('hedge_fees_paid', 0.0)
}
# We use 'target_value' as a proxy for 'Initial Hedge Equity' + 'Initial Uni Val' if strictly tracking strategy?
# For now, let's pass what we have.
# To get 'hedge_equity', we ideally need clp_hedger to write it to JSON.
# Current implementation of kpi_tracker uses 'hedge_equity' in NAV.
# If we leave it 0, NAV will be underreported.
# WORKAROUND: Assume Hedge PnL Realized IS the equity change if we ignore margin.
log_kpi_snapshot(snapshot)
if not in_range and CLOSE_POSITION_ENABLED:
logger.warning(f"🛑 Closing Position {token_id} (Out of Range)")
update_position_status(token_id, "CLOSING")
# 1. Remove Liquidity
if decrease_liquidity(w3, npm, account, token_id, pos_details['liquidity'], pos_details['token0_decimals'], pos_details['token1_decimals']):
# 2. Collect Fees
collect_fees(w3, npm, account, token_id)
update_position_status(token_id, "CLOSED")
# 3. Optional Rebalance (Sell 50% WETH if fell below)
if REBALANCE_ON_CLOSE_BELOW_RANGE and current_tick < tick_lower:
# Simple rebalance logic here (similar to original check_and_swap surplus logic)
pass
elif OPEN_POSITION_ENABLED:
logger.info("🔍 No active position. Analyzing market (Fast scan: 37s)...")
# Setup logic for new position
tA = clean_address(WETH_ADDRESS)
tB = clean_address(USDC_ADDRESS)
if tA.lower() < tB.lower():
token0, token1 = tA, tB
else:
token0, token1 = tB, tA
fee = POOL_FEE
pool_addr = factory.functions.getPool(token0, token1, fee).call()
pool_c = w3.eth.contract(address=pool_addr, abi=UNISWAP_V3_POOL_ABI)
pool_data = get_pool_dynamic_data(pool_c)
if pool_data:
tick = pool_data['tick']
# Define Range (+/- 2.5%)
# log(1.025) / log(1.0001) approx 247 tick delta
tick_delta = int(math.log(1 + float(RANGE_WIDTH_PCT)) / math.log(1.0001))
# Fetch actual tick spacing from pool
tick_spacing = pool_c.functions.tickSpacing().call()
logger.info(f"📏 Tick Spacing: {tick_spacing}")
tick_lower = (tick - tick_delta) // tick_spacing * tick_spacing
tick_upper = (tick + tick_delta) // tick_spacing * tick_spacing
# Calculate Amounts
# Target Value logic
d0 = 18 # Default WETH (Corrected below if needed, but we rely on raw logic)
# Actually, we should fetch decimals from contract to be safe, but config assumes standard.
# Fetch Decimals for precision
t0_c = w3.eth.contract(address=token0, abi=ERC20_ABI)
t1_c = w3.eth.contract(address=token1, abi=ERC20_ABI)
d0 = t0_c.functions.decimals().call()
d1 = t1_c.functions.decimals().call()
# Determine Investment Value in Token1 terms
target_usd = Decimal(str(TARGET_INVESTMENT_VALUE_USDC))
# Check which is stable
t0_sym = t0_c.functions.symbol().call().upper()
t1_sym = t1_c.functions.symbol().call().upper()
stable_symbols = ["USDC", "USDT", "DAI", "FDUSD", "USDS"]
is_t1_stable = any(s in t1_sym for s in stable_symbols)
is_t0_stable = any(s in t0_sym for s in stable_symbols)
price_0_in_1 = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1)
investment_val_token1 = Decimal("0")
if str(TARGET_INVESTMENT_VALUE_USDC).upper() == "MAX":
# ... (Existing MAX logic needs update too, but skipping for brevity as user uses fixed amount)
pass
else:
if is_t1_stable:
# T1 is stable (e.g. ETH/USDC). Target 2000 USD = 2000 Token1.
investment_val_token1 = target_usd
elif is_t0_stable:
# T0 is stable (e.g. USDT/BNB). Target 2000 USD = 2000 Token0.
# We need value in Token1.
# Price 0 in 1 = (BNB per USDT) approx 0.0012
# Val T1 = Val T0 * Price(0 in 1)
investment_val_token1 = target_usd * price_0_in_1
logger.info(f"💱 Converted ${target_usd} -> {investment_val_token1:.4f} {t1_sym} (Price: {price_0_in_1:.6f})")
else:
# Fallback: Assume T1 is Stable (Dangerous but standard default)
logger.warning("⚠️ Could not detect Stable token. Assuming T1 is stable.")
investment_val_token1 = target_usd
amt0, amt1 = calculate_mint_amounts(tick, tick_lower, tick_upper, investment_val_token1, d0, d1, pool_data['sqrtPriceX96'])
if check_and_swap_for_deposit(w3, router, account, token0, token1, amt0, amt1, pool_data['sqrtPriceX96'], d0, d1):
minted = mint_new_position(w3, npm, account, token0, token1, amt0, amt1, tick_lower, tick_upper, d0, d1)
if minted:
# Calculate entry price and amounts for JSON compatibility
price_0_in_1 = price_from_sqrt_price_x96(pool_data['sqrtPriceX96'], d0, d1)
fmt_amt0 = float(Decimal(minted['amount0']) / Decimal(10**d0))
fmt_amt1 = float(Decimal(minted['amount1']) / Decimal(10**d1))
if is_t1_stable:
entry_price = float(price_0_in_1)
actual_value = (fmt_amt0 * entry_price) + fmt_amt1
r_upper = float(price_from_tick(minted['tick_upper'], d0, d1))
r_lower = float(price_from_tick(minted['tick_lower'], d0, d1))
else:
# Inverted (T0 is stable)
entry_price = float(Decimal("1") / price_0_in_1)
actual_value = fmt_amt0 + (fmt_amt1 * entry_price)
r_upper = float(Decimal("1") / price_from_tick(minted['tick_lower'], d0, d1))
r_lower = float(Decimal("1") / price_from_tick(minted['tick_upper'], d0, d1))
# Prepare ordered data with specific rounding
new_position_data = {
"type": "AUTOMATIC",
"target_value": round(float(actual_value), 2),
"entry_price": round(entry_price, 4),
"amount0_initial": round(fmt_amt0, 4),
"amount1_initial": round(fmt_amt1, 4),
"liquidity": str(minted['liquidity']),
"range_upper": round(r_upper, 4),
"range_lower": round(r_lower, 4),
"token0_decimals": d0,
"token1_decimals": d1,
"timestamp_open": int(time.time()),
"time_open": datetime.now().strftime("%d.%m.%y %H:%M:%S")
}
update_position_status(minted['token_id'], "OPEN", new_position_data)
# Dynamic Sleep: 37s if no position, else configured interval
sleep_time = MONITOR_INTERVAL_SECONDS if active_auto_pos else 37
sleep_time = run_tick(w3, account, npm, factory, router)
time.sleep(sleep_time)
except KeyboardInterrupt:

View File

@ -0,0 +1,49 @@
import csv
import sys
import os
def analyze():
current_dir = os.path.dirname(os.path.abspath(__file__))
results_file = os.path.join(current_dir, "optimization_results.csv")
if not os.path.exists(results_file):
print(f"File not found: {results_file}")
return
print(f"Analyzing {results_file}...")
best_pnl = -float('inf')
best_config = None
rows = []
with open(results_file, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
rows.append(row)
pnl = float(row['TOTAL_PNL'])
uni_fees = float(row['UNI_FEES'])
hl_pnl = float(row['HL_PNL'])
print(f"Config: Range={row['RANGE_WIDTH_PCT']}, Thresh={row['BASE_REBALANCE_THRESHOLD_PCT']} | PnL: ${pnl:.2f} (Fees: ${uni_fees:.2f}, Hedge: ${hl_pnl:.2f})")
if pnl > best_pnl:
best_pnl = pnl
best_config = row
print("\n" + "="*40)
print(f"🏆 BEST CONFIGURATION")
print("="*40)
if best_config:
print(f"Range Width: {float(best_config['RANGE_WIDTH_PCT'])*100:.2f}%")
print(f"Rebalance Thresh: {float(best_config['BASE_REBALANCE_THRESHOLD_PCT'])*100:.0f}%")
print(f"Total PnL: ${float(best_config['TOTAL_PNL']):.2f}")
print(f" > Uni Fees: ${float(best_config['UNI_FEES']):.2f}")
print(f" > Hedge PnL: ${float(best_config['HL_PNL']):.2f}")
else:
print("No valid results found.")
if __name__ == "__main__":
analyze()

View File

@ -0,0 +1,312 @@
import sys
import os
import csv
import time
import json
import logging
from decimal import Decimal
from unittest.mock import MagicMock, patch
# Add project root to path
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(os.path.dirname(current_dir))
sys.path.append(project_root)
from tests.backtest.mocks import MockExchangeState, MockWeb3, MockExchangeAPI, MockInfo, MockContract
# Setup Logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(message)s')
logger = logging.getLogger("BACKTESTER")
class Backtester:
def __init__(self, book_file, trades_file, config_overrides=None):
self.book_file = book_file
self.trades_file = trades_file
self.config_overrides = config_overrides or {}
self.events = []
self.state = MockExchangeState()
# Mocks
self.mock_web3 = MockWeb3(self.state)
self.mock_hl_api = MockExchangeAPI(self.state)
self.mock_hl_info = MockInfo(self.state)
# Components (Lazy loaded)
self.manager = None
self.hedger = None
def load_data(self):
logger.info("Loading Market Data...")
# Load Book
with open(self.book_file, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
self.events.append({
"type": "BOOK",
"ts": int(row['timestamp_ms']),
"data": row
})
# Load Trades
# (Optional: Trades are useful for market impact, but for basic PnL tracking
# based on mid-price, Book is sufficient. Loading trades just to advance time)
with open(self.trades_file, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
self.events.append({
"type": "TRADE",
"ts": int(row['timestamp_ms']),
"data": row
})
# Sort by Timestamp
self.events.sort(key=lambda x: x['ts'])
logger.info(f"Loaded {len(self.events)} events.")
def patch_and_init(self):
logger.info("Initializing Logic...")
# --- PATCH MANAGER ---
# We need to patch clp_manager.Web3 to return our MockWeb3
# And os.environ for config
with patch.dict(os.environ, {
"TARGET_DEX": "PANCAKESWAP_BNB", # Example
"MAIN_WALLET_PRIVATE_KEY": "0x0000000000000000000000000000000000000000000000000000000000000001",
"BNB_RPC_URL": "http://mock",
"HEDGER_PRIVATE_KEY": "0x0000000000000000000000000000000000000000000000000000000000000001",
"MAIN_WALLET_ADDRESS": "0xMyWallet"
}):
import clp_manager
import clp_hedger
# Apply Config Overrides
if self.config_overrides:
logger.info(f"Applying Config Overrides: {self.config_overrides}")
for k, v in self.config_overrides.items():
# Patch Manager
if hasattr(clp_manager, k):
setattr(clp_manager, k, v)
# Patch Hedger
if hasattr(clp_hedger, k):
setattr(clp_hedger, k, v)
# 1. Init Manager
# clp_manager.main() connects to Web3. We need to inject our mock.
# Since clp_manager creates w3 inside main(), we can't inject easily without patching Web3 class.
self.manager_module = clp_manager
self.hedger_module = clp_hedger
def run(self):
self.load_data()
self.patch_and_init()
# MOCK TIME
start_time = self.events[0]['ts'] / 1000.0
# STATUS FILE MOCK
self.status_memory = [] # List[Dict]
def mock_load_status():
logger.info(f"MOCK LOAD STATUS: Found {len(self.status_memory)} items")
return self.status_memory
def mock_save_status(data):
logger.info(f"MOCK SAVE STATUS: Saving {len(data)} items")
self.status_memory = data
def mock_hedger_scan():
return []
# We need to globally patch time.time and the Libraries
web3_class_mock = MagicMock(return_value=self.mock_web3)
web3_class_mock.to_wei = self.mock_web3.to_wei
web3_class_mock.from_wei = self.mock_web3.from_wei
web3_class_mock.is_address = self.mock_web3.is_address
web3_class_mock.to_checksum_address = lambda x: x
# Mock Web3.keccak to return correct topics
def mock_keccak(text=None, hexstr=None):
# Known Topics
if text == "Transfer(address,address,uint256)":
return bytes.fromhex("ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
if text == "IncreaseLiquidity(uint256,uint128,uint256,uint256)":
return bytes.fromhex("7a53080ba414158be7ec69b987b5fb7d07dee101fe85488f0853ae16239d0bde")
if text == "DecreaseLiquidity(uint256,uint128,uint256,uint256)":
# 0x26f6a048ee9138f2c0ce266f322cb99228e8d619ae2bff30c67f8dcf9d2377b4
return bytes.fromhex("26f6a048ee9138f2c0ce266f322cb99228e8d619ae2bff30c67f8dcf9d2377b4")
if text == "Collect(uint256,address,uint256,uint256)":
# 0x70935338e69775456a85ddef226c395fb668b63fa0115f5f206227278f746d4d
return bytes.fromhex("70935338e69775456a85ddef226c395fb668b63fa0115f5f206227278f746d4d")
return b'\x00'*32
web3_class_mock.keccak = MagicMock(side_effect=mock_keccak)
# Ensure environment is patched during the whole run
env_patch = {
"TARGET_DEX": "PANCAKESWAP_BNB",
"MAIN_WALLET_PRIVATE_KEY": "0x0000000000000000000000000000000000000000000000000000000000000001",
"BNB_RPC_URL": "http://mock",
"HEDGER_PRIVATE_KEY": "0x0000000000000000000000000000000000000000000000000000000000000001",
"MAIN_WALLET_ADDRESS": "0xMyWallet"
}
with patch.dict(os.environ, env_patch), \
patch('time.time', side_effect=lambda: self.state.current_time_ms / 1000.0), \
patch('clp_manager.Web3', web3_class_mock), \
patch('clp_hedger.Account.from_key', return_value=MagicMock(address="0xMyWallet")), \
patch('clp_hedger.Exchange', return_value=self.mock_hl_api), \
patch('clp_hedger.Info', return_value=self.mock_hl_info), \
patch('clp_manager.load_status_data', side_effect=mock_load_status), \
patch('clp_manager.save_status_data', side_effect=mock_save_status), \
patch('clp_manager.clean_address', side_effect=lambda x: x), \
patch('clp_hedger.glob.glob', return_value=[]):
# Initialize Hedger (It creates the classes in __init__)
self.hedger = self.hedger_module.UnifiedHedger()
# Initialize Manager Components manually (simulate main setup)
w3 = self.mock_web3
account = MagicMock(address="0xMyWallet")
npm = w3.eth.contract("0xNPM", [])
factory = w3.eth.contract("0xFactory", [])
router = w3.eth.contract("0xRouter", [])
# --- SIMULATION LOOP ---
last_manager_tick = 0
manager_interval = 60 * 1000
trade_count = len([e for e in self.events if e['type'] == "TRADE"])
book_count = len([e for e in self.events if e['type'] == "BOOK"])
logger.info(f"SIMULATION START: {len(self.events)} total events ({book_count} BOOK, {trade_count} TRADE)")
for event in self.events:
self.state.current_time_ms = event['ts']
# Update Market
if event['type'] == "BOOK":
row = event['data']
mid = Decimal(row['mid_price'])
self.state.update_price("BNB", mid)
if event['type'] == "TRADE":
self.state.process_trade(event['data'])
# Run Logic
# 1. Manager (Every X seconds)
if self.state.current_time_ms - last_manager_tick > manager_interval:
self.manager_module.run_tick(w3, account, npm, factory, router)
last_manager_tick = self.state.current_time_ms
# SYNC MANAGER STATUS TO HEDGER
for pos in self.status_memory:
if pos.get('status') == 'OPEN' and pos.get('type') == 'AUTOMATIC':
key = ("MOCK", pos['token_id'])
if key not in self.hedger.strategies:
self.hedger._init_single_strategy(key, pos, "BNB")
else:
self.hedger.strategy_states[key]['status'] = pos.get('status', 'OPEN')
elif pos.get('status') == 'CLOSED':
key = ("MOCK", pos['token_id'])
if key in self.hedger.strategies:
self.hedger.strategy_states[key]['status'] = 'CLOSED'
# 2. Hedger (Every Tick/Event)
self.hedger.run_tick()
# Finalize: Collect accrued fees from open positions
logger.info(f"Finalizing... Checking {len(self.state.uni_positions)} open positions.")
for token_id, pos in self.state.uni_positions.items():
raw_owed0 = pos.get('tokensOwed0', 0)
logger.info(f"DEBUG: Position {token_id} Raw TokensOwed0: {raw_owed0}")
owed0 = Decimal(raw_owed0) / Decimal(10**18)
owed1 = Decimal(pos.get('tokensOwed1', 0)) / Decimal(10**18)
# Convert to USD
price = self.state.prices.get("BNB", Decimal("0"))
# Fee0 is USDT (USD), Fee1 is WBNB
usd_val = owed0 + (owed1 * price)
if usd_val > 0:
self.state.uni_fees_collected += usd_val
logger.info(f"Finalizing Open Position {token_id}: Accrued Fees ${usd_val:.2f}")
logger.info("Backtest Complete.")
logger.info(f"Final Uni Fees: {self.state.uni_fees_collected}")
logger.info(f"Final HL PnL: {self.state.hl_realized_pnl - self.state.hl_fees_paid}")
def calculate_final_nav(self):
"""Calculates total Net Asset Value (USD) at the end of simulation."""
total_usd = Decimal("0")
# 1. Wallet Balances
# We assume T0=USDT, T1=WBNB for this profile
price = self.state.prices.get("BNB", Decimal("0"))
for sym, bal in self.state.wallet_balances.items():
if sym in ["USDC", "USDT"]:
total_usd += bal
elif sym in ["BNB", "WBNB", "NATIVE"]:
total_usd += bal * price
elif sym in ["ETH", "WETH"]:
# If ETH price available? We mocked update_price("BNB") only.
# Assuming ETH price static or 0 if not tracked
eth_price = self.state.prices.get("ETH", Decimal("0"))
total_usd += bal * eth_price
# 2. Uniswap Positions (Liquidity Value)
# Value = Amount0 * Price0 + Amount1 * Price1
# We need to calculate amounts from liquidity & current price
import math
# Helper to get amounts from liquidity
def get_amounts(liquidity, sqrt_price_x96, tick_lower, tick_upper):
# Simplified: Use the amounts we stored at mint time?
# No, that's initial. We need current value.
# But calculating precise amounts from liquidity/sqrtPrice requires complex math.
# For approximation, we can look at what the manager logged as "Deposited"
# if price hasn't moved much, or implement full liquidity math.
# Since implementing full math here is complex, let's use a simplified approach:
# If we are in range, we have a mix.
# If out of range, we have 100% of one token.
# Better: The Mock 'mint' stored initial amounts.
# We can adjust by price ratio? No, IL is non-linear.
# Let's use the 'decrease_liquidity' logic mock if available?
# Or just assume Liquidity Value = Initial Value + PnL (Fees) - IL.
# For this MVP, let's just count the Fees collected (Realized) + Initial Capital (Wallet).
# BUT we spent wallet funds to open LP.
# So Wallet is LOW. LP has Value.
# We MUST value the LP.
# Let's approximate:
# Value = Liquidity / (something) ...
# Actually, `clp_manager.py` calculates `actual_value` on entry.
# We can track `entry_value` in the position state.
return Decimal("0") # Placeholder if we can't calc easily
# 3. Hyperliquid Positions (Unrealized PnL + Margin)
hl_equity = self.state.hl_balances.get("USDC", 0) # Margin
for sym, pos in self.state.hl_positions.items():
hl_equity += pos['unrealized_pnl']
total_usd += hl_equity
return total_usd
if __name__ == "__main__":
# Example usage:
# python tests/backtest/backtester.py market_data/BNB_raw_20251230_book.csv market_data/BNB_raw_20251230_trades.csv
if len(sys.argv) < 3:
print("Usage: python backtester.py <book_csv> <trades_csv>")
else:
bt = Backtester(sys.argv[1], sys.argv[2])
bt.run()

View File

@ -0,0 +1,82 @@
import sys
import os
import csv
import itertools
from decimal import Decimal
# Add project root to path
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(os.path.dirname(current_dir))
sys.path.append(project_root)
from tests.backtest.backtester import Backtester
def main():
# Grid Parameters
# We want to optimize:
# 1. RANGE_WIDTH_PCT: How wide is the LP position? (e.g. 0.01 = +/-1%, 0.05 = +/-5%)
# 2. BASE_REBALANCE_THRESHOLD_PCT: When do we hedge? (e.g. 0.10 = 10% delta drift, 0.20 = 20%)
param_grid = {
"RANGE_WIDTH_PCT": [0.005, 0.01, 0.025, 0.05],
"BASE_REBALANCE_THRESHOLD_PCT": [0.01, 0.05]
}
keys, values = zip(*param_grid.items())
combinations = [dict(zip(keys, v)) for v in itertools.product(*values)]
results = []
book_file = os.path.join(project_root, "market_data", "BNB_raw_20251230_book.csv")
trades_file = os.path.join(project_root, "market_data", "BNB_raw_20251230_trades.csv")
print(f"Starting Grid Search with {len(combinations)} combinations...")
for idx, config in enumerate(combinations):
print(f"\n--- Run {idx+1}/{len(combinations)}: {config} ---")
# Initialize Backtester with overrides
bt = Backtester(book_file, trades_file, config_overrides=config)
try:
bt.run()
# Collect Metrics
uni_fees = bt.state.uni_fees_collected
hl_realized = bt.state.hl_realized_pnl - bt.state.hl_fees_paid
# HL Unrealized
hl_unrealized = sum(p['unrealized_pnl'] for p in bt.state.hl_positions.values())
# Total PnL (Yield + Hedge Result) - Ignoring IL for now (Mock limitation)
total_pnl = uni_fees + hl_realized + hl_unrealized
result = {
**config,
"UNI_FEES": float(uni_fees),
"HL_REALIZED": float(hl_realized),
"HL_UNREALIZED": float(hl_unrealized),
"TOTAL_PNL": float(total_pnl)
}
results.append(result)
print(f"Result: {result}")
except Exception as e:
print(f"Run failed: {e}")
import traceback
traceback.print_exc()
# Save Results
out_file = os.path.join(current_dir, "optimization_results.csv")
keys = list(combinations[0].keys()) + ["UNI_FEES", "HL_REALIZED", "HL_UNREALIZED", "TOTAL_PNL"]
with open(out_file, 'w', newline='') as f:
writer = csv.DictWriter(f, fieldnames=keys)
writer.writeheader()
writer.writerows(results)
print(f"\nGrid Search Complete. Results saved to {out_file}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,607 @@
import time
import logging
from decimal import Decimal
from typing import Dict, List, Any, Optional
from hexbytes import HexBytes
logger = logging.getLogger("BACKTEST_MOCK")
class MockExchangeState:
"""
Central source of truth for the simulation.
Acts as the "Blockchain" and the "CEX Engine".
"""
def __init__(self):
self.current_time_ms = 0
self.prices = {} # symbol -> price (Decimal)
self.ticks = {} # symbol -> current tick (int)
# Balances
self.wallet_balances = {
"NATIVE": Decimal("100.0"), # ETH or BNB
"ETH": Decimal("100.0"),
"USDC": Decimal("100000.0"),
"WETH": Decimal("100.0"),
"BNB": Decimal("100.0"),
"WBNB": Decimal("100.0"),
"USDT": Decimal("100000.0")
}
self.hl_balances = {"USDC": Decimal("10000.0"), "USDT": Decimal("10000.0")}
self.hl_positions = {} # symbol -> {size, entry_px, unrealized_pnl}
# Uniswap Positions
self.next_token_id = 1000
self.uni_positions = {} # token_id -> {liquidity, tickLower, tickUpper, ...}
# Hyperliquid Orders
self.hl_orders = [] # List of {oid, coin, side, limitPx, sz, timestamp}
self.next_oid = 1
# Fees/PnL Tracking
self.uni_fees_collected = Decimal("0.0")
self.hl_fees_paid = Decimal("0.0")
self.hl_realized_pnl = Decimal("0.0")
# Pending TXs (Simulating Mempool/Execution)
self.pending_txs = []
def update_price(self, symbol: str, price: Decimal, tick: int = 0):
self.prices[symbol] = price
if tick:
self.ticks[symbol] = tick
# Update PnL for open positions
if symbol in self.hl_positions:
pos = self.hl_positions[symbol]
size = pos['size']
if size != 0:
# Long: (Price - Entry) * Size
# Short: (Entry - Price) * abs(Size)
if size > 0:
pos['unrealized_pnl'] = (price - pos['entry_px']) * size
else:
pos['unrealized_pnl'] = (pos['entry_px'] - price) * abs(size)
def process_trade(self, trade_data):
"""Simulate fee accumulation from market trades."""
# trade_data: {price, size, ...} from CSV
try:
# DEBUG: Confirm entry
if getattr(self, '_debug_trade_entry', False) is False:
logger.info(f"DEBUG: Processing Trades... Positions: {len(self.uni_positions)}")
self._debug_trade_entry = True
price = Decimal(trade_data['price'])
size = Decimal(trade_data['size']) # Amount in Base Token (BNB)
# Simple Fee Logic:
# If trade price is within a position's range, it earns fees.
# Fee = Volume * 0.05% (Fee Tier) * MarketShare (Assume 10%)
fee_tier = Decimal("0.0005") # 0.05%
# Realistic Market Share Simulation
# Assume Pool Depth in active ticks is $5,000,000
# Our Position is approx $1,000
# Share = 1,000 / 5,000,000 = 0.0002 (0.02%)
market_share = Decimal("0.0002")
import math
# Current Tick of the trade
try:
# price = 1.0001^tick -> tick = log(price) / log(1.0001)
# Note: If T0=USDT, Price T0/T1 = 1/Price_USD.
# But our TickLower/Upper in Mock are generated based on T0=USDT logic?
# clp_manager calculates ticks based on price_from_tick logic.
# If T0=USDT, T1=WBNB. Price (T0/T1) ~ 0.00116.
# Ticks will be negative.
# Trade Price is 860.
# We need to invert price to get tick if the pool is inverted.
# For BNB tests, we know T0=USDT.
# Invert price for tick calc
inv_price = Decimal("1") / price
tick = int(math.log(float(inv_price)) / math.log(1.0001))
except:
tick = 0
# Iterate all OPEN Uniswap positions
for token_id, pos in self.uni_positions.items():
# Check Range
if pos['tickLower'] <= tick <= pos['tickUpper']:
vol_usd = price * size
fee_earned = vol_usd * fee_tier * market_share
pos['tokensOwed0'] = pos.get('tokensOwed0', 0) + int(fee_earned * 10**18)
# Debug logging (Disabled for production runs)
# if getattr(self, '_debug_fee_log_count', 0) < 10:
# logger.info(f"DEBUG: Fee Earned! Tick {tick} inside {pos['tickLower']} <-> {pos['tickUpper']}. Fee: {fee_earned}")
# self._debug_fee_log_count = getattr(self, '_debug_fee_log_count', 0) + 1
else:
# Debug logging (Disabled)
# if getattr(self, '_debug_tick_log_count', 0) < 10:
# logger.info(f"DEBUG: Trade Tick {tick} OUTSIDE {pos['tickLower']} <-> {pos['tickUpper']} (Price: {price})")
# self._debug_tick_log_count = getattr(self, '_debug_tick_log_count', 0) + 1
pass
except Exception as e:
logger.error(f"Error processing trade: {e}")
def process_transaction(self, tx_data):
"""Executes a pending transaction and updates state."""
func = tx_data['func']
args = tx_data['args']
value = tx_data.get('value', 0)
contract_addr = tx_data['contract']
logger.info(f"PROCESSING TX: {func} on {contract_addr} Val: {value}")
# 1. DEPOSIT (Wrap)
if func == "deposit":
# Wrap Native -> Wrapped
# Assume contract_addr is the wrapped token
# In mocks, we map address to symbol
# But we don't have the instance here easily, so we guess.
# If value > 0, it's a wrap.
amount = Decimal(value) / Decimal(10**18)
if self.wallet_balances.get("NATIVE", 0) >= amount:
self.wallet_balances["NATIVE"] -= amount
# Find which token this is.
# If it's the WETH/WBNB address
target_token = "WBNB" # Default assumption for this test profile
if contract_addr == "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1": target_token = "WETH"
self.wallet_balances[target_token] = self.wallet_balances.get(target_token, 0) + amount
logger.info(f"Wrapped {amount} NATIVE to {target_token}")
else:
logger.error("Insufficient NATIVE balance for wrap")
# 2. MINT (New Position)
elif func == "mint":
# Params: (token0, token1, fee, tickLower, tickUpper, amount0Desired, amount1Desired, ...)
params = args[0]
token0 = params[0]
token1 = params[1]
amount0 = Decimal(params[5]) / Decimal(10**18) # Approx decimals
amount1 = Decimal(params[6]) / Decimal(10**18)
logger.info(f"Minting Position: {amount0} T0, {amount1} T1")
# Deduct Balances (Simplified: assuming we have enough)
# In real mock we should check symbols
self.wallet_balances["WBNB"] = max(0, self.wallet_balances.get("WBNB", 0) - amount0)
self.wallet_balances["USDT"] = max(0, self.wallet_balances.get("USDT", 0) - amount1)
# Create Position
token_id = self.next_token_id
self.next_token_id += 1
self.uni_positions[token_id] = {
"token0": token0,
"token1": token1,
"tickLower": params[3],
"tickUpper": params[4],
"liquidity": 1000000 # Dummy liquidity
}
logger.info(f"Minted TokenID: {token_id}")
self.last_minted_token_id = token_id
return token_id # Helper return
# 3. COLLECT (Fees)
elif func == "collect":
# Params: (params) -> (tokenId, recipient, amount0Max, amount1Max)
params = args[0]
token_id = params[0]
# Retrieve accumulated fees
if token_id in self.uni_positions:
pos = self.uni_positions[token_id]
owed1 = Decimal(pos.get('tokensOwed1', 0)) / Decimal(10**18)
owed0 = Decimal(pos.get('tokensOwed0', 0)) / Decimal(10**18)
# Reset
pos['tokensOwed1'] = 0
pos['tokensOwed0'] = 0
fee0 = owed0
fee1 = owed1
else:
fee0 = Decimal("0")
fee1 = Decimal("0")
self.wallet_balances["WBNB"] = self.wallet_balances.get("WBNB", 0) + fee0
self.wallet_balances["USDT"] = self.wallet_balances.get("USDT", 0) + fee1
# Calculate USD Value of fees
# T0 = USDT, T1 = WBNB
# fee0 is USDT, fee1 is WBNB
price = self.state.prices.get("BNB", Decimal("0"))
usd_val = fee0 + (fee1 * price)
self.uni_fees_collected += usd_val
logger.info(f"Collected Fees for {token_id}: {fee0:.4f} T0 + {fee1:.4f} T1 = ${usd_val:.2f}")
# 4. SWAP (ExactInputSingle)
elif func == "exactInputSingle":
# Params: (params) -> struct
# struct ExactInputSingleParams {
# address tokenIn; address tokenOut; fee; recipient; deadline; amountIn; amountOutMinimum; sqrtPriceLimitX96;
# }
# Since args[0] is the struct (tuple/list)
# We need to guess indices or check ABI.
# Standard: tokenIn(0), tokenOut(1), fee(2), recipient(3), deadline(4), amountIn(5), minOut(6)
params = args[0]
token_in_addr = params[0]
token_out_addr = params[1]
amount_in_wei = params[5]
# Map address to symbol
# We can't access contract instance here easily, so use known map or iterate
sym_map = {
"0x82aF49447D8a07e3bd95BD0d56f35241523fBab1": "WETH",
"0xaf88d065e77c8cC2239327C5EDb3A432268e5831": "USDC",
"0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c": "WBNB",
"0x55d398326f99059fF775485246999027B3197955": "USDT"
}
sym_in = sym_map.get(token_in_addr, "UNKNOWN")
sym_out = sym_map.get(token_out_addr, "UNKNOWN")
amount_in = Decimal(amount_in_wei) / Decimal(10**18) # Approx
if sym_in in ["USDC", "USDT"]: amount_in = Decimal(amount_in_wei) / Decimal(10**18) # Mock usually 18 dec for simplicity unless specified
# Price calculation
# If swapping Base (WBNB) -> Quote (USDT), Price is ~300
# If Quote -> Base, Price is 1/300
price = self.prices.get("BNB", Decimal("300"))
amount_out = 0
if sym_in == "WBNB" and sym_out == "USDT":
amount_out = amount_in * price
elif sym_in == "USDT" and sym_out == "WBNB":
amount_out = amount_in / price
else:
amount_out = amount_in # 1:1 fallback
self.wallet_balances[sym_in] = max(0, self.wallet_balances.get(sym_in, 0) - amount_in)
self.wallet_balances[sym_out] = self.wallet_balances.get(sym_out, 0) + amount_out
logger.info(f"SWAP: {amount_in:.4f} {sym_in} -> {amount_out:.4f} {sym_out}")
def match_orders(self):
"""Simple order matching against current price."""
# In a real backtest, we'd check High/Low of the candle or Orderbook depth.
# Here we assume perfect liquidity at current price for simplicity,
# or implement simple slippage.
pass
# --- WEB3 MOCKS ---
class MockContractFunction:
def __init__(self, name, parent_contract, state: MockExchangeState):
self.name = name
self.contract = parent_contract
self.state = state
self.args = []
def __call__(self, *args, **kwargs):
self.args = args
return self
def call(self, transaction=None):
# SIMULATE READS
if self.name == "slot0":
# Determine Pair
symbol = "BNB" if "BNB" in self.state.prices else "ETH"
price = self.state.prices.get(symbol, Decimal("300"))
# For BNB Chain, T0 is USDT (0x55d), T1 is WBNB (0xbb4)
# Price of T0 (USDT) in T1 (WBNB) is 1 / Price
if symbol == "BNB":
if price > 0:
price = Decimal("1") / price
else:
price = Decimal("0")
sqrt_px = price.sqrt() * (2**96)
# Tick
import math
try:
# price = 1.0001^tick
tick = int(math.log(float(price)) / math.log(1.0001))
except:
tick = 0
return (int(sqrt_px), tick, 0, 0, 0, 0, True)
if self.name == "positions":
token_id = self.args[0]
if token_id in self.state.uni_positions:
p = self.state.uni_positions[token_id]
return (0, "", p['token0'], p['token1'], 500, p['tickLower'], p['tickUpper'], p['liquidity'],
0, 0, p.get('tokensOwed0', 0), p.get('tokensOwed1', 0))
else:
raise Exception("Position not found")
if self.name == "balanceOf":
addr = self.args[0]
# Hacky: detect token by contract address
symbol = self.contract.symbol_map.get(self.contract.address, "UNKNOWN")
return int(self.state.wallet_balances.get(symbol, 0) * (10**self.contract.decimals_val))
if self.name == "decimals":
return self.contract.decimals_val
if self.name == "symbol":
return self.contract.symbol_val
if self.name == "allowance":
return 10**50 # Infinite allowance
# Pool Methods
if self.name == "tickSpacing":
return 10
if self.name == "token0":
# Return USDT for BNB profile (0x55d < 0xbb4)
if "BNB" in self.state.prices:
return "0x55d398326f99059fF775485246999027B3197955"
return "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1" # WETH
if self.name == "token1":
# Return WBNB for BNB profile
if "BNB" in self.state.prices:
return "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c"
return "0xaf88d065e77c8cC2239327C5EDb3A432268e5831" # USDC
if self.name == "fee":
return 500
if self.name == "liquidity":
return 1000000000000000000
return None
def build_transaction(self, tx_params):
# Queue Transaction for Execution
self.state.pending_txs.append({
"func": self.name,
"args": self.args,
"contract": self.contract.address,
"value": tx_params.get("value", 0)
})
return {"data": "0xMOCK", "to": self.contract.address, "value": tx_params.get("value", 0)}
def estimate_gas(self, tx_params):
return 100000
class MockContract:
def __init__(self, address, abi, state: MockExchangeState):
self.address = address
self.abi = abi
self.state = state
self.functions = self
# Meta for simulation
self.symbol_map = {
"0x82aF49447D8a07e3bd95BD0d56f35241523fBab1": "WETH",
"0xaf88d065e77c8cC2239327C5EDb3A432268e5831": "USDC",
# BNB Chain
"0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c": "WBNB", # FIXED: Was BNB, but address is WBNB
"0x55d398326f99059fF775485246999027B3197955": "USDT"
}
symbol = self.symbol_map.get(address, "MOCK")
is_stable = symbol in ["USDC", "USDT"]
self.decimals_val = 18 if not is_stable else 6
if symbol == "USDT": self.decimals_val = 18 # BNB USDT is 18
self.symbol_val = symbol
def __getattr__(self, name):
return MockContractFunction(name, self, self.state)
class MockEth:
def __init__(self, state: MockExchangeState):
self.state = state
self.chain_id = 42161
self.max_priority_fee = 100000000
def contract(self, address, abi):
return MockContract(address, abi, self.state)
def get_block(self, block_identifier):
return {'baseFeePerGas': 100000000, 'timestamp': self.state.current_time_ms // 1000}
def get_balance(self, address):
# Native balance
return int(self.state.wallet_balances.get("NATIVE", 0) * 10**18)
def get_transaction_count(self, account, block_identifier=None):
return 1
def send_raw_transaction(self, raw_tx):
# EXECUTE PENDING TX
if self.state.pending_txs:
tx_data = self.state.pending_txs.pop(0)
res = self.state.process_transaction(tx_data)
return b'\x00' * 32
def wait_for_transaction_receipt(self, tx_hash, timeout=120):
# MOCK LOGS GENERATION
# We assume every tx is a successful Mint for now to test the flow
# In a real engine we'd inspect the tx data to determine the event
# Transfer Topic: 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
transfer_topic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
# IncreaseLiquidity Topic: 0x7a53080ba414158be7ec69b987b5fb7d07dee101fe85488f0853ae16239d0bde
increase_topic = "0x7a53080ba414158be7ec69b987b5fb7d07dee101fe85488f0853ae16239d0bde"
# Use the actual minted ID if available, else dummy
real_token_id = getattr(self.state, 'last_minted_token_id', 123456)
token_id_hex = hex(real_token_id)[2:].zfill(64)
# Liquidity + Amount0 + Amount1
# 1000 Liquidity, 1 ETH (18 dec), 3000 USDC (6 dec)
# 1 ETH = 1e18, 3000 USDC = 3e9
data_liq = "00000000000000000000000000000000000000000000000000000000000003e8" # 1000
data_amt0 = "0000000000000000000000000000000000000000000000000de0b6b3a7640000" # 1e18
data_amt1 = "00000000000000000000000000000000000000000000000000000000b2d05e00" # 3e9
class Receipt:
status = 1
blockNumber = 12345
logs = [
{
'topics': [
HexBytes(transfer_topic),
HexBytes("0x0000000000000000000000000000000000000000000000000000000000000000"), # From
HexBytes("0x0000000000000000000000000000000000000000000000000000000000000000"), # To
HexBytes("0x" + token_id_hex) # TokenID
],
'data': b''
},
{
'topics': [
HexBytes(increase_topic)
],
'data': bytes.fromhex(data_liq + data_amt0 + data_amt1)
}
]
return Receipt()
class MockWeb3:
def __init__(self, state: MockExchangeState):
self.eth = MockEth(state)
self.middleware_onion = type('obj', (object,), {'inject': lambda *args, **kwargs: None})
def is_connected(self):
return True
def to_checksum_address(self, addr):
return addr
def is_address(self, addr):
return True
@staticmethod
def to_wei(val, unit):
if unit == 'gwei': return int(val * 10**9)
if unit == 'ether': return int(val * 10**18)
return int(val)
@staticmethod
def from_wei(val, unit):
if unit == 'gwei': return Decimal(val) / Decimal(10**9)
if unit == 'ether': return Decimal(val) / Decimal(10**18)
return Decimal(val)
@staticmethod
def keccak(text=None):
return b'\x00'*32 # Dummy
# --- HYPERLIQUID MOCKS ---
class MockInfo:
def __init__(self, state: MockExchangeState):
self.state = state
def all_mids(self):
# Return string prices as per API
return {k: str(v) for k, v in self.state.prices.items()}
def user_state(self, address):
positions = []
for sym, pos in self.state.hl_positions.items():
positions.append({
"position": {
"coin": sym,
"szi": str(pos['size']),
"entryPx": str(pos['entry_px']),
"unrealizedPnl": str(pos['unrealized_pnl'])
}
})
return {
"marginSummary": {
"accountValue": str(self.state.hl_balances.get("USDC", 0)),
"totalMarginUsed": "0",
"totalNtlPos": "0",
"totalRawUsd": "0"
},
"assetPositions": positions
}
def open_orders(self, address):
return self.state.hl_orders
def user_fills(self, address):
return [] # TODO: Store fills in state
def l2_snapshot(self, coin):
# Generate artificial orderbook around mid price
price = self.state.prices.get(coin, Decimal("0"))
if price == 0: return {'levels': [[], []]}
# Spread 0.05%
bid = price * Decimal("0.99975")
ask = price * Decimal("1.00025")
return {
"levels": [
[{"px": str(bid), "sz": "100.0", "n": 1}], # Bids
[{"px": str(ask), "sz": "100.0", "n": 1}] # Asks
]
}
class MockExchangeAPI:
def __init__(self, state: MockExchangeState):
self.state = state
def order(self, coin, is_buy, sz, limit_px, order_type, reduce_only=False):
# Execute immediately for IO/Market, or add to book
# Simulating Fill
price = Decimal(str(limit_px))
size = Decimal(str(sz))
cost = price * size
# Fee (Taker 0.035%)
fee = cost * Decimal("0.00035")
self.state.hl_fees_paid += fee
# Update Position
if coin not in self.state.hl_positions:
self.state.hl_positions[coin] = {'size': Decimal(0), 'entry_px': Decimal(0), 'unrealized_pnl': Decimal(0)}
pos = self.state.hl_positions[coin]
current_size = pos['size']
# Update Entry Price (Weighted Average)
# New Entry = (OldSize * OldEntry + NewSize * NewPrice) / (OldSize + NewSize)
signed_size = size if is_buy else -size
new_size = current_size + signed_size
if new_size == 0:
pos['entry_px'] = 0
elif (current_size > 0 and signed_size > 0) or (current_size < 0 and signed_size < 0):
# Increasing position
val_old = abs(current_size) * pos['entry_px']
val_new = size * price
pos['entry_px'] = (val_old + val_new) / abs(new_size)
else:
# Closing/Reducing - Entry Price doesn't change, PnL is realized
# Fraction closed
closed_ratio = min(abs(signed_size), abs(current_size)) / abs(current_size)
# This logic is simplified, real PnL logic is complex
pos['size'] = new_size
logger.info(f"MOCK HL EXEC: {coin} {'BUY' if is_buy else 'SELL'} {size} @ {price}. New Size: {new_size}")
return {"status": "ok", "response": {"data": {"statuses": [{"filled": {"oid": 123}}]}}}
def cancel(self, coin, oid):
self.state.hl_orders = [o for o in self.state.hl_orders if o['oid'] != oid]
return {"status": "ok"}

View File

@ -0,0 +1,25 @@
import os
import sys
# Add project root to path
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(os.path.dirname(current_dir))
sys.path.append(project_root)
from tests.backtest.backtester import Backtester
def main():
book_file = os.path.join(project_root, "market_data", "BNB_raw_20251230_book.csv")
trades_file = os.path.join(project_root, "market_data", "BNB_raw_20251230_trades.csv")
if not os.path.exists(book_file):
print(f"Error: Data file not found: {book_file}")
return
print(f"Starting Backtest on {book_file}...")
bt = Backtester(book_file, trades_file)
bt.run()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,103 @@
import os
import sys
import json
from web3 import Web3
from dotenv import load_dotenv
from decimal import Decimal
# Add project root
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Load Env
load_dotenv()
RPC_URL = os.environ.get("MAINNET_RPC_URL") # Arbitrum RPC
POOL_ADDRESS = "0xC6962004f452bE9203591991D15f6b388e09E8D0" # ARB/WETH 500
ERC20_ABI = [
{"constant": True, "inputs": [], "name": "symbol", "outputs": [{"name": "", "type": "string"}], "payable": False, "stateMutability": "view", "type": "function"},
{"constant": True, "inputs": [], "name": "decimals", "outputs": [{"name": "", "type": "uint8"}], "payable": False, "stateMutability": "view", "type": "function"},
{"constant": True, "inputs": [{"name": "_owner", "type": "address"}], "name": "balanceOf", "outputs": [{"name": "balance", "type": "uint256"}], "payable": False, "stateMutability": "view", "type": "function"}
]
POOL_ABI = [
{"inputs": [], "name": "slot0", "outputs": [{"internalType": "uint160", "name": "sqrtPriceX96", "type": "uint160"}, {"internalType": "int24", "name": "tick", "type": "int24"}, {"internalType": "uint16", "name": "observationIndex", "type": "uint16"}, {"internalType": "uint16", "name": "observationCardinality", "type": "uint16"}, {"internalType": "uint16", "name": "observationCardinalityNext", "type": "uint16"}, {"internalType": "uint32", "name": "feeProtocol", "type": "uint32"}, {"internalType": "bool", "name": "unlocked", "type": "bool"}], "stateMutability": "view", "type": "function"},
{"inputs": [], "name": "liquidity", "outputs": [{"internalType": "uint128", "name": "", "type": "uint128"}], "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"}
]
def main():
if not RPC_URL:
print("Error: MAINNET_RPC_URL not found in .env")
return
w3 = Web3(Web3.HTTPProvider(RPC_URL))
if not w3.is_connected():
print("Error: Could not connect to RPC")
return
print(f"Connected to Arbitrum: {w3.eth.block_number}")
pool_contract = w3.eth.contract(address=POOL_ADDRESS, abi=POOL_ABI)
# 1. Metadata
t0_addr = pool_contract.functions.token0().call()
t1_addr = pool_contract.functions.token1().call()
fee = pool_contract.functions.fee().call()
t0_contract = w3.eth.contract(address=t0_addr, abi=ERC20_ABI)
t1_contract = w3.eth.contract(address=t1_addr, abi=ERC20_ABI)
t0_sym = t0_contract.functions.symbol().call()
t1_sym = t1_contract.functions.symbol().call()
t0_dec = t0_contract.functions.decimals().call()
t1_dec = t1_contract.functions.decimals().call()
print(f"\nPool: {t0_sym} / {t1_sym} ({fee/10000}%)")
print(f"Token0: {t0_sym} ({t0_addr}) - {t0_dec} dec")
print(f"Token1: {t1_sym} ({t1_addr}) - {t1_dec} dec")
# 2. State
slot0 = pool_contract.functions.slot0().call()
sqrt_price = slot0[0]
tick = slot0[1]
liquidity = pool_contract.functions.liquidity().call()
print(f"\nState:")
print(f"Liquidity: {liquidity}")
print(f"Tick: {tick}")
print(f"SqrtPriceX96: {sqrt_price}")
# 3. Price Calc
# price = (sqrt / 2^96)^2
p = (Decimal(sqrt_price) / Decimal(2**96)) ** 2
# Adjust for decimals: Price = raw_price * 10^(d0 - d1)
adj_price = p * (Decimal(10) ** (t0_dec - t1_dec))
inv_price = Decimal(1) / adj_price if adj_price > 0 else 0
print(f"\nPrices:")
print(f"1 {t0_sym} = {adj_price:.6f} {t1_sym}")
print(f"1 {t1_sym} = {inv_price:.6f} {t0_sym}")
# 4. TVL Estimation (Balances)
t0_bal = t0_contract.functions.balanceOf(POOL_ADDRESS).call()
t1_bal = t1_contract.functions.balanceOf(POOL_ADDRESS).call()
t0_human = Decimal(t0_bal) / Decimal(10**t0_dec)
t1_human = Decimal(t1_bal) / Decimal(10**t1_dec)
print(f"\nTVL (Locked in Contract):")
print(f"{t0_human:,.2f} {t0_sym}")
print(f"{t1_human:,.2f} {t1_sym}")
# Assume WETH is approx 3350 (or whatever current market is, we can use slot0 price if one is stable)
# If one is USD stable, we can calc total.
# ARB / WETH. WETH is ~$3300.
# Let's just output raw.
if __name__ == "__main__":
main()