Files
2026-01-02 09:05:01 +01:00

608 lines
24 KiB
Python

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"}