Skip to content

Commit 83ab348

Browse files
committed
feat: first demo version of alpha bot; fix: correct contract validation and patch it on the market maker bot
1 parent a727828 commit 83ab348

7 files changed

Lines changed: 468 additions & 20 deletions

File tree

ctenex/bot/bots/alpha/bot.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import asyncio
2+
from uuid import UUID
3+
4+
from loguru import logger
5+
6+
from ctenex.bot.bots.alpha.exchange_bot import ExchangeBot
7+
from ctenex.bot.settings.bot import get_bot_settings
8+
9+
settings = get_bot_settings()
10+
11+
BOT_TRADER_ID = UUID("208384fa-4a29-46a4-a24b-8b11c8f278f3")
12+
13+
14+
async def main():
15+
bot = ExchangeBot(
16+
trader_id=BOT_TRADER_ID,
17+
base_url=str(settings.exchange_api.base_url),
18+
contract_id="UK-BL-MAR-25",
19+
)
20+
try:
21+
await bot.validate_contract_id()
22+
await bot.run()
23+
except KeyboardInterrupt:
24+
logger.info("Bot stopped by user")
25+
finally:
26+
await bot.close()
27+
28+
29+
if __name__ == "__main__":
30+
asyncio.run(main())
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import asyncio
2+
from datetime import datetime, timedelta, timezone
3+
from decimal import Decimal
4+
from uuid import UUID
5+
6+
import httpx
7+
from loguru import logger
8+
from sqlalchemy import text
9+
10+
from ctenex.bot.db.async_session import AsyncSessionStream, db
11+
from ctenex.domain.order_book.order.schemas import (
12+
OrderAddRequest,
13+
OrderAddResponse,
14+
OrderGetResponse,
15+
)
16+
from ctenex.utils.contracts import validate_contract_id
17+
18+
19+
class ExchangeBot:
20+
def __init__(
21+
self,
22+
trader_id: UUID,
23+
contract_id: str,
24+
base_url: str,
25+
number_of_orders: int = 2,
26+
sample_interval: Decimal = Decimal(1000.0), # ms
27+
poll_interval: Decimal = Decimal(1000.0), # ms
28+
):
29+
# Configuration
30+
self.base_url = base_url
31+
self.trader_id = trader_id
32+
self.contract_id = contract_id
33+
self.sample_interval = sample_interval
34+
self.poll_interval = poll_interval
35+
self.number_of_orders = number_of_orders
36+
self.last_processed_order_timestamp: datetime = datetime.now(timezone.utc)
37+
38+
# Dependencies
39+
self.exchange_client = httpx.AsyncClient(base_url=base_url)
40+
41+
async def validate_contract_id(self) -> None:
42+
contract = validate_contract_id(self.contract_id, self.base_url)
43+
self.tick_size = contract.tick_size
44+
45+
async def get_orders(
46+
self,
47+
contract_id: str,
48+
start_time: datetime | None = None,
49+
end_time: datetime | None = None,
50+
) -> list[OrderGetResponse]:
51+
# TODO: Validate both start_time and end_time are provided
52+
logger.debug(f"Getting orders for contract {contract_id}")
53+
54+
query_parameters = {
55+
"contract_id": contract_id,
56+
"sort_by": "placed_at",
57+
"sort_order": "asc",
58+
}
59+
if start_time:
60+
query_parameters["placed_at_or_after"] = str(start_time)
61+
if end_time:
62+
query_parameters["placed_before"] = str(end_time)
63+
64+
response = await self.exchange_client.get(
65+
url="/v1/stateless/orders",
66+
params=query_parameters,
67+
)
68+
response.raise_for_status()
69+
return [OrderGetResponse(**order) for order in response.json()]
70+
71+
async def place_order(self, order: OrderAddRequest) -> OrderAddResponse:
72+
response = await self.exchange_client.post(
73+
url="/v1/stateless/orders", json=order.model_dump(mode="json")
74+
)
75+
response.raise_for_status()
76+
logger.info(f"Placed order: {response.json()}")
77+
return OrderAddResponse(**response.json())
78+
79+
async def process_orders(
80+
self,
81+
orders: list[OrderGetResponse],
82+
session_stream: AsyncSessionStream,
83+
) -> None:
84+
"""
85+
Orders should be sorted by placed_at in ascending order.
86+
"""
87+
88+
if not orders:
89+
logger.debug(f"No orders to process for contract {self.contract_id}")
90+
return
91+
logger.debug(f"Processing {len(orders)} orders for contract {self.contract_id}")
92+
93+
sample_start_time = orders[0].placed_at
94+
sample_interval_in_seconds = self.sample_interval / 1000
95+
96+
if len(orders) == 1:
97+
total_interval_in_seconds = Decimal(1)
98+
number_of_samples = 1
99+
sample_end_time = sample_start_time + timedelta(
100+
seconds=int(sample_interval_in_seconds)
101+
)
102+
else:
103+
total_interval_in_seconds = Decimal(
104+
str((orders[-1].placed_at - orders[0].placed_at).total_seconds())
105+
)
106+
number_of_samples = int(
107+
total_interval_in_seconds / sample_interval_in_seconds
108+
)
109+
sample_end_time = orders[-1].placed_at
110+
111+
price_moments = []
112+
113+
for _ in range(number_of_samples):
114+
logger.info(
115+
f"Number of samples for interval [{sample_start_time} - {sample_end_time}] seconds: {number_of_samples}"
116+
)
117+
118+
sample_orders = [
119+
order
120+
for order in orders
121+
if order.placed_at >= sample_start_time
122+
and order.placed_at < sample_end_time
123+
]
124+
125+
if not sample_orders:
126+
continue
127+
128+
# Calculate best bid and ask
129+
sample_best_bid, sample_best_ask = min_and_max_price_for_limit_orders(
130+
sample_orders
131+
)
132+
133+
# Assume market orders have an effective price equal to the best bid or ask
134+
# TODO: Check if this is correct
135+
for order in sample_orders:
136+
if order.type == "market":
137+
order.price = (
138+
sample_best_bid if order.side == "buy" else sample_best_ask
139+
)
140+
141+
# Calculate volume and price based on the sample interval
142+
sample_volume = sum(order.quantity for order in sample_orders)
143+
sample_price = (
144+
sum(
145+
order.price * order.quantity
146+
for order in sample_orders
147+
if order.price is not None
148+
)
149+
/ sample_volume
150+
)
151+
152+
price_moments = {
153+
"timestamp": sample_start_time,
154+
"price": float(sample_price),
155+
"volume": float(sample_volume),
156+
"best_bid": float(sample_best_bid),
157+
"best_ask": float(sample_best_ask),
158+
}
159+
160+
sample_start_time = sample_start_time + timedelta(
161+
seconds=int(sample_interval_in_seconds)
162+
)
163+
164+
if not price_moments:
165+
logger.info("No price moments to trigger state update")
166+
continue
167+
168+
await self.update_state(
169+
session_stream=session_stream,
170+
price_moments=price_moments,
171+
)
172+
logger.info(f"Processed {len(orders)} orders for contract {self.contract_id}")
173+
174+
async def update_state(
175+
self,
176+
session_stream: AsyncSessionStream,
177+
price_moments: dict,
178+
):
179+
logger.info("Updating state and strategy")
180+
181+
# Update price moments
182+
async with session_stream() as session:
183+
await session.execute(
184+
text(
185+
"""
186+
INSERT INTO price_moments (
187+
timestamp,
188+
price,
189+
volume,
190+
best_bid,
191+
best_ask
192+
)
193+
VALUES (
194+
:timestamp,
195+
:price,
196+
:volume,
197+
:best_bid,
198+
:best_ask
199+
)
200+
"""
201+
),
202+
price_moments,
203+
)
204+
await session.commit()
205+
206+
# TODO: Update strategy. Issue #22
207+
208+
async def run(self) -> None:
209+
logger.info(f"Starting exchange bot for contract {self.contract_id}")
210+
211+
poll_interval_in_seconds = self.poll_interval / 1000
212+
213+
start_time = self.last_processed_order_timestamp
214+
end_time = datetime.now(timezone.utc)
215+
216+
orders_in_exchange = await self.get_orders(
217+
contract_id=self.contract_id,
218+
start_time=start_time,
219+
end_time=end_time,
220+
)
221+
222+
# For audit only
223+
if orders_in_exchange:
224+
self.last_processed_order_timestamp = orders_in_exchange[-1].placed_at
225+
226+
while True:
227+
logger.debug(
228+
f"Orders in exchange until {self.last_processed_order_timestamp}: {len(orders_in_exchange)}"
229+
)
230+
await self.process_orders(orders=orders_in_exchange, session_stream=db())
231+
await asyncio.sleep(int(poll_interval_in_seconds))
232+
233+
end_time = datetime.now(timezone.utc)
234+
235+
orders_in_exchange = await self.get_orders(
236+
contract_id=self.contract_id,
237+
start_time=start_time,
238+
end_time=end_time,
239+
)
240+
241+
# For audit only
242+
if orders_in_exchange:
243+
self.last_processed_order_timestamp = orders_in_exchange[-1].placed_at
244+
245+
async def close(self) -> None:
246+
await self.exchange_client.aclose()
247+
248+
249+
def min_and_max_price_for_limit_orders(
250+
orders: list[OrderGetResponse],
251+
) -> tuple[Decimal, Decimal]:
252+
limit_bids = [
253+
order.price
254+
for order in orders
255+
if order.type == "limit" and order.side == "buy" and order.price is not None
256+
]
257+
limit_asks = [
258+
order.price
259+
for order in orders
260+
if order.type == "limit" and order.side == "sell" and order.price is not None
261+
]
262+
263+
min_limit_bid = min(limit_bids) if limit_bids else Decimal(0.00)
264+
max_limit_ask = max(limit_asks) if limit_asks else Decimal(0.00)
265+
266+
return min_limit_bid, max_limit_ask

ctenex/bot/bots/basic/exchange_bot.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,9 @@ def __init__(
5151
self.tick_size: Decimal = Decimal(0.00)
5252
self.spread: Decimal = Decimal(0.00)
5353

54-
async def validate_contract_id(self, contract_id: str) -> None:
55-
contracts = validate_contract_id(contract_id, self.base_url)
56-
self.tick_size = next(
57-
(c.tick_size for c in contracts if c.contract_id == contract_id),
58-
Decimal(0.00),
59-
)
54+
async def validate_contract_id(self) -> None:
55+
contract = validate_contract_id(self.contract_id, self.base_url)
56+
self.tick_size = contract.tick_size
6057

6158
async def get_orders(self, contract_id: str, status: str) -> list[OrderGetResponse]:
6259
logger.info(f"Polling {status} orders for contract {contract_id}")

ctenex/bot/bots/basic/market_maker/bot.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ async def main():
1818
contract_id="UK-BL-MAR-25",
1919
)
2020
try:
21-
await bot.validate_contract_id("UK-BL-MAR-25")
21+
await bot.validate_contract_id()
2222
await bot.run()
2323
except KeyboardInterrupt:
2424
logger.info("Bot stopped by user")

0 commit comments

Comments
 (0)