diff --git a/README.md b/README.md index e9a2eee..90187c6 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,9 @@ polymarket markets list --limit 2 ``` ``` - Question Price (Yes) Volume Liquidity Status - Will Trump win the 2024 election? 52.00¢ $145.2M $1.2M Active - Will BTC hit $100k by Dec 2024? 67.30¢ $89.4M $430.5K Active + Question Price Volume Liquidity Status + Will Trump win the 2024 election? Yes: 52.00¢ $145.2M $1.2M Active + Will BTC hit $100k by Dec 2024? Yes: 67.30¢ $89.4M $430.5K Active ``` ```bash diff --git a/src/commands/comments.rs b/src/commands/comments.rs index 4003911..63a42ff 100644 --- a/src/commands/comments.rs +++ b/src/commands/comments.rs @@ -19,9 +19,9 @@ pub struct CommentsArgs { #[derive(Subcommand)] pub enum CommentsCommand { - /// List comments on an event, market, or series + /// List comments on an event or series List { - /// Parent entity type: event, market, or series + /// Parent entity type: event or series #[arg(long)] entity_type: EntityType, @@ -78,7 +78,6 @@ pub enum CommentsCommand { #[derive(Clone, Debug, clap::ValueEnum)] pub enum EntityType { Event, - Market, Series, } @@ -86,7 +85,6 @@ impl From for ParentEntityType { fn from(v: EntityType) -> Self { match v { EntityType::Event => ParentEntityType::Event, - EntityType::Market => ParentEntityType::Market, EntityType::Series => ParentEntityType::Series, } } @@ -152,3 +150,23 @@ pub async fn execute( Ok(()) } + +#[cfg(test)] +mod tests { + use super::EntityType; + use clap::ValueEnum; + + #[test] + fn entity_type_does_not_expose_market_variant() { + let names: Vec = EntityType::value_variants() + .iter() + .filter_map(|variant| { + variant + .to_possible_value() + .map(|value| value.get_name().to_string()) + }) + .collect(); + + assert!(!names.iter().any(|name| name == "market")); + } +} diff --git a/src/commands/events.rs b/src/commands/events.rs index d001210..ccc8d80 100644 --- a/src/commands/events.rs +++ b/src/commands/events.rs @@ -2,10 +2,13 @@ use anyhow::Result; use clap::{Args, Subcommand}; use polymarket_client_sdk::gamma::{ self, - types::request::{EventByIdRequest, EventBySlugRequest, EventTagsRequest, EventsRequest}, + types::{ + request::{EventByIdRequest, EventBySlugRequest, EventTagsRequest, EventsRequest}, + response::Event, + }, }; -use super::is_numeric_id; +use super::{flag_matches, is_numeric_id}; use crate::output::OutputFormat; use crate::output::events::{print_event, print_events}; use crate::output::tags::print_tags; @@ -62,6 +65,19 @@ pub enum EventsCommand { }, } +fn apply_status_filters( + events: Vec, + active_filter: Option, + closed_filter: Option, +) -> Vec { + events + .into_iter() + .filter(|event| { + flag_matches(event.active, active_filter) && flag_matches(event.closed, closed_filter) + }) + .collect() +} + pub async fn execute(client: &gamma::Client, args: EventsArgs, output: OutputFormat) -> Result<()> { match args.command { EventsCommand::List { @@ -73,11 +89,10 @@ pub async fn execute(client: &gamma::Client, args: EventsArgs, output: OutputFor ascending, tag, } => { - let resolved_closed = closed.or_else(|| active.map(|a| !a)); - let request = EventsRequest::builder() .limit(limit) - .maybe_closed(resolved_closed) + .maybe_active(active) + .maybe_closed(closed) .maybe_offset(offset) .ascending(ascending) .maybe_tag_slug(tag) @@ -85,7 +100,7 @@ pub async fn execute(client: &gamma::Client, args: EventsArgs, output: OutputFor .order(order.into_iter().collect()) .build(); - let events = client.events(&request).await?; + let events = apply_status_filters(client.events(&request).await?, active, closed); print_events(&events, &output)?; } @@ -112,3 +127,40 @@ pub async fn execute(client: &gamma::Client, args: EventsArgs, output: OutputFor Ok(()) } + +#[cfg(test)] +mod tests { + use super::apply_status_filters; + use polymarket_client_sdk::gamma::types::response::Event; + use serde_json::json; + + fn make_event(value: serde_json::Value) -> Event { + serde_json::from_value(value).unwrap() + } + + #[test] + fn status_filters_are_independent() { + let events = vec![ + make_event(json!({"id":"1", "active": true, "closed": true})), + make_event(json!({"id":"2", "active": false, "closed": true})), + make_event(json!({"id":"3", "active": false, "closed": false})), + ]; + + let filtered = apply_status_filters(events, Some(false), Some(true)); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].id, "2"); + } + + #[test] + fn active_filter_does_not_imply_closed_filter() { + let events = vec![ + make_event(json!({"id":"1", "active": false, "closed": true})), + make_event(json!({"id":"2", "active": false, "closed": false})), + ]; + + let filtered = apply_status_filters(events, Some(false), None); + + assert_eq!(filtered.len(), 2); + } +} diff --git a/src/commands/markets.rs b/src/commands/markets.rs index 2544d18..2cecd9e 100644 --- a/src/commands/markets.rs +++ b/src/commands/markets.rs @@ -11,7 +11,7 @@ use polymarket_client_sdk::gamma::{ }, }; -use super::is_numeric_id; +use super::{flag_matches, is_numeric_id}; use crate::output::OutputFormat; use crate::output::markets::{print_market, print_markets}; use crate::output::tags::print_tags; @@ -74,6 +74,73 @@ pub enum MarketsCommand { }, } +fn apply_status_filters( + markets: Vec, + active_filter: Option, + closed_filter: Option, +) -> Vec { + markets + .into_iter() + .filter(|market| { + flag_matches(market.active, active_filter) && flag_matches(market.closed, closed_filter) + }) + .collect() +} + +async fn list_markets( + client: &gamma::Client, + limit: i32, + offset: Option, + order: Option, + ascending: bool, + active: Option, + closed: Option, +) -> Result> { + if limit <= 0 { + return Ok(Vec::new()); + } + let page_size = limit; + let mut next_offset = offset.unwrap_or(0); + let mut collected: Vec = Vec::new(); + + loop { + let request = MarketsRequest::builder() + .limit(page_size) + .maybe_closed(closed) + .maybe_offset(Some(next_offset)) + .maybe_order(order.clone()) + .ascending(ascending) + .build(); + + let page = client.markets(&request).await?; + if page.is_empty() { + break; + } + + let raw_count = page.len(); + collected.extend(apply_status_filters(page, active, closed)); + + if collected.len() >= page_size as usize { + collected.truncate(page_size as usize); + break; + } + + // Without an active filter, the API-side limit should be authoritative. + if active.is_none() { + break; + } + + // Reached end of available results from the backend. + if raw_count < page_size as usize { + break; + } + + next_offset += raw_count as i32; + } + + Ok(collected) +} + pub async fn execute( client: &gamma::Client, args: MarketsArgs, @@ -88,17 +155,8 @@ pub async fn execute( order, ascending, } => { - let resolved_closed = closed.or_else(|| active.map(|a| !a)); - - let request = MarketsRequest::builder() - .limit(limit) - .maybe_closed(resolved_closed) - .maybe_offset(offset) - .maybe_order(order) - .ascending(ascending) - .build(); - - let markets = client.markets(&request).await?; + let markets = + list_markets(client, limit, offset, order, ascending, active, closed).await?; print_markets(&markets, &output)?; } @@ -143,3 +201,40 @@ pub async fn execute( Ok(()) } + +#[cfg(test)] +mod tests { + use super::apply_status_filters; + use polymarket_client_sdk::gamma::types::response::Market; + use serde_json::json; + + fn make_market(value: serde_json::Value) -> Market { + serde_json::from_value(value).unwrap() + } + + #[test] + fn status_filters_are_independent() { + let markets = vec![ + make_market(json!({"id":"1", "active": true, "closed": true})), + make_market(json!({"id":"2", "active": false, "closed": true})), + make_market(json!({"id":"3", "active": false, "closed": false})), + ]; + + let filtered = apply_status_filters(markets, Some(false), Some(true)); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].id, "2"); + } + + #[test] + fn active_filter_does_not_imply_closed_filter() { + let markets = vec![ + make_market(json!({"id":"1", "active": false, "closed": true})), + make_market(json!({"id":"2", "active": false, "closed": false})), + ]; + + let filtered = apply_status_filters(markets, Some(false), None); + + assert_eq!(filtered.len(), 2); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index d4c985b..d182c45 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -21,6 +21,10 @@ pub(crate) fn is_numeric_id(id: &str) -> bool { id.parse::().is_ok() } +pub(crate) fn flag_matches(value: Option, filter: Option) -> bool { + filter.is_none_or(|expected| value == Some(expected)) +} + #[cfg(test)] mod tests { use super::*; @@ -42,4 +46,17 @@ mod tests { fn is_numeric_id_rejects_empty() { assert!(!is_numeric_id("")); } + + #[test] + fn flag_matches_true_cases() { + assert!(flag_matches(Some(true), Some(true))); + assert!(flag_matches(Some(false), Some(false))); + assert!(flag_matches(Some(true), None)); + } + + #[test] + fn flag_matches_false_cases() { + assert!(!flag_matches(Some(true), Some(false))); + assert!(!flag_matches(None, Some(true))); + } } diff --git a/src/output/markets.rs b/src/output/markets.rs index b9a3588..1625853 100644 --- a/src/output/markets.rs +++ b/src/output/markets.rs @@ -12,7 +12,7 @@ use super::{ struct MarketRow { #[tabled(rename = "Question")] question: String, - #[tabled(rename = "Price (Yes)")] + #[tabled(rename = "Price")] price_yes: String, #[tabled(rename = "Volume")] volume: String, @@ -24,13 +24,13 @@ struct MarketRow { fn market_to_row(m: &Market) -> MarketRow { let question = m.question.as_deref().unwrap_or(DASH); - let price_yes = m - .outcome_prices - .as_ref() - .and_then(|p| p.first()) - .map_or_else( + let price_yes = + primary_outcome_price(m).map_or_else( || DASH.into(), - |p| format!("{:.2}¢", p * Decimal::from(100)), + |(outcome, price)| match outcome { + Some(outcome) => format!("{outcome}: {:.2}¢", price * Decimal::from(100)), + None => format!("{:.2}¢", price * Decimal::from(100)), + }, ); MarketRow { @@ -42,6 +42,20 @@ fn market_to_row(m: &Market) -> MarketRow { } } +fn primary_outcome_price(m: &Market) -> Option<(Option, Decimal)> { + let prices = m.outcome_prices.as_ref()?; + + if let Some(outcomes) = m.outcomes.as_ref() { + outcomes + .iter() + .zip(prices.iter()) + .find(|(outcome, _)| outcome.eq_ignore_ascii_case("yes")) + .or_else(|| outcomes.iter().zip(prices.iter()).next()) + .map(|(outcome, price)| (Some(outcome.clone()), *price)) + } else { + prices.first().map(|price| (None, *price)) + } +} pub fn print_markets(markets: &[Market], output: &OutputFormat) -> anyhow::Result<()> { match output { OutputFormat::Table => { @@ -212,6 +226,36 @@ mod tests { #[test] fn row_formats_price_as_cents() { + let m = make_market(json!({ + "id": "1", + "outcomes": "[\"Yes\",\"No\"]", + "outcomePrices": "[\"0.65\",\"0.35\"]" + })); + assert_eq!(market_to_row(&m).price_yes, "Yes: 65.00¢"); + } + + #[test] + fn row_prefers_yes_outcome_when_not_first() { + let m = make_market(json!({ + "id": "1", + "outcomes": "[\"No\",\"Yes\"]", + "outcomePrices": "[\"0.35\",\"0.65\"]" + })); + assert_eq!(market_to_row(&m).price_yes, "Yes: 65.00¢"); + } + + #[test] + fn row_uses_first_outcome_for_non_binary_market() { + let m = make_market(json!({ + "id": "1", + "outcomes": "[\"Long\",\"Short\"]", + "outcomePrices": "[\"0.58\",\"0.42\"]" + })); + assert_eq!(market_to_row(&m).price_yes, "Long: 58.00¢"); + } + + #[test] + fn row_shows_unlabeled_price_when_outcomes_missing() { let m = make_market(json!({ "id": "1", "outcomePrices": "[\"0.65\",\"0.35\"]"