Skip to content

Commit 2ad94dc

Browse files
committed
fix: drop intervals from order processing as there is a fixed time range for all the orders passed in; fix: add drift adjustment; chore: remove poll attribute
1 parent 83ab348 commit 2ad94dc

6 files changed

Lines changed: 458 additions & 101 deletions

File tree

ctenex/bot/bots/alpha/exchange_bot.py

Lines changed: 92 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import httpx
77
from loguru import logger
8+
from pydantic import BaseModel
89
from sqlalchemy import text
910

1011
from ctenex.bot.db.async_session import AsyncSessionStream, db
@@ -16,22 +17,27 @@
1617
from ctenex.utils.contracts import validate_contract_id
1718

1819

20+
class ProcessingResult(BaseModel):
21+
number_of_orders_processed: int
22+
last_processed_order_timestamp: datetime | None = None
23+
24+
1925
class ExchangeBot:
2026
def __init__(
2127
self,
2228
trader_id: UUID,
2329
contract_id: str,
2430
base_url: str,
2531
number_of_orders: int = 2,
26-
sample_interval: Decimal = Decimal(1000.0), # ms
27-
poll_interval: Decimal = Decimal(1000.0), # ms
32+
sample_interval_in_ms: Decimal = Decimal(1000.0),
33+
base_drift_in_ms: Decimal = Decimal(1100.0),
2834
):
2935
# Configuration
3036
self.base_url = base_url
3137
self.trader_id = trader_id
3238
self.contract_id = contract_id
33-
self.sample_interval = sample_interval
34-
self.poll_interval = poll_interval
39+
self.sample_interval_in_ms = sample_interval_in_ms
40+
self.base_drift_in_ms = base_drift_in_ms
3541
self.number_of_orders = number_of_orders
3642
self.last_processed_order_timestamp: datetime = datetime.now(timezone.utc)
3743

@@ -48,9 +54,6 @@ async def get_orders(
4854
start_time: datetime | None = None,
4955
end_time: datetime | None = None,
5056
) -> list[OrderGetResponse]:
51-
# TODO: Validate both start_time and end_time are provided
52-
logger.debug(f"Getting orders for contract {contract_id}")
53-
5457
query_parameters = {
5558
"contract_id": contract_id,
5659
"sort_by": "placed_at",
@@ -80,96 +83,71 @@ async def process_orders(
8083
self,
8184
orders: list[OrderGetResponse],
8285
session_stream: AsyncSessionStream,
83-
) -> None:
86+
) -> ProcessingResult:
8487
"""
8588
Orders should be sorted by placed_at in ascending order.
8689
"""
90+
processing_result = ProcessingResult(
91+
number_of_orders_processed=0,
92+
last_processed_order_timestamp=None,
93+
)
8794

8895
if not orders:
8996
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}")
97+
return processing_result
9298

93-
sample_start_time = orders[0].placed_at
94-
sample_interval_in_seconds = self.sample_interval / 1000
99+
first_order_timestamp = orders[0].placed_at
100+
last_order_timestamp = orders[-1].placed_at
95101

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
102+
logger.debug(
103+
f"Processing {len(orders)} orders for contract {self.contract_id}. "
104+
f"Batch interval: [{first_order_timestamp} - {last_order_timestamp}]"
105+
)
110106

111107
price_moments = []
112108

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-
]
109+
# Calculate best bid and ask
110+
best_bid, best_ask = min_and_max_price_for_limit_orders(orders)
124111

125-
if not sample_orders:
126-
continue
112+
# Assume market orders have an effective price equal to the best bid or ask
113+
# TODO: Check if this assumption is correct
114+
for order in orders:
115+
if order.type == "market":
116+
order.price = best_bid if order.side == "buy" else best_ask
127117

128-
# Calculate best bid and ask
129-
sample_best_bid, sample_best_ask = min_and_max_price_for_limit_orders(
130-
sample_orders
118+
# Calculate volume and price based on the sample interval
119+
sample_volume = sum(order.quantity for order in orders)
120+
sample_price = (
121+
sum(
122+
order.price * order.quantity
123+
for order in orders
124+
if order.price is not None
131125
)
126+
/ sample_volume
127+
)
132128

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-
)
129+
price_moments = {
130+
"timestamp": first_order_timestamp,
131+
"price": float(sample_price),
132+
"volume": float(sample_volume),
133+
"best_bid": float(best_bid),
134+
"best_ask": float(best_ask),
135+
}
140136

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-
)
137+
await self.update_state(
138+
session_stream=session_stream,
139+
price_moments=price_moments,
140+
)
151141

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-
}
142+
processing_result.number_of_orders_processed = len(orders)
143+
processing_result.last_processed_order_timestamp = last_order_timestamp
159144

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
145+
logger.info(
146+
f"Processed {processing_result.number_of_orders_processed} orders for contract {self.contract_id}. "
147+
f"Interval: [{first_order_timestamp} - {last_order_timestamp}]"
148+
)
167149

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}")
150+
return processing_result
173151

