55
66import httpx
77from loguru import logger
8+ from pydantic import BaseModel
89from sqlalchemy import text
910
1011from ctenex .bot .db .async_session import AsyncSessionStream , db
1617from 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+
1925class 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
0 commit comments