Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions assets/images/coverage.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
148 changes: 148 additions & 0 deletions examples/basic_grid_trading_bot.py
Original file line number Diff line number Diff line change
@@ -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()
144 changes: 144 additions & 0 deletions tests/basic_grid_trading_bot_test.py
Original file line number Diff line number Diff line change
@@ -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
)