use std::time::{SystemTime, UNIX_EPOCH};
use super::{Coin, ShortFormInterval};
const DISCOVERY_RETRIES: u32 = 3;
const DISCOVERY_RETRY_DELAY_MS: u64 = 2000;
#[derive(Debug, Clone)]
pub struct ShortFormMarket {
pub coin: Coin,
pub slug: String,
pub title: String,
pub condition_id: String,
pub window_start: i64,
pub window_end: i64,
pub outcomes: Vec<String>,
pub outcome_prices: Vec<f64>,
pub clob_token_ids: Vec<String>,
pub up_odds: f64,
pub down_odds: f64,
pub liquidity: f64,
pub volume_24h: f64,
pub price_to_beat: Option<f64>,
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
pub fn current_window_start(interval: ShortFormInterval) -> i64 {
let now = now_secs();
let w = interval.window_seconds();
(now / w) * w
}
pub fn current_window_end(interval: ShortFormInterval) -> i64 {
current_window_start(interval) + interval.window_seconds()
}
fn build_url(base_url: &str, coin: Coin, interval: ShortFormInterval) -> String {
if matches!(interval, ShortFormInterval::Hourly) {
let slug = build_hourly_slug(coin);
format!("{}/proxy/gamma/events?slug={}", base_url, slug)
} else {
let ts = current_window_start(interval);
let slug = format!("{}-updown-{}-{}", coin.id(), interval.slug_suffix(), ts);
format!("{}/proxy/gamma/events?slug={}", base_url, slug)
}
}
fn build_hourly_slug(coin: Coin) -> String {
let now = now_secs();
let et_offset = et_utc_offset(now);
let et_secs = now + et_offset;
let months = [
"january", "february", "march", "april", "may", "june",
"july", "august", "september", "october", "november", "december",
];
let secs_per_day: i64 = 86400;
let mut remaining = et_secs;
let mut year = 1970i64;
loop {
let days_in_year = if is_leap(year) { 366 } else { 365 };
let year_secs = days_in_year * secs_per_day;
if remaining < year_secs { break; }
remaining -= year_secs;
year += 1;
}
let month_days_arr = [31, if is_leap(year) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut month_idx = 0usize;
for &md in &month_days_arr {
let month_secs = md as i64 * secs_per_day;
if remaining < month_secs { break; }
remaining -= month_secs;
month_idx += 1;
}
let day = remaining / secs_per_day + 1;
remaining %= secs_per_day;
let hour_24 = remaining / 3600;
let (hour_12, ampm) = match hour_24 {
0 => (12, "am"),
1..=11 => (hour_24, "am"),
12 => (12, "pm"),
_ => (hour_24 - 12, "pm"),
};
format!(
"{}-up-or-down-{}-{}-{}-{}{}-et",
coin.full_name(),
months[month_idx],
day,
year,
hour_12,
ampm,
)
}
fn et_utc_offset(utc_secs: i64) -> i64 {
let secs_per_day: i64 = 86400;
let mut remaining = utc_secs;
let mut year = 1970i64;
loop {
let days_in_year = if is_leap(year) { 366 } else { 365 };
let year_secs = days_in_year * secs_per_day;
if remaining < year_secs { break; }
remaining -= year_secs;
year += 1;
}
let month_days_arr = [31, if is_leap(year) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let day_of_year: i64 = remaining / secs_per_day;
let time_of_day = remaining % secs_per_day;
let mut _month = 0usize;
let mut day_in_month = day_of_year;
for &md in &month_days_arr {
if day_in_month < md as i64 { break; }
day_in_month -= md as i64;
_month += 1;
}
let _mday = day_in_month + 1;
let _jan1_dow = day_of_week(year, 1, 1);
let march1_dow = day_of_week(year, 3, 1);
let second_sunday_march = if march1_dow == 0 { 8 } else { 15 - march1_dow as i64 };
let dst_start_doy = 31 + (if is_leap(year) { 29 } else { 28 }) + second_sunday_march - 1;
let dst_start_utc = dst_start_doy * secs_per_day + 7 * 3600;
let nov1_dow = day_of_week(year, 11, 1);
let first_sunday_nov = if nov1_dow == 0 { 1 } else { 8 - nov1_dow as i64 };
let dst_end_doy = 31+28+31+30+31+30+31+31+30+31 + first_sunday_nov - 1
+ if is_leap(year) { 1 } else { 0 };
let dst_end_utc = dst_end_doy * secs_per_day + 6 * 3600;
let utc_day_secs = day_of_year * secs_per_day + time_of_day;
if utc_day_secs >= dst_start_utc && utc_day_secs < dst_end_utc {
-14400 } else {
-18000 }
}
fn day_of_week(year: i64, month: i64, day: i64) -> i64 {
let t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
let y = if month < 3 { year - 1 } else { year };
((y + y/4 - y/100 + y/400 + t[(month - 1) as usize] + day) % 7).abs()
}
pub async fn discover_markets(
http: &reqwest::Client,
base_url: &str,
interval: ShortFormInterval,
coins: &[Coin],
) -> Vec<ShortFormMarket> {
let futs: Vec<_> = coins
.iter()
.map(|&coin| discover_coin(http, base_url, interval, coin))
.collect();
let mut markets: Vec<ShortFormMarket> = futures_util::future::join_all(futs)
.await
.into_iter()
.flatten()
.collect();
let price_futs: Vec<_> = markets
.iter()
.map(|m| fetch_price_to_beat(http, base_url, m.coin, interval, m.window_start, m.window_end))
.collect();
let prices = futures_util::future::join_all(price_futs).await;
for (market, price) in markets.iter_mut().zip(prices) {
market.price_to_beat = price;
}
markets
}
async fn fetch_price_to_beat(
http: &reqwest::Client,
base_url: &str,
coin: Coin,
interval: ShortFormInterval,
window_start: i64,
window_end: i64,
) -> Option<f64> {
let start_iso = unix_to_iso(window_start);
let end_iso = unix_to_iso(window_end);
let url = format!(
"{}/proxy/polymarket/api/crypto/crypto-price?symbol={}&eventStartTime={}&variant={}&endDate={}",
base_url,
coin.symbol(),
urlencoded(&start_iso),
interval.price_variant(),
urlencoded(&end_iso),
);
let resp = http
.get(&url)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let data: serde_json::Value = resp.json().await.ok()?;
data.get("openPrice").and_then(|v| v.as_f64())
}
async fn discover_coin(
http: &reqwest::Client,
base_url: &str,
interval: ShortFormInterval,
coin: Coin,
) -> Option<ShortFormMarket> {
let url = build_url(base_url, coin, interval);
for attempt in 0..DISCOVERY_RETRIES {
match try_fetch(http, &url, coin, interval).await {
Ok(Some(market)) => return Some(market),
Ok(None) | Err(_) => {
if attempt < DISCOVERY_RETRIES - 1 {
tokio::time::sleep(tokio::time::Duration::from_millis(DISCOVERY_RETRY_DELAY_MS)).await;
}
}
}
}
None
}
async fn try_fetch(
http: &reqwest::Client,
url: &str,
coin: Coin,
interval: ShortFormInterval,
) -> Result<Option<ShortFormMarket>, reqwest::Error> {
let resp = http
.get(url)
.timeout(std::time::Duration::from_secs(5))
.send()
.await?;
if !resp.status().is_success() {
return Ok(None);
}
let data: Vec<serde_json::Value> = resp.json().await?;
if data.is_empty() {
return Ok(None);
}
Ok(parse_gamma_event(coin, interval, &data[0]))
}
fn parse_gamma_event(
coin: Coin,
interval: ShortFormInterval,
event: &serde_json::Value,
) -> Option<ShortFormMarket> {
let market = event.get("markets")?.as_array()?.first()?;
let slug = event.get("slug")?.as_str().unwrap_or_default().to_string();
let title = event.get("title").and_then(|v| v.as_str()).unwrap_or_default().to_string();
let condition_id = market.get("conditionId").and_then(|v| v.as_str()).unwrap_or_default().to_string();
let outcomes = parse_string_or_array(market.get("outcomes"));
let outcome_prices: Vec<f64> = parse_string_or_array(market.get("outcomePrices"))
.iter()
.filter_map(|s| s.parse().ok())
.collect();
let clob_token_ids = parse_string_or_array(market.get("clobTokenIds"));
let mut up_odds = 0.5;
let mut down_odds = 0.5;
for (i, label) in outcomes.iter().enumerate() {
let price = outcome_prices.get(i).copied().unwrap_or(0.0);
let lower = label.to_lowercase();
if lower.contains("up") {
up_odds = price;
}
if lower.contains("down") {
down_odds = price;
}
}
let liquidity = parse_float_field(market, "liquidity");
let volume_24h = parse_float_field(market, "volume24hr")
.or_else(|| parse_float_field(market, "volume"))
.unwrap_or(0.0);
let (window_start, window_end) = if matches!(interval, ShortFormInterval::Hourly) {
if let Some(end_date) = event.get("endDate").and_then(|v| v.as_str()) {
if let Ok(dt) = chrono_parse_rfc3339(end_date) {
(dt - 3600, dt)
} else {
let ws = current_window_start(interval);
(ws, ws + 3600)
}
} else {
let ws = current_window_start(interval);
(ws, ws + 3600)
}
} else {
let ts = slug
.rsplit('-')
.next()
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or_else(|| current_window_start(interval));
(ts, ts + interval.window_seconds())
};
Some(ShortFormMarket {
coin,
slug,
title,
condition_id,
window_start,
window_end,
outcomes,
outcome_prices,
clob_token_ids,
up_odds,
down_odds,
liquidity: liquidity.unwrap_or(0.0),
volume_24h,
price_to_beat: None, })
}
fn parse_float_field(obj: &serde_json::Value, key: &str) -> Option<f64> {
match obj.get(key) {
Some(serde_json::Value::Number(n)) => n.as_f64(),
Some(serde_json::Value::String(s)) => s.parse().ok(),
_ => None,
}
}
fn parse_string_or_array(value: Option<&serde_json::Value>) -> Vec<String> {
match value {
Some(serde_json::Value::String(s)) => {
serde_json::from_str(s).unwrap_or_default()
}
Some(serde_json::Value::Array(arr)) => {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
}
_ => Vec::new(),
}
}
fn chrono_parse_rfc3339(s: &str) -> Result<i64, ()> {
let s = s.trim_end_matches('Z');
let (date, time) = s.split_once('T').ok_or(())?;
let parts: Vec<&str> = date.split('-').collect();
if parts.len() != 3 { return Err(()); }
let year: i64 = parts[0].parse().map_err(|_| ())?;
let month: i64 = parts[1].parse().map_err(|_| ())?;
let day: i64 = parts[2].parse().map_err(|_| ())?;
let time_main = time.split('.').next().ok_or(())?;
let tparts: Vec<&str> = time_main.split(':').collect();
if tparts.len() != 3 { return Err(()); }
let hour: i64 = tparts[0].parse().map_err(|_| ())?;
let min: i64 = tparts[1].parse().map_err(|_| ())?;
let sec: i64 = tparts[2].parse().map_err(|_| ())?;
let mut days: i64 = 0;
for y in 1970..year {
days += if is_leap(y) { 366 } else { 365 };
}
let month_days = [31, if is_leap(year) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
for m in 0..(month - 1) as usize {
days += month_days[m] as i64;
}
days += day - 1;
Ok(days * 86400 + hour * 3600 + min * 60 + sec)
}
fn is_leap(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
fn unix_to_iso(ts: i64) -> String {
let secs_per_day: i64 = 86400;
let mut remaining = ts;
let mut year = 1970i64;
loop {
let days_in_year = if is_leap(year) { 366 } else { 365 };
let year_secs = days_in_year * secs_per_day;
if remaining < year_secs { break; }
remaining -= year_secs;
year += 1;
}
let month_days = [31, if is_leap(year) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut month = 1;
for &md in &month_days {
let month_secs = md as i64 * secs_per_day;
if remaining < month_secs { break; }
remaining -= month_secs;
month += 1;
}
let day = remaining / secs_per_day + 1;
remaining %= secs_per_day;
let hour = remaining / 3600;
remaining %= 3600;
let min = remaining / 60;
let sec = remaining % 60;
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", year, month, day, hour, min, sec)
}
fn urlencoded(s: &str) -> String {
s.replace(':', "%3A")
}