diff --git a/assets/images/coverage.svg b/assets/images/coverage.svg index 5cc1bb5..1581a9a 100644 --- a/assets/images/coverage.svg +++ b/assets/images/coverage.svg @@ -15,7 +15,7 @@ coverage coverage - 42% - 42% + 48% + 48% diff --git a/examples/basic_grid_trading_bot.py b/examples/basic_grid_trading_bot.py new file mode 100644 index 0000000..6b04f3f --- /dev/null +++ b/examples/basic_grid_trading_bot.py @@ -0,0 +1,148 @@ +import time +from typing import Dict, List, Optional +from examples import example_utils +from hyperliquid.utils import constants +from unittest.mock import Mock + +class GridTradingBot: + def __init__(self, coin: str, grid_size: int, price_spacing_percent: float, order_size: float): + """ + Initialize grid trading bot + + Args: + coin: Trading pair (e.g. "ETH") + grid_size: Number of buy and sell orders on each side + price_spacing_percent: Percentage between each grid level + order_size: Size of each order + + Raises: + ValueError: If grid_size <= 0 or price_spacing_percent <= 0 + """ + if grid_size <= 0: + raise ValueError("grid_size must be positive") + if price_spacing_percent <= 0: + raise ValueError("price_spacing_percent must be positive") + + self.address, self.info, self.exchange = example_utils.setup( + constants.TESTNET_API_URL, + skip_ws=True + ) + self.coin = coin + self.grid_size = grid_size + self.price_spacing = price_spacing_percent + self.order_size = order_size + self.active_orders: Dict[int, dict] = {} # oid -> order details + + def get_mid_price(self) -> float: + """Get current mid price from order book""" + book = self.info.l2_snapshot(self.coin) + best_bid = float(book["levels"][0][0]["px"]) + best_ask = float(book["levels"][1][0]["px"]) + return (best_bid + best_ask) / 2 + + def create_grid(self): + """Create initial grid of orders""" + mid_price = self.get_mid_price() + + # Create buy orders below current price + for i in range(self.grid_size): + grid_price = mid_price * (1 - (i + 1) * self.price_spacing) + result = self.exchange.order( + self.coin, + is_buy=True, + sz=self.order_size, + limit_px=grid_price, + order_type={"limit": {"tif": "Gtc"}} + ) + if result["status"] == "ok": + status = result["response"]["data"]["statuses"][0] + if "resting" in status: + self.active_orders[status["resting"]["oid"]] = { + "price": grid_price, + "is_buy": True + } + + # Create sell orders above current price + for i in range(self.grid_size): + grid_price = mid_price * (1 + (i + 1) * self.price_spacing) + result = self.exchange.order( + self.coin, + is_buy=False, + sz=self.order_size, + limit_px=grid_price, + order_type={"limit": {"tif": "Gtc"}} + ) + if result["status"] == "ok": + status = result["response"]["data"]["statuses"][0] + if "resting" in status: + self.active_orders[status["resting"]["oid"]] = { + "price": grid_price, + "is_buy": False + } + + def check_and_replace_filled_orders(self): + """Check for filled orders and place new ones""" + orders_to_process = list(self.active_orders.items()) + orders_to_remove = [] + + # Check each active order + for oid, order_details in orders_to_process: + status = self.info.query_order_by_oid(self.address, oid) + + # If order is no longer active (filled) + if status.get("status") != "active": + # Place a new order on opposite side + new_price = order_details["price"] + result = self.exchange.order( + self.coin, + is_buy=not order_details["is_buy"], + sz=self.order_size, + limit_px=new_price, + order_type={"limit": {"tif": "Gtc"}} + ) + + if result["status"] == "ok": + status = result["response"]["data"]["statuses"][0] + if "resting" in status: + self.active_orders[status["resting"]["oid"]] = { + "price": new_price, + "is_buy": not order_details["is_buy"] + } + + orders_to_remove.append(oid) + + # Remove filled orders from tracking + for oid in orders_to_remove: + del self.active_orders[oid] + + def run(self): + """Run the grid trading bot""" + print(f"Starting grid trading bot for {self.coin}") + print(f"Grid size: {self.grid_size}") + print(f"Price spacing: {self.price_spacing*100}%") + print(f"Order size: {self.order_size}") + + # Create initial grid + self.create_grid() + + # Main loop + while True: + try: + self.check_and_replace_filled_orders() + time.sleep(5) # Check every 5 seconds + except Exception as e: + print(f"Error: {e}") + time.sleep(5) # Wait before retrying + +def main(): + # Example configuration + bot = GridTradingBot( + coin="ETH", # Trading ETH + grid_size=10, # 10 orders on each side + price_spacing_percent=0.01, # 1% between each level + order_size=0.1 # 0.1 ETH per order + ) + bot.run() + +if __name__ == "__main__": + main() diff --git a/tests/basic_grid_trading_bot_test.py b/tests/basic_grid_trading_bot_test.py new file mode 100644 index 0000000..d7574da --- /dev/null +++ b/tests/basic_grid_trading_bot_test.py @@ -0,0 +1,144 @@ +import pytest +from unittest.mock import Mock, patch + +from hyperliquid.info import Info +from hyperliquid.exchange import Exchange + +class TestBasicGridTradingBot: + @pytest.fixture + def mock_setup(self): + with patch('examples.example_utils.setup') as mock: + mock.return_value = ( + "0x123...789", # address + Mock(spec=Info), # info + Mock(spec=Exchange) # exchange + ) + yield mock + + @pytest.fixture + def bot(self, mock_setup): + from examples.basic_grid_trading_bot import GridTradingBot + return GridTradingBot( + coin="ETH", + grid_size=5, + price_spacing_percent=0.01, + order_size=0.1 + ) + + def test_initialization(self, bot): + assert bot.coin == "ETH" + assert bot.grid_size == 5 + assert bot.price_spacing == 0.01 + assert bot.order_size == 0.1 + assert isinstance(bot.active_orders, dict) + + def test_get_mid_price(self, bot): + # Mock the l2_snapshot response + bot.info.l2_snapshot.return_value = { + "levels": [ + [{"px": "1900"}], # Best bid + [{"px": "2100"}] # Best ask + ] + } + + mid_price = bot.get_mid_price() + assert mid_price == 2000.0 + bot.info.l2_snapshot.assert_called_once_with("ETH") + + def test_create_grid(self, bot): + # Mock the get_mid_price method + bot.get_mid_price = Mock(return_value=2000.0) + + # Mock successful order placement with incrementing OIDs + oid_counter = 100 + def mock_order(*args, **kwargs): + nonlocal oid_counter + oid_counter += 1 + return { + "status": "ok", + "response": { + "data": { + "statuses": [{ + "resting": { + "oid": oid_counter + } + }] + } + } + } + + bot.exchange.order = Mock(side_effect=mock_order) + + bot.create_grid() + + # Should create grid_size * 2 orders (buys and sells) + assert bot.exchange.order.call_count == bot.grid_size * 2 + + # Verify active orders were tracked + assert len(bot.active_orders) == bot.grid_size * 2 + + def test_check_and_replace_filled_orders(self, bot): + # Setup mock active orders + bot.active_orders = { + 123: {"price": 1900.0, "is_buy": True}, + 456: {"price": 2100.0, "is_buy": False} + } + + # Mock order status checks + def mock_query_order(address, oid): + return {"status": "filled" if oid == 123 else "active"} + + bot.info.query_order_by_oid = Mock(side_effect=mock_query_order) + + # Mock new order placement with a new OID + bot.exchange.order.return_value = { + "status": "ok", + "response": { + "data": { + "statuses": [{ + "resting": { + "oid": 789 + } + }] + } + } + } + + bot.check_and_replace_filled_orders() + + # Verify filled order was replaced + assert 123 not in bot.active_orders + assert 456 in bot.active_orders + assert 789 in bot.active_orders + + def test_error_handling_in_create_grid(self, bot): + bot.get_mid_price = Mock(return_value=2000.0) + + # Mock failed order placement + bot.exchange.order.return_value = { + "status": "error", + "message": "Insufficient funds" + } + + bot.create_grid() + + # Should attempt to create orders but not track them + assert bot.exchange.order.call_count == bot.grid_size * 2 + assert len(bot.active_orders) == 0 + + @pytest.mark.parametrize("price_spacing,grid_size", [ + (-0.01, 5), # Negative spacing + (0, 5), # Zero spacing + (0.01, 0), # Zero grid size + (0.01, -1), # Negative grid size + ]) + def test_invalid_parameters(self, mock_setup, price_spacing, grid_size): + from examples.basic_grid_trading_bot import GridTradingBot + + with pytest.raises(ValueError): + GridTradingBot( + coin="ETH", + grid_size=grid_size, + price_spacing_percent=price_spacing, + order_size=0.1 + )