agent-finance-cli 0.1.2 → 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.
- package/Cargo.lock +3920 -779
- package/Cargo.toml +2 -1
- package/README.md +7 -1
- package/package.json +6 -6
- package/skills/core-full.md +14 -0
- package/skills/core.md +9 -0
- package/skills/prediction-markets.md +47 -0
- package/skills/providers.md +2 -1
- package/src/app.rs +57 -2
- package/src/cli.rs +63 -0
- package/src/model.rs +83 -0
- package/src/output.rs +161 -1
- package/src/providers/capabilities.rs +49 -0
- package/src/providers/mod.rs +1 -0
- package/src/providers/polymarket.rs +1071 -0
- package/src/skills.rs +7 -1
|
@@ -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, ¶ms))
|
|
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
|
+
}
|