agent-finance-cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1071 @@
1
+ use std::cmp::Ordering;
2
+ use std::collections::{BTreeMap, BTreeSet};
3
+ use std::str::FromStr;
4
+ use std::time::Duration;
5
+
6
+ use anyhow::{Context, Result, anyhow};
7
+ use chrono::{SecondsFormat, TimeZone, Utc};
8
+ use polymarket_client_sdk_v2::clob::Client as ClobClient;
9
+ use polymarket_client_sdk_v2::clob::types::request::{
10
+ OrderBookSummaryRequest, PriceHistoryRequest,
11
+ };
12
+ use polymarket_client_sdk_v2::clob::types::response::PricePoint as ClobPricePoint;
13
+ use polymarket_client_sdk_v2::clob::types::{Interval, TimeRange};
14
+ use polymarket_client_sdk_v2::data::Client as DataClient;
15
+ use polymarket_client_sdk_v2::data::types::request::{HoldersRequest, OpenInterestRequest};
16
+ use polymarket_client_sdk_v2::gamma::Client as GammaClient;
17
+ use polymarket_client_sdk_v2::gamma::types::request::{
18
+ MarketByIdRequest, MarketBySlugRequest, SearchRequest,
19
+ };
20
+ use polymarket_client_sdk_v2::types::{B256, U256};
21
+ use serde_json::Value;
22
+ #[cfg(test)]
23
+ use serde_json::json;
24
+ use wreq::Client;
25
+
26
+ use crate::cache;
27
+ use crate::model::{
28
+ PredictionMarketReport, PredictionMarketSummary, PredictionOutcome, PredictionPricePoint,
29
+ PredictionSearchReport,
30
+ };
31
+ use crate::time::{format_local, utc_to_local};
32
+
33
+ const PROVIDER: &str = "polymarket";
34
+ const INTERPRETATION_NOTE: &str = "Polymarket prices are prediction-market probabilities backed by user capital. Treat them as quantifiable sentiment/event-probability signals, not as confirmed facts, inside information, or equity quotes.";
35
+ const SEARCH_URL: &str = "https://gamma-api.polymarket.com/public-search";
36
+ const GAMMA_MARKET_URL: &str = "https://gamma-api.polymarket.com/markets";
37
+ const CLOB_URL: &str = "https://clob-v2.polymarket.com";
38
+ const DATA_URL: &str = "https://data-api.polymarket.com";
39
+
40
+ #[derive(Debug, Clone)]
41
+ pub struct SearchRequestOptions {
42
+ pub query: String,
43
+ pub limit: usize,
44
+ pub include_closed: bool,
45
+ pub min_volume: Option<f64>,
46
+ pub refresh: bool,
47
+ pub cache_ttl_seconds: u64,
48
+ pub timeout_seconds: u64,
49
+ pub use_http_transport: bool,
50
+ }
51
+
52
+ #[derive(Debug, Clone)]
53
+ pub struct MarketRequestOptions {
54
+ pub identifier: String,
55
+ pub limit: usize,
56
+ pub include_closed: bool,
57
+ pub min_volume: Option<f64>,
58
+ pub refresh: bool,
59
+ pub cache_ttl_seconds: u64,
60
+ pub timeout_seconds: u64,
61
+ pub use_http_transport: bool,
62
+ }
63
+
64
+ pub async fn search_report(
65
+ client: &Client,
66
+ options: &SearchRequestOptions,
67
+ timezone: &str,
68
+ ) -> Result<PredictionSearchReport> {
69
+ let (fetched_at_utc, cache_status, payload) = cached_json(
70
+ "polymarket-search",
71
+ &format!(
72
+ "{}:{}:{}",
73
+ options.query, options.limit, options.include_closed
74
+ ),
75
+ options.cache_ttl_seconds,
76
+ options.refresh,
77
+ || fetch_search_payload(client, options),
78
+ )
79
+ .await?;
80
+ let fetched_at_local = utc_to_local(Some(&fetched_at_utc), timezone)
81
+ .unwrap_or_else(|| format_local(Utc::now(), timezone));
82
+ let markets = collect_search_markets(
83
+ &payload,
84
+ options.limit,
85
+ options.include_closed,
86
+ options.min_volume,
87
+ timezone,
88
+ )?;
89
+
90
+ Ok(PredictionSearchReport {
91
+ provider: PROVIDER.to_string(),
92
+ query: options.query.clone(),
93
+ fetched_at_utc,
94
+ fetched_at_local,
95
+ cache_status,
96
+ source_urls: vec![
97
+ search_source_url(&options.query),
98
+ "https://polymarket.com/search".to_string(),
99
+ ],
100
+ interpretation_note: INTERPRETATION_NOTE.to_string(),
101
+ markets,
102
+ payload,
103
+ })
104
+ }
105
+
106
+ pub async fn market_report(
107
+ client: &Client,
108
+ options: &MarketRequestOptions,
109
+ timezone: &str,
110
+ ) -> Result<PredictionMarketReport> {
111
+ let (fetched_at_utc, cache_status, market_payload) = cached_json(
112
+ "polymarket-market",
113
+ &options.identifier,
114
+ options.cache_ttl_seconds,
115
+ options.refresh,
116
+ || fetch_market_payload(client, options),
117
+ )
118
+ .await?;
119
+ let fetched_at_local = utc_to_local(Some(&fetched_at_utc), timezone)
120
+ .unwrap_or_else(|| format_local(Utc::now(), timezone));
121
+ let mut market = market_summary_from_value(&market_payload, None, timezone)?;
122
+ reject_filtered_market(&market, options.include_closed, options.min_volume)?;
123
+
124
+ let mut data_errors = BTreeMap::new();
125
+ let enrichment_fetched_at_utc = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
126
+ let enrichment_fetched_at_local = utc_to_local(Some(&enrichment_fetched_at_utc), timezone)
127
+ .unwrap_or_else(|| format_local(Utc::now(), timezone));
128
+ hydrate_outcome_books(client, &mut market, options, &mut data_errors).await;
129
+ let price_history =
130
+ fetch_first_history(client, &market, options, timezone, &mut data_errors).await;
131
+ let open_interest = fetch_open_interest(client, &market, options, &mut data_errors).await;
132
+ let holder_preview_count =
133
+ fetch_holder_preview_count(client, &market, options, &mut data_errors).await;
134
+ market.open_interest = open_interest.or(market.open_interest);
135
+ let enrichment_status = if data_errors.is_empty() {
136
+ "live".to_string()
137
+ } else {
138
+ "live_partial".to_string()
139
+ };
140
+
141
+ let outcomes = market.outcomes.clone();
142
+ Ok(PredictionMarketReport {
143
+ provider: PROVIDER.to_string(),
144
+ identifier: options.identifier.clone(),
145
+ fetched_at_utc,
146
+ fetched_at_local,
147
+ cache_status: format!("gamma_{cache_status}"),
148
+ enrichment_status,
149
+ enrichment_fetched_at_utc,
150
+ enrichment_fetched_at_local,
151
+ source_urls: market_source_urls(&market),
152
+ interpretation_note: INTERPRETATION_NOTE.to_string(),
153
+ market,
154
+ outcomes,
155
+ price_history,
156
+ open_interest,
157
+ holder_preview_count,
158
+ data_errors,
159
+ payload: market_payload,
160
+ })
161
+ }
162
+
163
+ async fn cached_json<F, Fut>(
164
+ namespace: &str,
165
+ key: &str,
166
+ ttl_seconds: u64,
167
+ refresh: bool,
168
+ fetch: F,
169
+ ) -> Result<(String, String, Value)>
170
+ where
171
+ F: FnOnce() -> Fut,
172
+ Fut: std::future::Future<Output = Result<Value>>,
173
+ {
174
+ if !refresh
175
+ && let Some((fetched_at_utc, payload)) = cache::read_json(namespace, key, ttl_seconds)
176
+ {
177
+ return Ok((fetched_at_utc, "hit".to_string(), payload));
178
+ }
179
+
180
+ let payload = fetch().await?;
181
+ let fetched_at_utc = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
182
+ cache::write_json(namespace, key, &fetched_at_utc, &payload)?;
183
+ Ok((fetched_at_utc, "miss".to_string(), payload))
184
+ }
185
+
186
+ async fn fetch_search_payload(client: &Client, options: &SearchRequestOptions) -> Result<Value> {
187
+ if options.use_http_transport {
188
+ let params = vec![
189
+ ("q", options.query.clone()),
190
+ ("limit_per_type", options.limit.to_string()),
191
+ ("search_profiles", "false".to_string()),
192
+ (
193
+ "keep_closed_markets",
194
+ if options.include_closed { "1" } else { "0" }.to_string(),
195
+ ),
196
+ ];
197
+ return client
198
+ .get(url_with_query(SEARCH_URL, &params))
199
+ .send()
200
+ .await?
201
+ .error_for_status()?
202
+ .json::<Value>()
203
+ .await
204
+ .context("failed to decode Polymarket search response");
205
+ }
206
+ let request = SearchRequest::builder()
207
+ .q(options.query.clone())
208
+ .limit_per_type(i32::try_from(options.limit.min(i32::MAX as usize))?)
209
+ .search_profiles(false)
210
+ .keep_closed_markets(i32::from(options.include_closed))
211
+ .build();
212
+ let response = with_timeout(
213
+ options.timeout_seconds,
214
+ GammaClient::default().search(&request),
215
+ "polymarket gamma search",
216
+ )
217
+ .await?;
218
+ Ok(serde_json::to_value(response)?)
219
+ }
220
+
221
+ async fn fetch_market_payload(client: &Client, options: &MarketRequestOptions) -> Result<Value> {
222
+ let identifier = options.identifier.as_str();
223
+ if options.use_http_transport {
224
+ let url = if identifier.chars().all(|ch| ch.is_ascii_digit()) {
225
+ format!("{GAMMA_MARKET_URL}/{identifier}")
226
+ } else {
227
+ format!("{GAMMA_MARKET_URL}/slug/{identifier}")
228
+ };
229
+ return client
230
+ .get(url_with_query(&url, &[("include_tag", "true".to_string())]))
231
+ .send()
232
+ .await?
233
+ .error_for_status()?
234
+ .json::<Value>()
235
+ .await
236
+ .context("failed to decode Polymarket market response");
237
+ }
238
+ let client = GammaClient::default();
239
+ let response = if identifier.chars().all(|ch| ch.is_ascii_digit()) {
240
+ let request = MarketByIdRequest::builder()
241
+ .id(identifier.to_string())
242
+ .include_tag(true)
243
+ .build();
244
+ with_timeout(
245
+ options.timeout_seconds,
246
+ client.market_by_id(&request),
247
+ "polymarket gamma market by id",
248
+ )
249
+ .await
250
+ } else {
251
+ let request = MarketBySlugRequest::builder()
252
+ .slug(identifier.to_string())
253
+ .include_tag(true)
254
+ .build();
255
+ with_timeout(
256
+ options.timeout_seconds,
257
+ client.market_by_slug(&request),
258
+ "polymarket gamma market by slug",
259
+ )
260
+ .await
261
+ }?;
262
+ Ok(serde_json::to_value(response)?)
263
+ }
264
+
265
+ async fn with_timeout<T>(
266
+ timeout_seconds: u64,
267
+ future: impl std::future::Future<Output = Result<T, polymarket_client_sdk_v2::error::Error>>,
268
+ label: &str,
269
+ ) -> Result<T> {
270
+ tokio::time::timeout(Duration::from_secs(timeout_seconds), future)
271
+ .await
272
+ .with_context(|| format!("{label} timed out after {timeout_seconds}s"))?
273
+ .map_err(Into::into)
274
+ }
275
+
276
+ fn collect_search_markets(
277
+ payload: &Value,
278
+ limit: usize,
279
+ include_closed: bool,
280
+ min_volume: Option<f64>,
281
+ timezone: &str,
282
+ ) -> Result<Vec<PredictionMarketSummary>> {
283
+ let mut markets = Vec::new();
284
+ let mut seen = BTreeSet::new();
285
+ for event in payload
286
+ .get("events")
287
+ .and_then(Value::as_array)
288
+ .into_iter()
289
+ .flatten()
290
+ {
291
+ let context = EventContext {
292
+ id: string_value(event, "id"),
293
+ slug: string_value(event, "slug"),
294
+ title: string_value(event, "title"),
295
+ };
296
+ for market in event
297
+ .get("markets")
298
+ .and_then(Value::as_array)
299
+ .into_iter()
300
+ .flatten()
301
+ {
302
+ let summary = market_summary_from_value(market, Some(&context), timezone)?;
303
+ let key = summary
304
+ .id
305
+ .clone()
306
+ .or_else(|| summary.slug.clone())
307
+ .unwrap_or_else(|| summary.title.clone());
308
+ if seen.insert(key) && market_passes(&summary, include_closed, min_volume) {
309
+ markets.push(summary);
310
+ }
311
+ }
312
+ }
313
+
314
+ markets.sort_by(compare_markets);
315
+ markets.truncate(limit);
316
+ Ok(markets)
317
+ }
318
+
319
+ fn reject_filtered_market(
320
+ market: &PredictionMarketSummary,
321
+ include_closed: bool,
322
+ min_volume: Option<f64>,
323
+ ) -> Result<()> {
324
+ if market_passes(market, include_closed, min_volume) {
325
+ Ok(())
326
+ } else {
327
+ Err(anyhow!(
328
+ "market was filtered out by include_closed={} min_volume={:?}",
329
+ include_closed,
330
+ min_volume
331
+ ))
332
+ }
333
+ }
334
+
335
+ fn market_passes(
336
+ market: &PredictionMarketSummary,
337
+ include_closed: bool,
338
+ min_volume: Option<f64>,
339
+ ) -> bool {
340
+ if !include_closed && market.closed == Some(true) {
341
+ return false;
342
+ }
343
+ let volume = market.volume.or(market.volume_24hr).unwrap_or(0.0);
344
+ min_volume.is_none_or(|threshold| volume >= threshold)
345
+ }
346
+
347
+ fn compare_markets(left: &PredictionMarketSummary, right: &PredictionMarketSummary) -> Ordering {
348
+ active_rank(right).cmp(&active_rank(left)).then_with(|| {
349
+ score(right)
350
+ .partial_cmp(&score(left))
351
+ .unwrap_or(Ordering::Equal)
352
+ })
353
+ }
354
+
355
+ fn active_rank(market: &PredictionMarketSummary) -> u8 {
356
+ match (market.active, market.closed) {
357
+ (Some(true), Some(false)) => 2,
358
+ (Some(true), _) => 1,
359
+ _ => 0,
360
+ }
361
+ }
362
+
363
+ fn score(market: &PredictionMarketSummary) -> f64 {
364
+ market.volume_24hr.unwrap_or(0.0) * 3.0
365
+ + market.liquidity.unwrap_or(0.0) * 2.0
366
+ + market.volume.unwrap_or(0.0)
367
+ }
368
+
369
+ fn market_summary_from_value(
370
+ market: &Value,
371
+ event: Option<&EventContext>,
372
+ timezone: &str,
373
+ ) -> Result<PredictionMarketSummary> {
374
+ let outcomes = aligned_outcomes(market)?;
375
+ let end_time_utc =
376
+ string_value(market, "endDate").or_else(|| string_value(market, "endDateIso"));
377
+ let end_time_local = end_time_utc
378
+ .as_deref()
379
+ .and_then(|value| utc_to_local(Some(value), timezone));
380
+ let event_id = event.and_then(|value| value.id.clone());
381
+ let event_slug = event.and_then(|value| value.slug.clone());
382
+ let slug = string_value(market, "slug");
383
+ let title = string_value(market, "question")
384
+ .or_else(|| event.and_then(|value| value.title.clone()))
385
+ .or_else(|| slug.clone())
386
+ .unwrap_or_else(|| "untitled market".to_string());
387
+
388
+ Ok(PredictionMarketSummary {
389
+ id: string_value(market, "id"),
390
+ condition_id: string_value(market, "conditionId"),
391
+ slug: slug.clone(),
392
+ event_id,
393
+ event_slug: event_slug.clone(),
394
+ title,
395
+ question: string_value(market, "question"),
396
+ active: bool_value(market, "active"),
397
+ closed: bool_value(market, "closed"),
398
+ accepting_orders: bool_value(market, "acceptingOrders"),
399
+ end_time_utc,
400
+ end_time_local,
401
+ volume: number_value(market, "volume").or_else(|| number_value(market, "volumeNum")),
402
+ volume_24hr: number_value(market, "volume24hr")
403
+ .or_else(|| number_value(market, "volume24hrClob")),
404
+ liquidity: number_value(market, "liquidity")
405
+ .or_else(|| number_value(market, "liquidityNum")),
406
+ open_interest: number_value(market, "openInterest"),
407
+ best_bid: number_value(market, "bestBid"),
408
+ best_ask: number_value(market, "bestAsk"),
409
+ spread: number_value(market, "spread"),
410
+ last_trade_price: number_value(market, "lastTradePrice"),
411
+ one_hour_price_change: number_value(market, "oneHourPriceChange"),
412
+ one_day_price_change: number_value(market, "oneDayPriceChange"),
413
+ one_week_price_change: number_value(market, "oneWeekPriceChange"),
414
+ market_url: market_url(event_slug.as_deref(), slug.as_deref()),
415
+ outcomes,
416
+ })
417
+ }
418
+
419
+ fn aligned_outcomes(market: &Value) -> Result<Vec<PredictionOutcome>> {
420
+ let labels = string_array(market.get("outcomes"))
421
+ .context("market outcomes must be a JSON array or JSON-encoded array")?;
422
+ let prices = optional_number_array(market.get("outcomePrices"))?;
423
+ let token_ids = optional_string_array(market.get("clobTokenIds"))?;
424
+ if let Some(prices) = &prices
425
+ && prices.len() != labels.len()
426
+ {
427
+ return Err(anyhow!(
428
+ "outcomePrices length {} does not match outcomes length {}",
429
+ prices.len(),
430
+ labels.len()
431
+ ));
432
+ }
433
+ if let Some(token_ids) = &token_ids
434
+ && token_ids.len() != labels.len()
435
+ {
436
+ return Err(anyhow!(
437
+ "clobTokenIds length {} does not match outcomes length {}",
438
+ token_ids.len(),
439
+ labels.len()
440
+ ));
441
+ }
442
+
443
+ Ok(labels
444
+ .into_iter()
445
+ .enumerate()
446
+ .map(|(index, label)| PredictionOutcome {
447
+ label,
448
+ implied_probability: prices
449
+ .as_ref()
450
+ .and_then(|values| values.get(index).copied()),
451
+ clob_token_id: token_ids
452
+ .as_ref()
453
+ .and_then(|values| values.get(index).cloned()),
454
+ best_bid: None,
455
+ best_ask: None,
456
+ spread: None,
457
+ last_trade_price: None,
458
+ bid_count: 0,
459
+ ask_count: 0,
460
+ })
461
+ .collect())
462
+ }
463
+
464
+ async fn hydrate_outcome_books(
465
+ client: &Client,
466
+ market: &mut PredictionMarketSummary,
467
+ options: &MarketRequestOptions,
468
+ errors: &mut BTreeMap<String, String>,
469
+ ) {
470
+ let sdk_client = ClobClient::default();
471
+ for outcome in &mut market.outcomes {
472
+ let Some(token_id) = outcome.clob_token_id.as_deref() else {
473
+ continue;
474
+ };
475
+ let book = if options.use_http_transport {
476
+ fetch_order_book_http(client, token_id).await
477
+ } else {
478
+ fetch_order_book_sdk(&sdk_client, token_id, options.timeout_seconds).await
479
+ };
480
+ match book {
481
+ Ok(value) => apply_book(outcome, &value, options.limit),
482
+ Err(error) => {
483
+ errors.insert(format!("orderbook:{token_id}"), error.to_string());
484
+ }
485
+ }
486
+ }
487
+ if let Some(first) = market.outcomes.first() {
488
+ market.best_bid = market.best_bid.or(first.best_bid);
489
+ market.best_ask = market.best_ask.or(first.best_ask);
490
+ market.spread = market.spread.or(first.spread);
491
+ }
492
+ }
493
+
494
+ async fn fetch_order_book_sdk(
495
+ client: &ClobClient,
496
+ token_id: &str,
497
+ timeout_seconds: u64,
498
+ ) -> Result<Value> {
499
+ let token = token_id.parse::<U256>()?;
500
+ let request = OrderBookSummaryRequest::builder().token_id(token).build();
501
+ let book = with_timeout(
502
+ timeout_seconds,
503
+ client.order_book(&request),
504
+ "polymarket clob orderbook",
505
+ )
506
+ .await?;
507
+ Ok(serde_json::to_value(book)?)
508
+ }
509
+
510
+ async fn fetch_order_book_http(client: &Client, token_id: &str) -> Result<Value> {
511
+ client
512
+ .get(url_with_query(
513
+ &format!("{CLOB_URL}/book"),
514
+ &[("token_id", token_id.to_string())],
515
+ ))
516
+ .send()
517
+ .await?
518
+ .error_for_status()?
519
+ .json::<Value>()
520
+ .await
521
+ .context("failed to decode Polymarket orderbook response")
522
+ }
523
+
524
+ fn apply_book(outcome: &mut PredictionOutcome, book: &Value, limit: usize) {
525
+ let bids = book.get("bids").and_then(Value::as_array);
526
+ let asks = book.get("asks").and_then(Value::as_array);
527
+ outcome.best_bid = best_price(bids, BookSide::Bid);
528
+ outcome.best_ask = best_price(asks, BookSide::Ask);
529
+ outcome.spread = match (outcome.best_bid, outcome.best_ask) {
530
+ (Some(bid), Some(ask)) => Some((ask - bid).max(0.0)),
531
+ _ => None,
532
+ };
533
+ outcome.last_trade_price = number_value(book, "lastTradePrice");
534
+ outcome.bid_count = bids.map_or(0, |rows| rows.len().min(limit));
535
+ outcome.ask_count = asks.map_or(0, |rows| rows.len().min(limit));
536
+ }
537
+
538
+ async fn fetch_first_history(
539
+ client: &Client,
540
+ market: &PredictionMarketSummary,
541
+ options: &MarketRequestOptions,
542
+ timezone: &str,
543
+ errors: &mut BTreeMap<String, String>,
544
+ ) -> Vec<PredictionPricePoint> {
545
+ let Some(token_id) = market
546
+ .outcomes
547
+ .iter()
548
+ .find_map(|outcome| outcome.clob_token_id.as_deref())
549
+ else {
550
+ return Vec::new();
551
+ };
552
+ let history = if options.use_http_transport {
553
+ fetch_history_http(client, token_id, timezone).await
554
+ } else {
555
+ fetch_history_sdk(token_id, options.timeout_seconds, timezone).await
556
+ };
557
+ match history {
558
+ Ok(points) => points,
559
+ Err(error) => {
560
+ errors.insert(format!("history:{token_id}"), error.to_string());
561
+ Vec::new()
562
+ }
563
+ }
564
+ }
565
+
566
+ async fn fetch_history_sdk(
567
+ token_id: &str,
568
+ timeout_seconds: u64,
569
+ timezone: &str,
570
+ ) -> Result<Vec<PredictionPricePoint>> {
571
+ let token = token_id.parse::<U256>()?;
572
+ let request = PriceHistoryRequest::builder()
573
+ .market(token)
574
+ .time_range(TimeRange::from_interval(Interval::OneWeek))
575
+ .fidelity(60)
576
+ .build();
577
+ let response = with_timeout(
578
+ timeout_seconds,
579
+ ClobClient::default().price_history(&request),
580
+ "polymarket clob price history",
581
+ )
582
+ .await?;
583
+ Ok(price_points_from_clob(response.history, timezone))
584
+ }
585
+
586
+ async fn fetch_history_http(
587
+ client: &Client,
588
+ token_id: &str,
589
+ timezone: &str,
590
+ ) -> Result<Vec<PredictionPricePoint>> {
591
+ let value = client
592
+ .get(url_with_query(
593
+ &format!("{CLOB_URL}/prices-history"),
594
+ &[
595
+ ("market", token_id.to_string()),
596
+ ("interval", "1w".to_string()),
597
+ ("fidelity", "60".to_string()),
598
+ ],
599
+ ))
600
+ .send()
601
+ .await?
602
+ .error_for_status()?
603
+ .json::<Value>()
604
+ .await
605
+ .context("failed to decode Polymarket price history response")?;
606
+ Ok(price_points_from_value(&value, timezone))
607
+ }
608
+
609
+ fn price_points_from_clob(
610
+ points: Vec<ClobPricePoint>,
611
+ timezone: &str,
612
+ ) -> Vec<PredictionPricePoint> {
613
+ points
614
+ .into_iter()
615
+ .filter_map(|point| {
616
+ let price = point.p.to_string().parse::<f64>().ok()?;
617
+ let time_utc = Utc
618
+ .timestamp_opt(point.t, 0)
619
+ .single()
620
+ .map(|time| time.to_rfc3339_opts(SecondsFormat::Secs, true));
621
+ let time_local = time_utc
622
+ .as_deref()
623
+ .and_then(|value| utc_to_local(Some(value), timezone));
624
+ Some(PredictionPricePoint {
625
+ time_utc,
626
+ time_local,
627
+ price,
628
+ })
629
+ })
630
+ .collect()
631
+ }
632
+
633
+ fn price_points_from_value(value: &Value, timezone: &str) -> Vec<PredictionPricePoint> {
634
+ value
635
+ .get("history")
636
+ .and_then(Value::as_array)
637
+ .into_iter()
638
+ .flatten()
639
+ .filter_map(|point| {
640
+ let price = number_value(point, "p")?;
641
+ let time_utc = point
642
+ .get("t")
643
+ .and_then(Value::as_i64)
644
+ .and_then(|timestamp| Utc.timestamp_opt(timestamp, 0).single())
645
+ .map(|time| time.to_rfc3339_opts(SecondsFormat::Secs, true));
646
+ let time_local = time_utc
647
+ .as_deref()
648
+ .and_then(|value| utc_to_local(Some(value), timezone));
649
+ Some(PredictionPricePoint {
650
+ time_utc,
651
+ time_local,
652
+ price,
653
+ })
654
+ })
655
+ .collect()
656
+ }
657
+
658
+ async fn fetch_open_interest(
659
+ client: &Client,
660
+ market: &PredictionMarketSummary,
661
+ options: &MarketRequestOptions,
662
+ errors: &mut BTreeMap<String, String>,
663
+ ) -> Option<f64> {
664
+ let condition_id = condition_id(market)?;
665
+ let value = if options.use_http_transport {
666
+ fetch_open_interest_http(client, &condition_id.to_string()).await
667
+ } else {
668
+ fetch_open_interest_sdk(condition_id, options.timeout_seconds).await
669
+ };
670
+ match value {
671
+ Ok(value) => value,
672
+ Err(error) => {
673
+ errors.insert("open_interest".to_string(), error.to_string());
674
+ None
675
+ }
676
+ }
677
+ }
678
+
679
+ async fn fetch_open_interest_sdk(condition_id: B256, timeout_seconds: u64) -> Result<Option<f64>> {
680
+ let request = OpenInterestRequest::builder()
681
+ .markets(vec![condition_id])
682
+ .build();
683
+ let response = with_timeout(
684
+ timeout_seconds,
685
+ DataClient::default().open_interest(&request),
686
+ "polymarket data open interest",
687
+ )
688
+ .await?;
689
+ Ok(response
690
+ .first()
691
+ .and_then(|row| row.value.to_string().parse::<f64>().ok()))
692
+ }
693
+
694
+ async fn fetch_open_interest_http(client: &Client, condition_id: &str) -> Result<Option<f64>> {
695
+ let value = client
696
+ .get(url_with_query(
697
+ &format!("{DATA_URL}/oi"),
698
+ &[("market", condition_id.to_string())],
699
+ ))
700
+ .send()
701
+ .await?
702
+ .error_for_status()?
703
+ .json::<Value>()
704
+ .await
705
+ .context("failed to decode Polymarket open-interest response")?;
706
+ Ok(value
707
+ .as_array()
708
+ .and_then(|rows| rows.first())
709
+ .and_then(|row| number_value(row, "value")))
710
+ }
711
+
712
+ async fn fetch_holder_preview_count(
713
+ client: &Client,
714
+ market: &PredictionMarketSummary,
715
+ options: &MarketRequestOptions,
716
+ errors: &mut BTreeMap<String, String>,
717
+ ) -> Option<usize> {
718
+ let condition_id = condition_id(market)?;
719
+ let value = if options.use_http_transport {
720
+ fetch_holder_preview_count_http(client, &condition_id.to_string(), options.limit).await
721
+ } else {
722
+ fetch_holder_preview_count_sdk(condition_id, options.limit, options.timeout_seconds).await
723
+ };
724
+ match value {
725
+ Ok(value) => value,
726
+ Err(error) => {
727
+ errors.insert("holders".to_string(), error.to_string());
728
+ None
729
+ }
730
+ }
731
+ }
732
+
733
+ async fn fetch_holder_preview_count_sdk(
734
+ condition_id: B256,
735
+ limit: usize,
736
+ timeout_seconds: u64,
737
+ ) -> Result<Option<usize>> {
738
+ let builder = HoldersRequest::builder()
739
+ .markets(vec![condition_id])
740
+ .limit(i32::try_from(limit.min(20))?)?;
741
+ let request = builder.build();
742
+ let response = with_timeout(
743
+ timeout_seconds,
744
+ DataClient::default().holders(&request),
745
+ "polymarket data holders",
746
+ )
747
+ .await?;
748
+ Ok(Some(response.iter().map(|token| token.holders.len()).sum()))
749
+ }
750
+
751
+ async fn fetch_holder_preview_count_http(
752
+ client: &Client,
753
+ condition_id: &str,
754
+ limit: usize,
755
+ ) -> Result<Option<usize>> {
756
+ let limit = limit.min(20).to_string();
757
+ let value = client
758
+ .get(url_with_query(
759
+ &format!("{DATA_URL}/holders"),
760
+ &[
761
+ ("market", condition_id.to_string()),
762
+ ("limit", limit.to_string()),
763
+ ],
764
+ ))
765
+ .send()
766
+ .await?
767
+ .error_for_status()?
768
+ .json::<Value>()
769
+ .await
770
+ .context("failed to decode Polymarket holders response")?;
771
+ Ok(Some(
772
+ value
773
+ .as_array()
774
+ .into_iter()
775
+ .flatten()
776
+ .filter_map(|token| token.get("holders").and_then(Value::as_array))
777
+ .map(Vec::len)
778
+ .sum(),
779
+ ))
780
+ }
781
+
782
+ fn condition_id(market: &PredictionMarketSummary) -> Option<B256> {
783
+ market
784
+ .condition_id
785
+ .as_deref()
786
+ .and_then(|value| B256::from_str(value).ok())
787
+ }
788
+
789
+ fn market_source_urls(market: &PredictionMarketSummary) -> Vec<String> {
790
+ let mut urls = vec![
791
+ GAMMA_MARKET_URL.to_string(),
792
+ CLOB_URL.to_string(),
793
+ DATA_URL.to_string(),
794
+ ];
795
+ if let Some(url) = &market.market_url {
796
+ urls.push(url.clone());
797
+ }
798
+ urls
799
+ }
800
+
801
+ fn search_source_url(query: &str) -> String {
802
+ let query = url::form_urlencoded::Serializer::new(String::new())
803
+ .append_pair("q", query)
804
+ .finish();
805
+ format!("{SEARCH_URL}?{query}")
806
+ }
807
+
808
+ fn url_with_query(base: &str, params: &[(&str, String)]) -> String {
809
+ let query = params
810
+ .iter()
811
+ .fold(
812
+ url::form_urlencoded::Serializer::new(String::new()),
813
+ |mut builder, (key, value)| {
814
+ builder.append_pair(key, value);
815
+ builder
816
+ },
817
+ )
818
+ .finish();
819
+ format!("{base}?{query}")
820
+ }
821
+
822
+ fn market_url(event_slug: Option<&str>, market_slug: Option<&str>) -> Option<String> {
823
+ event_slug
824
+ .map(|slug| format!("https://polymarket.com/event/{slug}"))
825
+ .or_else(|| market_slug.map(|slug| format!("https://polymarket.com/market/{slug}")))
826
+ }
827
+
828
+ #[derive(Debug)]
829
+ struct EventContext {
830
+ id: Option<String>,
831
+ slug: Option<String>,
832
+ title: Option<String>,
833
+ }
834
+
835
+ #[derive(Clone, Copy)]
836
+ enum BookSide {
837
+ Bid,
838
+ Ask,
839
+ }
840
+
841
+ fn best_price(rows: Option<&Vec<Value>>, side: BookSide) -> Option<f64> {
842
+ rows?
843
+ .iter()
844
+ .filter_map(|row| number_value(row, "price"))
845
+ .fold(None, |best, price| match (best, side) {
846
+ (None, _) => Some(price),
847
+ (Some(best), BookSide::Bid) => Some(best.max(price)),
848
+ (Some(best), BookSide::Ask) => Some(best.min(price)),
849
+ })
850
+ }
851
+
852
+ fn string_array(value: Option<&Value>) -> Result<Vec<String>> {
853
+ let Some(value) = value else {
854
+ return Ok(Vec::new());
855
+ };
856
+ match value {
857
+ Value::Array(rows) => Ok(rows
858
+ .iter()
859
+ .filter_map(|value| value.as_str().map(ToString::to_string))
860
+ .collect()),
861
+ Value::String(raw) => {
862
+ let parsed = serde_json::from_str::<Value>(raw)?;
863
+ string_array(Some(&parsed))
864
+ }
865
+ _ => Err(anyhow!("expected string array")),
866
+ }
867
+ }
868
+
869
+ fn optional_string_array(value: Option<&Value>) -> Result<Option<Vec<String>>> {
870
+ match value {
871
+ Some(Value::Null) | None => Ok(None),
872
+ Some(value) => string_array(Some(value)).map(Some),
873
+ }
874
+ }
875
+
876
+ fn optional_number_array(value: Option<&Value>) -> Result<Option<Vec<f64>>> {
877
+ let Some(value) = value else {
878
+ return Ok(None);
879
+ };
880
+ let array = match value {
881
+ Value::Array(rows) => rows.iter().filter_map(number_scalar).collect(),
882
+ Value::String(raw) => {
883
+ let parsed = serde_json::from_str::<Value>(raw)?;
884
+ return optional_number_array(Some(&parsed));
885
+ }
886
+ Value::Null => return Ok(None),
887
+ _ => return Err(anyhow!("expected number array")),
888
+ };
889
+ Ok(Some(array))
890
+ }
891
+
892
+ fn string_value(value: &Value, key: &str) -> Option<String> {
893
+ value.get(key).and_then(|value| match value {
894
+ Value::String(value) if !value.is_empty() => Some(value.clone()),
895
+ Value::Number(value) => Some(value.to_string()),
896
+ _ => None,
897
+ })
898
+ }
899
+
900
+ fn bool_value(value: &Value, key: &str) -> Option<bool> {
901
+ value.get(key).and_then(Value::as_bool)
902
+ }
903
+
904
+ fn number_value(value: &Value, key: &str) -> Option<f64> {
905
+ value.get(key).and_then(number_scalar)
906
+ }
907
+
908
+ fn number_scalar(value: &Value) -> Option<f64> {
909
+ match value {
910
+ Value::Number(number) => number.as_f64(),
911
+ Value::String(value) => value.parse::<f64>().ok(),
912
+ _ => None,
913
+ }
914
+ }
915
+
916
+ #[cfg(test)]
917
+ mod tests {
918
+ use super::*;
919
+
920
+ #[test]
921
+ fn aligns_gamma_outcomes_with_prices_and_tokens() {
922
+ let market = json!({
923
+ "outcomes": "[\"Yes\",\"No\"]",
924
+ "outcomePrices": "[\"0.61\",\"0.39\"]",
925
+ "clobTokenIds": "[\"111\",\"222\"]"
926
+ });
927
+
928
+ let outcomes = aligned_outcomes(&market).unwrap();
929
+
930
+ assert_eq!(outcomes[0].label, "Yes");
931
+ assert_eq!(outcomes[0].implied_probability, Some(0.61));
932
+ assert_eq!(outcomes[0].clob_token_id.as_deref(), Some("111"));
933
+ assert_eq!(outcomes[1].label, "No");
934
+ assert_eq!(outcomes[1].implied_probability, Some(0.39));
935
+ assert_eq!(outcomes[1].clob_token_id.as_deref(), Some("222"));
936
+ }
937
+
938
+ #[test]
939
+ fn rejects_misaligned_outcome_arrays() {
940
+ let market = json!({
941
+ "outcomes": "[\"Yes\",\"No\"]",
942
+ "outcomePrices": "[\"0.61\"]",
943
+ "clobTokenIds": "[\"111\",\"222\"]"
944
+ });
945
+
946
+ let error = aligned_outcomes(&market).unwrap_err().to_string();
947
+
948
+ assert!(error.contains("outcomePrices length 1 does not match outcomes length 2"));
949
+ }
950
+
951
+ #[test]
952
+ fn treats_null_outcome_prices_as_missing_probabilities() {
953
+ let market = json!({
954
+ "outcomes": "[\"Yes\",\"No\"]",
955
+ "outcomePrices": null,
956
+ "clobTokenIds": "[\"111\",\"222\"]"
957
+ });
958
+
959
+ let outcomes = aligned_outcomes(&market).unwrap();
960
+
961
+ assert_eq!(outcomes.len(), 2);
962
+ assert_eq!(outcomes[0].implied_probability, None);
963
+ assert_eq!(outcomes[1].implied_probability, None);
964
+ }
965
+
966
+ #[test]
967
+ fn computes_best_prices_from_unsorted_orderbook() {
968
+ let mut outcome = PredictionOutcome {
969
+ label: "Yes".to_string(),
970
+ implied_probability: Some(0.6),
971
+ clob_token_id: Some("111".to_string()),
972
+ best_bid: None,
973
+ best_ask: None,
974
+ spread: None,
975
+ last_trade_price: None,
976
+ bid_count: 0,
977
+ ask_count: 0,
978
+ };
979
+ let book = json!({
980
+ "bids": [{"price":"0.58","size":"100"}, {"price":"0.61","size":"10"}],
981
+ "asks": [{"price":"0.66","size":"100"}, {"price":"0.64","size":"10"}],
982
+ "lastTradePrice": "0.62"
983
+ });
984
+
985
+ apply_book(&mut outcome, &book, 10);
986
+
987
+ assert_eq!(outcome.best_bid, Some(0.61));
988
+ assert_eq!(outcome.best_ask, Some(0.64));
989
+ assert_eq!(outcome.spread, Some(0.030000000000000027));
990
+ assert_eq!(outcome.last_trade_price, Some(0.62));
991
+ assert_eq!(outcome.bid_count, 2);
992
+ assert_eq!(outcome.ask_count, 2);
993
+ }
994
+
995
+ #[test]
996
+ fn filters_closed_and_low_volume_search_results_then_sorts_by_signal_strength() {
997
+ let payload = json!({
998
+ "events": [
999
+ {
1000
+ "id": "event-1",
1001
+ "slug": "space-event",
1002
+ "title": "Space Event",
1003
+ "markets": [
1004
+ {
1005
+ "id": "1",
1006
+ "question": "Low volume active",
1007
+ "slug": "low",
1008
+ "active": true,
1009
+ "closed": false,
1010
+ "volume": "10",
1011
+ "liquidity": "10",
1012
+ "outcomes": "[\"Yes\",\"No\"]",
1013
+ "outcomePrices": "[\"0.5\",\"0.5\"]",
1014
+ "clobTokenIds": "[\"1\",\"2\"]"
1015
+ },
1016
+ {
1017
+ "id": "2",
1018
+ "question": "High volume active",
1019
+ "slug": "high",
1020
+ "active": true,
1021
+ "closed": false,
1022
+ "volume": "100",
1023
+ "volume24hr": "40",
1024
+ "liquidity": "50",
1025
+ "outcomes": "[\"Yes\",\"No\"]",
1026
+ "outcomePrices": "[\"0.7\",\"0.3\"]",
1027
+ "clobTokenIds": "[\"3\",\"4\"]"
1028
+ },
1029
+ {
1030
+ "id": "3",
1031
+ "question": "Closed market",
1032
+ "slug": "closed",
1033
+ "active": false,
1034
+ "closed": true,
1035
+ "volume": "1000",
1036
+ "outcomes": "[\"Yes\",\"No\"]",
1037
+ "outcomePrices": "[\"0.9\",\"0.1\"]",
1038
+ "clobTokenIds": "[\"5\",\"6\"]"
1039
+ }
1040
+ ]
1041
+ }
1042
+ ]
1043
+ });
1044
+
1045
+ let markets = collect_search_markets(&payload, 5, false, Some(20.0), "UTC").unwrap();
1046
+
1047
+ assert_eq!(markets.len(), 1);
1048
+ assert_eq!(markets[0].slug.as_deref(), Some("high"));
1049
+ assert_eq!(markets[0].event_slug.as_deref(), Some("space-event"));
1050
+ }
1051
+
1052
+ #[test]
1053
+ fn parses_price_history_points_with_local_time() {
1054
+ let payload = json!({
1055
+ "history": [
1056
+ {"t": 1760000000, "p": "0.52"},
1057
+ {"t": 1760000060, "p": 0.53}
1058
+ ]
1059
+ });
1060
+
1061
+ let points = price_points_from_value(&payload, "Asia/Singapore");
1062
+
1063
+ assert_eq!(points.len(), 2);
1064
+ assert_eq!(points[0].price, 0.52);
1065
+ assert_eq!(points[0].time_utc.as_deref(), Some("2025-10-09T08:53:20Z"));
1066
+ assert_eq!(
1067
+ points[0].time_local.as_deref(),
1068
+ Some("2025-10-09T16:53:20+08:00")
1069
+ );
1070
+ }
1071
+ }