Skip to content

Commit e74a2ea

Browse files
authored
feat: Add persistence layer for alpha bot boilerplate implementation (jordan-dimov#29)
1 parent 8ca24cc commit e74a2ea

28 files changed

Lines changed: 1473 additions & 367 deletions

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,9 @@ cython_debug/
175175

176176
# VSCode
177177
.vscode/
178+
179+
# Database
180+
**/*.db
181+
182+
# Work organization
183+
.todo

ctenex/bot/README.md

Lines changed: 0 additions & 15 deletions
This file was deleted.

ctenex/bot/bots/__init__.py

Whitespace-only changes.

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: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
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 pydantic import BaseModel
9+
from sqlalchemy import text
10+
11+
from ctenex.bot.db.async_session import AsyncSessionStream, db
12+
from ctenex.domain.order_book.order.schemas import (
13+
OrderAddRequest,
14+
OrderAddResponse,
15+
OrderGetResponse,
16+
)
17+
from ctenex.utils.contracts import validate_contract_id
18+
19+
20+
class ProcessingResult(BaseModel):
21+
number_of_orders_processed: int
22+
last_processed_order_timestamp: datetime | None = None
23+
24+
25+
class ExchangeBot:
26+
def __init__(
27+
self,
28+
trader_id: UUID,
29+
contract_id: str,
30+
base_url: str,
31+
sample_interval_in_ms: Decimal = Decimal(1000.0),
32+
base_drift_in_ms: Decimal = Decimal(1100.0),
33+
):
34+
"""
35+
Args:
36+
trader_id: The trader ID.
37+
contract_id: The contract ID.
38+
base_url: The base URL of the exchange.
39+
sample_interval_in_ms: The interval between samples.
40+
base_drift_in_ms: The base drift in milliseconds.
41+
42+
The sample interval is a fixed time interval used to filter the orders when querying.
43+
Each query issued fetches orders placed within the sample interval. This is effectively
44+
used to calculate the value for the `placed_before` filter from the value of the
45+
`placed_at_or_after` filter.
46+
47+
The base drift is the difference between the real time at which an orders query is issued and
48+
the time used to filter orders (i.e. the value used for the `placed_at_or_after` filter).
49+
"""
50+
51+
# Configuration
52+
self.base_url = base_url
53+
self.trader_id = trader_id
54+
self.contract_id = contract_id
55+
self.sample_interval_in_ms = sample_interval_in_ms
56+
self.base_drift_in_ms = base_drift_in_ms
57+
self.last_processed_order_timestamp: datetime = datetime.now(timezone.utc)
58+
59+
# Dependencies
60+
self.exchange_client = httpx.AsyncClient(base_url=base_url)
61+
62+
async def validate_contract_id(self) -> None:
63+
contract = validate_contract_id(self.contract_id, self.base_url)
64+
self.tick_size = contract.tick_size
65+
66+
async def get_orders(
67+
self,
68+
contract_id: str,
69+
start_time: datetime | None = None,
70+
end_time: datetime | None = None,
71+
) -> list[OrderGetResponse]:
72+
"""
73+
Get the orders for the given contract.
74+
75+
Args:
76+
contract_id: The contract ID.
77+
start_time: The start time of the interval.
78+
end_time: The end time of the interval.
79+
"""
80+
81+
query_parameters = {
82+
"contract_id": contract_id,
83+
"sort_by": "placed_at",
84+
"sort_order": "asc",
85+
}
86+
if start_time:
87+
query_parameters["placed_at_or_after"] = str(start_time)
88+
if end_time:
89+
query_parameters["placed_before"] = str(end_time)
90+
91+
response = await self.exchange_client.get(
92+
url="/v1/stateless/orders",
93+
params=query_parameters,
94+
)
95+
response.raise_for_status()
96+
return [OrderGetResponse(**order) for order in response.json()]
97+
98+
async def place_order(self, order: OrderAddRequest) -> OrderAddResponse:
99+
"""
100+
Place an order on the exchange.
101+
102+
Args:
103+
order: The order to place.
104+
"""
105+
106+
response = await self.exchange_client.post(
107+
url="/v1/stateless/orders", json=order.model_dump(mode="json")
108+
)
109+
response.raise_for_status()
110+
logger.info(f"Placed order: {response.json()}")
111+
return OrderAddResponse(**response.json())
112+
113+
async def process_orders(
114+
self,
115+
orders: list[OrderGetResponse],
116+
session_stream: AsyncSessionStream,
117+
) -> ProcessingResult:
118+
"""
119+
Process the orders for the given contract.
120+
121+
Args:
122+
orders: The orders to process (sorted by `placed_at` in ascending order).
123+
session_stream: A session stream to persist the state changes.
124+
"""
125+
126+
processing_result = ProcessingResult(
127+
number_of_orders_processed=0,
128+
last_processed_order_timestamp=None,
129+
)
130+
131+
if not orders:
132+
logger.debug(f"No orders to process for contract {self.contract_id}")
133+
return processing_result
134+
135+
first_order_timestamp = orders[0].placed_at
136+
last_order_timestamp = orders[-1].placed_at
137+
138+
logger.debug(
139+
f"Processing {len(orders)} orders for contract {self.contract_id}. "
140+
f"Batch interval: [{first_order_timestamp} - {last_order_timestamp}]"
141+
)
142+
143+
price_moments = []
144+
145+
# Calculate best bid and ask
146+
best_bid, best_ask = best_bid_and_ask_for_limit_orders(orders)
147+
148+
# Assume market orders have an effective price equal to the best bid or ask
149+
# TODO: Check if this assumption is correct
150+
for order in orders:
151+
if order.type == "market":
152+
order.price = best_bid if order.side == "buy" else best_ask
153+
154+
# Calculate volume and price based on the sample interval
155+
sample_volume = sum(order.quantity for order in orders)
156+
sample_price = (
157+
sum(
158+
order.price * order.quantity
159+
for order in orders
160+
if order.price is not None
161+
)
162+
/ sample_volume
163+
)
164+
165+
price_moments = {
166+
"timestamp": first_order_timestamp,
167+
"price": float(sample_price),
168+
"volume": float(sample_volume),
169+
"best_bid": float(best_bid),
170+
"best_ask": float(best_ask),
171+
}
172+
173+
await self.update_state(
174+
session_stream=session_stream,
175+
price_moments=price_moments,
176+
)
177+
178+
processing_result.number_of_orders_processed = len(orders)
179+
processing_result.last_processed_order_timestamp = last_order_timestamp
180+
181+
logger.info(
182+
f"Processed {processing_result.number_of_orders_processed} orders for contract {self.contract_id}. "
183+
f"Interval: [{first_order_timestamp} - {last_order_timestamp}]"
184+
)
185+
186+
return processing_result
187+
188+
async def update_state(
189+
self,
190+
session_stream: AsyncSessionStream,
191+
price_moments: dict,
192+
):
193+
logger.info("Updating state and strategy")
194+
195+
# Update price moments
196+
async with session_stream() as session:
197+
await session.execute(
198+
text(
199+
"""
200+
INSERT INTO price_moments (
201+
timestamp,
202+
price,
203+
volume,
204+
best_bid,
205+
best_ask
206+
)
207+
VALUES (
208+
:timestamp,
209+
:price,
210+
:volume,
211+
:best_bid,
212+
:best_ask
213+
)
214+
"""
215+
),
216+
price_moments,
217+
)
218+
await session.commit()
219+
220+
# TODO: Update strategy. Issue #22
221+
222+
async def run(self) -> None:
223+
logger.info(f"Starting exchange bot for contract {self.contract_id}")
224+
225+
current_timestamp = datetime.now(timezone.utc)
226+
start_timestamp = current_timestamp - timedelta(
227+
milliseconds=int(self.base_drift_in_ms)
228+
)
229+
end_timestamp = start_timestamp + timedelta(
230+
milliseconds=int(self.sample_interval_in_ms)
231+
)
232+
233+
logger.debug(
234+
f"Getting orders for interval [{start_timestamp} - {end_timestamp}]"
235+
)
236+
orders_in_exchange = await self.get_orders(
237+
contract_id=self.contract_id,
238+
start_time=start_timestamp,
239+
end_time=end_timestamp,
240+
)
241+
242+
while True:
243+
await self.process_orders(orders=orders_in_exchange, session_stream=db())
244+
245+
current_timestamp = datetime.now(timezone.utc)
246+
247+
start_timestamp = end_timestamp
248+
end_timestamp = start_timestamp + timedelta(
249+
milliseconds=int(self.sample_interval_in_ms)
250+
)
251+
drift_error_in_ms = (current_timestamp - start_timestamp) / timedelta(
252+
milliseconds=1
253+
)
254+
255+
### Use the error to adjust the period and keep the drift constant
256+
real_drift_in_ms = int(self.base_drift_in_ms) - drift_error_in_ms
257+
adjusted_period_in_millis = real_drift_in_ms - drift_error_in_ms
258+
# logger.debug(
259+
# f"Real drift in ms: {real_drift_in_ms}. "
260+
# f"Drift error in ms: {drift_error_in_ms}. "
261+
# f"Adjusted period in ms: {adjusted_period_in_millis}"
262+
# )
263+
await asyncio.sleep(adjusted_period_in_millis / 1000)
264+
265+
logger.debug(
266+
f"Getting orders for interval [{start_timestamp} - {end_timestamp}]"
267+
)
268+
orders_in_exchange = await self.get_orders(
269+
contract_id=self.contract_id,
270+
start_time=start_timestamp,
271+
end_time=end_timestamp,
272+
)
273+
274+
async def close(self) -> None:
275+
await self.exchange_client.aclose()
276+
277+
278+
def best_bid_and_ask_for_limit_orders(
279+
orders: list[OrderGetResponse],
280+
) -> tuple[Decimal, Decimal]:
281+
"""
282+
Calculate the minimum and maximum price from the limit orders.
283+
"""
284+
285+
limit_bids = [
286+
order.price
287+
for order in orders
288+
if order.type == "limit" and order.side == "buy" and order.price is not None
289+
]
290+
limit_asks = [
291+
order.price
292+
for order in orders
293+
if order.type == "limit" and order.side == "sell" and order.price is not None
294+
]
295+
296+
best_bid = max(limit_bids) if limit_bids else Decimal(0.00)
297+
best_ask = min(limit_asks) if limit_asks else Decimal(0.00)
298+
299+
return best_bid, best_ask
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
DROP TABLE IF EXISTS price_moments;
2+
DROP TABLE IF EXISTS ema_calculations;
3+
DROP TABLE IF EXISTS spread_analysis;
4+
DROP TABLE IF EXISTS trading_signals;

0 commit comments

Comments
 (0)