From ae3366910f8f636169eb8a36eda74744db778458 Mon Sep 17 00:00:00 2001 From: morluto Date: Wed, 25 Feb 2026 02:57:41 +0800 Subject: [PATCH 1/4] fix(cli): correct market price labeling and status/comment filters - Make market list price outcome-aware and rename column from Price (Yes) to Price\n- Restrict comments entity-type enum to Event/Series to match Gamma validation\n- Stop translating active into closed; apply active/closed filters independently in markets/events list\n- Add regression tests for outcome labeling and independent status filter behavior\n- Update README examples to match new market table output --- README.md | 6 ++-- src/commands/comments.rs | 26 +++++++++++++--- src/commands/events.rs | 66 +++++++++++++++++++++++++++++++++++++--- src/commands/markets.rs | 60 +++++++++++++++++++++++++++++++++--- src/output/markets.rs | 46 +++++++++++++++++++++++----- 5 files changed, 181 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index af8c442..d6139bb 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,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 9c55fad..53868e6 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(e: EntityType) -> Self { match e { EntityType::Event => ParentEntityType::Event, - EntityType::Market => ParentEntityType::Market, EntityType::Series => ParentEntityType::Series, } } @@ -164,3 +162,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 b5d947a..dc8d593 100644 --- a/src/commands/events.rs +++ b/src/commands/events.rs @@ -2,7 +2,10 @@ 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; @@ -62,6 +65,23 @@ 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() +} + +fn flag_matches(value: Option, filter: Option) -> bool { + filter.is_none_or(|expected| value == Some(expected)) +} + pub async fn execute(client: &gamma::Client, args: EventsArgs, output: OutputFormat) -> Result<()> { match args.command { EventsCommand::List { @@ -73,18 +93,17 @@ 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) .maybe_ascending(if ascending { Some(true) } else { None }) .maybe_tag_slug(tag) .order(order.into_iter().collect::>()) .build(); - let events = client.events(&request).await?; + let events = apply_status_filters(client.events(&request).await?, active, closed); match output { OutputFormat::Table => print_events_table(&events), @@ -121,3 +140,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 68e5491..4ae6c37 100644 --- a/src/commands/markets.rs +++ b/src/commands/markets.rs @@ -74,6 +74,23 @@ 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() +} + +fn flag_matches(value: Option, filter: Option) -> bool { + filter.is_none_or(|expected| value == Some(expected)) +} + pub async fn execute( client: &gamma::Client, args: MarketsArgs, @@ -88,17 +105,15 @@ 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_closed(closed) .maybe_offset(offset) .maybe_order(order) .maybe_ascending(if ascending { Some(true) } else { None }) .build(); - let markets = client.markets(&request).await?; + let markets = apply_status_filters(client.markets(&request).await?, active, closed); match output { OutputFormat::Table => print_markets_table(&markets), @@ -156,3 +171,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/output/markets.rs b/src/output/markets.rs index 1698a23..8dc18bd 100644 --- a/src/output/markets.rs +++ b/src/output/markets.rs @@ -9,7 +9,7 @@ use super::{detail_field, format_decimal, print_detail_table, truncate}; struct MarketRow { #[tabled(rename = "Question")] question: String, - #[tabled(rename = "Price (Yes)")] + #[tabled(rename = "Price")] price_yes: String, #[tabled(rename = "Volume")] volume: String, @@ -31,11 +31,10 @@ fn market_status(m: &Market) -> &'static str { fn market_to_row(m: &Market) -> MarketRow { let question = m.question.as_deref().unwrap_or("—"); - let price_yes = m - .outcome_prices - .as_ref() - .and_then(|p| p.first()) - .map_or_else(|| "—".into(), |p| format!("{:.2}¢", p * Decimal::from(100))); + let price_yes = primary_outcome_price(m).map_or_else( + || "—".into(), + |(outcome, price)| format!("{outcome}: {:.2}¢", price * Decimal::from(100)), + ); MarketRow { question: truncate(question, 60), @@ -46,6 +45,18 @@ fn market_to_row(m: &Market) -> MarketRow { } } +fn primary_outcome_price(m: &Market) -> Option<(String, Decimal)> { + let outcomes = m.outcomes.as_ref()?; + let prices = m.outcome_prices.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)| (outcome.clone(), *price)) +} + pub fn print_markets_table(markets: &[Market]) { if markets.is_empty() { println!("No markets found."); @@ -208,9 +219,30 @@ mod tests { 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, "65.00¢"); + 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] From 5949b7924b06c52d5868415b297dbc409d71fb09 Mon Sep 17 00:00:00 2001 From: morluto Date: Wed, 25 Feb 2026 03:07:46 +0800 Subject: [PATCH 2/4] fix(cli): backfill active-filtered markets and dedupe status helper - Keep markets list filling up to --limit when --active is set by paginating additional API pages\n- Preserve independent active/closed semantics while avoiding undersized result pages\n- Move shared Option filter matcher into commands/mod.rs and reuse in events/markets\n- Add/retain tests and run full suite --- src/commands/events.rs | 6 +--- src/commands/markets.rs | 64 +++++++++++++++++++++++++++++++++-------- src/commands/mod.rs | 17 +++++++++++ 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/commands/events.rs b/src/commands/events.rs index dc8d593..01da201 100644 --- a/src/commands/events.rs +++ b/src/commands/events.rs @@ -8,7 +8,7 @@ use polymarket_client_sdk::gamma::{ }, }; -use super::is_numeric_id; +use super::{flag_matches, is_numeric_id}; use crate::output::events::{print_event_detail, print_events_table}; use crate::output::tags::print_tags_table; use crate::output::{OutputFormat, print_json}; @@ -78,10 +78,6 @@ fn apply_status_filters( .collect() } -fn flag_matches(value: Option, filter: Option) -> bool { - filter.is_none_or(|expected| value == Some(expected)) -} - pub async fn execute(client: &gamma::Client, args: EventsArgs, output: OutputFormat) -> Result<()> { match args.command { EventsCommand::List { diff --git a/src/commands/markets.rs b/src/commands/markets.rs index 4ae6c37..6bd3a2c 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::markets::{print_market_detail, print_markets_table}; use crate::output::tags::print_tags_table; use crate::output::{OutputFormat, print_json}; @@ -87,8 +87,55 @@ fn apply_status_filters( .collect() } -fn flag_matches(value: Option, filter: Option) -> bool { - filter.is_none_or(|expected| value == Some(expected)) +async fn list_markets( + client: &gamma::Client, + limit: i32, + offset: Option, + order: Option, + ascending: bool, + active: Option, + closed: Option, +) -> Result> { + let page_size = limit.max(1); + 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()) + .maybe_ascending(if ascending { Some(true) } else { None }) + .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( @@ -105,15 +152,8 @@ pub async fn execute( order, ascending, } => { - let request = MarketsRequest::builder() - .limit(limit) - .maybe_closed(closed) - .maybe_offset(offset) - .maybe_order(order) - .maybe_ascending(if ascending { Some(true) } else { None }) - .build(); - - let markets = apply_status_filters(client.markets(&request).await?, active, closed); + let markets = + list_markets(client, limit, offset, order, ascending, active, closed).await?; match output { OutputFormat::Table => print_markets_table(&markets), diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 671c0ee..265b2eb 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -30,6 +30,10 @@ pub fn parse_condition_id(s: &str) -> anyhow::Result { .map_err(|_| anyhow::anyhow!("Invalid condition ID: must be a 0x-prefixed 32-byte hex")) } +pub fn flag_matches(value: Option, filter: Option) -> bool { + filter.is_none_or(|expected| value == Some(expected)) +} + #[cfg(test)] mod tests { use super::*; @@ -87,4 +91,17 @@ mod tests { let err = parse_condition_id("garbage").unwrap_err().to_string(); assert!(err.contains("32-byte"), "got: {err}"); } + + #[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))); + } } From 4ecd009e883ea7b009bab8c2ea0899ec30607ead Mon Sep 17 00:00:00 2001 From: morluto Date: Wed, 25 Feb 2026 03:30:58 +0800 Subject: [PATCH 3/4] fix(cli): keep price visible when outcomes are missing - Preserve market list price display when outcomePrices is present but outcomes is absent\n- Fallback to unlabeled first outcome price instead of rendering an em dash\n- Add regression test for outcomes-missing case --- src/output/markets.rs | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/output/markets.rs b/src/output/markets.rs index 8dc18bd..48083a2 100644 --- a/src/output/markets.rs +++ b/src/output/markets.rs @@ -31,10 +31,14 @@ fn market_status(m: &Market) -> &'static str { fn market_to_row(m: &Market) -> MarketRow { let question = m.question.as_deref().unwrap_or("—"); - let price_yes = primary_outcome_price(m).map_or_else( - || "—".into(), - |(outcome, price)| format!("{outcome}: {:.2}¢", price * Decimal::from(100)), - ); + let price_yes = + primary_outcome_price(m).map_or_else( + || "—".into(), + |(outcome, price)| match outcome { + Some(outcome) => format!("{outcome}: {:.2}¢", price * Decimal::from(100)), + None => format!("{:.2}¢", price * Decimal::from(100)), + }, + ); MarketRow { question: truncate(question, 60), @@ -45,16 +49,19 @@ fn market_to_row(m: &Market) -> MarketRow { } } -fn primary_outcome_price(m: &Market) -> Option<(String, Decimal)> { - let outcomes = m.outcomes.as_ref()?; +fn primary_outcome_price(m: &Market) -> Option<(Option, Decimal)> { let prices = m.outcome_prices.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)| (outcome.clone(), *price)) + 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_table(markets: &[Market]) { @@ -245,6 +252,15 @@ mod tests { 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\"]" + })); + assert_eq!(market_to_row(&m).price_yes, "65.00¢"); + } + #[test] fn row_truncates_long_question() { let long_q = "a".repeat(100); From 3c9cb35acefa3622adfca962b551559a89c06335 Mon Sep 17 00:00:00 2001 From: morluto Date: Wed, 25 Feb 2026 03:35:35 +0800 Subject: [PATCH 4/4] fix(cli): preserve --limit 0 semantics in markets list Return empty result when limit <= 0 before pagination to avoid forcing one-row output. --- src/commands/markets.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/markets.rs b/src/commands/markets.rs index 6bd3a2c..8f941c8 100644 --- a/src/commands/markets.rs +++ b/src/commands/markets.rs @@ -96,7 +96,10 @@ async fn list_markets( active: Option, closed: Option, ) -> Result> { - let page_size = limit.max(1); + 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();