mirror of
https://github.com/floor-licker/polyfill-rs.git
synced 2026-08-02 23:37:43 +00:00
feat(api): add /prices-history helper methods
This commit is contained in:
+160
-3
@@ -460,6 +460,126 @@ impl ClobClient {
|
||||
Ok(price)
|
||||
}
|
||||
|
||||
fn validate_prices_history_asset_id(asset_id: &str) -> Result<()> {
|
||||
if asset_id.is_empty() {
|
||||
return Err(PolyfillError::validation(
|
||||
"asset_id is required (use the decimal token_id / asset_id)",
|
||||
));
|
||||
}
|
||||
|
||||
// Common footgun: passing a condition id (0x...) instead of the decimal asset id.
|
||||
if asset_id.starts_with("0x") || asset_id.starts_with("0X") {
|
||||
return Err(PolyfillError::validation(
|
||||
"`/prices-history` expects a decimal token_id/asset_id, not a hex condition_id",
|
||||
));
|
||||
}
|
||||
|
||||
if !asset_id.as_bytes().iter().all(u8::is_ascii_digit) {
|
||||
return Err(PolyfillError::validation(
|
||||
"asset_id must be a decimal string (token_id / asset_id)",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get price history for a single outcome (`token_id` / `asset_id`) over a fixed interval.
|
||||
///
|
||||
/// Important: the upstream API query parameter is named `market`, but it expects the
|
||||
/// decimal outcome asset id (not the hex `condition_id`).
|
||||
pub async fn get_prices_history_interval(
|
||||
&self,
|
||||
asset_id: &str,
|
||||
interval: PricesHistoryInterval,
|
||||
fidelity: Option<u32>,
|
||||
) -> Result<PricesHistoryResponse> {
|
||||
Self::validate_prices_history_asset_id(asset_id)?;
|
||||
|
||||
let mut request = self
|
||||
.http_client
|
||||
.get(format!("{}/prices-history", self.base_url))
|
||||
.query(&[("market", asset_id), ("interval", interval.as_str())]);
|
||||
|
||||
if let Some(fidelity) = fidelity {
|
||||
request = request.query(&[("fidelity", fidelity)]);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let message = serde_json::from_str::<Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
if body.is_empty() {
|
||||
"Failed to get prices history".to_string()
|
||||
} else {
|
||||
body
|
||||
}
|
||||
});
|
||||
return Err(PolyfillError::api(status, message));
|
||||
}
|
||||
|
||||
Ok(response.json::<PricesHistoryResponse>().await?)
|
||||
}
|
||||
|
||||
/// Get price history for a single outcome (`token_id` / `asset_id`) over a timestamp range.
|
||||
///
|
||||
/// `start_ts` and `end_ts` are Unix timestamps (seconds).
|
||||
pub async fn get_prices_history_range(
|
||||
&self,
|
||||
asset_id: &str,
|
||||
start_ts: u64,
|
||||
end_ts: u64,
|
||||
fidelity: Option<u32>,
|
||||
) -> Result<PricesHistoryResponse> {
|
||||
Self::validate_prices_history_asset_id(asset_id)?;
|
||||
|
||||
if start_ts >= end_ts {
|
||||
return Err(PolyfillError::validation(
|
||||
"start_ts must be < end_ts for prices history",
|
||||
));
|
||||
}
|
||||
|
||||
let mut request = self
|
||||
.http_client
|
||||
.get(format!("{}/prices-history", self.base_url))
|
||||
.query(&[("market", asset_id)])
|
||||
.query(&[("startTs", start_ts), ("endTs", end_ts)]);
|
||||
|
||||
if let Some(fidelity) = fidelity {
|
||||
request = request.query(&[("fidelity", fidelity)]);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let message = serde_json::from_str::<Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
if body.is_empty() {
|
||||
"Failed to get prices history".to_string()
|
||||
} else {
|
||||
body
|
||||
}
|
||||
});
|
||||
return Err(PolyfillError::api(status, message));
|
||||
}
|
||||
|
||||
Ok(response.json::<PricesHistoryResponse>().await?)
|
||||
}
|
||||
|
||||
/// Get tick size for a token
|
||||
pub async fn get_tick_size(&self, token_id: &str) -> Result<Decimal> {
|
||||
let response = self
|
||||
@@ -1723,8 +1843,8 @@ impl ClobClient {
|
||||
// Re-export types from the canonical location in types.rs
|
||||
pub use crate::types::{
|
||||
ExtraOrderArgs, Market, MarketOrderArgs, MarketsResponse, MidpointResponse, NegRiskResponse,
|
||||
OrderBookSummary, OrderSummary, PriceResponse, Rewards, SpreadResponse, TickSizeResponse,
|
||||
Token,
|
||||
OrderBookSummary, OrderSummary, PriceResponse, PricesHistoryInterval, PricesHistoryResponse,
|
||||
Rewards, SpreadResponse, TickSizeResponse, Token,
|
||||
};
|
||||
|
||||
// Compatibility types that need to stay in client.rs
|
||||
@@ -1740,7 +1860,7 @@ pub type PolyfillClient = ClobClient;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ClobClient, OrderArgs as ClientOrderArgs};
|
||||
use crate::types::Side;
|
||||
use crate::types::{PricesHistoryInterval, Side};
|
||||
use crate::{ApiCredentials, PolyfillError};
|
||||
use mockito::{Matcher, Server};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -2030,6 +2150,43 @@ mod tests {
|
||||
assert_eq!(response.price, Decimal::from_str("0.76").unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_get_prices_history_interval_rejects_hex_condition_id() {
|
||||
let client = create_test_client("https://test.example.com");
|
||||
let result = client
|
||||
.get_prices_history_interval("0xdeadbeef", PricesHistoryInterval::OneDay, None)
|
||||
.await;
|
||||
assert!(matches!(result, Err(PolyfillError::Validation { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_get_prices_history_interval_success() {
|
||||
let mut server = Server::new_async().await;
|
||||
let mock_response = r#"{"history":[{"t":1}]}"#;
|
||||
|
||||
let mock = server
|
||||
.mock("GET", "/prices-history")
|
||||
.match_query(Matcher::AllOf(vec![
|
||||
Matcher::UrlEncoded("market".into(), "12345".into()),
|
||||
Matcher::UrlEncoded("interval".into(), "1d".into()),
|
||||
Matcher::UrlEncoded("fidelity".into(), "5".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(mock_response)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = create_test_client(&server.url());
|
||||
let response = client
|
||||
.get_prices_history_interval("12345", PricesHistoryInterval::OneDay, Some(5))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
mock.assert_async().await;
|
||||
assert_eq!(response.history.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_get_tick_size_success() {
|
||||
let mut server = Server::new_async().await;
|
||||
|
||||
@@ -117,6 +117,8 @@ pub use crate::types::{
|
||||
OrderSummary,
|
||||
OrderType,
|
||||
PriceResponse,
|
||||
PricesHistoryInterval,
|
||||
PricesHistoryResponse,
|
||||
Rewards,
|
||||
Side,
|
||||
SimplifiedMarket,
|
||||
|
||||
@@ -1225,6 +1225,44 @@ pub struct PriceResponse {
|
||||
pub price: Decimal,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PRICE HISTORY (ANALYTICS)
|
||||
// ============================================================================
|
||||
|
||||
/// Time bucket for the `/prices-history` endpoint.
|
||||
///
|
||||
/// Note: this endpoint uses a confusing query parameter name (`market`) but expects an
|
||||
/// outcome asset id (`token_id` / `asset_id`) in **decimal string** form.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PricesHistoryInterval {
|
||||
OneMinute,
|
||||
OneHour,
|
||||
SixHours,
|
||||
OneDay,
|
||||
OneWeek,
|
||||
}
|
||||
|
||||
impl PricesHistoryInterval {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::OneMinute => "1m",
|
||||
Self::OneHour => "1h",
|
||||
Self::SixHours => "6h",
|
||||
Self::OneDay => "1d",
|
||||
Self::OneWeek => "1w",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw response from `/prices-history`.
|
||||
///
|
||||
/// We intentionally keep `history` entries as `serde_json::Value` because the upstream API has
|
||||
/// no stable public schema here and currently may return empty history for many markets.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PricesHistoryResponse {
|
||||
pub history: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SpreadResponse {
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
|
||||
Reference in New Issue
Block a user