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, convert::Infallible, result::Result as StdResult};
19
20use anyhow::Context;
21use nautilus_core::{UnixNanos, consts::NAUTILUS_USER_AGENT};
22use nautilus_model::{
23    data::TradeTick,
24    enums::AggressorSide,
25    identifiers::{InstrumentId, TradeId},
26    types::{Price, Quantity},
27};
28use nautilus_network::{
29    http::{HttpClient, HttpClientError, Method, USER_AGENT},
30    websocket::proxy::ProxyUrl,
31};
32use rust_decimal::Decimal;
33
34use crate::{
35    common::enums::PolymarketOrderSide,
36    http::{
37        error::{Error, Result},
38        models::{DataApiPosition, DataApiTrade},
39        pagination::{
40            CollectAll, Completion, FetchOutcome, OffsetProtocol, PageFingerprint, PageReducer,
41            Paginator, encode_length_prefixed, fingerprint_multiset,
42        },
43    },
44};
45
46// Composite key for stabilising same-second trades across paginated responses
47fn data_api_trade_sort_key(t: &DataApiTrade) -> (i64, &str, &str, &'static str, Decimal, Decimal) {
48    (
49        t.timestamp,
50        t.transaction_hash.as_str(),
51        t.asset.as_str(),
52        match t.side {
53            PolymarketOrderSide::Buy => "BUY",
54            PolymarketOrderSide::Sell => "SELL",
55        },
56        t.price,
57        t.size,
58    )
59}
60
61// Composite TradeId: tx hashes recur across multi-fill swaps, so a per-(tx,
62// asset) sequence is appended to disambiguate fills that would otherwise
63// collide on the last 36 chars of the transaction hash.
64pub(crate) fn build_polymarket_trade_id(transaction_hash: &str, asset: &str, seq: u32) -> String {
65    let hash_suffix = if transaction_hash.len() > 24 {
66        &transaction_hash[transaction_hash.len() - 24..]
67    } else {
68        transaction_hash
69    };
70    let asset_suffix = if asset.len() > 4 {
71        &asset[asset.len() - 4..]
72    } else {
73        asset
74    };
75    format!("{hash_suffix}-{asset_suffix}-{seq:06}")
76}
77
78const POLYMARKET_DATA_API_URL: &str = "https://data-api.polymarket.com";
79
80fn position_page_fingerprint(rows: &[DataApiPosition]) -> PageFingerprint {
81    let descriptors = rows
82        .iter()
83        .map(|position| {
84            let mut descriptor = Vec::new();
85            encode_length_prefixed(&mut descriptor, position.condition_id.as_bytes());
86            encode_length_prefixed(&mut descriptor, position.asset.as_bytes());
87            descriptor
88        })
89        .collect();
90    fingerprint_multiset(1, descriptors)
91}
92
93fn trade_page_fingerprint(rows: &[DataApiTrade]) -> PageFingerprint {
94    let descriptors = rows
95        .iter()
96        .map(|trade| {
97            let mut descriptor = Vec::new();
98            encode_length_prefixed(&mut descriptor, trade.transaction_hash.as_bytes());
99            encode_length_prefixed(&mut descriptor, trade.asset.as_bytes());
100            descriptor.push(match trade.side {
101                PolymarketOrderSide::Buy => 0,
102                PolymarketOrderSide::Sell => 1,
103            });
104            encode_decimal(&mut descriptor, trade.price);
105            encode_decimal(&mut descriptor, trade.size);
106            descriptor.extend_from_slice(&trade.timestamp.to_be_bytes());
107            descriptor
108        })
109        .collect();
110    fingerprint_multiset(2, descriptors)
111}
112
113fn validate_trade_page_scope(
114    rows: Vec<DataApiTrade>,
115    expected_condition_id: &str,
116) -> anyhow::Result<Vec<DataApiTrade>> {
117    match rows.iter().find(|trade| {
118        !trade
119            .condition_id
120            .eq_ignore_ascii_case(expected_condition_id)
121    }) {
122        Some(trade) => anyhow::bail!(
123            "Polymarket Data API returned trade for condition {} while requesting {expected_condition_id}",
124            trade.condition_id
125        ),
126        None => Ok(rows),
127    }
128}
129
130fn encode_decimal(output: &mut Vec<u8>, value: Decimal) {
131    let normalized = value.normalize();
132    output.extend_from_slice(&normalized.mantissa().to_be_bytes());
133    output.extend_from_slice(&normalized.scale().to_be_bytes());
134}
135
136#[derive(Clone, Debug, Eq, PartialEq)]
137enum TradeTickStop {
138    CallerCapped,
139    VenueOffsetCeiling(OffsetCeilingSource),
140}
141
142#[derive(Clone, Debug, Eq, PartialEq)]
143enum OffsetCeilingSource {
144    Local,
145    Remote(String),
146}
147
148struct TradeTickReducer {
149    rows: Vec<DataApiTrade>,
150    instrument_id: InstrumentId,
151    condition_id: String,
152    token_id: String,
153    price_precision: u8,
154    size_precision: u8,
155    start: Option<UnixNanos>,
156    end: Option<UnixNanos>,
157    limit: Option<usize>,
158}
159
160impl PageReducer<DataApiTrade, anyhow::Error> for TradeTickReducer {
161    type Output = Vec<TradeTick>;
162    type Stop = TradeTickStop;
163
164    fn consume(&mut self, rows: Vec<DataApiTrade>) -> anyhow::Result<Option<Self::Stop>> {
165        self.rows.extend(rows);
166        let capped = self.start.is_none()
167            && self.limit.is_some_and(|target| {
168                count_matching_trades_within_end(&self.rows, &self.token_id, self.end) >= target
169            });
170        Ok(capped.then_some(TradeTickStop::CallerCapped))
171    }
172
173    fn finish(self, completion: &Completion<Self::Stop>) -> anyhow::Result<Self::Output> {
174        if self.start.is_some()
175            && matches!(
176                completion,
177                Completion::Stopped(TradeTickStop::VenueOffsetCeiling(_))
178            )
179        {
180            anyhow::bail!(
181                "Polymarket public trades API reached the historical offset ceiling for condition {}; cannot guarantee complete start-anchored results, narrow the time window",
182                self.condition_id
183            );
184        }
185
186        let start_secs = self
187            .start
188            .map(|value| (value.as_u64() / 1_000_000_000) as i64);
189        let end_secs = self
190            .end
191            .map(|value| (value.as_u64() / 1_000_000_000) as i64);
192        let mut trades = parse_trade_ticks(
193            self.rows,
194            self.instrument_id,
195            &self.token_id,
196            self.price_precision,
197            self.size_precision,
198        )?;
199        trades.retain(|trade| {
200            let event_secs = trade.ts_event.as_u64() / 1_000_000_000;
201            start_secs.is_none_or(|start| event_secs >= start as u64)
202                && end_secs.is_none_or(|end| event_secs <= end as u64)
203        });
204
205        if let Some(target) = self.limit
206            && trades.len() > target
207        {
208            if self.start.is_some() {
209                trades.truncate(target);
210            } else {
211                trades.drain(..trades.len() - target);
212            }
213        }
214
215        Ok(trades)
216    }
217}
218
219/// Provides an unauthenticated HTTP client for the Polymarket Data API.
220///
221/// Used for fetching historical trade data from `GET /trades`.
222#[derive(Debug, Clone)]
223pub struct PolymarketDataApiHttpClient {
224    client: HttpClient,
225    base_url: String,
226}
227
228impl PolymarketDataApiHttpClient {
229    /// Creates a new [`PolymarketDataApiHttpClient`].
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if the HTTP client cannot be created.
234    pub fn new(base_url: Option<String>, timeout_secs: u64) -> StdResult<Self, HttpClientError> {
235        Self::new_with_proxy(base_url, timeout_secs, None)
236    }
237
238    /// Creates a new client with an optional validated proxy URL.
239    ///
240    /// # Errors
241    ///
242    /// Returns an error if the HTTP client cannot be created.
243    pub fn new_with_proxy(
244        base_url: Option<String>,
245        timeout_secs: u64,
246        proxy_url: Option<ProxyUrl>,
247    ) -> StdResult<Self, HttpClientError> {
248        Ok(Self {
249            client: HttpClient::builder()
250                .headers(HashMap::from([
251                    (USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string()),
252                    ("Content-Type".to_string(), "application/json".to_string()),
253                ]))
254                .timeout_secs(timeout_secs)
255                .maybe_proxy_url(proxy_url.map(|url| url.expose().to_string()))
256                .build()?,
257            base_url: base_url
258                .unwrap_or_else(|| POLYMARKET_DATA_API_URL.to_string())
259                .trim_end_matches('/')
260                .to_string(),
261        })
262    }
263
264    /// Fetches all positions for a user from the Data API.
265    ///
266    /// Paginates through `GET /positions?user={address}&sizeThreshold=0`
267    /// until a partial page is returned. Fails if the venue's maximum supported offset is
268    /// exhausted before the response is complete.
269    pub async fn get_positions(&self, user_address: &str) -> Result<Vec<DataApiPosition>> {
270        // Polymarket defines the `/positions` maximum offset as 10,000 inclusive
271        const MAX_OFFSET: u32 = 10_000;
272        const PAGE_SIZE: usize = 100;
273
274        let protocol = OffsetProtocol::<DataApiPosition, Infallible>::new(
275            "/positions",
276            PAGE_SIZE,
277            position_page_fingerprint,
278            None,
279        );
280        let paginator = Paginator::new("/positions", protocol, CollectAll::new());
281        let completed = paginator
282            .run(
283                |offset| async move {
284                    if offset > MAX_OFFSET {
285                        return Err(Error::decode(format!(
286                            "/positions pagination exhausted the maximum supported offset {MAX_OFFSET}"
287                        )));
288                    }
289
290                    let params = vec![
291                        ("user".to_string(), user_address.to_string()),
292                        ("limit".to_string(), PAGE_SIZE.to_string()),
293                        ("offset".to_string(), offset.to_string()),
294                        ("sizeThreshold".to_string(), "0".to_string()),
295                        ("sortBy".to_string(), "TOKENS".to_string()),
296                        ("sortDirection".to_string(), "DESC".to_string()),
297                    ];
298                    let url = format!("{}/positions", self.base_url);
299                    let response = self
300                        .client
301                        .request_with_params(
302                            Method::GET,
303                            url,
304                            Some(&params),
305                            None,
306                            None,
307                            None,
308                            None,
309                        )
310                        .await
311                        .map_err(Error::from_http_client)?;
312
313                    if response.status.is_success() {
314                        let rows = serde_json::from_slice(&response.body).map_err(Error::Serde)?;
315                        Ok(FetchOutcome::Page { rows, wire: () })
316                    } else {
317                        Err(Error::from_status_code(
318                            response.status.as_u16(),
319                            &response.body,
320                        ))
321                    }
322                },
323                |e| Error::decode(e.to_string()),
324            )
325            .await?;
326
327        match completed.completion {
328            Completion::WireExhausted => Ok(completed.output),
329            Completion::Stopped(never) => match never {},
330        }
331    }
332
333    /// Fetches trades from the Data API for the given condition ID.
334    pub async fn get_trades(
335        &self,
336        condition_id: &str,
337        limit: Option<u32>,
338        offset: Option<u32>,
339    ) -> Result<Vec<DataApiTrade>> {
340        self.get_trades_in_window(condition_id, limit, offset, None, None)
341            .await
342    }
343
344    /// Fetches trades from the Data API within an inclusive epoch-second window.
345    pub async fn get_trades_in_window(
346        &self,
347        condition_id: &str,
348        limit: Option<u32>,
349        offset: Option<u32>,
350        start: Option<i64>,
351        end: Option<i64>,
352    ) -> Result<Vec<DataApiTrade>> {
353        let mut params = vec![("market".to_string(), condition_id.to_string())];
354
355        if let Some(l) = limit {
356            params.push(("limit".to_string(), l.to_string()));
357        }
358
359        if let Some(o) = offset {
360            params.push(("offset".to_string(), o.to_string()));
361        }
362
363        if let Some(start) = start {
364            params.push(("start".to_string(), start.to_string()));
365        }
366
367        if let Some(end) = end {
368            params.push(("end".to_string(), end.to_string()));
369        }
370
371        let url = format!("{}/trades", self.base_url);
372        let response = self
373            .client
374            .request_with_params(Method::GET, url, Some(&params), None, None, None, None)
375            .await
376            .map_err(Error::from_http_client)?;
377
378        if response.status.is_success() {
379            serde_json::from_slice(&response.body).map_err(Error::Serde)
380        } else {
381            Err(Error::from_status_code(
382                response.status.as_u16(),
383                &response.body,
384            ))
385        }
386    }
387
388    /// Fetches trades and converts them to [`TradeTick`] for the given instrument.
389    ///
390    /// Automatically paginates through all available results (up to `limit`
391    /// if specified). Filters by `token_id` (since the API returns trades for
392    /// all outcomes of the condition) and returns results in chronological
393    /// order.
394    ///
395    /// The Polymarket Data API caps offset-based pagination on high-activity
396    /// markets; when this ceiling is hit a warning is logged and the trades
397    /// fetched so far are returned.
398    #[expect(clippy::too_many_arguments)]
399    pub async fn request_trade_ticks(
400        &self,
401        instrument_id: InstrumentId,
402        condition_id: &str,
403        token_id: &str,
404        price_precision: u8,
405        size_precision: u8,
406        start: Option<UnixNanos>,
407        end: Option<UnixNanos>,
408        limit: Option<u32>,
409    ) -> anyhow::Result<Vec<TradeTick>> {
410        const PAGE_SIZE: u32 = 500;
411        const MAX_OFFSET: u32 = 10_000;
412
413        if let (Some(start), Some(end)) = (start, end)
414            && start > end
415        {
416            anyhow::bail!("start must not be later than end");
417        }
418
419        if limit == Some(0) {
420            anyhow::bail!("limit must be greater than zero");
421        }
422
423        let start_secs = start.map(|value| (value.as_u64() / 1_000_000_000) as i64);
424        let end_secs = end.map(|value| (value.as_u64() / 1_000_000_000) as i64);
425        let protocol = OffsetProtocol::new(
426            "/trades",
427            PAGE_SIZE as usize,
428            trade_page_fingerprint,
429            Some((
430                MAX_OFFSET,
431                TradeTickStop::VenueOffsetCeiling(OffsetCeilingSource::Local),
432            )),
433        );
434        let reducer = TradeTickReducer {
435            rows: Vec::new(),
436            instrument_id,
437            condition_id: condition_id.to_string(),
438            token_id: token_id.to_string(),
439            price_precision,
440            size_precision,
441            start,
442            end,
443            limit: limit.map(|value| value as usize),
444        };
445        let paginator = Paginator::new("/trades", protocol, reducer);
446        let completed = paginator
447            .run(
448                |offset| async move {
449                    match self
450                        .get_trades_in_window(
451                            condition_id,
452                            Some(PAGE_SIZE),
453                            Some(offset),
454                            start_secs,
455                            end_secs,
456                        )
457                        .await
458                    {
459                        Ok(rows) => Ok(FetchOutcome::Page {
460                            rows: validate_trade_page_scope(rows, condition_id)?,
461                            wire: (),
462                        }),
463                        Err(e) if is_historical_offset_ceiling(&e) => {
464                            Ok(FetchOutcome::Stop(TradeTickStop::VenueOffsetCeiling(
465                                OffsetCeilingSource::Remote(e.to_string()),
466                            )))
467                        }
468                        Err(e) => Err(anyhow::Error::new(e)),
469                    }
470                },
471                anyhow::Error::new,
472            )
473            .await?;
474
475        match completed.completion {
476            Completion::WireExhausted | Completion::Stopped(TradeTickStop::CallerCapped) => {
477                Ok(completed.output)
478            }
479            Completion::Stopped(TradeTickStop::VenueOffsetCeiling(OffsetCeilingSource::Local)) => {
480                log::warn!(
481                    "Polymarket public trades API reached the historical offset ceiling for condition {condition_id}; returning partial results"
482                );
483                Ok(completed.output)
484            }
485            Completion::Stopped(TradeTickStop::VenueOffsetCeiling(
486                OffsetCeilingSource::Remote(error),
487            )) => {
488                log::warn!(
489                    "Polymarket public trades API hit its historical offset ceiling for condition {condition_id}; returning partial results: {error}"
490                );
491                Ok(completed.output)
492            }
493        }
494    }
495}
496
497fn is_historical_offset_ceiling(error: &Error) -> bool {
498    match error {
499        Error::Http { message, .. } | Error::RateLimit { message, .. } => {
500            message.contains("max historical activity offset")
501        }
502        Error::Transport(_)
503        | Error::Serde(_)
504        | Error::Auth(_)
505        | Error::BadRequest(_)
506        | Error::BurstExceeded { .. }
507        | Error::Exchange(_)
508        | Error::Timeout
509        | Error::Decode(_)
510        | Error::UrlParse(_)
511        | Error::Io(_) => false,
512    }
513}
514
515fn count_matching_trades_within_end(
516    trades: &[DataApiTrade],
517    token_id: &str,
518    end: Option<UnixNanos>,
519) -> usize {
520    let Some(end) = end else {
521        return trades
522            .iter()
523            .filter(|trade| trade.asset == token_id)
524            .count();
525    };
526    let end_secs = (end.as_u64() / 1_000_000_000) as i64;
527    trades
528        .iter()
529        .filter(|trade| trade.asset == token_id && trade.timestamp <= end_secs)
530        .count()
531}
532
533// Extracted from `request_trade_ticks` so the parse behavior can be
534// unit-tested without HTTP
535fn parse_trade_ticks(
536    mut data_api_trades: Vec<DataApiTrade>,
537    instrument_id: InstrumentId,
538    token_id: &str,
539    price_precision: u8,
540    size_precision: u8,
541) -> anyhow::Result<Vec<TradeTick>> {
542    // Composite sort to stabilise same-second trades across pages
543    data_api_trades.sort_by(|a, b| data_api_trade_sort_key(a).cmp(&data_api_trade_sort_key(b)));
544
545    let mut timestamp_counts: HashMap<u64, u32> = HashMap::new();
546    let mut tx_asset_counts: HashMap<(String, String), u32> = HashMap::new();
547    let mut trades: Vec<TradeTick> = Vec::new();
548
549    for t in data_api_trades {
550        if t.asset != token_id {
551            continue;
552        }
553
554        let price = Price::from_decimal_dp(t.price, price_precision).with_context(|| {
555            format!(
556                "failed to convert Data API trade price {} with precision {price_precision}",
557                t.price
558            )
559        })?;
560        let size = Quantity::from_decimal_dp(t.size, size_precision).with_context(|| {
561            format!(
562                "failed to convert Data API trade size {} with precision {size_precision}",
563                t.size
564            )
565        })?;
566        let aggressor_side = AggressorSide::from(t.side);
567
568        let base_ns = (t.timestamp as u64) * 1_000_000_000;
569        let occurrence = timestamp_counts.entry(base_ns).or_insert(0);
570        let tiebreaker = (*occurrence).min(999_999_999) as u64;
571        *occurrence += 1;
572        let ts_event = nautilus_core::UnixNanos::from(base_ns + tiebreaker);
573
574        let key = (t.transaction_hash.clone(), t.asset.clone());
575        let seq = *tx_asset_counts
576            .entry(key)
577            .and_modify(|n| *n += 1)
578            .or_insert(0);
579        let trade_id = TradeId::new(build_polymarket_trade_id(
580            &t.transaction_hash,
581            &t.asset,
582            seq,
583        ));
584
585        trades.push(TradeTick::new(
586            instrument_id,
587            price,
588            size,
589            aggressor_side,
590            trade_id,
591            ts_event,
592            ts_event,
593        ));
594    }
595
596    Ok(trades)
597}
598
599#[cfg(test)]
600mod tests {
601    use nautilus_model::{enums::AggressorSide, identifiers::InstrumentId};
602    use rstest::rstest;
603    use rust_decimal_macros::dec;
604
605    use super::*;
606    use crate::http::models::{DataApiPosition, DataApiTrade};
607
608    fn load_positions() -> Vec<DataApiPosition> {
609        let path = "test_data/data_api_positions_response.json";
610        let content = std::fs::read_to_string(path).expect("Failed to read test data");
611        serde_json::from_str(&content).expect("Failed to parse test data")
612    }
613
614    fn load_trades() -> Vec<DataApiTrade> {
615        // Constructed fixture retained for conversion, filtering, and ordering tests
616        let path = "test_data/data_api_trades_response.json";
617        let content = std::fs::read_to_string(path).expect("Failed to read test data");
618        serde_json::from_str(&content).expect("Failed to parse test data")
619    }
620
621    #[rstest]
622    fn test_data_api_position_deserialization() {
623        let positions = load_positions();
624
625        assert_eq!(positions.len(), 4);
626        assert_eq!(positions[0].size, dec!(150.5));
627        assert_eq!(positions[0].avg_price, Some(dec!(0.55)));
628        assert_eq!(
629            positions[0].condition_id,
630            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
631        );
632    }
633
634    #[rstest]
635    fn test_data_api_trade_deserialization() {
636        let trades = load_trades();
637
638        assert_eq!(trades.len(), 3);
639
640        assert_eq!(
641            trades[0].asset,
642            "71321045863084981365469005770620412523470745398083994982746259498689308907982"
643        );
644        assert_eq!(
645            trades[0].condition_id,
646            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47"
647        );
648        assert_eq!(trades[0].price, dec!(0.55));
649        assert_eq!(trades[0].size, dec!(100.0));
650        assert_eq!(trades[0].timestamp, 1710000000);
651        assert_eq!(
652            trades[0].transaction_hash,
653            "0xabc123def456789012345678901234567890abcdef1234567890abcdef123456"
654        );
655    }
656
657    #[rstest]
658    fn test_data_api_trade_ignores_extra_fields() {
659        let trades = load_trades();
660        // proxyWallet, title, slug should be silently ignored
661        assert_eq!(trades.len(), 3);
662    }
663
664    #[rstest]
665    fn test_build_trade_ticks_filters_by_token_id() {
666        let trades = load_trades();
667        let instrument_id = InstrumentId::from(
668            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
669        );
670        let token_id =
671            "71321045863084981365469005770620412523470745398083994982746259498689308907982";
672        let price_precision = 2u8;
673        let size_precision = 2u8;
674
675        let ticks: Vec<TradeTick> = trades
676            .into_iter()
677            .filter(|t| t.asset == token_id)
678            .map(|t| {
679                let price = Price::from_decimal_dp(t.price, price_precision).unwrap();
680                let size = Quantity::from_decimal_dp(t.size, size_precision).unwrap();
681                let aggressor_side = AggressorSide::from(t.side);
682                // TradeId max length is 36; tx hash is 66 chars, take last 36
683                let hash = &t.transaction_hash;
684                let trade_id_str = if hash.len() > 36 {
685                    &hash[hash.len() - 36..]
686                } else {
687                    hash.as_str()
688                };
689                let trade_id = TradeId::new(trade_id_str);
690                let ts_event = nautilus_core::UnixNanos::from(t.timestamp as u64 * 1_000_000_000);
691
692                TradeTick::new(
693                    instrument_id,
694                    price,
695                    size,
696                    aggressor_side,
697                    trade_id,
698                    ts_event,
699                    ts_event,
700                )
701            })
702            .collect();
703
704        // Should filter out the third trade (different asset)
705        assert_eq!(ticks.len(), 2);
706        assert_eq!(ticks[0].aggressor_side, AggressorSide::Buy);
707        assert_eq!(ticks[1].aggressor_side, AggressorSide::Sell);
708    }
709
710    #[rstest]
711    fn test_build_trade_ticks_chronological_order() {
712        let trades = load_trades();
713        let instrument_id = InstrumentId::from(
714            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
715        );
716        let token_id =
717            "71321045863084981365469005770620412523470745398083994982746259498689308907982";
718
719        let mut ticks: Vec<TradeTick> = trades
720            .into_iter()
721            .filter(|t| t.asset == token_id)
722            .map(|t| {
723                let price = Price::from_decimal_dp(t.price, 2).unwrap();
724                let size = Quantity::from_decimal_dp(t.size, 2).unwrap();
725                let aggressor_side = AggressorSide::from(t.side);
726                // TradeId max length is 36; tx hash is 66 chars, take last 36
727                let hash = &t.transaction_hash;
728                let trade_id_str = if hash.len() > 36 {
729                    &hash[hash.len() - 36..]
730                } else {
731                    hash.as_str()
732                };
733                let trade_id = TradeId::new(trade_id_str);
734                let ts_event = nautilus_core::UnixNanos::from(t.timestamp as u64 * 1_000_000_000);
735
736                TradeTick::new(
737                    instrument_id,
738                    price,
739                    size,
740                    aggressor_side,
741                    trade_id,
742                    ts_event,
743                    ts_event,
744                )
745            })
746            .collect();
747
748        // Reverse to get chronological order (API returns newest-first)
749        ticks.reverse();
750
751        assert_eq!(ticks.len(), 2);
752        // First tick should be the older one (lower timestamp)
753        assert!(ticks[0].ts_event < ticks[1].ts_event);
754    }
755
756    fn make_trade(
757        timestamp: i64,
758        transaction_hash: &str,
759        asset: &str,
760        side: PolymarketOrderSide,
761        price: f64,
762        size: f64,
763    ) -> DataApiTrade {
764        DataApiTrade {
765            proxy_wallet: None,
766            asset: asset.to_string(),
767            condition_id: "0xcond".to_string(),
768            side,
769            price: Decimal::from_str_exact(&price.to_string()).unwrap(),
770            size: Decimal::from_str_exact(&size.to_string()).unwrap(),
771            timestamp,
772            title: None,
773            slug: None,
774            icon: None,
775            event_slug: None,
776            outcome: None,
777            outcome_index: None,
778            name: None,
779            pseudonym: None,
780            bio: None,
781            profile_image: None,
782            profile_image_optimized: None,
783            transaction_hash: transaction_hash.to_string(),
784        }
785    }
786
787    fn test_instrument_id() -> InstrumentId {
788        InstrumentId::from(
789            "0xc8f1cf5d4f26e0fd9c8fe89f2a7b3263b902cf14fde7bfccef525753bb492e47-71321045863084981365469005770620412523470745398083994982746259498689308907982.POLYMARKET",
790        )
791    }
792
793    #[rstest]
794    fn test_data_api_trade_sort_key_orders_pages_deterministically() {
795        let mut trades = [
796            make_trade(1729000005, "0xZ", "T", PolymarketOrderSide::Buy, 0.5, 1.0),
797            make_trade(1729000000, "0xC", "T", PolymarketOrderSide::Buy, 0.5, 1.0),
798            make_trade(1729000000, "0xA", "T", PolymarketOrderSide::Sell, 0.5, 1.0),
799            make_trade(1729000000, "0xB", "T", PolymarketOrderSide::Buy, 0.5, 1.0),
800        ];
801
802        trades.sort_by(|a, b| data_api_trade_sort_key(a).cmp(&data_api_trade_sort_key(b)));
803
804        let order: Vec<&str> = trades.iter().map(|t| t.transaction_hash.as_str()).collect();
805        assert_eq!(order, ["0xA", "0xB", "0xC", "0xZ"]);
806    }
807
808    #[rstest]
809    fn test_data_api_trade_sort_key_uses_full_composite_for_inner_ties() {
810        // Locks ordering on the (asset, side, price, size) tail of the key
811        let mut trades = [
812            // (ts, hash) all equal; tail differs across asset/side/price/size
813            make_trade(1, "0xH", "Tb", PolymarketOrderSide::Buy, 0.5, 1.0),
814            make_trade(1, "0xH", "Ta", PolymarketOrderSide::Sell, 0.5, 1.0),
815            make_trade(1, "0xH", "Ta", PolymarketOrderSide::Buy, 0.6, 1.0),
816            make_trade(1, "0xH", "Ta", PolymarketOrderSide::Buy, 0.5, 2.0),
817            make_trade(1, "0xH", "Ta", PolymarketOrderSide::Buy, 0.5, 1.0),
818        ];
819
820        trades.sort_by(|a, b| data_api_trade_sort_key(a).cmp(&data_api_trade_sort_key(b)));
821
822        // Sort key composite: (ts, hash, asset, side, price, size)
823        // Expected ordering across the five trades:
824        //   1. asset=Ta side=BUY  price=0.5 size=1.0 (lex-min on side first)
825        //   2. asset=Ta side=BUY  price=0.5 size=2.0 (size breaks tie)
826        //   3. asset=Ta side=BUY  price=0.6 size=1.0 (price breaks tie)
827        //   4. asset=Ta side=SELL price=0.5 size=1.0 (side breaks tie)
828        //   5. asset=Tb side=BUY  price=0.5 size=1.0 (asset breaks tie)
829        let key: Vec<(String, String, Decimal, Decimal)> = trades
830            .iter()
831            .map(|t| (t.asset.clone(), t.side.to_string(), t.price, t.size))
832            .collect();
833        assert_eq!(key[0], ("Ta".into(), "BUY".into(), dec!(0.5), dec!(1.0)));
834        assert_eq!(key[1], ("Ta".into(), "BUY".into(), dec!(0.5), dec!(2.0)));
835        assert_eq!(key[2], ("Ta".into(), "BUY".into(), dec!(0.6), dec!(1.0)));
836        assert_eq!(key[3], ("Ta".into(), "SELL".into(), dec!(0.5), dec!(1.0)));
837        assert_eq!(key[4], ("Tb".into(), "BUY".into(), dec!(0.5), dec!(1.0)));
838    }
839
840    #[rstest]
841    fn test_parse_trade_ticks_filters_other_tokens() {
842        let token_id = "T_KEEP";
843        let trades = vec![
844            make_trade(
845                1729000000,
846                "0xa",
847                token_id,
848                PolymarketOrderSide::Buy,
849                0.5,
850                1.0,
851            ),
852            make_trade(
853                1729000000,
854                "0xb",
855                "T_DROP",
856                PolymarketOrderSide::Sell,
857                0.5,
858                1.0,
859            ),
860        ];
861
862        let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2).unwrap();
863
864        assert_eq!(trades.len(), 1);
865        assert_eq!(trades[0].aggressor_side, AggressorSide::Buy);
866    }
867
868    #[rstest]
869    fn test_parse_trade_ticks_disambiguates_multi_fill_transaction() {
870        // Two fills sharing tx + asset must produce distinct TradeIds
871        let token_id = "12345token";
872        let same_hash = "0x000000000000000000000000000000000000000000000000000000000000abcdef";
873        let trades = vec![
874            make_trade(
875                1729000000,
876                same_hash,
877                token_id,
878                PolymarketOrderSide::Buy,
879                0.5,
880                1.0,
881            ),
882            make_trade(
883                1729000000,
884                same_hash,
885                token_id,
886                PolymarketOrderSide::Sell,
887                0.5,
888                1.0,
889            ),
890        ];
891
892        let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2).unwrap();
893
894        assert_eq!(trades.len(), 2);
895        assert_ne!(trades[0].trade_id, trades[1].trade_id);
896        // ts_event monotonic: same epoch second + nanosecond tiebreaker
897        assert!(trades[0].ts_event < trades[1].ts_event);
898        assert_eq!(
899            u64::from(trades[1].ts_event) - u64::from(trades[0].ts_event),
900            1
901        );
902        // ID format ends with the per-(tx, asset) sequence
903        assert!(trades[0].trade_id.to_string().ends_with("-000000"));
904        assert!(trades[1].trade_id.to_string().ends_with("-000001"));
905    }
906
907    #[rstest]
908    fn test_parse_trade_ticks_distinct_tx_share_timestamp() {
909        // Different transactions in the same epoch second still get distinct
910        // ts_event values (the tiebreaker is per-second, not per-transaction).
911        let token_id = "T";
912        let trades = vec![
913            make_trade(
914                1729000000,
915                "0xtx1",
916                token_id,
917                PolymarketOrderSide::Buy,
918                0.5,
919                1.0,
920            ),
921            make_trade(
922                1729000000,
923                "0xtx2",
924                token_id,
925                PolymarketOrderSide::Buy,
926                0.5,
927                1.0,
928            ),
929            make_trade(
930                1729000000,
931                "0xtx3",
932                token_id,
933                PolymarketOrderSide::Buy,
934                0.5,
935                1.0,
936            ),
937        ];
938
939        let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2).unwrap();
940
941        assert_eq!(trades.len(), 3);
942        // Strictly increasing ts_event
943        assert!(trades[0].ts_event < trades[1].ts_event);
944        assert!(trades[1].ts_event < trades[2].ts_event);
945        // Each trade is the first fill on its (tx, asset) so all have seq 0
946        for trade in &trades {
947            assert!(trade.trade_id.to_string().ends_with("-000000"));
948        }
949    }
950
951    #[rstest]
952    fn test_parse_trade_ticks_assigns_per_second_tiebreakers() {
953        // Same-second fills get strictly increasing nanosecond tiebreakers
954        // starting at zero, all bounded below 1 second.
955        let token_id = "T";
956        let mut trades = Vec::new();
957
958        for i in 0..3 {
959            let hash = format!("0x{i:064x}");
960            trades.push(make_trade(
961                1729000000,
962                &hash,
963                token_id,
964                PolymarketOrderSide::Buy,
965                0.5,
966                1.0,
967            ));
968        }
969
970        let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2).unwrap();
971
972        assert_eq!(trades.len(), 3);
973        let base_ns = 1_729_000_000u64 * 1_000_000_000;
974
975        for (i, trade) in trades.iter().enumerate() {
976            assert!(u64::from(trade.ts_event) - base_ns < 1_000_000_000);
977            assert_eq!(u64::from(trade.ts_event) - base_ns, i as u64);
978        }
979    }
980
981    #[rstest]
982    fn test_parse_trade_ticks_sorts_inputs_by_composite_key() {
983        // Mirror what the API may return: same-second fills delivered out
984        // of order. parse_trade_ticks must produce a deterministic stream.
985        let token_id = "T";
986        let trades = vec![
987            make_trade(
988                1729000005,
989                "0xZ",
990                token_id,
991                PolymarketOrderSide::Buy,
992                0.5,
993                1.0,
994            ),
995            make_trade(
996                1729000000,
997                "0xC",
998                token_id,
999                PolymarketOrderSide::Buy,
1000                0.5,
1001                1.0,
1002            ),
1003            make_trade(
1004                1729000000,
1005                "0xA",
1006                token_id,
1007                PolymarketOrderSide::Sell,
1008                0.5,
1009                1.0,
1010            ),
1011            make_trade(
1012                1729000000,
1013                "0xB",
1014                token_id,
1015                PolymarketOrderSide::Buy,
1016                0.5,
1017                1.0,
1018            ),
1019        ];
1020
1021        let trades = parse_trade_ticks(trades, test_instrument_id(), token_id, 2, 2).unwrap();
1022
1023        assert_eq!(trades.len(), 4);
1024
1025        // Strictly non-decreasing ts_event
1026        for i in 1..trades.len() {
1027            assert!(trades[i - 1].ts_event <= trades[i].ts_event);
1028        }
1029
1030        // Composite tiebreaker: same-second trades order by transactionHash
1031        let trade_ids: Vec<String> = trades.iter().map(|t| t.trade_id.to_string()).collect();
1032        assert!(trade_ids[0].contains("0xA"));
1033        assert!(trade_ids[1].contains("0xB"));
1034        assert!(trade_ids[2].contains("0xC"));
1035        assert!(trade_ids[3].contains("0xZ"));
1036    }
1037
1038    #[rstest]
1039    fn test_parse_trade_ticks_propagates_invalid_price() {
1040        let token_id = "T";
1041        let mut trade = make_trade(
1042            1729000000,
1043            "0xtx",
1044            token_id,
1045            PolymarketOrderSide::Buy,
1046            0.5,
1047            1.0,
1048        );
1049        trade.price = Decimal::from_str_exact("99999999999999999999.99").unwrap();
1050
1051        let error = parse_trade_ticks(vec![trade], test_instrument_id(), token_id, 2, 2)
1052            .expect_err("out-of-range price should fail");
1053
1054        assert_eq!(
1055            error.to_string(),
1056            "failed to convert Data API trade price 99999999999999999999.99 with precision 2"
1057        );
1058        assert_eq!(error.chain().count(), 2);
1059    }
1060
1061    #[rstest]
1062    fn test_parse_trade_ticks_propagates_invalid_size() {
1063        let token_id = "T";
1064        let trade = make_trade(
1065            1729000000,
1066            "0xtx",
1067            token_id,
1068            PolymarketOrderSide::Buy,
1069            0.5,
1070            -1.5,
1071        );
1072
1073        let error = parse_trade_ticks(vec![trade], test_instrument_id(), token_id, 2, 2)
1074            .expect_err("negative size should fail");
1075
1076        assert_eq!(
1077            error.to_string(),
1078            "failed to convert Data API trade size -1.5 with precision 2"
1079        );
1080        assert_eq!(error.chain().count(), 2);
1081    }
1082}