diff --git a/florida/GEMINI.md b/florida/GEMINI.md index 167f5a7..14639ae 100644 --- a/florida/GEMINI.md +++ b/florida/GEMINI.md @@ -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. \ No newline at end of file diff --git a/florida/PANCAKESWAP_BNB_status.json b/florida/PANCAKESWAP_BNB_status.json index 68c2e5d..b8ce5c1 100644 --- a/florida/PANCAKESWAP_BNB_status.json +++ b/florida/PANCAKESWAP_BNB_status.json @@ -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 } ] \ No newline at end of file diff --git a/florida/UNISWAP_V3_status.json b/florida/UNISWAP_V3_status.json index 1a77aa6..d7d240f 100644 --- a/florida/UNISWAP_V3_status.json +++ b/florida/UNISWAP_V3_status.json @@ -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 } ] \ No newline at end of file diff --git a/florida/clp_config.py b/florida/clp_config.py index a4807ee..52c7aca 100644 --- a/florida/clp_config.py +++ b/florida/clp_config.py @@ -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"), diff --git a/florida/clp_hedger.py b/florida/clp_hedger.py index f83c4cc..8c4d9f2 100644 --- a/florida/clp_hedger.py +++ b/florida/clp_hedger.py @@ -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: diff --git a/florida/clp_manager.py b/florida/clp_manager.py index c6eb687..172f4f0 100644 --- a/florida/clp_manager.py +++ b/florida/clp_manager.py @@ -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: diff --git a/florida/tests/backtest/analyze_results.py b/florida/tests/backtest/analyze_results.py new file mode 100644 index 0000000..f81822d --- /dev/null +++ b/florida/tests/backtest/analyze_results.py @@ -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() diff --git a/florida/tests/backtest/backtester.py b/florida/tests/backtest/backtester.py new file mode 100644 index 0000000..2d1b2dd --- /dev/null +++ b/florida/tests/backtest/backtester.py @@ -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 ") + else: + bt = Backtester(sys.argv[1], sys.argv[2]) + bt.run() diff --git a/florida/tests/backtest/grid_search.py b/florida/tests/backtest/grid_search.py new file mode 100644 index 0000000..aa01f2d --- /dev/null +++ b/florida/tests/backtest/grid_search.py @@ -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() diff --git a/florida/tests/backtest/mocks.py b/florida/tests/backtest/mocks.py new file mode 100644 index 0000000..fb613e4 --- /dev/null +++ b/florida/tests/backtest/mocks.py @@ -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"} diff --git a/florida/tests/backtest/run_backtest.py b/florida/tests/backtest/run_backtest.py new file mode 100644 index 0000000..214f808 --- /dev/null +++ b/florida/tests/backtest/run_backtest.py @@ -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() diff --git a/florida/tools/check_arbitrum_pool.py b/florida/tools/check_arbitrum_pool.py new file mode 100644 index 0000000..5c79bab --- /dev/null +++ b/florida/tools/check_arbitrum_pool.py @@ -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()