174152
async def update_state(
175153
self,
@@ -208,40 +186,55 @@ async def update_state(
208186
async def run(self) -> None:
209187
logger.info(f"Starting exchange bot for contract {self.contract_id}")
210188

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)
189+
current_timestamp = datetime.now(timezone.utc)
190+
start_timestamp = current_timestamp - timedelta(
191+
milliseconds=int(self.base_drift_in_ms)
192+
)
193+
end_timestamp = start_timestamp + timedelta(
194+
milliseconds=int(self.sample_interval_in_ms)
195+
)
215196

197+
logger.debug(
198+
f"Getting orders for interval [{start_timestamp} - {end_timestamp}]"
199+
)
216200
orders_in_exchange = await self.get_orders(
217201
contract_id=self.contract_id,
218-
start_time=start_time,
219-
end_time=end_time,
202+
start_time=start_timestamp,
203+
end_time=end_timestamp,
220204
)
221205

222-
# For audit only
223-
if orders_in_exchange:
224-
self.last_processed_order_timestamp = orders_in_exchange[-1].placed_at
225-
226206
while True:
227-
logger.debug(
228-
f"Orders in exchange until {self.last_processed_order_timestamp}: {len(orders_in_exchange)}"
229-
)
230207
await self.process_orders(orders=orders_in_exchange, session_stream=db())
231-
await asyncio.sleep(int(poll_interval_in_seconds))
232208

233-
end_time = datetime.now(timezone.utc)
209+
current_timestamp = datetime.now(timezone.utc)
210+
211+
start_timestamp = end_timestamp
212+
end_timestamp = start_timestamp + timedelta(
213+
milliseconds=int(self.sample_interval_in_ms)
214+
)
215+
drift_error_in_ms = (current_timestamp - start_timestamp) / timedelta(
216+
milliseconds=1
217+
)
218+
219+
### Use the error to adjust the period and keep the drift constant
220+
real_drift_in_ms = int(self.base_drift_in_ms) - drift_error_in_ms
221+
adjusted_period_in_millis = real_drift_in_ms - drift_error_in_ms
222+
# logger.debug(
223+
# f"Real drift in ms: {real_drift_in_ms}. "
224+
# f"Drift error in ms: {drift_error_in_ms}. "
225+
# f"Adjusted period in ms: {adjusted_period_in_millis}"
226+
# )
227+
await asyncio.sleep(adjusted_period_in_millis / 1000)
234228

229+
logger.debug(
230+
f"Getting orders for interval [{start_timestamp} - {end_timestamp}]"
231+
)
235232
orders_in_exchange = await self.get_orders(
236233
contract_id=self.contract_id,
237-
start_time=start_time,
238-
end_time=end_time,
234+
start_time=start_timestamp,
235+
end_time=end_timestamp,
239236
)
240237

241-
# For audit only
242-
if orders_in_exchange:
243-
self.last_processed_order_timestamp = orders_in_exchange[-1].placed_at
244-
245238
async def close(self) -> None:
246239
await self.exchange_client.aclose()
247240

ctenex/bot/db/cli.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55

66
from ctenex.bot.utils.async_typer import AsyncTyper
77

8-
DbSchemaAction = Literal["setup", "teardown"]
9-
108

119
async def apply_schema_action(
1210
bot_name: str,

ctenex/bot/order_generation/cli.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from ctenex.bot.order_generation.order_generator import OrderGenerator
2+
from ctenex.bot.settings.bot import get_bot_settings
3+
from ctenex.bot.utils.async_typer import AsyncTyper
4+
5+
settings = get_bot_settings()
6+
7+
8+
app = AsyncTyper()
9+
10+
11+
@app.command()
12+
async def run_scenario(scenario_name: str):
13+
generator = OrderGenerator(
14+
scenario_name=scenario_name,
15+
base_url=str(settings.exchange_api.base_url),
16+
)
17+
18+
generator.load_scenario()
19+
await generator.place_orders()
20+
21+
22+
if __name__ == "__main__":
23+
app()
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from datetime import datetime, timedelta
2+
from decimal import Decimal
3+
from uuid import UUID
4+
5+
from pydantic import BaseModel, Field, computed_field
6+
7+
from ctenex.domain.contracts import ContractCode
8+
from ctenex.domain.entities import OrderSide, OrderType
9+
10+
11+
class OrderSpecification(BaseModel):
12+
"""Represents a synthetic order."""
13+
14+
id: UUID = Field(description="Order UUID")
15+
contract_id: ContractCode = Field(description="Contract identifier")
16+
trader_id: UUID = Field(description="Trader UUID")
17+
side: OrderSide = Field(description="Order side (buy/sell)")
18+
type: OrderType = Field(description="Order type (limit/market)")
19+
price: Decimal = Field(description="Order price")
20+
quantity: Decimal = Field(description="Order quantity")
21+
22+
timestamp_reference: datetime = Field(
23+
default_factory=datetime.now,
24+
exclude=True,
25+
repr=False,
26+
)
27+
placed_at_absolute_timedelta_in_ms: int = Field(
28+
description="Time delta (in ms) from the reference time (absolute) to the order placement",
29+
exclude=True,
30+
repr=False,
31+
)
32+
33+
@computed_field
34+
@property
35+
def placed_at(self) -> datetime:
36+
return self.timestamp_reference + timedelta(
37+
milliseconds=self.placed_at_absolute_timedelta_in_ms
38+
)
39+
40+
41+
class Scenario(BaseModel):
42+
"""
43+
Represents a test scenario. A scenario is a collection of orders and a description
44+
of what they represent or mean.
45+
"""
46+
47+
description: str = Field(description="Description of the test scenario")
48+
orders: list[OrderSpecification] = Field(description="List of orders")
49+
50+
51+
class OrderGeneratorInput(BaseModel):
52+
"""The input for the order generator."""
53+
54+
scenario: Scenario

0 commit comments

Comments
 (0)