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(
133 core: ExecutionClientCore,
134 config: PolymarketExecutionClientConfig,
135 ) -> anyhow::Result<Self> {
136 config.validate()?;
137
138 let proxy_url = config.validated_proxy_url()?;
139 config.validate_signer()?;
140 let secrets = Secrets::resolve(
141 config.private_key.clone(),
142 config.api_key.clone(),
143 config.api_secret.clone(),
144 config.passphrase.clone(),
145 config.funder.clone(),
146 )
147 .context("failed to resolve Polymarket credentials")?;
148
149 let signer_address = secrets.address.clone();
150 let maker_address = resolve_maker_address(
151 config.signature_type,
152 &signer_address,
153 secrets.funder.as_deref(),
154 )?;
155 let http_client = PolymarketClobHttpClient::new_with_proxy(
156 secrets.credential.clone(),
157 signer_address.clone(),
158 config.base_url_http.clone(),
159 config.http_timeout_secs,
160 proxy_url.clone(),
161 )
162 .map_err(|e| anyhow::anyhow!("{e}"))
163 .context("failed to create CLOB HTTP client")?;
164
165 let data_api_client = PolymarketDataApiHttpClient::new_with_proxy(
166 Some(config.data_api_url()),
167 config.http_timeout_secs,
168 proxy_url.clone(),
169 )
170 .map_err(|e| anyhow::anyhow!("{e}"))
171 .context("failed to create Data API HTTP client")?;
172
173 let order_signer = OrderSigner::new(&secrets.private_key)
174 .context("failed to create order signer")?
175 .with_signer_type(config.signer_type);
176
177 let order_builder = Arc::new(PolymarketOrderBuilder::new(
178 order_signer,
179 signer_address,
180 maker_address,
181 config.signature_type,
182 ));
183
184 let retry_config = RetryConfig {
185 max_retries: config.max_retries,
186 initial_delay_ms: config.retry_delay_initial_ms,
187 max_delay_ms: config.retry_delay_max_ms,
188 backoff_factor: 2.0,
189 jitter_ms: 1_000,
190 operation_timeout_ms: Some(config.http_timeout_secs * 1_000),
191 immediate_first: false,
192 max_elapsed_ms: Some(180_000),
193 };
194
195 let settlement = Arc::new(SettlementRegistry::new(core.account_id));
196
197 let submitter = OrderSubmitter::new(
198 http_client.clone(),
199 order_builder,
200 retry_config,
201 settlement.clone(),
202 );
203
204 let ws_client = PolymarketWebSocketClient::new_user_with_proxy(
205 config.base_url_ws.clone(),
206 secrets.credential.clone(),
207 config.transport_backend,
208 proxy_url,
209 );
210
211 let ws_client = ws_client.with_socket_control(SocketControl::new(
212 core.client_id,
213 Some(*POLYMARKET_VENUE),
214 USER_STREAMS_ENDPOINT,
215 ));
216
217 let clock = get_atomic_clock_realtime();
218 let pusd = get_pusd_currency();
219 let emitter = ExecutionEventEmitter::new(
220 clock,
221 core.trader_id,
222 core.account_id,
223 AccountType::Cash,
224 Some(pusd),
225 );
226
227 let session_tasks = TaskGroup::new();
228 let pending_tasks = TaskGroup::new();
229
230 Ok(Self {
231 core,
232 clock,
233 config,
234 emitter,
235 http_client,
236 data_api_client,
237 submitter,
238 ws_client,
239 secrets,
240 session_tasks,
241 pending_tasks,
242 shutdown_errors: Vec::new(),
243 stopping: Arc::new(AtomicBool::new(false)),
244 heartbeat_healthy: Arc::new(AtomicBool::new(true)),
245 order_event_handler: None,
246 position_event_handler: None,
247 fill_observer: None,
248 void_observer: None,
249 decline_observer: None,
250 shared_token_instruments: Arc::new(AtomicMap::new()),
251 neg_risk_index: Arc::new(AtomicMap::new()),
252 pending_submits: PendingSubmitTracker::default(),
253 pending_cancels: PendingCancelTracker::default(),
254 order_contexts: Arc::new(OrderContextRegistry::default()),
255 collateral_locks: Arc::new(Mutex::new(collateral::CollateralLocks::default())),
256 fill_tracker: Arc::new(OrderFillTrackerMap::new()),
257 settlement,
258 ws_dispatch_state: Arc::new(Mutex::new(WsDispatchState::default())),
259 })
260 }
261
262 fn check_not_faulted(&self) -> anyhow::Result<()> {
263 if let Some(reason) = self.settlement.client_fault_reason() {
264 anyhow::bail!("Polymarket execution client is faulted closed until restart: {reason}");
265 }
266
267 Ok(())
268 }
269}
270
271fn resolve_maker_address(
272 signature_type: PolymarketSignatureType,
273 signer_address: &str,
274 funder: Option<&str>,
275) -> anyhow::Result<String> {
276 let maker_address = match signature_type {
277 PolymarketSignatureType::Eoa => funder.unwrap_or(signer_address),
278 PolymarketSignatureType::PolyProxy
279 | PolymarketSignatureType::PolyGnosisSafe
280 | PolymarketSignatureType::Poly1271 => funder.ok_or_else(|| {
281 anyhow::anyhow!(
282 "Polymarket {signature_type:?} signature type requires a funder wallet address",
283 )
284 })?,
285 };
286
287 if signature_type != PolymarketSignatureType::Eoa
288 && maker_address.eq_ignore_ascii_case(signer_address)
289 {
290 anyhow::bail!(
291 "Polymarket {signature_type:?} signature type requires a funder distinct from the signing address",
292 );
293 }
294
295 Ok(maker_address.to_string())
296}
297
298#[async_trait(?Send)]
299impl ExecutionClient for PolymarketExecutionClient {
300 fn is_connected(&self) -> bool {
301 self.core.is_connected()
302 && !self.settlement.client_faulted()
303 && (!self.config.heartbeat_enabled
304 || self
305 .heartbeat_healthy
306 .load(std::sync::atomic::Ordering::Acquire))
307 }
308
309 fn client_id(&self) -> ClientId {
310 self.core.client_id
311 }
312
313 fn account_id(&self) -> AccountId {
314 self.core.account_id
315 }
316
317 fn venue(&self) -> Venue {
318 *POLYMARKET_VENUE
319 }
320
321 fn oms_type(&self) -> OmsType {
322 OmsType::Netting
323 }
324
325 fn get_account(&self) -> Option<AccountAny> {
326 self.core.cache().account_owned(&self.core.account_id)
327 }
328
329 fn position_reconciliation_tolerance(&self) -> Decimal {
330 crate::common::consts::POSITION_RECONCILIATION_TOLERANCE
331 }
332
333 fn allows_reconciliation_overfill(&self, order: &OrderAny, report: &FillReport) -> bool {
334 order.order_side() == OrderSide::Buy
335 && report.order_side == OrderSide::Buy
336 && !order.is_quote_quantity()
337 && order
338 .venue_order_ids()
339 .iter()
340 .any(|venue_order_id| **venue_order_id == report.venue_order_id)
341 }
342
343 fn provides_bulk_position_coverage(&self, _instrument_id: InstrumentId) -> bool {
346 false
347 }
348
349 fn generate_account_state(
350 &self,
351 balances: Vec<AccountBalance>,
352 margins: Vec<MarginBalance>,
353 reported: bool,
354 ts_event: UnixNanos,
355 info: Option<Params>,
356 ) -> anyhow::Result<()> {
357 self.emitter
358 .emit_account_state(balances, margins, reported, ts_event, info);
359 Ok(())
360 }
361
362 fn start(&mut self) -> anyhow::Result<()> {
363 self.start_client();
364 Ok(())
365 }
366
367 fn stop(&mut self) -> anyhow::Result<()> {
368 self.stop_client();
369 Ok(())
370 }
371
372 fn reset(&mut self) -> anyhow::Result<()> {
373 self.reset_client();
374 Ok(())
375 }
376
377 fn submit_order(&self, cmd: SubmitOrder) -> anyhow::Result<()> {
378 self.settlement.ensure_submission_allowed()?;
379 self.submit_order_command(&cmd)
380 }
381
382 fn submit_order_list(&self, cmd: SubmitOrderList) -> anyhow::Result<()> {
383 self.settlement.ensure_submission_allowed()?;
384 self.submit_order_list_command(&cmd);
385 Ok(())
386 }
387
388 fn modify_order(&self, cmd: ModifyOrder) -> anyhow::Result<()> {
389 self.check_not_faulted()?;
390
391 if let Err(e) = self.settlement.ensure_submission_allowed() {
392 self.emitter.emit_order_modify_rejected_event(
393 cmd.strategy_id,
394 cmd.instrument_id,
395 cmd.client_order_id,
396 cmd.venue_order_id,
397 &e.to_string(),
398 self.clock.get_time_ns(),
399 );
400 return Ok(());
401 }
402
403 self.modify_order_command(&cmd);
404 Ok(())
405 }
406
407 fn cancel_order(&self, cmd: CancelOrder) -> anyhow::Result<()> {
408 self.check_not_faulted()?;
409 self.cancel_order_command(&cmd);
410 Ok(())
411 }
412
413 fn cancel_all_orders(&self, cmd: CancelAllOrders) -> anyhow::Result<()> {
414 self.check_not_faulted()?;
415 self.cancel_all_orders_command(&cmd)
416 }
417
418 fn batch_cancel_orders(&self, cmd: BatchCancelOrders) -> anyhow::Result<()> {
419 self.check_not_faulted()?;
420 self.batch_cancel_orders_command(&cmd);
421 Ok(())
422 }
423
424 fn query_account(&self, cmd: QueryAccount) -> anyhow::Result<()> {
425 self.query_account_command(cmd);
426 Ok(())
427 }
428
429 fn query_order(&self, cmd: QueryOrder) -> anyhow::Result<()> {
430 self.query_order_command(&cmd);
431 Ok(())
432 }
433
434 fn retain_unresolved_submissions(&self) -> bool {
435 true
436 }
437
438 fn register_external_order(
439 &self,
440 _client_order_id: ClientOrderId,
441 _venue_order_id: VenueOrderId,
442 _instrument_id: InstrumentId,
443 _strategy_id: StrategyId,
444 _ts_init: UnixNanos,
445 ) {
446 }
447
448 fn on_instrument(&mut self, instrument: InstrumentAny) {
449 self.on_instrument_update(&instrument);
450 }
451
452 fn calculate_commission(
453 &self,
454 instrument: &InstrumentAny,
455 last_qty: Quantity,
456 last_px: Price,
457 liquidity_side: LiquiditySide,
458 ) -> anyhow::Result<Option<Money>> {
459 self.calculate_commission_impl(instrument, last_qty, last_px, liquidity_side)
460 .map(Some)
461 }
462
463 async fn connect(&mut self) -> anyhow::Result<()> {
464 self.connect_client().await
465 }
466
467 async fn disconnect(&mut self) -> anyhow::Result<()> {
468 self.disconnect_client().await
469 }
470
471 async fn generate_order_status_report(
472 &self,
473 cmd: &GenerateOrderStatusReport,
474 ) -> anyhow::Result<Option<OrderStatusReport>> {
475 gate_report(
476 || {
477 self.settlement
478 .ensure_resolved(cmd.instrument_id, "order status report")
479 },
480 Box::pin(self.generate_order_status_report_impl(cmd)),
481 )
482 .await
483 }
484
485 async fn generate_order_status_reports(
486 &self,
487 cmd: &GenerateOrderStatusReports,
488 ) -> anyhow::Result<Vec<OrderStatusReport>> {
489 gate_report(
490 || {
491 self.settlement
492 .ensure_resolved(cmd.instrument_id, "order status reports")
493 },
494 self.generate_order_status_reports_impl(cmd),
495 )
496 .await
497 }
498
499 async fn generate_fill_reports(
500 &self,
501 cmd: GenerateFillReports,
502 ) -> anyhow::Result<Vec<FillReport>> {
503 let (instrument_id, venue_order_id) = (cmd.instrument_id, cmd.venue_order_id);
504 gate_report(
505 || match venue_order_id {
506 Some(venue_order_id) => self
507 .settlement
508 .ensure_order_resolved(&venue_order_id, "fill reports"),
509 None => self
510 .settlement
511 .ensure_resolved(instrument_id, "fill reports"),
512 },
513 self.generate_fill_reports_impl(cmd),
514 )
515 .await
516 }
517
518 async fn generate_position_status_reports(
519 &self,
520 cmd: &GeneratePositionStatusReports,
521 ) -> anyhow::Result<Vec<PositionStatusReport>> {
522 gate_report(
523 || {
524 self.settlement
525 .ensure_resolved(cmd.instrument_id, "position status reports")
526 },
527 self.generate_position_status_reports_impl(cmd),
528 )
529 .await
530 }
531
532 async fn generate_mass_status(
533 &self,
534 lookback_mins: Option<u64>,
535 ) -> anyhow::Result<Option<ExecutionMassStatus>> {
536 gate_report(
537 || self.settlement.ensure_resolved(None, "mass status"),
538 self.generate_mass_status_impl(lookback_mins),
539 )
540 .await
541 }
542}
543
544async fn gate_report<T>(
547 gate: impl Fn() -> anyhow::Result<()>,
548 report: impl Future<Output = anyhow::Result<T>>,
549) -> anyhow::Result<T> {
550 gate()?;
551 let report = report.await?;
552 gate()?;
553 Ok(report)
554}
555
556#[cfg(test)]
557mod tests {
558 use rstest::rstest;
559
560 use super::*;
561
562 #[rstest]
563 #[tokio::test]
564 async fn test_gate_report_rechecks_settlement_after_venue_reads() {
565 let settlement = SettlementRegistry::new(AccountId::from("POLYMARKET-001"));
566
567 let result = gate_report(|| settlement.ensure_resolved(None, "mass status"), async {
568 settlement.quarantine_invalid_trade("trade-quarantined-during-read");
569 Ok(())
570 })
571 .await;
572
573 assert_eq!(
574 result.unwrap_err().to_string(),
575 "cannot generate mass status: Polymarket settlement registry holds 1 record(s) with \
576 unresolved evidence"
577 );
578 }
579
580 #[rstest]
581 #[case(PolymarketSignatureType::PolyProxy)]
582 #[case(PolymarketSignatureType::PolyGnosisSafe)]
583 #[case(PolymarketSignatureType::Poly1271)]
584 fn proxy_signature_types_require_funder(#[case] signature_type: PolymarketSignatureType) {
585 let error = resolve_maker_address(signature_type, "0xsigner", None).unwrap_err();
586
587 assert!(
588 error
589 .to_string()
590 .contains("requires a funder wallet address")
591 );
592 }
593
594 #[rstest]
595 #[case(PolymarketSignatureType::PolyProxy)]
596 #[case(PolymarketSignatureType::PolyGnosisSafe)]
597 #[case(PolymarketSignatureType::Poly1271)]
598 fn proxy_signature_types_require_distinct_funder(
599 #[case] signature_type: PolymarketSignatureType,
600 ) {
601 let error =
602 resolve_maker_address(signature_type, "0xsigner", Some("0xSIGNER")).unwrap_err();
603
604 assert!(error.to_string().contains("requires a funder distinct"));
605 }
606
607 #[rstest]
608 #[case(None, "0xsigner")]
609 #[case(Some("0xfunder"), "0xfunder")]
610 fn eoa_uses_configured_funder_or_signer(#[case] funder: Option<&str>, #[case] expected: &str) {
611 let maker_address =
612 resolve_maker_address(PolymarketSignatureType::Eoa, "0xsigner", funder).unwrap();
613
614 assert_eq!(maker_address, expected);
615 }
616
617 #[rstest]
618 fn test_execution_client_rejects_duplicate_config_before_setup() {
619 use std::{cell::RefCell, rc::Rc};
620
621 use nautilus_common::{cache::Cache, config::ConfigError};
622 use nautilus_model::{
623 enums::{AccountType, OmsType},
624 identifiers::{TraderId, Venue},
625 };
626
627 use crate::config::PolymarketInstrumentProviderConfig;
628
629 let core = ExecutionClientCore::new(
630 TraderId::from("TESTER-001"),
631 ClientId::from("POLYMARKET"),
632 Venue::from("POLYMARKET"),
633 OmsType::Netting,
634 AccountId::from("POLYMARKET-001"),
635 AccountType::Cash,
636 None,
637 Rc::new(RefCell::new(Cache::default())),
638 );
639
640 let config = PolymarketExecutionClientConfig {
641 instrument_config: Some(PolymarketInstrumentProviderConfig {
642 series_ids: Some(vec![1, 1]),
643 ..Default::default()
644 }),
645 ..Default::default()
646 };
647
648 let error = PolymarketExecutionClient::new(core, config)
649 .expect_err("duplicate config must fail before credentials or client setup");
650 assert_eq!(
651 error.downcast_ref::<ConfigError>(),
652 Some(&ConfigError::duplicate("series_ids", None))
653 );
654 }
655}