1pub mod order_builder;
19pub mod parse;
20
21pub(crate) mod context;
22pub(crate) mod fill_tracker;
23pub(crate) mod pending;
24pub(crate) mod reconciliation;
25pub(crate) mod settlement;
26pub(crate) mod submitter;
27pub(crate) mod types;
28
29mod cancellations;
30mod collateral;
31mod lifecycle;
32mod orders;
33mod reports;
34mod responses;
35
36use std::sync::{Arc, atomic::AtomicBool};
37
38use anyhow::Context;
39use async_trait::async_trait;
40use nautilus_common::{
41 clients::ExecutionClient,
42 messages::execution::{
43 BatchCancelOrders, CancelAllOrders, CancelOrder, GenerateFillReports,
44 GenerateOrderStatusReport, GenerateOrderStatusReports, GeneratePositionStatusReports,
45 ModifyOrder, QueryAccount, QueryOrder, SubmitOrder, SubmitOrderList,
46 },
47 msgbus::TypedHandler,
48};
49use nautilus_core::{
50 Params, UnixNanos,
51 collections::AtomicMap,
52 time::{AtomicTime, get_atomic_clock_realtime},
53};
54use nautilus_live::{ExecutionClientCore, ExecutionEventEmitter, SocketControl, task::TaskGroup};
55use nautilus_model::{
56 accounts::AccountAny,
57 enums::{AccountType, LiquiditySide, OmsType, OrderSide},
58 events::{OrderEventAny, PositionEvent},
59 identifiers::{
60 AccountId, ClientId, ClientOrderId, InstrumentId, StrategyId, Venue, VenueOrderId,
61 },
62 instruments::InstrumentAny,
63 orders::{Order, OrderAny},
64 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
65 types::{AccountBalance, MarginBalance, Money, Price, Quantity},
66};
67use nautilus_network::retry::RetryConfig;
68use parking_lot::Mutex;
69pub(crate) use responses::is_post_only_crossing;
70use rust_decimal::Decimal;
71use ustr::Ustr;
72
73pub(crate) use self::reports::get_pusd_currency;
74use self::{
75 context::OrderContextRegistry,
76 fill_tracker::OrderFillTrackerMap,
77 order_builder::PolymarketOrderBuilder,
78 pending::{PendingCancelTracker, PendingSubmitTracker},
79 settlement::SettlementRegistry,
80 submitter::OrderSubmitter,
81};
82use crate::{
83 common::{consts::POLYMARKET_VENUE, credential::Secrets, enums::PolymarketSignatureType},
84 config::PolymarketExecutionClientConfig,
85 http::{clob::PolymarketClobHttpClient, data_api::PolymarketDataApiHttpClient},
86 signing::eip712::OrderSigner,
87 websocket::{
88 USER_STREAMS_ENDPOINT, client::PolymarketWebSocketClient, dispatch::WsDispatchState,
89 },
90};
91
92#[derive(Debug)]
94pub struct PolymarketExecutionClient {
95 core: ExecutionClientCore,
96 clock: &'static AtomicTime,
97 config: PolymarketExecutionClientConfig,
98 emitter: ExecutionEventEmitter,
99 http_client: PolymarketClobHttpClient,
100 data_api_client: PolymarketDataApiHttpClient,
101 submitter: OrderSubmitter,
102 ws_client: PolymarketWebSocketClient,
103 secrets: Secrets,
104 session_tasks: TaskGroup,
105 pending_tasks: TaskGroup,
106 shutdown_errors: Vec<String>,
107 stopping: Arc<AtomicBool>,
108 heartbeat_healthy: Arc<AtomicBool>,
109 order_event_handler: Option<TypedHandler<OrderEventAny>>,
110 position_event_handler: Option<TypedHandler<PositionEvent>>,
111 fill_observer: Option<TypedHandler<OrderEventAny>>,
112 void_observer: Option<TypedHandler<OrderEventAny>>,
113 decline_observer: Option<TypedHandler<OrderEventAny>>,
114 shared_token_instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
115 neg_risk_index: Arc<AtomicMap<InstrumentId, bool>>,
116 pending_submits: PendingSubmitTracker,
117 pending_cancels: PendingCancelTracker,
118 order_contexts: Arc<OrderContextRegistry>,
119 collateral_locks: Arc<Mutex<collateral::CollateralLocks>>,
120 fill_tracker: Arc<OrderFillTrackerMap>,
121 settlement: Arc<SettlementRegistry>,
122 ws_dispatch_state: Arc<Mutex<WsDispatchState>>,
123}
124
125impl PolymarketExecutionClient {
126 pub fn new(
132 core: ExecutionClientCore,
133 config: PolymarketExecutionClientConfig,
134 ) -> anyhow::Result<Self> {
135 let proxy_url = config.validated_proxy_url()?;
136 config.validate_signer()?;
137 let secrets = Secrets::resolve(
138 config.private_key.clone(),
139 config.api_key.clone(),
140 config.api_secret.clone(),
141 config.passphrase.clone(),
142 config.funder.clone(),
143 )
144 .context("failed to resolve Polymarket credentials")?;
145
146 let signer_address = secrets.address.clone();
147 let maker_address = resolve_maker_address(
148 config.signature_type,
149 &signer_address,
150 secrets.funder.as_deref(),
151 )?;
152 let http_client = PolymarketClobHttpClient::new_with_proxy(
153 secrets.credential.clone(),
154 signer_address.clone(),
155 config.base_url_http.clone(),
156 config.http_timeout_secs,
157 proxy_url.clone(),
158 )
159 .map_err(|e| anyhow::anyhow!("{e}"))
160 .context("failed to create CLOB HTTP client")?;
161
162 let data_api_client = PolymarketDataApiHttpClient::new_with_proxy(
163 Some(config.data_api_url()),
164 config.http_timeout_secs,
165 proxy_url.clone(),
166 )
167 .map_err(|e| anyhow::anyhow!("{e}"))
168 .context("failed to create Data API HTTP client")?;
169
170 let order_signer = OrderSigner::new(&secrets.private_key)
171 .context("failed to create order signer")?
172 .with_signer_type(config.signer_type);
173
174 let order_builder = Arc::new(PolymarketOrderBuilder::new(
175 order_signer,
176 signer_address,
177 maker_address,
178 config.signature_type,
179 ));
180
181 let retry_config = RetryConfig {
182 max_retries: config.max_retries,
183 initial_delay_ms: config.retry_delay_initial_ms,
184 max_delay_ms: config.retry_delay_max_ms,
185 backoff_factor: 2.0,
186 jitter_ms: 1_000,
187 operation_timeout_ms: Some(config.http_timeout_secs * 1_000),
188 immediate_first: false,
189 max_elapsed_ms: Some(180_000),
190 };
191
192 let settlement = Arc::new(SettlementRegistry::new(core.account_id));
193
194 let submitter = OrderSubmitter::new(
195 http_client.clone(),
196 order_builder,
197 retry_config,
198 settlement.clone(),
199 );
200
201 let ws_client = PolymarketWebSocketClient::new_user_with_proxy(
202 config.base_url_ws.clone(),
203 secrets.credential.clone(),
204 config.transport_backend,
205 proxy_url,
206 );
207
208 let ws_client = ws_client.with_socket_control(SocketControl::new(
209 core.client_id,
210 Some(*POLYMARKET_VENUE),
211 USER_STREAMS_ENDPOINT,
212 ));
213
214 let clock = get_atomic_clock_realtime();
215 let pusd = get_pusd_currency();
216 let emitter = ExecutionEventEmitter::new(
217 clock,
218 core.trader_id,
219 core.account_id,
220 AccountType::Cash,
221 Some(pusd),
222 );
223
224 let session_tasks = TaskGroup::new();
225 let pending_tasks = TaskGroup::new();
226
227 Ok(Self {
228 core,
229 clock,
230 config,
231 emitter,
232 http_client,
233 data_api_client,
234 submitter,
235 ws_client,
236 secrets,
237 session_tasks,
238 pending_tasks,
239 shutdown_errors: Vec::new(),
240 stopping: Arc::new(AtomicBool::new(false)),
241 heartbeat_healthy: Arc::new(AtomicBool::new(true)),
242 order_event_handler: None,
243 position_event_handler: None,
244 fill_observer: None,
245 void_observer: None,
246 decline_observer: None,
247 shared_token_instruments: Arc::new(AtomicMap::new()),
248 neg_risk_index: Arc::new(AtomicMap::new()),
249 pending_submits: PendingSubmitTracker::default(),
250 pending_cancels: PendingCancelTracker::default(),
251 order_contexts: Arc::new(OrderContextRegistry::default()),
252 collateral_locks: Arc::new(Mutex::new(collateral::CollateralLocks::default())),
253 fill_tracker: Arc::new(OrderFillTrackerMap::new()),
254 settlement,
255 ws_dispatch_state: Arc::new(Mutex::new(WsDispatchState::default())),
256 })
257 }
258
259 fn check_not_faulted(&self) -> anyhow::Result<()> {
260 if let Some(reason) = self.settlement.client_fault_reason() {
261 anyhow::bail!("Polymarket execution client is faulted closed until restart: {reason}");
262 }
263
264 Ok(())
265 }
266}
267
268fn resolve_maker_address(
269 signature_type: PolymarketSignatureType,
270 signer_address: &str,
271 funder: Option<&str>,
272) -> anyhow::Result<String> {
273 let maker_address = match signature_type {
274 PolymarketSignatureType::Eoa => funder.unwrap_or(signer_address),
275 PolymarketSignatureType::PolyProxy
276 | PolymarketSignatureType::PolyGnosisSafe
277 | PolymarketSignatureType::Poly1271 => funder.ok_or_else(|| {
278 anyhow::anyhow!(
279 "Polymarket {signature_type:?} signature type requires a funder wallet address",
280 )
281 })?,
282 };
283
284 if signature_type != PolymarketSignatureType::Eoa
285 && maker_address.eq_ignore_ascii_case(signer_address)
286 {
287 anyhow::bail!(
288 "Polymarket {signature_type:?} signature type requires a funder distinct from the signing address",
289 );
290 }
291
292 Ok(maker_address.to_string())
293}
294
295#[async_trait(?Send)]
296impl ExecutionClient for PolymarketExecutionClient {
297 fn is_connected(&self) -> bool {
298 self.core.is_connected()
299 && !self.settlement.client_faulted()
300 && (!self.config.heartbeat_enabled
301 || self
302 .heartbeat_healthy
303 .load(std::sync::atomic::Ordering::Acquire))
304 }
305
306 fn client_id(&self) -> ClientId {
307 self.core.client_id
308 }
309
310 fn account_id(&self) -> AccountId {
311 self.core.account_id
312 }
313
314 fn venue(&self) -> Venue {
315 *POLYMARKET_VENUE
316 }
317
318 fn oms_type(&self) -> OmsType {
319 OmsType::Netting
320 }
321
322 fn get_account(&self) -> Option<AccountAny> {
323 self.core.cache().account_owned(&self.core.account_id)
324 }
325
326 fn position_reconciliation_tolerance(&self) -> Decimal {
327 crate::common::consts::POSITION_RECONCILIATION_TOLERANCE
328 }
329
330 fn allows_reconciliation_overfill(&self, order: &OrderAny, report: &FillReport) -> bool {
331 order.order_side() == OrderSide::Buy
332 && report.order_side == OrderSide::Buy
333 && !order.is_quote_quantity()
334 && order
335 .venue_order_ids()
336 .iter()
337 .any(|venue_order_id| **venue_order_id == report.venue_order_id)
338 }
339
340 fn provides_bulk_position_coverage(&self, _instrument_id: InstrumentId) -> bool {
343 false
344 }
345
346 fn generate_account_state(
347 &self,
348 balances: Vec<AccountBalance>,
349 margins: Vec<MarginBalance>,
350 reported: bool,
351 ts_event: UnixNanos,
352 info: Option<Params>,
353 ) -> anyhow::Result<()> {
354 self.emitter
355 .emit_account_state(balances, margins, reported, ts_event, info);
356 Ok(())
357 }
358
359 fn start(&mut self) -> anyhow::Result<()> {
360 self.start_client();
361 Ok(())
362 }
363
364 fn stop(&mut self) -> anyhow::Result<()> {
365 self.stop_client();
366 Ok(())
367 }
368
369 fn reset(&mut self) -> anyhow::Result<()> {
370 self.reset_client();
371 Ok(())
372 }
373
374 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
375 self.check_not_faulted()?;
376 self.submit_order_command(&cmd)
377 }
378
379 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
380 self.check_not_faulted()?;
381 self.submit_order_list_command(&cmd);
382 Ok(())
383 }
384
385 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
386 self.check_not_faulted()?;
387 self.modify_order_command(&cmd);
388 Ok(())
389 }
390
391 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
392 self.check_not_faulted()?;
393 self.cancel_order_command(&cmd);
394 Ok(())
395 }
396
397 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
398 self.check_not_faulted()?;
399 self.cancel_all_orders_command(&cmd)
400 }
401
402 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
403 self.check_not_faulted()?;
404 self.batch_cancel_orders_command(&cmd);
405 Ok(())
406 }
407
408 fn query_account(&self, cmd: QueryAccount) -> anyhow::Result<()> {
409 self.query_account_command(cmd);
410 Ok(())
411 }
412
413 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
414 self.query_order_command(&cmd);
415 Ok(())
416 }
417
418 fn retain_unresolved_submissions(&self) -> bool {
419 true
420 }
421
422 fn register_external_order(
423 &self,
424 _client_order_id: ClientOrderId,
425 _venue_order_id: VenueOrderId,
426 _instrument_id: InstrumentId,
427 _strategy_id: StrategyId,
428 _ts_init: UnixNanos,
429 ) {
430 }
431
432 fn on_instrument(&mut self, instrument: InstrumentAny) {
433 self.on_instrument_update(&instrument);
434 }
435
436 fn calculate_commission(
437 &self,
438 instrument: &InstrumentAny,
439 last_qty: Quantity,
440 last_px: Price,
441 liquidity_side: LiquiditySide,
442 ) -> anyhow::Result<Option<Money>> {
443 self.calculate_commission_impl(instrument, last_qty, last_px, liquidity_side)
444 .map(Some)
445 }
446
447 async fn connect(&mut self) -> anyhow::Result<()> {
448 self.connect_client().await
449 }
450
451 async fn disconnect(&mut self) -> anyhow::Result<()> {
452 self.disconnect_client().await
453 }
454
455 async fn generate_order_status_report(
456 &self,
457 cmd: &GenerateOrderStatusReport,
458 ) -> anyhow::Result<Option<OrderStatusReport>> {
459 gate_report(
460 || {
461 self.settlement
462 .ensure_resolved(cmd.instrument_id, "order status report")
463 },
464 Box::pin(self.generate_order_status_report_impl(cmd)),
465 )
466 .await
467 }
468
469 async fn generate_order_status_reports(
470 &self,
471 cmd: &GenerateOrderStatusReports,
472 ) -> anyhow::Result<Vec<OrderStatusReport>> {
473 gate_report(
474 || {
475 self.settlement
476 .ensure_resolved(cmd.instrument_id, "order status reports")
477 },
478 self.generate_order_status_reports_impl(cmd),
479 )
480 .await
481 }
482
483 async fn generate_fill_reports(
484 &self,
485 cmd: GenerateFillReports,
486 ) -> anyhow::Result<Vec<FillReport>> {
487 let (instrument_id, venue_order_id) = (cmd.instrument_id, cmd.venue_order_id);
488 gate_report(
489 || match venue_order_id {
490 Some(venue_order_id) => self
491 .settlement
492 .ensure_order_resolved(&venue_order_id, "fill reports"),
493 None => self
494 .settlement
495 .ensure_resolved(instrument_id, "fill reports"),
496 },
497 self.generate_fill_reports_impl(cmd),
498 )
499 .await
500 }
501
502 async fn generate_position_status_reports(
503 &self,
504 cmd: &GeneratePositionStatusReports,
505 ) -> anyhow::Result<Vec<PositionStatusReport>> {
506 gate_report(
507 || {
508 self.settlement
509 .ensure_resolved(cmd.instrument_id, "position status reports")
510 },
511 self.generate_position_status_reports_impl(cmd),
512 )
513 .await
514 }
515
516 async fn generate_mass_status(
517 &self,
518 lookback_mins: Option<u64>,
519 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
520 gate_report(
521 || self.settlement.ensure_resolved(None, "mass status"),
522 self.generate_mass_status_impl(lookback_mins),
523 )
524 .await
525 }
526}
527
528async fn gate_report<T>(
531 gate: impl Fn() -> anyhow::Result<()>,
532 report: impl Future<Output = anyhow::Result<T>>,
533) -> anyhow::Result<T> {
534 gate()?;
535 let report = report.await?;
536 gate()?;
537 Ok(report)
538}
539
540#[cfg(test)]
541mod tests {
542 use rstest::rstest;
543
544 use super::*;
545
546 #[rstest]
547 #[tokio::test]
548 async fn test_gate_report_rechecks_settlement_after_venue_reads() {
549 let settlement = SettlementRegistry::new(AccountId::from("POLYMARKET-001"));
550
551 let result = gate_report(|| settlement.ensure_resolved(None, "mass status"), async {
552 settlement.quarantine_invalid_trade("trade-quarantined-during-read");
553 Ok(())
554 })
555 .await;
556
557 assert_eq!(
558 result.unwrap_err().to_string(),
559 "cannot generate mass status: Polymarket settlement registry holds 1 record(s) with \
560 unresolved evidence"
561 );
562 }
563
564 #[rstest]
565 #[case(PolymarketSignatureType::PolyProxy)]
566 #[case(PolymarketSignatureType::PolyGnosisSafe)]
567 #[case(PolymarketSignatureType::Poly1271)]
568 fn proxy_signature_types_require_funder(#[case] signature_type: PolymarketSignatureType) {
569 let error = resolve_maker_address(signature_type, "0xsigner", None).unwrap_err();
570
571 assert!(
572 error
573 .to_string()
574 .contains("requires a funder wallet address")
575 );
576 }
577
578 #[rstest]
579 #[case(PolymarketSignatureType::PolyProxy)]
580 #[case(PolymarketSignatureType::PolyGnosisSafe)]
581 #[case(PolymarketSignatureType::Poly1271)]
582 fn proxy_signature_types_require_distinct_funder(
583 #[case] signature_type: PolymarketSignatureType,
584 ) {
585 let error =
586 resolve_maker_address(signature_type, "0xsigner", Some("0xSIGNER")).unwrap_err();
587
588 assert!(error.to_string().contains("requires a funder distinct"));
589 }
590
591 #[rstest]
592 #[case(None, "0xsigner")]
593 #[case(Some("0xfunder"), "0xfunder")]
594 fn eoa_uses_configured_funder_or_signer(#[case] funder: Option<&str>, #[case] expected: &str) {
595 let maker_address =
596 resolve_maker_address(PolymarketSignatureType::Eoa, "0xsigner", funder).unwrap();
597
598 assert_eq!(maker_address, expected);
599 }
600}