Skip to content

Commit 6f703be

Browse files
authored
feat: Implement market-making bot (jordan-dimov#21)
1 parent b32ef21 commit 6f703be

18 files changed

Lines changed: 417 additions & 54 deletions

File tree

ctenex/api/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@
2222
lifespan=lifespan,
2323
routers=[status_router, stateful_exchange_router],
2424
)
25-
stateful_app_url = f"{base_url}{stateful_app_path}"
25+
stateful_app_url = f"{base_url}{stateful_app_path[1:]}"
2626
app.mount(stateful_app_path, stateful_app)
2727

2828
stateless_app_path = "/v1/stateless/"
2929
stateless_app = create_app(
3030
routers=[status_router, stateless_exchange_router],
3131
)
32-
stateless_app_url = f"{base_url}{stateless_app_path}"
32+
stateless_app_url = f"{base_url}{stateless_app_path[1:]}"
3333
app.mount(stateless_app_path, stateless_app)
3434

3535

ctenex/api/v1/controllers/exchange.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44

55
from ctenex.core.db.async_session import AsyncSessionStream, db
66
from ctenex.core.db.utils import get_entity_values
7-
from ctenex.domain.contracts import ContractCode
87
from ctenex.domain.entities import OpenOrderStatus
98
from ctenex.domain.matching_engine.model import matching_engine
109
from ctenex.domain.order_book.contract.reader import contracts_reader
1110
from ctenex.domain.order_book.contract.schemas import ContractGetResponse
1211
from ctenex.domain.order_book.order.model import Order
12+
from ctenex.domain.order_book.order.reader import OrderFilter
1313
from ctenex.domain.order_book.order.schemas import (
1414
OrderAddRequest,
1515
OrderAddResponse,
@@ -35,9 +35,15 @@ async def place_order(
3535

3636
@router.get("/orders")
3737
async def get_orders(
38-
contract_id: ContractCode,
38+
filter: Annotated[OrderFilter, Depends()],
39+
limit: int = 10,
40+
page: int = 1,
3941
) -> list[OrderGetResponse]:
40-
orders: list[Order] = await matching_engine.get_orders(contract_id)
42+
orders: list[Order] = await matching_engine.get_orders(
43+
filter=filter,
44+
page=page,
45+
limit=limit,
46+
)
4147
return [OrderGetResponse(**order.model_dump(exclude_none=True)) for order in orders]
4248

4349

ctenex/bot/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
Market maker bot
2+
=================
3+
4+
The basic approach is this:
5+
6+
1. Repeatedly poll the exchange API to track current market prices and market depth
7+
(the size of an order needed to move the market price by a given amount)
8+
2. Update internal state to track prices for each contract (the "mid-price") - edge
9+
case when there is no ask/bid
10+
3. Place multiple orders at different price levels around the mid-price - limit
11+
the increments, steps and number of steps
12+
4. Implement a spread strategy to profit from the difference between bid and ask prices
13+
5. Regularly cancel and replace orders as needed to maintain the desired spread
14+
6. Control exposure and limit potential losses
15+
7. Configurable settings to adjust the bot's behavior

ctenex/bot/__init__.py

Whitespace-only changes.

ctenex/bot/market_maker_bot.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import asyncio
2+
from datetime import datetime
3+
from decimal import Decimal
4+
from typing import Callable
5+
from uuid import UUID
6+
7+
import httpx
8+
from loguru import logger
9+
10+
from ctenex.bot.orders_generators.basic import orders_generator
11+
from ctenex.domain.entities import OrderSide, OrderType
12+
from ctenex.domain.order_book.order.schemas import (
13+
OrderAddRequest,
14+
OrderAddResponse,
15+
OrderGetResponse,
16+
)
17+
from ctenex.settings.application import get_app_settings
18+
from ctenex.utils.contracts import validate_contract_id
19+
20+
settings = get_app_settings()
21+
22+
23+
BOT_TRADER_ID = UUID("c4adb8ee-1425-4a10-bd10-87dd587670d3")
24+
25+
26+
class InternalStateError(Exception): ...
27+
28+
29+
class MatchingBot:
30+
def __init__(
31+
self,
32+
trader_id: UUID,
33+
contract_id: str,
34+
base_url: str,
35+
poll_interval: float = 1.0,
36+
poll_size: int = 5,
37+
number_of_orders: int = 2,
38+
orders_generator: Callable = orders_generator,
39+
):
40+
# Configuration
41+
self.base_url = base_url
42+
self.trader_id = trader_id
43+
self.contract_id = contract_id
44+
self.poll_interval = poll_interval
45+
self.poll_size = poll_size
46+
self.number_of_orders = number_of_orders
47+
48+
# Dependencies
49+
self.exchange_client = httpx.AsyncClient(base_url=base_url)
50+
self.orders_generator = orders_generator
51+
52+
# State
53+
self.last_processed_order_timestamp: datetime = datetime.now()
54+
self.mid_price: Decimal = Decimal(0.00)
55+
self.best_bid: Decimal = Decimal(0.00)
56+
self.best_ask: Decimal = Decimal(0.00)
57+
self.best_bid_quantity: Decimal = Decimal(0.00)
58+
self.best_ask_quantity: Decimal = Decimal(0.00)
59+
self.tick_size: Decimal = Decimal(0.00)
60+
self.spread: Decimal = Decimal(0.00)
61+
62+
async def validate_contract_id(self, contract_id: str) -> None:
63+
contracts = validate_contract_id(contract_id, self.base_url)
64+
self.tick_size = next(
65+
(c.tick_size for c in contracts if c.contract_id == contract_id),
66+
Decimal(0.00),
67+
)
68+
69+
async def get_orders(self, contract_id: str, status: str) -> list[OrderGetResponse]:
70+
logger.info(f"Polling {status} orders for contract {contract_id}")
71+
72+
response = await self.exchange_client.get(
73+
url="/v1/stateless/orders",
74+
params={
75+
"contract_id": contract_id,
76+
"status": status,
77+
"limit": self.poll_size,
78+
"page": 1,
79+
},
80+
)
81+
response.raise_for_status()
82+
return [OrderGetResponse(**order) for order in response.json()]
83+
84+
async def place_order(self, order: OrderAddRequest) -> OrderAddResponse:
85+
response = await self.exchange_client.post(
86+
url="/v1/stateless/orders", json=order.model_dump(mode="json")
87+
)
88+
response.raise_for_status()
89+
logger.info(f"Placed order: {response.json()}")
90+
return OrderAddResponse(**response.json())
91+
92+
async def process_orders(self, orders: list[OrderGetResponse], status: str) -> None:
93+
if not orders:
94+
logger.info("No orders to process")
95+
return
96+
97+
logger.info(
98+
f"Processing {len(orders)} {status} order(s) for contract {self.contract_id}"
99+
)
100+
101+
for order in orders:
102+
# Skip our own orders or already processed orders
103+
if order.trader_id == self.trader_id:
104+
continue
105+
106+
# TODO: Handle market orders
107+
# Market orders sound like the ideal opportunity to make a profit.
108+
# How do we make sure the bot offers a fair price even for market orders?
109+
order_price = order.price if order.price is not None else self.mid_price
110+
if order.type == OrderType.MARKET and order.price is None:
111+
continue
112+
113+
# Update state for order price and quantity decision
114+
order_quantity = (
115+
order.remaining_quantity
116+
if order.remaining_quantity is not None
117+
else order.quantity
118+
)
119+
120+
self.update_state(
121+
price=order_price,
122+
side=order.side,
123+
quantity=order_quantity,
124+
)
125+
126+
# Generate orders around the mid-price and considering the spread
127+
generated_orders = self.orders_generator(
128+
contract_id=self.contract_id,
129+
trader_id=self.trader_id,
130+
number_of_orders=self.number_of_orders,
131+
side=order.side,
132+
price=self.mid_price,
133+
tick_size=self.tick_size,
134+
spread=self.spread,
135+
)
136+
137+
# Place orders
138+
for generated_order in generated_orders:
139+
try:
140+
await self.place_order(generated_order)
141+
self.last_processed_order_timestamp = datetime.now()
142+
except Exception as e:
143+
logger.error(f"Failed to place matching order: {e}")
144+
145+
def update_state(
146+
self,
147+
price: Decimal,
148+
side: OrderSide,
149+
quantity: Decimal,
150+
) -> None:
151+
if side == OrderSide.BUY and price > self.best_bid:
152+
self.best_bid = price
153+
self.best_bid_quantity = quantity
154+
elif side == OrderSide.SELL and (
155+
price < self.best_ask or self.best_ask == Decimal(0.00)
156+
):
157+
self.best_ask = price
158+
self.best_ask_quantity = quantity
159+
160+
if self.best_bid == Decimal(0.00):
161+
self.mid_price = self.best_ask
162+
elif self.best_ask == Decimal(0.00):
163+
self.mid_price = self.best_bid
164+
else:
165+
self.mid_price = (self.best_bid + self.best_ask) / 2
166+
self.spread = abs(self.best_ask - self.best_bid)
167+
168+
async def run(self) -> None:
169+
logger.info(f"Starting matching bot for contract {self.contract_id}")
170+
while True:
171+
open_orders = await self.get_orders(
172+
self.contract_id,
173+
status="open",
174+
)
175+
176+
open_orders = [
177+
order for order in open_orders if order.trader_id != self.trader_id
178+
]
179+
await self.process_orders(open_orders, status="open")
180+
181+
partially_filled_orders = await self.get_orders(
182+
self.contract_id,
183+
status="partially_filled",
184+
)
185+
partially_filled_orders = [
186+
order
187+
for order in partially_filled_orders
188+
if order.trader_id != self.trader_id
189+
]
190+
await self.process_orders(
191+
partially_filled_orders, status="partially_filled"
192+
)
193+
194+
await asyncio.sleep(self.poll_interval)
195+
# break
196+
197+
async def close(self) -> None:
198+
await self.exchange_client.aclose()
199+
200+
201+
async def main():
202+
bot = MatchingBot(
203+
trader_id=BOT_TRADER_ID,
204+
base_url=str(settings.api.base_url),
205+
contract_id="UK-BL-MAR-25",
206+
)
207+
try:
208+
await bot.validate_contract_id("UK-BL-MAR-25")
209+
await bot.run()
210+
except KeyboardInterrupt:
211+
logger.info("Bot stopped by user")
212+
finally:
213+
await bot.close()
214+
215+
216+
if __name__ == "__main__":
217+
asyncio.run(main())

ctenex/bot/orders_generators/__init__.py

Whitespace-only changes.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from decimal import Decimal
2+
from random import randrange
3+
from uuid import UUID
4+
5+
from ctenex.domain.order_book.order.schemas import (
6+
OrderAddRequest,
7+
OrderSide,
8+
OrderType,
9+
)
10+
11+
RANDOM_TICKS_FACTOR = 3 # TODO: increase complexity
12+
13+
14+
def orders_generator(
15+
contract_id: str,
16+
trader_id: UUID,
17+
side: OrderSide,
18+
price: Decimal,
19+
tick_size: Decimal,
20+
spread: Decimal,
21+
number_of_orders: int = 3,
22+
) -> list[OrderAddRequest]:
23+
orders = []
24+
25+
for i in range(number_of_orders):
26+
# First match the order
27+
if i == 0:
28+
matching_side = OrderSide.SELL if side == OrderSide.BUY else OrderSide.BUY
29+
order_price = price
30+
# Then generate orders around the mid-price
31+
else:
32+
matching_side = OrderSide.SELL if randrange(2) == 0 else OrderSide.BUY
33+
order_price = price + tick_size * Decimal(
34+
randrange(-RANDOM_TICKS_FACTOR, RANDOM_TICKS_FACTOR)
35+
)
36+
37+
if spread == Decimal(0.00):
38+
order_quantity = Decimal(1.00)
39+
else:
40+
order_quantity = spread
41+
42+
matching_order = OrderAddRequest(
43+
contract_id=contract_id,
44+
trader_id=trader_id,
45+
side=matching_side,
46+
type=OrderType.LIMIT,
47+
price=order_price,
48+
quantity=order_quantity,
49+
)
50+
orders.append(matching_order)
51+
return orders

ctenex/core/utils/validators.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from enum import Enum
2+
from typing import Any
3+
4+
5+
def validate_enum(v: Any) -> Any:
6+
if v is not None and isinstance(v, Enum):
7+
return v.value
8+
return v

ctenex/domain/exceptions.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
class CoreException(Exception): ...
2+
3+
4+
class InvalidContractIdError(CoreException): ...

ctenex/domain/matching_engine/model.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
)
1818
from ctenex.domain.order_book.model import order_book
1919
from ctenex.domain.order_book.order.model import Order as OrderSchema
20+
from ctenex.domain.order_book.order.reader import OrderFilter
2021
from ctenex.domain.order_book.trade.filter_params import TradeFilterParams
2122
from ctenex.domain.order_book.trade.model import Trade as TradeSchema
2223
from ctenex.domain.order_book.trade.reader import trades_reader
@@ -61,9 +62,15 @@ async def add_order(self, order: OrderSchema) -> UUID:
6162

6263
async def get_orders(
6364
self,
64-
contract_id: ContractCode,
65+
filter: OrderFilter,
66+
limit: int = 10,
67+
page: int = 1,
6568
) -> list[OrderSchema]:
66-
return await self.order_book.get_orders(contract_id)
69+
return await self.order_book.get_orders(
70+
filter=filter,
71+
limit=limit,
72+
page=page,
73+
)
6774

6875
async def get_order(
6976
self,

0 commit comments

Comments
 (0)