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