Skip to main content

nautilus_polymarket/http/
data_api.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Provides the HTTP client for the Polymarket Data API.
17
18use std::{collections::HashMap, result::Result as StdResult};
19
20use nautilus_core::consts::NAUTILUS_USER_AGENT;
21use nautilus_model::{
22    data::TradeTick,
23    enums::AggressorSide,
24    identifiers::{InstrumentId, TradeId},
25    types::{Price, Quantity},
26};
27use nautilus_network::http::{HttpClient, HttpClientError, Method, USER_AGENT};
28
29use crate::{
30    common::enums::PolymarketOrderSide,
31    http::{
32        error::{Error, Result},
33        models::{DataApiPosition, DataApiTrade},
34    },
35};
36
37// Composite key for stabilising same-second trades across paginated responses
38fn data_api_trade_sort_key(t: &DataApiTrade) -> (i64, &str, &str, &'static str, String, String) {
39    (
40        t.timestamp,
41        t.transaction_hash.as_str(),
42        t.asset.as_str(),
43        match t.side {
44            PolymarketOrderSide::Buy => "BUY",
45            PolymarketOrderSide::Sell => "SELL",
46        },
47        t.price.to_string(),
48        t.size.to_string(),
49    )
50}
51
52// Composite TradeId: tx hashes recur across multi-fill swaps, so a per-(tx,
53// asset) sequence is appended to disambiguate fills that would otherwise
54// collide on the last 36 chars of the transaction hash.
55pub(crate) fn build_polymarket_trade_id(transaction_hash: &str, asset: &str, seq: u32) -> String {
56    let hash_suffix = if transaction_hash.len() > 24 {
57        &transaction_hash[transaction_hash.len() - 24..]
58    } else {
59        transaction_hash
60    };
61    let asset_suffix = if asset.len() > 4 {
62        &asset[asset.len() - 4..]
63    } else {
64        asset
65    };
66    format!("{hash_suffix}-{asset_suffix}-{seq:06}")
67}
68
69const POLYMARKET_DATA_API_URL: &str = "https://data-api.polymarket.com";
70
71/// Provides an unauthenticated HTTP client for the Polymarket Data API.
72///
73/// Used for fetching historical trade data from `GET /trades`.
74#[derive(Debug, Clone)]
75pub struct PolymarketDataApiHttpClient {
76    client: HttpClient,
77    base_url: String,
78}
79
80impl PolymarketDataApiHttpClient {
81    /// Creates a new [`PolymarketDataApiHttpClient`].
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if the HTTP client cannot be created.
86    pub fn new(base_url: Option<String>, timeout_secs: u64) -> StdResult<Self, HttpClientError> {
87        Ok(Self {
88            client: HttpClient::new(
89                HashMap::from([
90                    (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
91                    ("Content-Type".to_string(), "application/json".to_string()),
92                ]),
93                vec![],
94                vec![],
95                None,
96                Some(timeout_secs),
97                None,
98            )?,
99            base_url: base_url
100                .unwrap_or_else(|| POLYMARKET_DATA_API_URL.to_string())
101                .trim_end_matches('/')
102                .to_string(),
103        })
104    }
105
106    /// Fetches all positions for a user from the Data API.
107    ///
108    /// Paginates through `GET /positions?user={address}&sizeThreshold=0`
109    /// until a partial page is returned.
110    pub async fn get_positions(&self, user_address: &str) -> Result<Vec<DataApiPosition>> {
111        const PAGE_SIZE: u32 = 100;
112
113        let mut all_positions: Vec<DataApiPosition> = Vec::new();
114        let mut offset: u32 = 0;
115
116        loop {
117            let params = vec![
118                ("user".to_string(), user_address.to_string()),
119                ("limit".to_string(), PAGE_SIZE.to_string()),
120                ("offset".to_string(), offset.to_string()),
121                ("sizeThreshold".to_string(), "0".to_string()),
122                ("sortBy".to_string(), "TOKENS".to_string()),
123                ("sortDirection".to_string(), "DESC".to_string()),
124            ];
125
126            let url = format!("{}/positions", self.base_url);
127            let response = self
128                .client
129                .request_with_params(Method::GET, url, Some(&params), None, None, None, None)
130                .await
131                .map_err(Error::from_http_client)?;
132
133            if response.status.is_success() {
134                let page: Vec<DataApiPosition> =
135                    serde_json::from_slice(&response.body).map_err(Error::Serde)?;
136                let count = page.len() as u32;
137                all_positions.extend(page);
138
139                if count < PAGE_SIZE {
140                    break;
141                }
142                offset += count;
143            } else {
144                return Err(Error::from_status_code(
145                    response.status.as_u16(),
146                    &response.body,
147                ));
148            }
149        }
150
151        Ok(all_positions)
152    }
153
154    /// Fetches trades from the Data API for the given condition ID.
155    pub async fn get_trades(
156        &self,
157        condition_id: &str,
158        limit: Option<u32>,
159        offset: Option<u32>,
160    ) -> Result<Vec<DataApiTrade>> {
161        let mut params = vec![("market".to_string(), condition_id.to_string())];
162
163        if let Some(l) = limit {
164            params.push(("limit".to_string(), l.to_string()));
165        }
166
167        if let Some(o) = offset {
168            params.push(("offset".to_string(), o.to_string()));
169        }
170
171        let url = format!("{}/trades", self.base_url);
172        let response = self
173            .client
174            .request_with_params(Method::GET, url, Some(&params), None, None, None, None)
175            .await
176            .map_err(Error::from_http_client)?;
177
178        if response.status.is_success() {
179            serde_json::from_slice(&response.body).map_err(Error::Serde)
180        } else {
181            Err(Error::from_status_code(
182                response.status.as_u16(),
183                &response.body,
184            ))
185        }
186    }
187
188    /// Fetches trades and converts them to [`TradeTick`] for the given instrument.
189    ///
190    /// Automatically paginates through all available results (up to `limit`
191    /// if specified). Filters by `token_id` (since the API returns trades for
192    /// all outcomes of the condition) and returns results in chronological
193    /// order.
194    ///
195    /// The Polymarket Data API caps offset-based pagination on high-activity
196    /// markets; when this ceiling is hit a warning is logged and the trades
197    /// fetched so far are returned.
198    pub async fn request_trade_ticks(
199        &self,
200        instrument_id: InstrumentId,
201        condition_id: &str,
202        token_id: &str,
203        price_precision: u8,
204        size_precision: u8,
205        limit: Option<u32>,
206    ) -> anyhow::Result<Vec<TradeTick>> {
207        const PAGE_SIZE: u32 = 500;
208        // Polymarket Data API rejects offsets at or beyond this value
209        const MAX_OFFSET: u32 = 3000;
210
211        let page_size = limit.map_or(PAGE_SIZE, |l| l.min(PAGE_SIZE));
212        let mut all_trades: Vec<DataApiTrade> = Vec::new();
213        let mut offset: u32 = 0;
214
215        loop {
216            let page = match self
217                .get_trades(condition_id, Some(page_size), Some(offset))
218                .await
219            {
220                Ok(page) => page,
221                Err(e) => {
222                    if format!("{e}").contains("max historical activity offset") {
223                        // Public API caps pagination depth; warn and return partial
224                        log::warn!(
225                            "Polymarket public trades API hit its historical offset \
226                             ceiling for condition {condition_id}; returning partial \
227                             results: {e}",
228                        );
229                        break;
230                    }
231                    anyhow::bail!(e);
232                }
233            };
234
235            let count = page.len() as u32;
236            all_trades.extend(page);
237
238            // Partial page means no more data available
239            if count < page_size {
240                break;
241            }
242            // If we've collected enough for the caller's target, stop
243            if let Some(target) = limit
244                && all_trades.len() as u32 >= target
245            {
246                break;
247            }
248            offset += count;
249            // API hard limit on offset
250            if offset >= MAX_OFFSET {
251                break;
252            }
253        }
254
255        // Apply final truncation to honour the caller's limit
256        if let Some(target) = limit {
257            all_trades.truncate(target as usize);
258        }
259
260        Ok(parse_trade_ticks(
261            all_trades,
262            instrument_id,
263            token_id,
264            price_precision,
265            size_precision,
266        ))
267    }
268}
269
270// Extracted from `request_trade_ticks` so the parse behavior can be
271// unit-tested without HTTP
272fn parse_trade_ticks(
273    mut data_api_trades: Vec<DataApiTrade>,
274    instrument_id: InstrumentId,
275    token_id: &str,
276    price_precision: u8,
277    size_precision: u8,
278) -> Vec<TradeTick> {
279    // Composite sort to stabilise same-second trades across pages
280    data_api_trades.sort_by(|a, b| data_api_trade_sort_key(a).cmp(&data_api_trade_sort_key(b)));
281
282    let mut timestamp_counts: HashMap<u64, u32> = HashMap::new();
283    let mut tx_asset_counts: HashMap<(String, String), u32> = HashMap::new();
284    let mut trades: Vec<TradeTick> = Vec::new();
285
286    for t in data_api_trades {
287        if t.asset != token_id {
288            continue;
289        }
290
291        let price = Price::new(t.price, price_precision);
292        let size = Quantity::new(t.size, size_precision);
293        let aggressor_side = AggressorSide::from(t.side);
294
295        let base_ns = (t.timestamp as u64) * 1_000_000_000;
296        let occurrence = timestamp_counts.entry(base_ns).or_insert(0);
297        let tiebreaker = (*occurrence).min(999_999_999) as u64;
298        *occurrence += 1;
299        let ts_event = nautilus_core::UnixNanos::from(base_ns + tiebreaker);
300
301        let key = (t.transaction_hash.clone(), t.asset.clone());
302        let seq = *tx_asset_counts
303            .entry(key)
304            .and_modify(|n| *n += 1)
305            .or_insert(0);
306        let trade_id = TradeId::new(build_polymarket_trade_id(
307            &t.transaction_hash,
308            &t.asset,
309            seq,
310        ));
311
312        trades.push(TradeTick::new(
313            instrument_id,
314            price,
315            size,
316            aggressor_side,
317            trade_id,
318            ts_event,
319            ts_event,
320        ));
321    }
322
323    trades
324}
325
326#[cfg(test)]
327mod tests {
328    use nautilus_model::{
329        enums::AggressorSide,
330        identifiers::{AccountId, InstrumentId},
331    };
332    use rstest::rstest;
333    use rust_decimal::Decimal;
334
335    use super::*;
336    use crate::{
337        common::consts::USDC_DECIMALS,
338        execution::reconciliation::build_position_reports,
339        http::models::{DataApiPosition, DataApiTrade},
340    };
341
342    fn load_positions() -> Vec<DataApiPosition> {
343        let path = "test_data/data_api_positions_response.json";
344        let content = std::fs::read_to_string(path).expect("Failed to read test data");
345        serde_json::from_str(&content).expect("Failed to parse test data")
346    }
347
348    fn load_trades() -> Vec<DataApiTrade> {
349        let path = "test_data/data_api_trades_response.json";
350        let content = std::fs::read_to_string(path).expect("Failed to read test data");
351        serde_json::from_str(&content).expect("Failed to parse test data")
352    }
353
354    #[rstest]
355    fn test_data_api_position_deserialization() {
356        let positions = load_positions();
357
358        assert_eq!(positions.len(), 4);
359        assert_eq!(positions[0].size, 150.5);
360        assert_eq!(positions[0].avg_price, Some(0.55));
361        assert_eq!(
362            positions[0].condition_id,
363            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
364        );
365    }
366
367    #[rstest]
368    fn test_build_position_reports_filters_dust_and_zero() {
369        let positions = load_positions();
370        let account_id = AccountId::from("POLYMARKET-001");
371        let ts_now = nautilus_core::UnixNanos::from(1_000_000_000u64);
372
373        let reports = build_position_reports(&positions, account_id, ts_now);
374
375        // 4 positions: 150.5, 0.0, 42.0, 0.005 (dust)
376        // Only 150.5 and 42.0 pass the DUST_POSITION_THRESHOLD (0.01)
377        assert_eq!(reports.len(), 2);
378        assert!(reports[0].is_long());
379        assert!(reports[1].is_long());
380    }
381
382    #[rstest]
383    fn test_build_position_reports_carries_avg_price() {
384        let positions = load_positions();
385        let account_id = AccountId::from("POLYMARKET-001");
386        let ts_now = nautilus_core::UnixNanos::from(1_000_000_000u64);
387
388        let reports = build_position_reports(&positions, account_id, ts_now);
389
390        assert_eq!(reports.len(), 2);
391        assert_eq!(
392            reports[0].avg_px_open,
393            Some(Decimal::try_from(0.55).unwrap())
394        );
395        assert_eq!(
396            reports[1].avg_px_open,
397            Some(Decimal::try_from(0.3).unwrap())
398        );
399    }
400
401    #[rstest]
402    fn test_build_position_reports_uses_usdc_precision() {
403        let positions = load_positions();
404        let account_id = AccountId::from("POLYMARKET-001");
405        let ts_now = nautilus_core::UnixNanos::from(1_000_000_000u64);
406
407        let reports = build_position_reports(&positions, account_id, ts_now);
408
409        assert_eq!(reports.len(), 2);
410        assert_eq!(reports[0].quantity.precision, USDC_DECIMALS as u8);
411        assert_eq!(reports[1].quantity.precision, USDC_DECIMALS as u8);
412    }
413
414    #[rstest]
415    fn test_build_position_reports_handles_missing_avg_price() {
416        let positions = vec![DataApiPosition {
417            asset: "123".to_string(),
418            condition_id: "0xabc".to_string(),
419            size: 10.0,
420            avg_price: None,
421        }];
422        let account_id = AccountId::from("POLYMARKET-001");
423        let ts_now = nautilus_core::UnixNanos::from(1_000_000_000u64);
424
425        let reports = build_position_reports(&positions, account_id, ts_now);
426
427        assert_eq!(reports.len(), 1);
428        assert_eq!(reports[0].avg_px_open, None);
429    }
430
431    #[rstest]
432    fn test_data_api_trade_deserialization() {
433        let trades = load_trades();
434
435        assert_eq!(trades.len(), 3);
436
437        assert_eq!(
438            trades[0].asset,
439            "71321045863084981365469005770620412523470745398083994982746259498689308907982"
440        );
441        assert_eq!(
442            trades[0].condition_id,
443            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
444        );
445        assert_eq!(trades[0].price, 0.55);
446        assert_eq!(trades[0].size, 100.0);
447        assert_eq!(trades[0].timestamp, 1710000000);
448        assert_eq!(
449            trades[0].transaction_hash,
450            "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456"
451        );
452    }
453
454    #[rstest]
455    fn test_data_api_trade_ignores_extra_fields() {
456        let trades = load_trades();
457        // proxyWallet, title, slug should be silently ignored
458        assert_eq!(trades.len(), 3);
459    }
460
461    #[rstest]
462    fn test_build_trade_ticks_filters_by_token_id() {
463        let trades = load_trades();
464        let instrument_id = InstrumentId::from(
465            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
466        );
467        let token_id =
468            "71321045863084981365469005770620412523470745398083994982746259498689308907982";
469        let price_precision = 2u8;
470        let size_precision = 2u8;
471
472        let ticks: Vec<TradeTick> = trades
473            .into_iter()
474            .filter(|t| t.asset == token_id)
475            .map(|t| {
476                let price = Price::new(t.price, price_precision);
477                let size = Quantity::new(t.size, size_precision);
478                let aggressor_side = AggressorSide::from(t.side);
479                // TradeId max length is 36; tx hash is 66 chars, take last 36
480                let hash = &t.transaction_hash;
481                let trade_id_str = if hash.len() > 36 {
482                    &hash[hash.len() - 36..]
483                } else {
484                    hash.as_str()
485                };
486                let trade_id = TradeId::new(trade_id_str);
487                let ts_event = nautilus_core::UnixNanos::from(t.timestamp as u64 * 1_000_000_000);
488
489                TradeTick::new(
490                    instrument_id,
491                    price,
492                    size,
493                    aggressor_side,
494                    trade_id,
495                    ts_event,
496                    ts_event,
497                )
498            })
499            .collect();
500
501        // Should filter out the third trade (different asset)
502        assert_eq!(ticks.len(), 2);
503        assert_eq!(ticks[0].aggressor_side, AggressorSide::Buyer);
504        assert_eq!(ticks[1].aggressor_side, AggressorSide::Seller);
505    }
506
507    #[rstest]
508    fn test_build_trade_ticks_chronological_order() {
509        let trades = load_trades();
510        let instrument_id = InstrumentId::from(
511            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
512        );
513        let token_id =
514            "71321045863084981365469005770620412523470745398083994982746259498689308907982";
515
516        let mut ticks: Vec<TradeTick> = trades
517            .into_iter()
518            .filter(|t| t.asset == token_id)
519            .map(|t| {
520                let price = Price::new(t.price, 2);
521                let size = Quantity::new(t.size, 2);
522                let aggressor_side = AggressorSide::from(t.side);
523                // TradeId max length is 36; tx hash is 66 chars, take last 36
524                let hash = &t.transaction_hash;
525                let trade_id_str = if hash.len() > 36 {
526                    &hash[hash.len() - 36..]
527                } else {
528                    hash.as_str()
529                };
530                let trade_id = TradeId::new(trade_id_str);
531                let ts_event = nautilus_core::UnixNanos::from(t.timestamp as u64 * 1_000_000_000);
532
533                TradeTick::new(
534                    instrument_id,
535                    price,
536                    size,
537                    aggressor_side,
538                    trade_id,
539                    ts_event,
540                    ts_event,
541                )
542            })
543            .collect();
544
545        // Reverse to get chronological order (API returns newest-first)
546        ticks.reverse();
547
548        assert_eq!(ticks.len(), 2);
549        // First tick should be the older one (lower timestamp)
550        assert!(ticks[0].ts_event < ticks[1].ts_event);
551    }
552
553    fn make_trade(
554        timestamp: i64,
555        transaction_hash: &str,
556        asset: &str,
557        side: PolymarketOrderSide,
558        price: f64,
559        size: f64,
560    ) -> DataApiTrade {
561        DataApiTrade {
562            asset: asset.to_string(),
563            condition_id: "0xcond".to_string(),
564            side,
565            price,
566            size,
567            timestamp,
568            transaction_hash: transaction_hash.to_string(),
569        }
570    }
571
572    fn test_instrument_id() -> InstrumentId {
573        InstrumentId::from(
574            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
575        )
576    }
577
578    #[rstest]
579    fn test_data_api_trade_sort_key_orders_pages_deterministically() {
580        let mut trades = [
581            make_trade(1729000005, "0xZ", "T", PolymarketOrderSide::Buy, 0.5, 1.0),
582            make_trade(1729000000, "0xC", "T", PolymarketOrderSide::Buy, 0.5, 1.0),
583            make_trade(1729000000, "0xA", "T", PolymarketOrderSide::Sell, 0.5, 1.0),
584            make_trade(1729000000, "0xB", "T", PolymarketOrderSide::Buy, 0.5, 1.0),
585        ];
586
587        trades.sort_by(|a, b| data_api_trade_sort_key(a).cmp(&data_api_trade_sort_key(b)));
588
589        let order: Vec<&str> = trades.iter().map(|t| t.transaction_hash.as_str()).collect();
590        assert_eq!(order, ["0xA", "0xB", "0xC", "0xZ"]);
591    }
592
593    #[rstest]
594    fn test_data_api_trade_sort_key_uses_full_composite_for_inner_ties() {
595        // Locks ordering on the (asset, side, price, size) tail of the key
596        let mut trades = [
597            // (ts, hash) all equal; tail differs across asset/side/price/size
598            make_trade(1, "0xH", "Tb", PolymarketOrderSide::Buy, 0.5, 1.0),
599            make_trade(1, "0xH", "Ta", PolymarketOrderSide::Sell, 0.5, 1.0),
600            make_trade(1, "0xH", "Ta", PolymarketOrderSide::Buy, 0.6, 1.0),
601            make_trade(1, "0xH", "Ta", PolymarketOrderSide::Buy, 0.5, 2.0),
602            make_trade(1, "0xH", "Ta", PolymarketOrderSide::Buy, 0.5, 1.0),
603        ];
604
605        trades.sort_by(|a, b| data_api_trade_sort_key(a).cmp(&data_api_trade_sort_key(b)));
606
607        // Sort key composite: (ts, hash, asset, side, price, size)
608        // Expected ordering across the five trades:
609        //   1. asset=Ta side=BUY  price=0.5 size=1.0 (lex-min on side first)
610        //   2. asset=Ta side=BUY  price=0.5 size=2.0 (size breaks tie)
611        //   3. asset=Ta side=BUY  price=0.6 size=1.0 (price breaks tie)
612        //   4. asset=Ta side=SELL price=0.5 size=1.0 (side breaks tie)
613        //   5. asset=Tb side=BUY  price=0.5 size=1.0 (asset breaks tie)
614        let key: Vec<(String, String, f64, f64)> = trades
615            .iter()
616            .map(|t| (t.asset.clone(), t.side.to_string(), t.price, t.size))
617            .collect();
618        assert_eq!(key[0], ("Ta".into(), "BUY".into(), 0.5, 1.0));
619        assert_eq!(key[1], ("Ta".into(), "BUY".into(), 0.5, 2.0));
620        assert_eq!(key[2], ("Ta".into(), "BUY".into(), 0.6, 1.0));
621        assert_eq!(key[3], ("Ta".into(), "SELL".into(), 0.5, 1.0));
622        assert_eq!(key[4], ("Tb".into(), "BUY".into(), 0.5, 1.0));
623    }
624
625    #[rstest]
626    fn test_parse_trade_ticks_filters_other_tokens() {
627        let token_id = "T_KEEP";
628        let trades = vec![
629            make_trade(
630                1729000000,
631                "0xa",
632                token_id,
633                PolymarketOrderSide::Buy,
634                0.5,
635                1.0,
636            ),
637            make_trade(
638                1729000000,
639                "0xb",
640                "T_DROP",
641                PolymarketOrderSide::Sell,
642                0.5,
643                1.0,
644            ),
645        ];
646
647        let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2);
648
649        assert_eq!(trades.len(), 1);
650        assert_eq!(trades[0].aggressor_side, AggressorSide::Buyer);
651    }
652
653    #[rstest]
654    fn test_parse_trade_ticks_disambiguates_multi_fill_transaction() {
655        // Two fills sharing tx + asset must produce distinct TradeIds
656        let token_id = "12345token";
657        let same_hash = "0x000000000000000000000000000000000000000000000000000000000000abcdef";
658        let trades = vec![
659            make_trade(
660                1729000000,
661                same_hash,
662                token_id,
663                PolymarketOrderSide::Buy,
664                0.5,
665                1.0,
666            ),
667            make_trade(
668                1729000000,
669                same_hash,
670                token_id,
671                PolymarketOrderSide::Sell,
672                0.5,
673                1.0,
674            ),
675        ];
676
677        let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2);
678
679        assert_eq!(trades.len(), 2);
680        assert_ne!(trades[0].trade_id, trades[1].trade_id);
681        // ts_event monotonic: same epoch second + nanosecond tiebreaker
682        assert!(trades[0].ts_event < trades[1].ts_event);
683        assert_eq!(
684            u64::from(trades[1].ts_event) - u64::from(trades[0].ts_event),
685            1
686        );
687        // ID format ends with the per-(tx, asset) sequence
688        assert!(trades[0].trade_id.to_string().ends_with("-000000"));
689        assert!(trades[1].trade_id.to_string().ends_with("-000001"));
690    }
691
692    #[rstest]
693    fn test_parse_trade_ticks_distinct_tx_share_timestamp() {
694        // Different transactions in the same epoch second still get distinct
695        // ts_event values (the tiebreaker is per-second, not per-transaction).
696        let token_id = "T";
697        let trades = vec![
698            make_trade(
699                1729000000,
700                "0xtx1",
701                token_id,
702                PolymarketOrderSide::Buy,
703                0.5,
704                1.0,
705            ),
706            make_trade(
707                1729000000,
708                "0xtx2",
709                token_id,
710                PolymarketOrderSide::Buy,
711                0.5,
712                1.0,
713            ),
714            make_trade(
715                1729000000,
716                "0xtx3",
717                token_id,
718                PolymarketOrderSide::Buy,
719                0.5,
720                1.0,
721            ),
722        ];
723
724        let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2);
725
726        assert_eq!(trades.len(), 3);
727        // Strictly increasing ts_event
728        assert!(trades[0].ts_event < trades[1].ts_event);
729        assert!(trades[1].ts_event < trades[2].ts_event);
730        // Each trade is the first fill on its (tx, asset) so all have seq 0
731        for trade in &trades {
732            assert!(trade.trade_id.to_string().ends_with("-000000"));
733        }
734    }
735
736    #[rstest]
737    fn test_parse_trade_ticks_assigns_per_second_tiebreakers() {
738        // Same-second fills get strictly increasing nanosecond tiebreakers
739        // starting at zero, all bounded below 1 second.
740        let token_id = "T";
741        let mut trades = Vec::new();
742
743        for i in 0..3 {
744            let hash = format!("0x{i:064x}");
745            trades.push(make_trade(
746                1729000000,
747                &hash,
748                token_id,
749                PolymarketOrderSide::Buy,
750                0.5,
751                1.0,
752            ));
753        }
754
755        let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2);
756
757        assert_eq!(trades.len(), 3);
758        let base_ns = 1_729_000_000u64 * 1_000_000_000;
759
760        for (i, trade) in trades.iter().enumerate() {
761            assert!(u64::from(trade.ts_event) - base_ns < 1_000_000_000);
762            assert_eq!(u64::from(trade.ts_event) - base_ns, i as u64);
763        }
764    }
765
766    #[rstest]
767    fn test_parse_trade_ticks_sorts_inputs_by_composite_key() {
768        // Mirror what the API may return: same-second fills delivered out
769        // of order. parse_trade_ticks must produce a deterministic stream.
770        let token_id = "T";
771        let trades = vec![
772            make_trade(
773                1729000005,
774                "0xZ",
775                token_id,
776                PolymarketOrderSide::Buy,
777                0.5,
778                1.0,
779            ),
780            make_trade(
781                1729000000,
782                "0xC",
783                token_id,
784                PolymarketOrderSide::Buy,
785                0.5,
786                1.0,
787            ),
788            make_trade(
789                1729000000,
790                "0xA",
791                token_id,
792                PolymarketOrderSide::Sell,
793                0.5,
794                1.0,
795            ),
796            make_trade(
797                1729000000,
798                "0xB",
799                token_id,
800                PolymarketOrderSide::Buy,
801                0.5,
802                1.0,
803            ),
804        ];
805
806        let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2);
807
808        assert_eq!(trades.len(), 4);
809
810        // Strictly non-decreasing ts_event
811        for i in 1..trades.len() {
812            assert!(trades[i - 1].ts_event <= trades[i].ts_event);
813        }
814
815        // Composite tiebreaker: same-second trades order by transactionHash
816        let trade_ids: Vec<String> = trades.iter().map(|t| t.trade_id.to_string()).collect();
817        assert!(trade_ids[0].contains("0xA"));
818        assert!(trade_ids[1].contains("0xB"));
819        assert!(trade_ids[2].contains("0xC"));
820        assert!(trade_ids[3].contains("0xZ"));
821    }
822}