diff --git a/src/auth.rs b/src/auth.rs index 15ad61e..a5cf34d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -9,7 +9,11 @@ use polymarket_client_sdk::{POLYGON, clob}; use crate::config; -pub const RPC_URL: &str = "https://polygon.drpc.org"; +const DEFAULT_RPC_URL: &str = "https://polygon.drpc.org"; + +fn rpc_url() -> String { + std::env::var("POLYMARKET_RPC_URL").unwrap_or_else(|_| DEFAULT_RPC_URL.to_string()) +} fn parse_signature_type(s: &str) -> SignatureType { match s { @@ -22,7 +26,7 @@ fn parse_signature_type(s: &str) -> SignatureType { pub fn resolve_signer( private_key: Option<&str>, ) -> Result { - let (key, _) = config::resolve_key(private_key); + let (key, _) = config::resolve_key(private_key)?; let key = key.ok_or_else(|| anyhow::anyhow!("{}", config::NO_WALLET_MSG))?; LocalSigner::from_str(&key) .context("Invalid private key") @@ -41,7 +45,7 @@ pub async fn authenticate_with_signer( signer: &(impl polymarket_client_sdk::auth::Signer + Sync), signature_type_flag: Option<&str>, ) -> Result>> { - let sig_type = parse_signature_type(&config::resolve_signature_type(signature_type_flag)); + let sig_type = parse_signature_type(&config::resolve_signature_type(signature_type_flag)?); clob::Client::default() .authentication_builder(signer) @@ -53,7 +57,7 @@ pub async fn authenticate_with_signer( pub async fn create_readonly_provider() -> Result { ProviderBuilder::new() - .connect(RPC_URL) + .connect(&rpc_url()) .await .context("Failed to connect to Polygon RPC") } @@ -61,14 +65,14 @@ pub async fn create_readonly_provider() -> Result, ) -> Result { - let (key, _) = config::resolve_key(private_key); + let (key, _) = config::resolve_key(private_key)?; let key = key.ok_or_else(|| anyhow::anyhow!("{}", config::NO_WALLET_MSG))?; let signer = LocalSigner::from_str(&key) .context("Invalid private key")? .with_chain_id(Some(POLYGON)); ProviderBuilder::new() .wallet(signer) - .connect(RPC_URL) + .connect(&rpc_url()) .await .context("Failed to connect to Polygon RPC with wallet") } diff --git a/src/commands/approve.rs b/src/commands/approve.rs index 329fa83..b52dc0c 100644 --- a/src/commands/approve.rs +++ b/src/commands/approve.rs @@ -12,6 +12,7 @@ use crate::auth; use crate::output::OutputFormat; use crate::output::approve::{ApprovalStatus, print_approval_status, print_tx_result}; +/// Polygon USDC (same address as `USDC_ADDRESS_STR`; `address!` requires a literal). const USDC_ADDRESS: Address = address!("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"); sol! { @@ -39,7 +40,7 @@ pub enum ApproveCommand { /// Check current contract approvals for a wallet Check { /// Wallet address to check (defaults to configured wallet) - address: Option, + address: Option
, }, /// Approve all required contracts for trading (sends on-chain transactions) Set, @@ -82,18 +83,18 @@ pub async fn execute( private_key: Option<&str>, ) -> Result<()> { match args.command { - ApproveCommand::Check { address } => check(address.as_deref(), private_key, output).await, + ApproveCommand::Check { address } => check(address, private_key, output).await, ApproveCommand::Set => set(private_key, output).await, } } async fn check( - address_arg: Option<&str>, + address_arg: Option
, private_key: Option<&str>, output: OutputFormat, ) -> Result<()> { let owner: Address = if let Some(addr) = address_arg { - super::parse_address(addr)? + addr } else { let signer = auth::resolve_signer(private_key)?; polymarket_client_sdk::auth::Signer::address(&signer) diff --git a/src/commands/bridge.rs b/src/commands/bridge.rs index 75d474e..9c9d859 100644 --- a/src/commands/bridge.rs +++ b/src/commands/bridge.rs @@ -1,6 +1,3 @@ -use super::parse_address; -use crate::output::OutputFormat; -use crate::output::bridge::{print_deposit, print_status, print_supported_assets}; use anyhow::Result; use clap::{Args, Subcommand}; use polymarket_client_sdk::bridge::{ @@ -8,6 +5,9 @@ use polymarket_client_sdk::bridge::{ types::{DepositRequest, StatusRequest}, }; +use crate::output::OutputFormat; +use crate::output::bridge::{print_deposit, print_status, print_supported_assets}; + #[derive(Args)] pub struct BridgeArgs { #[command(subcommand)] @@ -19,7 +19,7 @@ pub enum BridgeCommand { /// Get deposit addresses for a wallet (EVM, Solana, Bitcoin) Deposit { /// Polymarket wallet address (0x...) - address: String, + address: polymarket_client_sdk::types::Address, }, /// List supported chains and tokens for deposits @@ -39,9 +39,7 @@ pub async fn execute( ) -> Result<()> { match args.command { BridgeCommand::Deposit { address } => { - let request = DepositRequest::builder() - .address(parse_address(&address)?) - .build(); + let request = DepositRequest::builder().address(address).build(); let response = client.deposit(&request).await?; print_deposit(&response, &output)?; diff --git a/src/commands/clob.rs b/src/commands/clob.rs index d41302a..9ee089f 100644 --- a/src/commands/clob.rs +++ b/src/commands/clob.rs @@ -1,20 +1,5 @@ use std::str::FromStr; -use anyhow::Result; -use chrono::NaiveDate; -use clap::{Args, Subcommand}; -use polymarket_client_sdk::clob; -use polymarket_client_sdk::clob::types::{ - Amount, AssetType, Interval, OrderType, Side, TimeRange, - request::{ - BalanceAllowanceRequest, CancelMarketOrderRequest, DeleteNotificationsRequest, - LastTradePriceRequest, MidpointRequest, OrderBookSummaryRequest, OrdersRequest, - PriceHistoryRequest, PriceRequest, SpreadRequest, TradesRequest, UserRewardsEarningRequest, - }, -}; -use polymarket_client_sdk::types::{Decimal, U256}; - -use super::parse_condition_id; use crate::auth; use crate::output::OutputFormat; use crate::output::clob::{ @@ -28,6 +13,19 @@ use crate::output::clob::{ print_rewards, print_server_time, print_simplified_markets, print_spread, print_spreads, print_tick_size, print_trades, print_user_earnings_markets, }; +use anyhow::Result; +use chrono::NaiveDate; +use clap::{Args, Subcommand}; +use polymarket_client_sdk::clob; +use polymarket_client_sdk::clob::types::{ + Amount, AssetType, Interval, OrderType, Side, TimeRange, + request::{ + BalanceAllowanceRequest, CancelMarketOrderRequest, DeleteNotificationsRequest, + LastTradePriceRequest, MidpointRequest, OrderBookSummaryRequest, OrdersRequest, + PriceHistoryRequest, PriceRequest, SpreadRequest, TradesRequest, UserRewardsEarningRequest, + }, +}; +use polymarket_client_sdk::types::{B256, Decimal, U256}; #[derive(Args)] pub struct ClobArgs { @@ -183,10 +181,10 @@ pub enum ClobCommand { Orders { /// Filter by market condition ID #[arg(long)] - market: Option, + market: Option, /// Filter by asset/token ID #[arg(long)] - asset: Option, + asset: Option, /// Pagination cursor #[arg(long)] cursor: Option, @@ -274,20 +272,20 @@ pub enum ClobCommand { CancelMarket { /// Market condition ID #[arg(long)] - market: Option, + market: Option, /// Asset/token ID #[arg(long)] - asset: Option, + asset: Option, }, /// List trades (authenticated) Trades { /// Filter by market condition ID #[arg(long)] - market: Option, + market: Option, /// Filter by asset/token ID #[arg(long)] - asset: Option, + asset: Option, /// Pagination cursor #[arg(long)] cursor: Option, @@ -300,7 +298,7 @@ pub enum ClobCommand { asset_type: CliAssetType, /// Token ID (required for conditional) #[arg(long)] - token: Option, + token: Option, }, /// Refresh balance allowance on-chain (authenticated) @@ -310,7 +308,7 @@ pub enum ClobCommand { asset_type: CliAssetType, /// Token ID (required for conditional) #[arg(long)] - token: Option, + token: Option, }, /// List notifications (authenticated) @@ -393,15 +391,15 @@ pub enum ClobCommand { AccountStatus, } -#[derive(Clone, Debug, clap::ValueEnum)] +#[derive(Clone, Copy, Debug, clap::ValueEnum)] pub enum CliSide { Buy, Sell, } impl From for Side { - fn from(s: CliSide) -> Self { - match s { + fn from(v: CliSide) -> Self { + match v { CliSide::Buy => Side::Buy, CliSide::Sell => Side::Sell, } @@ -424,8 +422,8 @@ pub enum CliInterval { } impl From for Interval { - fn from(i: CliInterval) -> Self { - match i { + fn from(v: CliInterval) -> Self { + match v { CliInterval::OneMinute => Interval::OneMinute, CliInterval::OneHour => Interval::OneHour, CliInterval::SixHours => Interval::SixHours, @@ -466,8 +464,8 @@ pub enum CliAssetType { } impl From for AssetType { - fn from(a: CliAssetType) -> Self { - match a { + fn from(v: CliAssetType) -> Self { + match v { CliAssetType::Collateral => AssetType::Collateral, CliAssetType::Conditional => AssetType::Conditional, } @@ -487,232 +485,153 @@ fn parse_date(s: &str) -> Result { .map_err(|_| anyhow::anyhow!("Invalid date: expected YYYY-MM-DD format")) } +#[allow(clippy::too_many_lines)] pub async fn execute( args: ClobArgs, output: OutputFormat, private_key: Option<&str>, signature_type: Option<&str>, ) -> Result<()> { - match args.command { - // Unauthenticated read commands - ClobCommand::Ok - | ClobCommand::Price { .. } - | ClobCommand::BatchPrices { .. } - | ClobCommand::Midpoint { .. } - | ClobCommand::Midpoints { .. } - | ClobCommand::Spread { .. } - | ClobCommand::Spreads { .. } - | ClobCommand::Book { .. } - | ClobCommand::Books { .. } - | ClobCommand::LastTrade { .. } - | ClobCommand::LastTrades { .. } - | ClobCommand::Market { .. } - | ClobCommand::Markets { .. } - | ClobCommand::SamplingMarkets { .. } - | ClobCommand::SimplifiedMarkets { .. } - | ClobCommand::SamplingSimpMarkets { .. } - | ClobCommand::TickSize { .. } - | ClobCommand::FeeRate { .. } - | ClobCommand::NegRisk { .. } - | ClobCommand::PriceHistory { .. } - | ClobCommand::Time - | ClobCommand::Geoblock => execute_read(args.command, &output).await, - - // Authenticated trading commands - ClobCommand::Orders { .. } - | ClobCommand::Order { .. } - | ClobCommand::CreateOrder { .. } - | ClobCommand::PostOrders { .. } - | ClobCommand::MarketOrder { .. } - | ClobCommand::Cancel { .. } - | ClobCommand::CancelOrders { .. } - | ClobCommand::CancelAll - | ClobCommand::CancelMarket { .. } - | ClobCommand::Trades { .. } - | ClobCommand::Balance { .. } - | ClobCommand::UpdateBalance { .. } - | ClobCommand::Notifications - | ClobCommand::DeleteNotifications { .. } => { - execute_trade(args.command, &output, private_key, signature_type).await - } - - // Authenticated reward commands - ClobCommand::Rewards { .. } - | ClobCommand::Earnings { .. } - | ClobCommand::EarningsMarkets { .. } - | ClobCommand::RewardPercentages - | ClobCommand::CurrentRewards { .. } - | ClobCommand::MarketReward { .. } - | ClobCommand::OrderScoring { .. } - | ClobCommand::OrdersScoring { .. } => { - execute_rewards(args.command, &output, private_key, signature_type).await - } - - // Account management commands - ClobCommand::ApiKeys - | ClobCommand::DeleteApiKey - | ClobCommand::CreateApiKey - | ClobCommand::AccountStatus => { - execute_account(args.command, &output, private_key, signature_type).await - } - } -} + // Unauthenticated client — cheap to construct, used by read commands and CreateApiKey. + let unauth = clob::Client::default(); + let output = &output; -async fn execute_read(command: ClobCommand, output: &OutputFormat) -> Result<()> { - match command { + match args.command { + // ── Unauthenticated read commands ──────────────────────────────── ClobCommand::Ok => { - let client = clob::Client::default(); - let result = client.ok().await?; + let result = unauth.ok().await?; print_ok(&result, output)?; } ClobCommand::Price { token_id, side } => { - let client = clob::Client::default(); let request = PriceRequest::builder() .token_id(parse_token_id(&token_id)?) .side(Side::from(side)) .build(); - let result = client.price(&request).await?; + let result = unauth.price(&request).await?; print_price(&result, output)?; } ClobCommand::BatchPrices { token_ids, side } => { - let client = clob::Client::default(); let requests: Vec<_> = parse_token_ids(&token_ids)? .into_iter() .map(|id| { PriceRequest::builder() .token_id(id) - .side(Side::from(side.clone())) + .side(Side::from(side)) .build() }) .collect(); - let result = client.prices(&requests).await?; + let result = unauth.prices(&requests).await?; print_batch_prices(&result, output)?; } ClobCommand::Midpoint { token_id } => { - let client = clob::Client::default(); let request = MidpointRequest::builder() .token_id(parse_token_id(&token_id)?) .build(); - let result = client.midpoint(&request).await?; + let result = unauth.midpoint(&request).await?; print_midpoint(&result, output)?; } ClobCommand::Midpoints { token_ids } => { - let client = clob::Client::default(); let requests: Vec<_> = parse_token_ids(&token_ids)? .into_iter() .map(|id| MidpointRequest::builder().token_id(id).build()) .collect(); - let result = client.midpoints(&requests).await?; + let result = unauth.midpoints(&requests).await?; print_midpoints(&result, output)?; } ClobCommand::Spread { token_id, side } => { - let client = clob::Client::default(); let request = SpreadRequest::builder() .token_id(parse_token_id(&token_id)?) .maybe_side(side.map(Side::from)) .build(); - let result = client.spread(&request).await?; + let result = unauth.spread(&request).await?; print_spread(&result, output)?; } ClobCommand::Spreads { token_ids } => { - let client = clob::Client::default(); let requests: Vec<_> = parse_token_ids(&token_ids)? .into_iter() .map(|id| SpreadRequest::builder().token_id(id).build()) .collect(); - let result = client.spreads(&requests).await?; + let result = unauth.spreads(&requests).await?; print_spreads(&result, output)?; } ClobCommand::Book { token_id } => { - let client = clob::Client::default(); let request = OrderBookSummaryRequest::builder() .token_id(parse_token_id(&token_id)?) .build(); - let result = client.order_book(&request).await?; + let result = unauth.order_book(&request).await?; print_order_book(&result, output)?; } ClobCommand::Books { token_ids } => { - let client = clob::Client::default(); let requests: Vec<_> = parse_token_ids(&token_ids)? .into_iter() .map(|id| OrderBookSummaryRequest::builder().token_id(id).build()) .collect(); - let result = client.order_books(&requests).await?; + let result = unauth.order_books(&requests).await?; print_order_books(&result, output)?; } ClobCommand::LastTrade { token_id } => { - let client = clob::Client::default(); let request = LastTradePriceRequest::builder() .token_id(parse_token_id(&token_id)?) .build(); - let result = client.last_trade_price(&request).await?; + let result = unauth.last_trade_price(&request).await?; print_last_trade(&result, output)?; } ClobCommand::LastTrades { token_ids } => { - let client = clob::Client::default(); let requests: Vec<_> = parse_token_ids(&token_ids)? .into_iter() .map(|id| LastTradePriceRequest::builder().token_id(id).build()) .collect(); - let result = client.last_trades_prices(&requests).await?; + let result = unauth.last_trades_prices(&requests).await?; print_last_trades_prices(&result, output)?; } ClobCommand::Market { condition_id } => { - let client = clob::Client::default(); - let result = client.market(&condition_id).await?; + let result = unauth.market(&condition_id).await?; print_clob_market(&result, output)?; } ClobCommand::Markets { cursor } => { - let client = clob::Client::default(); - let result = client.markets(cursor).await?; + let result = unauth.markets(cursor).await?; print_clob_markets(&result, output)?; } ClobCommand::SamplingMarkets { cursor } => { - let client = clob::Client::default(); - let result = client.sampling_markets(cursor).await?; + let result = unauth.sampling_markets(cursor).await?; print_clob_markets(&result, output)?; } ClobCommand::SimplifiedMarkets { cursor } => { - let client = clob::Client::default(); - let result = client.simplified_markets(cursor).await?; + let result = unauth.simplified_markets(cursor).await?; print_simplified_markets(&result, output)?; } ClobCommand::SamplingSimpMarkets { cursor } => { - let client = clob::Client::default(); - let result = client.sampling_simplified_markets(cursor).await?; + let result = unauth.sampling_simplified_markets(cursor).await?; print_simplified_markets(&result, output)?; } ClobCommand::TickSize { token_id } => { - let client = clob::Client::default(); - let result = client.tick_size(parse_token_id(&token_id)?).await?; + let result = unauth.tick_size(parse_token_id(&token_id)?).await?; print_tick_size(&result, output)?; } ClobCommand::FeeRate { token_id } => { - let client = clob::Client::default(); - let result = client.fee_rate_bps(parse_token_id(&token_id)?).await?; + let result = unauth.fee_rate_bps(parse_token_id(&token_id)?).await?; print_fee_rate(&result, output)?; } ClobCommand::NegRisk { token_id } => { - let client = clob::Client::default(); - let result = client.neg_risk(parse_token_id(&token_id)?).await?; + let result = unauth.neg_risk(parse_token_id(&token_id)?).await?; print_neg_risk(&result, output)?; } @@ -721,61 +640,26 @@ async fn execute_read(command: ClobCommand, output: &OutputFormat) -> Result<()> interval, fidelity, } => { - let client = clob::Client::default(); let request = PriceHistoryRequest::builder() .market(parse_token_id(&token_id)?) .time_range(TimeRange::from_interval(Interval::from(interval))) .maybe_fidelity(fidelity) .build(); - let result = client.price_history(&request).await?; + let result = unauth.price_history(&request).await?; print_price_history(&result, output)?; } ClobCommand::Time => { - let client = clob::Client::default(); - let result = client.server_time().await?; + let result = unauth.server_time().await?; print_server_time(result, output)?; } ClobCommand::Geoblock => { - let client = clob::Client::default(); - let result = client.check_geoblock().await?; + let result = unauth.check_geoblock().await?; print_geoblock(&result, output)?; } - _ => unreachable!(), - } - - Ok(()) -} - -async fn execute_trade( - command: ClobCommand, - output: &OutputFormat, - private_key: Option<&str>, - signature_type: Option<&str>, -) -> Result<()> { - match command { - ClobCommand::Orders { - market, - asset, - cursor, - } => { - let client = auth::authenticated_clob_client(private_key, signature_type).await?; - let request = OrdersRequest::builder() - .maybe_market(market.map(|m| parse_condition_id(&m)).transpose()?) - .maybe_asset_id(asset.map(|a| parse_token_id(&a)).transpose()?) - .build(); - let result = client.orders(&request, cursor).await?; - print_orders(&result, output)?; - } - - ClobCommand::Order { order_id } => { - let client = auth::authenticated_clob_client(private_key, signature_type).await?; - let result = client.order(&order_id).await?; - print_order_detail(&result, output)?; - } - + // ── Authenticated trading commands (need signer for order signing) ── ClobCommand::CreateOrder { token, side, @@ -886,6 +770,27 @@ async fn execute_trade( print_post_order_result(&result, output)?; } + // ── Authenticated trading commands (no signer needed) ─────────── + ClobCommand::Orders { + market, + asset, + cursor, + } => { + let client = auth::authenticated_clob_client(private_key, signature_type).await?; + let request = OrdersRequest::builder() + .maybe_market(market) + .maybe_asset_id(asset) + .build(); + let result = client.orders(&request, cursor).await?; + print_orders(&result, output)?; + } + + ClobCommand::Order { order_id } => { + let client = auth::authenticated_clob_client(private_key, signature_type).await?; + let result = client.order(&order_id).await?; + print_order_detail(&result, output)?; + } + ClobCommand::Cancel { order_id } => { let client = auth::authenticated_clob_client(private_key, signature_type).await?; let result = client.cancel_order(&order_id).await?; @@ -908,8 +813,8 @@ async fn execute_trade( ClobCommand::CancelMarket { market, asset } => { let client = auth::authenticated_clob_client(private_key, signature_type).await?; let request = CancelMarketOrderRequest::builder() - .maybe_market(market.map(|m| parse_condition_id(&m)).transpose()?) - .maybe_asset_id(asset.map(|a| parse_token_id(&a)).transpose()?) + .maybe_market(market) + .maybe_asset_id(asset) .build(); let result = client.cancel_market_orders(&request).await?; print_cancel_result(&result, output)?; @@ -922,19 +827,19 @@ async fn execute_trade( } => { let client = auth::authenticated_clob_client(private_key, signature_type).await?; let request = TradesRequest::builder() - .maybe_market(market.map(|m| parse_condition_id(&m)).transpose()?) - .maybe_asset_id(asset.map(|a| parse_token_id(&a)).transpose()?) + .maybe_market(market) + .maybe_asset_id(asset) .build(); let result = client.trades(&request, cursor).await?; print_trades(&result, output)?; } ClobCommand::Balance { asset_type, token } => { - let is_collateral = matches!(asset_type, CliAssetType::Collateral); let client = auth::authenticated_clob_client(private_key, signature_type).await?; + let is_collateral = matches!(asset_type, CliAssetType::Collateral); let request = BalanceAllowanceRequest::builder() .asset_type(AssetType::from(asset_type)) - .maybe_token_id(token.map(|t| parse_token_id(&t)).transpose()?) + .maybe_token_id(token) .build(); let result = client.balance_allowance(request).await?; print_balance(&result, is_collateral, output)?; @@ -944,7 +849,7 @@ async fn execute_trade( let client = auth::authenticated_clob_client(private_key, signature_type).await?; let request = BalanceAllowanceRequest::builder() .asset_type(AssetType::from(asset_type)) - .maybe_token_id(token.map(|t| parse_token_id(&t)).transpose()?) + .maybe_token_id(token) .build(); client.update_balance_allowance(request).await?; match output { @@ -977,19 +882,7 @@ async fn execute_trade( } } - _ => unreachable!(), - } - - Ok(()) -} - -async fn execute_rewards( - command: ClobCommand, - output: &OutputFormat, - private_key: Option<&str>, - signature_type: Option<&str>, -) -> Result<()> { - match command { + // ── Authenticated reward commands ──────────────────────────────── ClobCommand::Rewards { date, cursor } => { let client = auth::authenticated_clob_client(private_key, signature_type).await?; let result = client @@ -1051,19 +944,13 @@ async fn execute_rewards( print_orders_scoring(&result, output)?; } - _ => unreachable!(), - } - - Ok(()) -} + // ── Account management commands ────────────────────────────────── + ClobCommand::CreateApiKey => { + let signer = auth::resolve_signer(private_key)?; + let result = unauth.create_or_derive_api_key(&signer, None).await?; + print_create_api_key(&result, output)?; + } -async fn execute_account( - command: ClobCommand, - output: &OutputFormat, - private_key: Option<&str>, - signature_type: Option<&str>, -) -> Result<()> { - match command { ClobCommand::ApiKeys => { let client = auth::authenticated_clob_client(private_key, signature_type).await?; let result = client.api_keys().await?; @@ -1076,20 +963,11 @@ async fn execute_account( print_delete_api_key(&result, output)?; } - ClobCommand::CreateApiKey => { - let signer = auth::resolve_signer(private_key)?; - let client = clob::Client::default(); - let result = client.create_or_derive_api_key(&signer, None).await?; - print_create_api_key(&result, output)?; - } - ClobCommand::AccountStatus => { let client = auth::authenticated_clob_client(private_key, signature_type).await?; let result = client.closed_only_mode().await?; print_account_status(&result, output)?; } - - _ => unreachable!(), } Ok(()) diff --git a/src/commands/comments.rs b/src/commands/comments.rs index 9c55fad..4003911 100644 --- a/src/commands/comments.rs +++ b/src/commands/comments.rs @@ -1,6 +1,3 @@ -use super::parse_address; -use crate::output::comments::{print_comment_detail, print_comments_table}; -use crate::output::{OutputFormat, print_json}; use anyhow::Result; use clap::{Args, Subcommand}; use polymarket_client_sdk::gamma::{ @@ -11,6 +8,9 @@ use polymarket_client_sdk::gamma::{ }, }; +use crate::output::OutputFormat; +use crate::output::comments::{print_comment, print_comments}; + #[derive(Args)] pub struct CommentsArgs { #[command(subcommand)] @@ -55,7 +55,7 @@ pub enum CommentsCommand { /// List comments by a user's wallet address ByUser { /// Wallet address (0x...) - address: String, + address: polymarket_client_sdk::types::Address, /// Max results #[arg(long, default_value = "25")] @@ -83,8 +83,8 @@ pub enum EntityType { } impl From for ParentEntityType { - fn from(e: EntityType) -> Self { - match e { + fn from(v: EntityType) -> Self { + match v { EntityType::Event => ParentEntityType::Event, EntityType::Market => ParentEntityType::Market, EntityType::Series => ParentEntityType::Series, @@ -112,15 +112,11 @@ pub async fn execute( .limit(limit) .maybe_offset(offset) .maybe_order(order) - .maybe_ascending(if ascending { Some(true) } else { None }) + .ascending(ascending) .build(); let comments = client.comments(&request).await?; - - match output { - OutputFormat::Table => print_comments_table(&comments), - OutputFormat::Json => print_json(&comments)?, - } + print_comments(&comments, &output)?; } CommentsCommand::Get { id } => { @@ -131,10 +127,7 @@ pub async fn execute( anyhow::bail!("Comment not found"); }; - match output { - OutputFormat::Table => print_comment_detail(comment), - OutputFormat::Json => print_json(&comment)?, - } + print_comment(comment, &output)?; } CommentsCommand::ByUser { @@ -144,21 +137,16 @@ pub async fn execute( order, ascending, } => { - let addr = parse_address(&address)?; let request = CommentsByUserAddressRequest::builder() - .user_address(addr) + .user_address(address) .limit(limit) .maybe_offset(offset) .maybe_order(order) - .maybe_ascending(if ascending { Some(true) } else { None }) + .ascending(ascending) .build(); let comments = client.comments_by_user_address(&request).await?; - - match output { - OutputFormat::Table => print_comments_table(&comments), - OutputFormat::Json => print_json(&comments)?, - } + print_comments(&comments, &output)?; } } diff --git a/src/commands/ctf.rs b/src/commands/ctf.rs index eec7170..5d06ea5 100644 --- a/src/commands/ctf.rs +++ b/src/commands/ctf.rs @@ -13,7 +13,7 @@ use crate::auth; use crate::output::OutputFormat; use crate::output::ctf as ctf_output; -const USDC_DECIMALS: Decimal = Decimal::from_parts(1_000_000, 0, 0, false, 0); +use super::{USDC_ADDRESS_STR, USDC_DECIMALS}; #[derive(Args)] pub struct CtfArgs { @@ -27,58 +27,58 @@ pub enum CtfCommand { Split { /// Condition ID (0x-prefixed 32-byte hex) #[arg(long)] - condition: String, + condition: B256, /// Amount in USDC (e.g. 10 for $10) #[arg(long)] amount: String, /// Collateral token address (defaults to USDC) - #[arg(long, default_value = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")] - collateral: String, + #[arg(long, default_value = USDC_ADDRESS_STR)] + collateral: Address, /// Custom partition as comma-separated index sets (e.g. "1,2" for binary, "1,2,4" for 3-outcome) #[arg(long)] partition: Option, /// Parent collection ID for nested positions (defaults to zero) #[arg(long)] - parent_collection: Option, + parent_collection: Option, }, /// Merge outcome tokens back into collateral Merge { /// Condition ID (0x-prefixed 32-byte hex) #[arg(long)] - condition: String, + condition: B256, /// Amount in USDC (e.g. 10 for $10) #[arg(long)] amount: String, /// Collateral token address (defaults to USDC) - #[arg(long, default_value = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")] - collateral: String, + #[arg(long, default_value = USDC_ADDRESS_STR)] + collateral: Address, /// Custom partition as comma-separated index sets (e.g. "1,2" for binary, "1,2,4" for 3-outcome) #[arg(long)] partition: Option, /// Parent collection ID for nested positions (defaults to zero) #[arg(long)] - parent_collection: Option, + parent_collection: Option, }, /// Redeem winning tokens after market resolution Redeem { /// Condition ID (0x-prefixed 32-byte hex) #[arg(long)] - condition: String, + condition: B256, /// Collateral token address (defaults to USDC) - #[arg(long, default_value = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")] - collateral: String, + #[arg(long, default_value = USDC_ADDRESS_STR)] + collateral: Address, /// Custom index sets as comma-separated values (e.g. "1,2" for binary, "1" for YES only) #[arg(long)] index_sets: Option, /// Parent collection ID for nested positions (defaults to zero) #[arg(long)] - parent_collection: Option, + parent_collection: Option, }, /// Redeem neg-risk positions RedeemNegRisk { /// Condition ID (0x-prefixed 32-byte hex) #[arg(long)] - condition: String, + condition: B256, /// Comma-separated amounts in USDC for each outcome (e.g. "10,5") #[arg(long)] amounts: String, @@ -87,10 +87,10 @@ pub enum CtfCommand { ConditionId { /// Oracle address (0x-prefixed) #[arg(long)] - oracle: String, + oracle: Address, /// Question ID (0x-prefixed 32-byte hex) #[arg(long)] - question: String, + question: B256, /// Number of outcomes (e.g. 2 for binary) #[arg(long)] outcomes: u64, @@ -99,27 +99,28 @@ pub enum CtfCommand { CollectionId { /// Condition ID (0x-prefixed 32-byte hex) #[arg(long)] - condition: String, + condition: B256, /// Index set (e.g. 1 for YES, 2 for NO in binary markets) #[arg(long)] index_set: u64, /// Parent collection ID (defaults to zero for top-level positions) #[arg(long)] - parent_collection: Option, + parent_collection: Option, }, /// Calculate a position ID (ERC1155 token ID) from collateral and collection PositionId { /// Collateral token address (defaults to USDC) - #[arg(long, default_value = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")] - collateral: String, + #[arg(long, default_value = USDC_ADDRESS_STR)] + collateral: Address, /// Collection ID (0x-prefixed 32-byte hex) #[arg(long)] - collection: String, + collection: B256, }, } fn usdc_to_raw(val: Decimal) -> Result { - let raw = val * USDC_DECIMALS; + let multiplier = Decimal::from(10u64.pow(USDC_DECIMALS)); + let raw = val * multiplier; anyhow::ensure!( raw.fract().is_zero(), "Amount {val} exceeds USDC precision (max 6 decimal places)" @@ -164,23 +165,10 @@ fn parse_u256_csv(s: &str) -> Result> { .collect() } -fn parse_optional_parent(parent: Option<&str>) -> Result { - match parent { - Some(p) => super::parse_condition_id(p), - None => Ok(B256::default()), - } -} - -fn resolve_collateral(collateral: &str) -> Result
{ - super::parse_address(collateral) -} - -fn default_partition() -> Vec { - vec![U256::from(1), U256::from(2)] -} +const DEFAULT_BINARY_SETS: [u64; 2] = [1, 2]; -fn default_index_sets() -> Vec { - vec![U256::from(1), U256::from(2)] +fn binary_u256_vec() -> Vec { + DEFAULT_BINARY_SETS.iter().map(|&n| U256::from(n)).collect() } pub async fn execute(args: CtfArgs, output: OutputFormat, private_key: Option<&str>) -> Result<()> { @@ -192,22 +180,20 @@ pub async fn execute(args: CtfArgs, output: OutputFormat, private_key: Option<&s partition, parent_collection, } => { - let condition_id = super::parse_condition_id(&condition)?; let usdc_amount = parse_usdc_amount(&amount)?; - let collateral_addr = resolve_collateral(&collateral)?; - let parent = parse_optional_parent(parent_collection.as_deref())?; + let parent = parent_collection.unwrap_or_default(); let partition = match partition { Some(p) => parse_u256_csv(&p)?, - None => default_partition(), + None => binary_u256_vec(), }; let provider = auth::create_provider(private_key).await?; let client = ctf::Client::new(provider, POLYGON)?; let req = SplitPositionRequest::builder() - .collateral_token(collateral_addr) + .collateral_token(collateral) .parent_collection_id(parent) - .condition_id(condition_id) + .condition_id(condition) .partition(partition) .amount(usdc_amount) .build(); @@ -226,22 +212,20 @@ pub async fn execute(args: CtfArgs, output: OutputFormat, private_key: Option<&s partition, parent_collection, } => { - let condition_id = super::parse_condition_id(&condition)?; let usdc_amount = parse_usdc_amount(&amount)?; - let collateral_addr = resolve_collateral(&collateral)?; - let parent = parse_optional_parent(parent_collection.as_deref())?; + let parent = parent_collection.unwrap_or_default(); let partition = match partition { Some(p) => parse_u256_csv(&p)?, - None => default_partition(), + None => binary_u256_vec(), }; let provider = auth::create_provider(private_key).await?; let client = ctf::Client::new(provider, POLYGON)?; let req = MergePositionsRequest::builder() - .collateral_token(collateral_addr) + .collateral_token(collateral) .parent_collection_id(parent) - .condition_id(condition_id) + .condition_id(condition) .partition(partition) .amount(usdc_amount) .build(); @@ -259,21 +243,19 @@ pub async fn execute(args: CtfArgs, output: OutputFormat, private_key: Option<&s index_sets, parent_collection, } => { - let condition_id = super::parse_condition_id(&condition)?; - let collateral_addr = resolve_collateral(&collateral)?; - let parent = parse_optional_parent(parent_collection.as_deref())?; + let parent = parent_collection.unwrap_or_default(); let index_sets = match index_sets { Some(s) => parse_u256_csv(&s)?, - None => default_index_sets(), + None => binary_u256_vec(), }; let provider = auth::create_provider(private_key).await?; let client = ctf::Client::new(provider, POLYGON)?; let req = RedeemPositionsRequest::builder() - .collateral_token(collateral_addr) + .collateral_token(collateral) .parent_collection_id(parent) - .condition_id(condition_id) + .condition_id(condition) .index_sets(index_sets) .build(); @@ -285,14 +267,13 @@ pub async fn execute(args: CtfArgs, output: OutputFormat, private_key: Option<&s ctf_output::print_tx_result("redeem", resp.transaction_hash, resp.block_number, &output) } CtfCommand::RedeemNegRisk { condition, amounts } => { - let condition_id = super::parse_condition_id(&condition)?; let amounts = parse_usdc_amounts(&amounts)?; let provider = auth::create_provider(private_key).await?; let client = ctf::Client::with_neg_risk(provider, POLYGON)?; let req = RedeemNegRiskRequest::builder() - .condition_id(condition_id) + .condition_id(condition) .amounts(amounts) .build(); @@ -313,15 +294,12 @@ pub async fn execute(args: CtfArgs, output: OutputFormat, private_key: Option<&s question, outcomes, } => { - let oracle_addr = super::parse_address(&oracle)?; - let question_id = super::parse_condition_id(&question)?; - let provider = auth::create_readonly_provider().await?; let client = ctf::Client::new(provider, POLYGON)?; let req = ConditionIdRequest::builder() - .oracle(oracle_addr) - .question_id(question_id) + .oracle(oracle) + .question_id(question) .outcome_slot_count(U256::from(outcomes)) .build(); @@ -333,15 +311,14 @@ pub async fn execute(args: CtfArgs, output: OutputFormat, private_key: Option<&s index_set, parent_collection, } => { - let condition_id = super::parse_condition_id(&condition)?; - let parent = parse_optional_parent(parent_collection.as_deref())?; + let parent = parent_collection.unwrap_or_default(); let provider = auth::create_readonly_provider().await?; let client = ctf::Client::new(provider, POLYGON)?; let req = CollectionIdRequest::builder() .parent_collection_id(parent) - .condition_id(condition_id) + .condition_id(condition) .index_set(U256::from(index_set)) .build(); @@ -352,15 +329,12 @@ pub async fn execute(args: CtfArgs, output: OutputFormat, private_key: Option<&s collateral, collection, } => { - let collateral_addr = super::parse_address(&collateral)?; - let collection_id = super::parse_condition_id(&collection)?; - let provider = auth::create_readonly_provider().await?; let client = ctf::Client::new(provider, POLYGON)?; let req = PositionIdRequest::builder() - .collateral_token(collateral_addr) - .collection_id(collection_id) + .collateral_token(collateral) + .collection_id(collection) .build(); let resp = client.position_id(&req).await?; @@ -503,32 +477,8 @@ mod tests { } #[test] - fn parse_optional_parent_none_is_zero() { - let result = parse_optional_parent(None).unwrap(); - assert_eq!(result, B256::default()); - } - - #[test] - fn parse_optional_parent_some_parses() { - let hex = "0x0000000000000000000000000000000000000000000000000000000000000001"; - let result = parse_optional_parent(Some(hex)).unwrap(); - assert_ne!(result, B256::default()); - } - - #[test] - fn parse_optional_parent_invalid_fails() { - assert!(parse_optional_parent(Some("garbage")).is_err()); - } - - #[test] - fn default_partition_is_binary() { - let p = default_partition(); + fn binary_u256_vec_is_binary() { + let p = binary_u256_vec(); assert_eq!(p, vec![U256::from(1u64), U256::from(2u64)]); } - - #[test] - fn default_index_sets_is_binary() { - let s = default_index_sets(); - assert_eq!(s, vec![U256::from(1u64), U256::from(2u64)]); - } } diff --git a/src/commands/data.rs b/src/commands/data.rs index bca7236..ac92d44 100644 --- a/src/commands/data.rs +++ b/src/commands/data.rs @@ -1,10 +1,3 @@ -use super::{parse_address, parse_condition_id}; -use crate::output::OutputFormat; -use crate::output::data::{ - print_activity, print_builder_leaderboard, print_builder_volume, print_closed_positions, - print_holders, print_leaderboard, print_live_volume, print_open_interest, print_positions, - print_traded, print_trades, print_value, -}; use anyhow::Result; use clap::{Args, Subcommand}; use polymarket_client_sdk::data::{ @@ -15,6 +8,14 @@ use polymarket_client_sdk::data::{ TraderLeaderboardRequest, TradesRequest, ValueRequest, }, }; +use polymarket_client_sdk::types::{Address, B256}; + +use crate::output::OutputFormat; +use crate::output::data::{ + print_activity, print_builder_leaderboard, print_builder_volume, print_closed_positions, + print_holders, print_leaderboard, print_live_volume, print_open_interest, print_positions, + print_traded, print_trades, print_value, +}; #[derive(Args)] pub struct DataArgs { @@ -27,7 +28,7 @@ pub enum DataCommand { /// Get open positions for a wallet address Positions { /// Wallet address (0x...) - address: String, + address: Address, /// Max results #[arg(long, default_value = "25")] @@ -41,7 +42,7 @@ pub enum DataCommand { /// Get closed positions for a wallet address ClosedPositions { /// Wallet address (0x...) - address: String, + address: Address, /// Max results #[arg(long, default_value = "25")] @@ -55,19 +56,19 @@ pub enum DataCommand { /// Get total position value for a wallet address Value { /// Wallet address (0x...) - address: String, + address: Address, }, /// Get count of unique markets traded by a wallet Traded { /// Wallet address (0x...) - address: String, + address: Address, }, /// Get trade history Trades { /// Wallet address (0x...) - address: String, + address: Address, /// Max results #[arg(long, default_value = "25")] @@ -81,7 +82,7 @@ pub enum DataCommand { /// Get on-chain activity for a wallet address Activity { /// Wallet address (0x...) - address: String, + address: Address, /// Max results #[arg(long, default_value = "25")] @@ -95,7 +96,7 @@ pub enum DataCommand { /// Get top token holders for a market Holders { /// Market condition ID (0x...) - market: String, + market: B256, /// Max results per token #[arg(long, default_value = "10")] @@ -105,7 +106,7 @@ pub enum DataCommand { /// Get open interest for markets OpenInterest { /// Market condition ID (0x...) - market: String, + market: B256, }, /// Get live volume for an event @@ -165,8 +166,8 @@ pub enum TimePeriod { } impl From for polymarket_client_sdk::data::types::TimePeriod { - fn from(t: TimePeriod) -> Self { - match t { + fn from(v: TimePeriod) -> Self { + match v { TimePeriod::Day => Self::Day, TimePeriod::Week => Self::Week, TimePeriod::Month => Self::Month, @@ -182,8 +183,8 @@ pub enum OrderBy { } impl From for polymarket_client_sdk::data::types::LeaderboardOrderBy { - fn from(o: OrderBy) -> Self { - match o { + fn from(v: OrderBy) -> Self { + match v { OrderBy::Pnl => Self::Pnl, OrderBy::Vol => Self::Vol, } @@ -192,47 +193,19 @@ impl From for polymarket_client_sdk::data::types::LeaderboardOrderBy { pub async fn execute(client: &data::Client, args: DataArgs, output: OutputFormat) -> Result<()> { match args.command { - // User-focused queries (positions, trades, activity, value) - DataCommand::Positions { .. } - | DataCommand::ClosedPositions { .. } - | DataCommand::Value { .. } - | DataCommand::Traded { .. } - | DataCommand::Trades { .. } - | DataCommand::Activity { .. } => execute_user(client, args.command, &output).await, - - // Market-focused queries (holders, open interest, volume) - DataCommand::Holders { .. } - | DataCommand::OpenInterest { .. } - | DataCommand::Volume { .. } => execute_market(client, args.command, &output).await, - - // Leaderboard queries - DataCommand::Leaderboard { .. } - | DataCommand::BuilderLeaderboard { .. } - | DataCommand::BuilderVolume { .. } => { - execute_leaderboard(client, args.command, &output).await - } - } -} - -async fn execute_user( - client: &data::Client, - command: DataCommand, - output: &OutputFormat, -) -> Result<()> { - match command { DataCommand::Positions { address, limit, offset, } => { let request = PositionsRequest::builder() - .user(parse_address(&address)?) + .user(address) .limit(limit)? .maybe_offset(offset)? .build(); let positions = client.positions(&request).await?; - print_positions(&positions, output)?; + print_positions(&positions, &output)?; } DataCommand::ClosedPositions { @@ -241,31 +214,27 @@ async fn execute_user( offset, } => { let request = ClosedPositionsRequest::builder() - .user(parse_address(&address)?) + .user(address) .limit(limit)? .maybe_offset(offset)? .build(); let positions = client.closed_positions(&request).await?; - print_closed_positions(&positions, output)?; + print_closed_positions(&positions, &output)?; } DataCommand::Value { address } => { - let request = ValueRequest::builder() - .user(parse_address(&address)?) - .build(); + let request = ValueRequest::builder().user(address).build(); let values = client.value(&request).await?; - print_value(&values, output)?; + print_value(&values, &output)?; } DataCommand::Traded { address } => { - let request = TradedRequest::builder() - .user(parse_address(&address)?) - .build(); + let request = TradedRequest::builder().user(address).build(); let traded = client.traded(&request).await?; - print_traded(&traded, output)?; + print_traded(&traded, &output)?; } DataCommand::Trades { @@ -274,13 +243,13 @@ async fn execute_user( offset, } => { let request = TradesRequest::builder() - .user(parse_address(&address)?) + .user(address) .limit(limit)? .maybe_offset(offset)? .build(); let trades = client.trades(&request).await?; - print_trades(&trades, output)?; + print_trades(&trades, &output)?; } DataCommand::Activity { @@ -289,64 +258,38 @@ async fn execute_user( offset, } => { let request = ActivityRequest::builder() - .user(parse_address(&address)?) + .user(address) .limit(limit)? .maybe_offset(offset)? .build(); let activity = client.activity(&request).await?; - print_activity(&activity, output)?; + print_activity(&activity, &output)?; } - _ => unreachable!(), - } - - Ok(()) -} - -async fn execute_market( - client: &data::Client, - command: DataCommand, - output: &OutputFormat, -) -> Result<()> { - match command { DataCommand::Holders { market, limit } => { - let cid = parse_condition_id(&market)?; let request = HoldersRequest::builder() - .markets(vec![cid]) + .markets(vec![market]) .limit(limit)? .build(); let holders = client.holders(&request).await?; - print_holders(&holders, output)?; + print_holders(&holders, &output)?; } DataCommand::OpenInterest { market } => { - let cid = parse_condition_id(&market)?; - let request = OpenInterestRequest::builder().markets(vec![cid]).build(); + let request = OpenInterestRequest::builder().markets(vec![market]).build(); let oi = client.open_interest(&request).await?; - print_open_interest(&oi, output)?; + print_open_interest(&oi, &output)?; } DataCommand::Volume { id } => { let request = LiveVolumeRequest::builder().id(id).build(); let volume = client.live_volume(&request).await?; - print_live_volume(&volume, output)?; + print_live_volume(&volume, &output)?; } - _ => unreachable!(), - } - - Ok(()) -} - -async fn execute_leaderboard( - client: &data::Client, - command: DataCommand, - output: &OutputFormat, -) -> Result<()> { - match command { DataCommand::Leaderboard { period, order_by, @@ -361,7 +304,7 @@ async fn execute_leaderboard( .build(); let entries = client.leaderboard(&request).await?; - print_leaderboard(&entries, output)?; + print_leaderboard(&entries, &output)?; } DataCommand::BuilderLeaderboard { @@ -376,7 +319,7 @@ async fn execute_leaderboard( .build(); let entries = client.builder_leaderboard(&request).await?; - print_builder_leaderboard(&entries, output)?; + print_builder_leaderboard(&entries, &output)?; } DataCommand::BuilderVolume { period } => { @@ -385,10 +328,8 @@ async fn execute_leaderboard( .build(); let entries = client.builder_volume(&request).await?; - print_builder_volume(&entries, output)?; + print_builder_volume(&entries, &output)?; } - - _ => unreachable!(), } Ok(()) diff --git a/src/commands/events.rs b/src/commands/events.rs index b5d947a..d001210 100644 --- a/src/commands/events.rs +++ b/src/commands/events.rs @@ -6,9 +6,9 @@ use polymarket_client_sdk::gamma::{ }; use super::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}; +use crate::output::OutputFormat; +use crate::output::events::{print_event, print_events}; +use crate::output::tags::print_tags; #[derive(Args)] pub struct EventsArgs { @@ -79,17 +79,14 @@ pub async fn execute(client: &gamma::Client, args: EventsArgs, output: OutputFor .limit(limit) .maybe_closed(resolved_closed) .maybe_offset(offset) - .maybe_ascending(if ascending { Some(true) } else { None }) + .ascending(ascending) .maybe_tag_slug(tag) - .order(order.into_iter().collect::>()) + // EventsRequest::order is Vec; into_iter on Option yields 0 or 1 items. + .order(order.into_iter().collect()) .build(); let events = client.events(&request).await?; - - match output { - OutputFormat::Table => print_events_table(&events), - OutputFormat::Json => print_json(&events)?, - } + print_events(&events, &output)?; } EventsCommand::Get { id } => { @@ -102,20 +99,14 @@ pub async fn execute(client: &gamma::Client, args: EventsArgs, output: OutputFor client.event_by_slug(&req).await? }; - match output { - OutputFormat::Table => print_event_detail(&event), - OutputFormat::Json => print_json(&event)?, - } + print_event(&event, &output)?; } EventsCommand::Tags { id } => { let req = EventTagsRequest::builder().id(id).build(); let tags = client.event_tags(&req).await?; - match output { - OutputFormat::Table => print_tags_table(&tags), - OutputFormat::Json => print_json(&tags)?, - } + print_tags(&tags, &output)?; } } diff --git a/src/commands/markets.rs b/src/commands/markets.rs index 68e5491..2544d18 100644 --- a/src/commands/markets.rs +++ b/src/commands/markets.rs @@ -12,9 +12,9 @@ use polymarket_client_sdk::gamma::{ }; use super::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}; +use crate::output::OutputFormat; +use crate::output::markets::{print_market, print_markets}; +use crate::output::tags::print_tags; #[derive(Args)] pub struct MarketsArgs { @@ -95,15 +95,11 @@ pub async fn execute( .maybe_closed(resolved_closed) .maybe_offset(offset) .maybe_order(order) - .maybe_ascending(if ascending { Some(true) } else { None }) + .ascending(ascending) .build(); let markets = client.markets(&request).await?; - - match output { - OutputFormat::Table => print_markets_table(&markets), - OutputFormat::Json => print_json(&markets)?, - } + print_markets(&markets, &output)?; } MarketsCommand::Get { id } => { @@ -116,10 +112,7 @@ pub async fn execute( client.market_by_slug(&req).await? }; - match output { - OutputFormat::Table => print_market_detail(&market), - OutputFormat::Json => print_json(&market)?, - } + print_market(&market, &output)?; } MarketsCommand::Search { query, limit } => { @@ -137,20 +130,14 @@ pub async fn execute( .flat_map(|e| e.markets.unwrap_or_default()) .collect(); - match output { - OutputFormat::Table => print_markets_table(&markets), - OutputFormat::Json => print_json(&markets)?, - } + print_markets(&markets, &output)?; } MarketsCommand::Tags { id } => { let req = MarketTagsRequest::builder().id(id).build(); let tags = client.market_tags(&req).await?; - match output { - OutputFormat::Table => print_tags_table(&tags), - OutputFormat::Json => print_json(&tags)?, - } + print_tags(&tags, &output)?; } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 671c0ee..d4c985b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,33 +1,24 @@ -use polymarket_client_sdk::types::{Address, B256}; - -pub mod approve; -pub mod bridge; -pub mod clob; -pub mod comments; -pub mod ctf; -pub mod data; -pub mod events; -pub mod markets; -pub mod profiles; -pub mod series; -pub mod setup; -pub mod sports; -pub mod tags; -pub mod upgrade; -pub mod wallet; - -pub fn is_numeric_id(id: &str) -> bool { - !id.is_empty() && id.chars().all(|c| c.is_ascii_digit()) -} - -pub fn parse_address(s: &str) -> anyhow::Result
{ - s.parse() - .map_err(|_| anyhow::anyhow!("Invalid address: must be a 0x-prefixed hex address")) -} - -pub fn parse_condition_id(s: &str) -> anyhow::Result { - s.parse() - .map_err(|_| anyhow::anyhow!("Invalid condition ID: must be a 0x-prefixed 32-byte hex")) +pub(crate) const USDC_ADDRESS_STR: &str = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"; +pub(crate) const USDC_DECIMALS: u32 = 6; + +pub(crate) mod approve; +pub(crate) mod bridge; +pub(crate) mod clob; +pub(crate) mod comments; +pub(crate) mod ctf; +pub(crate) mod data; +pub(crate) mod events; +pub(crate) mod markets; +pub(crate) mod profiles; +pub(crate) mod series; +pub(crate) mod setup; +pub(crate) mod sports; +pub(crate) mod tags; +pub(crate) mod upgrade; +pub(crate) mod wallet; + +pub(crate) fn is_numeric_id(id: &str) -> bool { + id.parse::().is_ok() } #[cfg(test)] @@ -51,40 +42,4 @@ mod tests { fn is_numeric_id_rejects_empty() { assert!(!is_numeric_id("")); } - - #[test] - fn parse_address_valid_hex() { - let addr = "0x0000000000000000000000000000000000000001"; - assert!(parse_address(addr).is_ok()); - } - - #[test] - fn parse_address_rejects_short_hex() { - let err = parse_address("0x1234").unwrap_err().to_string(); - assert!(err.contains("0x-prefixed"), "got: {err}"); - } - - #[test] - fn parse_address_rejects_garbage() { - let err = parse_address("not-an-address").unwrap_err().to_string(); - assert!(err.contains("0x-prefixed"), "got: {err}"); - } - - #[test] - fn parse_condition_id_valid_64_hex() { - let id = "0x0000000000000000000000000000000000000000000000000000000000000001"; - assert!(parse_condition_id(id).is_ok()); - } - - #[test] - fn parse_condition_id_rejects_wrong_length() { - let err = parse_condition_id("0x0001").unwrap_err().to_string(); - assert!(err.contains("32-byte"), "got: {err}"); - } - - #[test] - fn parse_condition_id_rejects_garbage() { - let err = parse_condition_id("garbage").unwrap_err().to_string(); - assert!(err.contains("32-byte"), "got: {err}"); - } } diff --git a/src/commands/profiles.rs b/src/commands/profiles.rs index c50c73d..7c95dfa 100644 --- a/src/commands/profiles.rs +++ b/src/commands/profiles.rs @@ -1,9 +1,10 @@ -use super::parse_address; -use crate::output::profiles::print_profile_detail; -use crate::output::{OutputFormat, print_json}; use anyhow::Result; use clap::{Args, Subcommand}; use polymarket_client_sdk::gamma::{self, types::request::PublicProfileRequest}; +use polymarket_client_sdk::types::Address; + +use crate::output::OutputFormat; +use crate::output::profiles::print_profile; #[derive(Args)] pub struct ProfilesArgs { @@ -16,7 +17,7 @@ pub enum ProfilesCommand { /// Get a public profile by wallet address Get { /// Wallet address (0x...) - address: String, + address: Address, }, } @@ -27,14 +28,10 @@ pub async fn execute( ) -> Result<()> { match args.command { ProfilesCommand::Get { address } => { - let addr = parse_address(&address)?; - let req = PublicProfileRequest::builder().address(addr).build(); + let req = PublicProfileRequest::builder().address(address).build(); let profile = client.public_profile(&req).await?; - match output { - OutputFormat::Table => print_profile_detail(&profile), - OutputFormat::Json => print_json(&profile)?, - } + print_profile(&profile, &output)?; } } diff --git a/src/commands/series.rs b/src/commands/series.rs index 11dba91..fc5e884 100644 --- a/src/commands/series.rs +++ b/src/commands/series.rs @@ -5,8 +5,8 @@ use polymarket_client_sdk::gamma::{ types::request::{SeriesByIdRequest, SeriesListRequest}, }; -use crate::output::series::{print_series_detail, print_series_table}; -use crate::output::{OutputFormat, print_json}; +use crate::output::OutputFormat; +use crate::output::series::{print_series, print_series_item}; #[derive(Args)] pub struct SeriesArgs { @@ -59,26 +59,19 @@ pub async fn execute(client: &gamma::Client, args: SeriesArgs, output: OutputFor .limit(limit) .maybe_offset(offset) .maybe_order(order) - .maybe_ascending(if ascending { Some(true) } else { None }) + .ascending(ascending) .maybe_closed(closed) .build(); let series = client.series(&request).await?; - - match output { - OutputFormat::Table => print_series_table(&series), - OutputFormat::Json => print_json(&series)?, - } + print_series(&series, &output)?; } SeriesCommand::Get { id } => { let req = SeriesByIdRequest::builder().id(id).build(); let series = client.series_by_id(&req).await?; - match output { - OutputFormat::Table => print_series_detail(&series), - OutputFormat::Json => print_json(&series)?, - } + print_series_item(&series, &output)?; } } diff --git a/src/commands/setup.rs b/src/commands/setup.rs index dd04671..2e4a66d 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -1,4 +1,3 @@ -use std::fmt::Write as _; use std::io::{self, BufRead, Write}; use std::str::FromStr; @@ -7,7 +6,6 @@ use polymarket_client_sdk::auth::{LocalSigner, Signer as _}; use polymarket_client_sdk::types::Address; use polymarket_client_sdk::{POLYGON, derive_proxy_wallet}; -use super::wallet::normalize_key; use crate::config; fn print_banner() { @@ -85,7 +83,7 @@ pub fn execute() -> Result<()> { step_header(1, total, "Wallet"); let address = if config::config_exists() { - let (key, source) = config::resolve_key(None); + let (key, source) = config::resolve_key(None)?; if let Some(k) = &key && let Ok(signer) = LocalSigner::from_str(k) { @@ -115,20 +113,15 @@ fn setup_wallet() -> Result
{ let (address, key_hex) = if has_key { let key = prompt(" Enter private key: ")?; - let normalized = normalize_key(&key); - let signer = LocalSigner::from_str(&normalized) + let signer = LocalSigner::from_str(&key) .context("Invalid private key")? .with_chain_id(Some(POLYGON)); - (signer.address(), normalized) + let hex = format!("{:#x}", signer.to_bytes()); + (signer.address(), hex) } else { let signer = LocalSigner::random().with_chain_id(Some(POLYGON)); let address = signer.address(); - let bytes = signer.credential().to_bytes(); - let mut hex = String::with_capacity(2 + bytes.len() * 2); - hex.push_str("0x"); - for b in &bytes { - write!(hex, "{b:02x}").unwrap(); - } + let hex = format!("{:#x}", signer.to_bytes()); (address, hex) }; diff --git a/src/commands/sports.rs b/src/commands/sports.rs index 8f46c72..0782fd1 100644 --- a/src/commands/sports.rs +++ b/src/commands/sports.rs @@ -2,8 +2,8 @@ use anyhow::Result; use clap::{Args, Subcommand}; use polymarket_client_sdk::gamma::{self, types::request::TeamsRequest}; -use crate::output::sports::{print_sport_types, print_sports_table, print_teams_table}; -use crate::output::{OutputFormat, print_json}; +use crate::output::OutputFormat; +use crate::output::sports::{print_sport_types, print_sports, print_teams}; #[derive(Args)] pub struct SportsArgs { @@ -47,20 +47,12 @@ pub async fn execute(client: &gamma::Client, args: SportsArgs, output: OutputFor match args.command { SportsCommand::List => { let sports = client.sports().await?; - - match output { - OutputFormat::Table => print_sports_table(&sports), - OutputFormat::Json => print_json(&sports)?, - } + print_sports(&sports, &output)?; } SportsCommand::MarketTypes => { let types = client.sports_market_types().await?; - - match output { - OutputFormat::Table => print_sport_types(&types), - OutputFormat::Json => print_json(&types)?, - } + print_sport_types(&types, &output)?; } SportsCommand::Teams { @@ -74,16 +66,12 @@ pub async fn execute(client: &gamma::Client, args: SportsArgs, output: OutputFor .limit(limit) .maybe_offset(offset) .maybe_order(order) - .maybe_ascending(if ascending { Some(true) } else { None }) - .league(league.into_iter().collect::>()) + .ascending(ascending) + .league(league.into_iter().collect()) .build(); let teams = client.teams(&request).await?; - - match output { - OutputFormat::Table => print_teams_table(&teams), - OutputFormat::Json => print_json(&teams)?, - } + print_teams(&teams, &output)?; } } diff --git a/src/commands/tags.rs b/src/commands/tags.rs index 537a2ff..64aae6a 100644 --- a/src/commands/tags.rs +++ b/src/commands/tags.rs @@ -9,8 +9,8 @@ use polymarket_client_sdk::gamma::{ }; use super::is_numeric_id; -use crate::output::tags::{print_related_tags_table, print_tag_detail, print_tags_table}; -use crate::output::{OutputFormat, print_json}; +use crate::output::OutputFormat; +use crate::output::tags::{print_related_tags, print_tag, print_tags}; #[derive(Args)] pub struct TagsArgs { @@ -72,15 +72,11 @@ pub async fn execute(client: &gamma::Client, args: TagsArgs, output: OutputForma let request = TagsRequest::builder() .limit(limit) .maybe_offset(offset) - .maybe_ascending(if ascending { Some(true) } else { None }) + .ascending(ascending) .build(); let tags = client.tags(&request).await?; - - match output { - OutputFormat::Table => print_tags_table(&tags), - OutputFormat::Json => print_json(&tags)?, - } + print_tags(&tags, &output)?; } TagsCommand::Get { id } => { @@ -93,10 +89,7 @@ pub async fn execute(client: &gamma::Client, args: TagsArgs, output: OutputForma client.tag_by_slug(&req).await? }; - match output { - OutputFormat::Table => print_tag_detail(&tag), - OutputFormat::Json => print_json(&tag)?, - } + print_tag(&tag, &output)?; } TagsCommand::Related { id, omit_empty } => { @@ -115,10 +108,7 @@ pub async fn execute(client: &gamma::Client, args: TagsArgs, output: OutputForma client.related_tags_by_slug(&req).await? }; - match output { - OutputFormat::Table => print_related_tags_table(&related), - OutputFormat::Json => print_json(&related)?, - } + print_related_tags(&related, &output)?; } TagsCommand::RelatedTags { id, omit_empty } => { @@ -137,10 +127,7 @@ pub async fn execute(client: &gamma::Client, args: TagsArgs, output: OutputForma client.tags_related_to_tag_by_slug(&req).await? }; - match output { - OutputFormat::Table => print_tags_table(&tags), - OutputFormat::Json => print_json(&tags)?, - } + print_tags(&tags, &output)?; } } diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index ce7597f..d54a63b 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -1,4 +1,3 @@ -use std::fmt::Write as _; use std::str::FromStr; use anyhow::{Context, Result, bail}; @@ -52,7 +51,7 @@ pub enum WalletCommand { pub fn execute( args: WalletArgs, - output: &OutputFormat, + output: OutputFormat, private_key_flag: Option<&str>, ) -> Result<()> { match args.command { @@ -81,25 +80,12 @@ fn guard_overwrite(force: bool) -> Result<()> { Ok(()) } -pub(crate) fn normalize_key(key: &str) -> String { - if key.starts_with("0x") || key.starts_with("0X") { - key.to_string() - } else { - format!("0x{key}") - } -} - -fn cmd_create(output: &OutputFormat, force: bool, signature_type: &str) -> Result<()> { +fn cmd_create(output: OutputFormat, force: bool, signature_type: &str) -> Result<()> { guard_overwrite(force)?; let signer = LocalSigner::random().with_chain_id(Some(POLYGON)); let address = signer.address(); - let bytes = signer.credential().to_bytes(); - let mut key_hex = String::with_capacity(2 + bytes.len() * 2); - key_hex.push_str("0x"); - for b in &bytes { - write!(key_hex, "{b:02x}").unwrap(); - } + let key_hex = format!("{:#x}", signer.to_bytes()); config::save_wallet(&key_hex, POLYGON, signature_type)?; let config_path = config::config_path()?; @@ -133,16 +119,16 @@ fn cmd_create(output: &OutputFormat, force: bool, signature_type: &str) -> Resul Ok(()) } -fn cmd_import(key: &str, output: &OutputFormat, force: bool, signature_type: &str) -> Result<()> { +fn cmd_import(key: &str, output: OutputFormat, force: bool, signature_type: &str) -> Result<()> { guard_overwrite(force)?; - let normalized = normalize_key(key); - let signer = LocalSigner::from_str(&normalized) + let signer = LocalSigner::from_str(key) .context("Invalid private key")? .with_chain_id(Some(POLYGON)); let address = signer.address(); + let key_hex = format!("{:#x}", signer.to_bytes()); - config::save_wallet(&normalized, POLYGON, signature_type)?; + config::save_wallet(&key_hex, POLYGON, signature_type)?; let config_path = config::config_path()?; let proxy_addr = derive_proxy_wallet(address, POLYGON); @@ -171,8 +157,8 @@ fn cmd_import(key: &str, output: &OutputFormat, force: bool, signature_type: &st Ok(()) } -fn cmd_address(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> { - let (key, _) = config::resolve_key(private_key_flag); +fn cmd_address(output: OutputFormat, private_key_flag: Option<&str>) -> Result<()> { + let (key, _) = config::resolve_key(private_key_flag)?; let key = key.ok_or_else(|| anyhow::anyhow!("{}", config::NO_WALLET_MSG))?; let signer = LocalSigner::from_str(&key).context("Invalid private key")?; @@ -189,8 +175,8 @@ fn cmd_address(output: &OutputFormat, private_key_flag: Option<&str>) -> Result< Ok(()) } -fn cmd_show(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> { - let (key, source) = config::resolve_key(private_key_flag); +fn cmd_show(output: OutputFormat, private_key_flag: Option<&str>) -> Result<()> { + let (key, source) = config::resolve_key(private_key_flag)?; let signer = key.as_deref().and_then(|k| LocalSigner::from_str(k).ok()); let address = signer.as_ref().map(|s| s.address().to_string()); let proxy_addr = signer @@ -198,7 +184,7 @@ fn cmd_show(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> .and_then(|s| derive_proxy_wallet(s.address(), POLYGON)) .map(|a| a.to_string()); - let sig_type = config::resolve_signature_type(None); + let sig_type = config::resolve_signature_type(None)?; let config_path = config::config_path()?; match output { @@ -231,7 +217,7 @@ fn cmd_show(output: &OutputFormat, private_key_flag: Option<&str>) -> Result<()> Ok(()) } -fn cmd_reset(output: &OutputFormat, force: bool) -> Result<()> { +fn cmd_reset(output: OutputFormat, force: bool) -> Result<()> { if !config::config_exists() { match output { OutputFormat::Table => println!("Nothing to reset. No config found."), @@ -277,28 +263,3 @@ fn cmd_reset(output: &OutputFormat, force: bool) -> Result<()> { } Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_key_adds_prefix() { - assert_eq!( - normalize_key("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"), - "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" - ); - } - - #[test] - fn normalize_key_with_prefix_unchanged() { - let key = "0xabcdef"; - assert_eq!(normalize_key(key), key); - } - - #[test] - fn normalize_key_uppercase_prefix() { - let key = "0Xabcdef"; - assert_eq!(normalize_key(key), key); - } -} diff --git a/src/config.rs b/src/config.rs index d2f5395..60c0179 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,13 +6,13 @@ use serde::{Deserialize, Serialize}; const ENV_VAR: &str = "POLYMARKET_PRIVATE_KEY"; const SIG_TYPE_ENV_VAR: &str = "POLYMARKET_SIGNATURE_TYPE"; -pub const DEFAULT_SIGNATURE_TYPE: &str = "proxy"; +pub(crate) const DEFAULT_SIGNATURE_TYPE: &str = "proxy"; -pub const NO_WALLET_MSG: &str = +pub(crate) const NO_WALLET_MSG: &str = "No wallet configured. Run `polymarket wallet create` or `polymarket wallet import `"; #[derive(Serialize, Deserialize)] -pub struct Config { +pub(crate) struct Config { pub private_key: String, pub chain_id: u64, #[serde(default = "default_signature_type")] @@ -23,7 +23,7 @@ fn default_signature_type() -> String { DEFAULT_SIGNATURE_TYPE.to_string() } -pub enum KeySource { +pub(crate) enum KeySource { Flag, EnvVar, ConfigFile, @@ -62,26 +62,36 @@ pub fn delete_config() -> Result<()> { Ok(()) } -pub fn load_config() -> Option { - let path = config_path().ok()?; - let data = fs::read_to_string(path).ok()?; - serde_json::from_str(&data).ok() +/// Load config from disk. Returns `Ok(None)` if no config file exists, +/// or `Err` if the file exists but can't be read or parsed. +pub fn load_config() -> Result> { + let path = config_path()?; + let data = match fs::read_to_string(&path) { + Ok(d) => d, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(anyhow::anyhow!(e).context(format!("Failed to read {}", path.display()))); + } + }; + let config = serde_json::from_str(&data) + .context(format!("Invalid JSON in config file {}", path.display()))?; + Ok(Some(config)) } /// Priority: CLI flag > env var > config file > default ("proxy"). -pub fn resolve_signature_type(cli_flag: Option<&str>) -> String { +pub fn resolve_signature_type(cli_flag: Option<&str>) -> Result { if let Some(st) = cli_flag { - return st.to_string(); + return Ok(st.to_string()); } if let Ok(st) = std::env::var(SIG_TYPE_ENV_VAR) && !st.is_empty() { - return st; + return Ok(st); } - if let Some(config) = load_config() { - return config.signature_type; + if let Some(config) = load_config()? { + return Ok(config.signature_type); } - DEFAULT_SIGNATURE_TYPE.to_string() + Ok(DEFAULT_SIGNATURE_TYPE.to_string()) } pub fn save_wallet(key: &str, chain_id: u64, signature_type: &str) -> Result<()> { @@ -126,19 +136,19 @@ pub fn save_wallet(key: &str, chain_id: u64, signature_type: &str) -> Result<()> } /// Priority: CLI flag > env var > config file. -pub fn resolve_key(cli_flag: Option<&str>) -> (Option, KeySource) { +pub fn resolve_key(cli_flag: Option<&str>) -> Result<(Option, KeySource)> { if let Some(key) = cli_flag { - return (Some(key.to_string()), KeySource::Flag); + return Ok((Some(key.to_string()), KeySource::Flag)); } if let Ok(key) = std::env::var(ENV_VAR) && !key.is_empty() { - return (Some(key), KeySource::EnvVar); + return Ok((Some(key), KeySource::EnvVar)); } - if let Some(config) = load_config() { - return (Some(config.private_key), KeySource::ConfigFile); + if let Some(config) = load_config()? { + return Ok((Some(config.private_key), KeySource::ConfigFile)); } - (None, KeySource::None) + Ok((None, KeySource::None)) } #[cfg(test)] @@ -161,7 +171,7 @@ mod tests { fn resolve_key_flag_overrides_env() { let _lock = ENV_LOCK.lock().unwrap(); unsafe { set(ENV_VAR, "env_key") }; - let (key, source) = resolve_key(Some("flag_key")); + let (key, source) = resolve_key(Some("flag_key")).unwrap(); assert_eq!(key.unwrap(), "flag_key"); assert!(matches!(source, KeySource::Flag)); unsafe { unset(ENV_VAR) }; @@ -171,7 +181,7 @@ mod tests { fn resolve_key_env_var_returns_env_value() { let _lock = ENV_LOCK.lock().unwrap(); unsafe { set(ENV_VAR, "env_key_value") }; - let (key, source) = resolve_key(None); + let (key, source) = resolve_key(None).unwrap(); assert_eq!(key.unwrap(), "env_key_value"); assert!(matches!(source, KeySource::EnvVar)); unsafe { unset(ENV_VAR) }; @@ -181,7 +191,7 @@ mod tests { fn resolve_key_skips_empty_env_var() { let _lock = ENV_LOCK.lock().unwrap(); unsafe { set(ENV_VAR, "") }; - let (_, source) = resolve_key(None); + let (_, source) = resolve_key(None).unwrap(); assert!(!matches!(source, KeySource::EnvVar)); unsafe { unset(ENV_VAR) }; } @@ -190,7 +200,10 @@ mod tests { fn resolve_sig_type_flag_overrides_env() { let _lock = ENV_LOCK.lock().unwrap(); unsafe { set(SIG_TYPE_ENV_VAR, "eoa") }; - assert_eq!(resolve_signature_type(Some("gnosis-safe")), "gnosis-safe"); + assert_eq!( + resolve_signature_type(Some("gnosis-safe")).unwrap(), + "gnosis-safe" + ); unsafe { unset(SIG_TYPE_ENV_VAR) }; } @@ -198,7 +211,7 @@ mod tests { fn resolve_sig_type_env_var_returns_env_value() { let _lock = ENV_LOCK.lock().unwrap(); unsafe { set(SIG_TYPE_ENV_VAR, "eoa") }; - assert_eq!(resolve_signature_type(None), "eoa"); + assert_eq!(resolve_signature_type(None).unwrap(), "eoa"); unsafe { unset(SIG_TYPE_ENV_VAR) }; } @@ -206,7 +219,7 @@ mod tests { fn resolve_sig_type_without_env_returns_nonempty() { let _lock = ENV_LOCK.lock().unwrap(); unsafe { unset(SIG_TYPE_ENV_VAR) }; - let result = resolve_signature_type(None); + let result = resolve_signature_type(None).unwrap(); assert!(!result.is_empty()); } } diff --git a/src/main.rs b/src/main.rs index 61af087..2abb55f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,14 +72,7 @@ async fn main() -> ExitCode { let output = cli.output; if let Err(e) = run(cli).await { - match output { - OutputFormat::Json => { - println!("{}", serde_json::json!({"error": e.to_string()})); - } - OutputFormat::Table => { - eprintln!("Error: {e}"); - } - } + output::print_error(&e, output); return ExitCode::FAILURE; } @@ -88,68 +81,21 @@ async fn main() -> ExitCode { #[allow(clippy::too_many_lines)] pub(crate) async fn run(cli: Cli) -> anyhow::Result<()> { + // Lazy-init so we only pay for the client we actually use. + let gamma = std::cell::LazyCell::new(polymarket_client_sdk::gamma::Client::default); + let data = std::cell::LazyCell::new(polymarket_client_sdk::data::Client::default); + let bridge = std::cell::LazyCell::new(polymarket_client_sdk::bridge::Client::default); + match cli.command { Commands::Setup => commands::setup::execute(), - Commands::Shell => { - Box::pin(shell::run_shell()).await; - Ok(()) - } - Commands::Markets(args) => { - commands::markets::execute( - &polymarket_client_sdk::gamma::Client::default(), - args, - cli.output, - ) - .await - } - Commands::Events(args) => { - commands::events::execute( - &polymarket_client_sdk::gamma::Client::default(), - args, - cli.output, - ) - .await - } - Commands::Tags(args) => { - commands::tags::execute( - &polymarket_client_sdk::gamma::Client::default(), - args, - cli.output, - ) - .await - } - Commands::Series(args) => { - commands::series::execute( - &polymarket_client_sdk::gamma::Client::default(), - args, - cli.output, - ) - .await - } - Commands::Comments(args) => { - commands::comments::execute( - &polymarket_client_sdk::gamma::Client::default(), - args, - cli.output, - ) - .await - } - Commands::Profiles(args) => { - commands::profiles::execute( - &polymarket_client_sdk::gamma::Client::default(), - args, - cli.output, - ) - .await - } - Commands::Sports(args) => { - commands::sports::execute( - &polymarket_client_sdk::gamma::Client::default(), - args, - cli.output, - ) - .await - } + Commands::Shell => Box::pin(shell::run_shell()).await, + Commands::Markets(args) => commands::markets::execute(&gamma, args, cli.output).await, + Commands::Events(args) => commands::events::execute(&gamma, args, cli.output).await, + Commands::Tags(args) => commands::tags::execute(&gamma, args, cli.output).await, + Commands::Series(args) => commands::series::execute(&gamma, args, cli.output).await, + Commands::Comments(args) => commands::comments::execute(&gamma, args, cli.output).await, + Commands::Profiles(args) => commands::profiles::execute(&gamma, args, cli.output).await, + Commands::Sports(args) => commands::sports::execute(&gamma, args, cli.output).await, Commands::Approve(args) => { commands::approve::execute(args, cli.output, cli.private_key.as_deref()).await } @@ -165,30 +111,14 @@ pub(crate) async fn run(cli: Cli) -> anyhow::Result<()> { Commands::Ctf(args) => { commands::ctf::execute(args, cli.output, cli.private_key.as_deref()).await } - Commands::Data(args) => { - commands::data::execute( - &polymarket_client_sdk::data::Client::default(), - args, - cli.output, - ) - .await - } - Commands::Bridge(args) => { - commands::bridge::execute( - &polymarket_client_sdk::bridge::Client::default(), - args, - cli.output, - ) - .await - } + Commands::Data(args) => commands::data::execute(&data, args, cli.output).await, + Commands::Bridge(args) => commands::bridge::execute(&bridge, args, cli.output).await, Commands::Wallet(args) => { - commands::wallet::execute(args, &cli.output, cli.private_key.as_deref()) + commands::wallet::execute(args, cli.output, cli.private_key.as_deref()) } Commands::Upgrade => commands::upgrade::execute(), Commands::Status => { - let status = polymarket_client_sdk::gamma::Client::default() - .status() - .await?; + let status = gamma.status().await?; match cli.output { OutputFormat::Json => { println!("{}", serde_json::json!({"status": status})); diff --git a/src/output/approve.rs b/src/output/approve.rs index 618b897..ec133e6 100644 --- a/src/output/approve.rs +++ b/src/output/approve.rs @@ -1,6 +1,3 @@ -#![allow(clippy::exhaustive_enums, reason = "Generated by sol! macro")] -#![allow(clippy::exhaustive_structs, reason = "Generated by sol! macro")] - use alloy::primitives::U256; use anyhow::Result; use tabled::Tabled; diff --git a/src/output/bridge.rs b/src/output/bridge.rs index 8c03e6d..7944912 100644 --- a/src/output/bridge.rs +++ b/src/output/bridge.rs @@ -1,5 +1,3 @@ -#![allow(clippy::items_after_statements)] - use polymarket_client_sdk::bridge::types::{ DepositResponse, DepositTransactionStatus, StatusResponse, SupportedAssetsResponse, }; @@ -7,7 +5,7 @@ use serde_json::json; use tabled::settings::Style; use tabled::{Table, Tabled}; -use super::{OutputFormat, detail_field, format_decimal, print_detail_table}; +use super::{DASH, OutputFormat, detail_field, format_decimal, print_detail_table}; pub fn print_deposit(response: &DepositResponse, output: &OutputFormat) -> anyhow::Result<()> { match output { @@ -142,7 +140,7 @@ pub fn print_status(response: &StatusResponse, output: &OutputFormat) -> anyhow: tx_hash: tx .tx_hash .as_deref() - .map_or_else(|| "—".into(), |h| super::truncate(h, 14)), + .map_or_else(|| DASH.into(), |h| super::truncate(h, 14)), }) .collect(); let table = Table::new(rows).with(Style::rounded()).to_string(); diff --git a/src/output/clob.rs b/src/output/clob.rs deleted file mode 100644 index 165e972..0000000 --- a/src/output/clob.rs +++ /dev/null @@ -1,1467 +0,0 @@ -#![allow(clippy::items_after_statements)] - -use polymarket_client_sdk::auth::Credentials; -use polymarket_client_sdk::clob::types::response::{ - ApiKeysResponse, BalanceAllowanceResponse, BanStatusResponse, CancelOrdersResponse, - CurrentRewardResponse, FeeRateResponse, GeoblockResponse, LastTradePriceResponse, - LastTradesPricesResponse, MarketResponse, MarketRewardResponse, MidpointResponse, - MidpointsResponse, NegRiskResponse, NotificationResponse, OpenOrderResponse, - OrderBookSummaryResponse, OrderScoringResponse, OrdersScoringResponse, Page, PostOrderResponse, - PriceHistoryResponse, PriceResponse, PricesResponse, RewardsPercentagesResponse, - SimplifiedMarketResponse, SpreadResponse, SpreadsResponse, TickSizeResponse, - TotalUserEarningResponse, TradeResponse, UserEarningResponse, UserRewardsEarningResponse, -}; -use polymarket_client_sdk::types::Decimal; -use serde_json::json; -use tabled::settings::Style; -use tabled::{Table, Tabled}; - -use super::{OutputFormat, format_decimal, truncate}; - -/// Base64-encoded empty cursor returned by the CLOB API when there are no more pages. -const END_CURSOR: &str = "LTE="; - -pub fn print_ok(result: &str, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => println!("CLOB API: {result}"), - OutputFormat::Json => { - super::print_json(&json!({"status": result}))?; - } - } - Ok(()) -} - -pub fn print_price(result: &PriceResponse, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => println!("Price: {}", result.price), - OutputFormat::Json => { - super::print_json(&json!({"price": result.price.to_string()}))?; - } - } - Ok(()) -} - -pub fn print_batch_prices(result: &PricesResponse, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - let Some(prices) = &result.prices else { - println!("No prices available."); - return Ok(()); - }; - if prices.is_empty() { - println!("No prices available."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Token ID")] - token_id: String, - #[tabled(rename = "Side")] - side: String, - #[tabled(rename = "Price")] - price: String, - } - let mut rows = Vec::new(); - for (token_id, sides) in prices { - for (side, price) in sides { - rows.push(Row { - token_id: truncate(&token_id.to_string(), 20), - side: side.to_string(), - price: price.to_string(), - }); - } - } - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - } - OutputFormat::Json => { - let data = result.prices.as_ref().map(|prices| { - prices - .iter() - .map(|(token_id, sides)| { - let side_map: serde_json::Map = sides - .iter() - .map(|(side, price)| (side.to_string(), json!(price.to_string()))) - .collect(); - (token_id.to_string(), json!(side_map)) - }) - .collect::>() - }); - super::print_json(&data)?; - } - } - Ok(()) -} - -pub fn print_midpoint(result: &MidpointResponse, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => println!("Midpoint: {}", result.mid), - OutputFormat::Json => { - super::print_json(&json!({"midpoint": result.mid.to_string()}))?; - } - } - Ok(()) -} - -pub fn print_midpoints(result: &MidpointsResponse, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.midpoints.is_empty() { - println!("No midpoints available."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Token ID")] - token_id: String, - #[tabled(rename = "Midpoint")] - midpoint: String, - } - let rows: Vec = result - .midpoints - .iter() - .map(|(id, mid)| Row { - token_id: truncate(&id.to_string(), 20), - midpoint: mid.to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - } - OutputFormat::Json => { - let data: serde_json::Map = result - .midpoints - .iter() - .map(|(id, mid)| (id.to_string(), json!(mid.to_string()))) - .collect(); - super::print_json(&data)?; - } - } - Ok(()) -} - -pub fn print_spread(result: &SpreadResponse, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => println!("Spread: {}", result.spread), - OutputFormat::Json => { - super::print_json(&json!({"spread": result.spread.to_string()}))?; - } - } - Ok(()) -} - -pub fn print_spreads(result: &SpreadsResponse, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - let Some(spreads) = &result.spreads else { - println!("No spreads available."); - return Ok(()); - }; - if spreads.is_empty() { - println!("No spreads available."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Token ID")] - token_id: String, - #[tabled(rename = "Spread")] - spread: String, - } - let rows: Vec = spreads - .iter() - .map(|(id, spread)| Row { - token_id: truncate(&id.to_string(), 20), - spread: spread.to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - } - OutputFormat::Json => { - let data = result.spreads.as_ref().map(|spreads| { - spreads - .iter() - .map(|(id, spread)| (id.to_string(), json!(spread.to_string()))) - .collect::>() - }); - super::print_json(&data)?; - } - } - Ok(()) -} - -fn order_book_to_json(book: &OrderBookSummaryResponse) -> serde_json::Value { - let bids: Vec<_> = book - .bids - .iter() - .map(|o| json!({"price": o.price.to_string(), "size": o.size.to_string()})) - .collect(); - let asks: Vec<_> = book - .asks - .iter() - .map(|o| json!({"price": o.price.to_string(), "size": o.size.to_string()})) - .collect(); - json!({ - "market": book.market.to_string(), - "asset_id": book.asset_id.to_string(), - "timestamp": book.timestamp.to_rfc3339(), - "bids": bids, - "asks": asks, - "min_order_size": book.min_order_size.to_string(), - "neg_risk": book.neg_risk, - "tick_size": book.tick_size.as_decimal().to_string(), - "last_trade_price": book.last_trade_price.map(|p| p.to_string()), - }) -} - -pub fn print_order_book( - result: &OrderBookSummaryResponse, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - println!("Market: {}", result.market); - println!("Asset: {}", result.asset_id); - println!( - "Last Trade: {}", - result - .last_trade_price - .map_or("—".into(), |p| p.to_string()) - ); - println!(); - - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Price")] - price: String, - #[tabled(rename = "Size")] - size: String, - } - - if result.bids.is_empty() { - println!("No bids."); - } else { - println!("Bids:"); - let rows: Vec = result - .bids - .iter() - .map(|o| Row { - price: o.price.to_string(), - size: o.size.to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - } - - println!(); - - if result.asks.is_empty() { - println!("No asks."); - } else { - println!("Asks:"); - let rows: Vec = result - .asks - .iter() - .map(|o| Row { - price: o.price.to_string(), - size: o.size.to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - } - } - OutputFormat::Json => { - super::print_json(&order_book_to_json(result))?; - } - } - Ok(()) -} - -pub fn print_order_books( - result: &[OrderBookSummaryResponse], - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.is_empty() { - println!("No order books found."); - return Ok(()); - } - for (i, book) in result.iter().enumerate() { - if i > 0 { - println!(); - } - print_order_book(book, output)?; - } - } - OutputFormat::Json => { - let data: Vec<_> = result.iter().map(order_book_to_json).collect(); - super::print_json(&data)?; - } - } - Ok(()) -} - -pub fn print_last_trade( - result: &LastTradePriceResponse, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => println!("Last Trade: {} ({})", result.price, result.side), - OutputFormat::Json => { - super::print_json(&json!({ - "price": result.price.to_string(), - "side": result.side.to_string(), - }))?; - } - } - Ok(()) -} - -pub fn print_last_trades_prices( - result: &[LastTradesPricesResponse], - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.is_empty() { - println!("No last trade prices found."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Token ID")] - token_id: String, - #[tabled(rename = "Price")] - price: String, - #[tabled(rename = "Side")] - side: String, - } - let rows: Vec = result - .iter() - .map(|t| Row { - token_id: truncate(&t.token_id.to_string(), 20), - price: t.price.to_string(), - side: t.side.to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - } - OutputFormat::Json => { - let data: Vec<_> = result - .iter() - .map(|t| { - json!({ - "token_id": t.token_id.to_string(), - "price": t.price.to_string(), - "side": t.side.to_string(), - }) - }) - .collect(); - super::print_json(&data)?; - } - } - Ok(()) -} - -pub fn print_clob_market(result: &MarketResponse, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - let mut rows = vec![ - ["Question".into(), result.question.clone()], - ["Description".into(), truncate(&result.description, 80)], - ["Slug".into(), result.market_slug.clone()], - [ - "Condition ID".into(), - result.condition_id.map_or("—".into(), |c| c.to_string()), - ], - ["Active".into(), result.active.to_string()], - ["Closed".into(), result.closed.to_string()], - [ - "Accepting Orders".into(), - result.accepting_orders.to_string(), - ], - [ - "Min Order Size".into(), - result.minimum_order_size.to_string(), - ], - ["Min Tick Size".into(), result.minimum_tick_size.to_string()], - ["Neg Risk".into(), result.neg_risk.to_string()], - [ - "End Date".into(), - result.end_date_iso.map_or("—".into(), |d| d.to_rfc3339()), - ], - ]; - for token in &result.tokens { - rows.push([ - format!("Token ({})", token.outcome), - format!( - "ID: {} | Price: {} | Winner: {}", - token.token_id, token.price, token.winner - ), - ]); - } - super::print_detail_table(rows); - } - OutputFormat::Json => { - super::print_json(result)?; - } - } - Ok(()) -} - -pub fn print_clob_markets( - result: &Page, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.data.is_empty() { - println!("No markets found."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Question")] - question: String, - #[tabled(rename = "Active")] - active: String, - #[tabled(rename = "Tokens")] - tokens: String, - #[tabled(rename = "Min Tick")] - min_tick: String, - } - let rows: Vec = result - .data - .iter() - .map(|m| Row { - question: truncate(&m.question, 50), - active: if m.active { "Yes" } else { "No" }.into(), - tokens: m.tokens.len().to_string(), - min_tick: m.minimum_tick_size.to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - if result.next_cursor != END_CURSOR { - println!("Next cursor: {}", result.next_cursor); - } - } - OutputFormat::Json => { - super::print_json(result)?; - } - } - Ok(()) -} - -pub fn print_simplified_markets( - result: &Page, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.data.is_empty() { - println!("No markets found."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Condition ID")] - condition_id: String, - #[tabled(rename = "Tokens")] - tokens: String, - #[tabled(rename = "Active")] - active: String, - #[tabled(rename = "Closed")] - closed: String, - #[tabled(rename = "Orders")] - accepting_orders: String, - } - let rows: Vec = result - .data - .iter() - .map(|m| Row { - condition_id: m - .condition_id - .map_or("—".into(), |c| truncate(&c.to_string(), 14)), - tokens: m.tokens.len().to_string(), - active: if m.active { "Yes" } else { "No" }.into(), - closed: if m.closed { "Yes" } else { "No" }.into(), - accepting_orders: if m.accepting_orders { "Yes" } else { "No" }.into(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - if result.next_cursor != END_CURSOR { - println!("Next cursor: {}", result.next_cursor); - } - } - OutputFormat::Json => { - super::print_json(result)?; - } - } - Ok(()) -} - -pub fn print_tick_size(result: &TickSizeResponse, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - println!("Tick size: {}", result.minimum_tick_size.as_decimal()); - } - OutputFormat::Json => { - super::print_json(&json!({ - "minimum_tick_size": result.minimum_tick_size.as_decimal().to_string(), - }))?; - } - } - Ok(()) -} - -pub fn print_fee_rate(result: &FeeRateResponse, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - println!("Fee rate: {} bps", result.base_fee); - } - OutputFormat::Json => { - super::print_json(&json!({ - "base_fee_bps": result.base_fee, - }))?; - } - } - Ok(()) -} - -pub fn print_neg_risk(result: &NegRiskResponse, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => println!("Neg risk: {}", result.neg_risk), - OutputFormat::Json => { - super::print_json(&json!({"neg_risk": result.neg_risk}))?; - } - } - Ok(()) -} - -pub fn print_price_history( - result: &PriceHistoryResponse, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.history.is_empty() { - println!("No price history found."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Timestamp")] - timestamp: String, - #[tabled(rename = "Price")] - price: String, - } - let rows: Vec = result - .history - .iter() - .map(|p| Row { - timestamp: chrono::DateTime::from_timestamp(p.t, 0) - .map_or(p.t.to_string(), |dt| { - dt.format("%Y-%m-%d %H:%M").to_string() - }), - price: p.p.to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - } - OutputFormat::Json => { - let data: Vec<_> = result - .history - .iter() - .map(|p| json!({"timestamp": p.t, "price": p.p.to_string()})) - .collect(); - super::print_json(&data)?; - } - } - Ok(()) -} - -pub fn print_server_time(timestamp: i64, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - let dt = chrono::DateTime::from_timestamp(timestamp, 0); - match dt { - Some(dt) => { - println!( - "Server time: {} ({timestamp})", - dt.format("%Y-%m-%d %H:%M:%S UTC") - ); - } - None => println!("Server time: {timestamp}"), - } - } - OutputFormat::Json => { - super::print_json(&json!({"timestamp": timestamp}))?; - } - } - Ok(()) -} - -pub fn print_geoblock(result: &GeoblockResponse, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - println!("Blocked: {}", result.blocked); - println!("IP: {}", result.ip); - println!("Country: {}", result.country); - println!("Region: {}", result.region); - } - OutputFormat::Json => { - super::print_json(&json!({ - "blocked": result.blocked, - "ip": result.ip, - "country": result.country, - "region": result.region, - }))?; - } - } - Ok(()) -} - -pub fn print_orders(result: &Page, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.data.is_empty() { - println!("No open orders."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "ID")] - id: String, - #[tabled(rename = "Side")] - side: String, - #[tabled(rename = "Price")] - price: String, - #[tabled(rename = "Size")] - original_size: String, - #[tabled(rename = "Matched")] - size_matched: String, - #[tabled(rename = "Status")] - status: String, - #[tabled(rename = "Type")] - order_type: String, - } - let rows: Vec = result - .data - .iter() - .map(|o| Row { - id: truncate(&o.id, 12), - side: o.side.to_string(), - price: o.price.to_string(), - original_size: o.original_size.to_string(), - size_matched: o.size_matched.to_string(), - status: o.status.to_string(), - order_type: o.order_type.to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - if result.next_cursor != END_CURSOR { - println!("Next cursor: {}", result.next_cursor); - } - } - OutputFormat::Json => { - let data: Vec<_> = result - .data - .iter() - .map(|o| { - json!({ - "id": o.id, - "status": o.status.to_string(), - "market": o.market.to_string(), - "asset_id": o.asset_id.to_string(), - "side": o.side.to_string(), - "price": o.price.to_string(), - "original_size": o.original_size.to_string(), - "size_matched": o.size_matched.to_string(), - "outcome": o.outcome, - "order_type": o.order_type.to_string(), - "created_at": o.created_at.to_rfc3339(), - "expiration": o.expiration.to_rfc3339(), - }) - }) - .collect(); - let wrapper = json!({"data": data, "next_cursor": result.next_cursor}); - super::print_json(&wrapper)?; - } - } - Ok(()) -} - -pub fn print_order_detail(result: &OpenOrderResponse, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - let rows = vec![ - ["ID".into(), result.id.clone()], - ["Status".into(), result.status.to_string()], - ["Market".into(), result.market.to_string()], - ["Asset ID".into(), result.asset_id.to_string()], - ["Side".into(), result.side.to_string()], - ["Price".into(), result.price.to_string()], - ["Original Size".into(), result.original_size.to_string()], - ["Size Matched".into(), result.size_matched.to_string()], - ["Outcome".into(), result.outcome.clone()], - ["Order Type".into(), result.order_type.to_string()], - ["Created".into(), result.created_at.to_rfc3339()], - ["Expiration".into(), result.expiration.to_rfc3339()], - ["Trades".into(), result.associate_trades.join(", ")], - ]; - super::print_detail_table(rows); - } - OutputFormat::Json => { - let data = json!({ - "id": result.id, - "status": result.status.to_string(), - "owner": result.owner.to_string(), - "maker_address": result.maker_address.to_string(), - "market": result.market.to_string(), - "asset_id": result.asset_id.to_string(), - "side": result.side.to_string(), - "price": result.price.to_string(), - "original_size": result.original_size.to_string(), - "size_matched": result.size_matched.to_string(), - "outcome": result.outcome, - "order_type": result.order_type.to_string(), - "created_at": result.created_at.to_rfc3339(), - "expiration": result.expiration.to_rfc3339(), - "associate_trades": result.associate_trades, - }); - super::print_json(&data)?; - } - } - Ok(()) -} - -fn post_order_to_json(r: &PostOrderResponse) -> serde_json::Value { - let tx_hashes: Vec<_> = r - .transaction_hashes - .iter() - .map(std::string::ToString::to_string) - .collect(); - json!({ - "order_id": r.order_id, - "status": r.status.to_string(), - "success": r.success, - "error_msg": r.error_msg, - "making_amount": r.making_amount.to_string(), - "taking_amount": r.taking_amount.to_string(), - "transaction_hashes": tx_hashes, - "trade_ids": r.trade_ids, - }) -} - -pub fn print_post_order_result( - result: &PostOrderResponse, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - println!("Order ID: {}", result.order_id); - println!("Status: {}", result.status); - println!("Success: {}", result.success); - if let Some(err) = &result.error_msg - && !err.is_empty() - { - println!("Error: {err}"); - } - println!("Making: {}", result.making_amount); - println!("Taking: {}", result.taking_amount); - } - OutputFormat::Json => { - super::print_json(&post_order_to_json(result))?; - } - } - Ok(()) -} - -pub fn print_post_orders_result( - results: &[PostOrderResponse], - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - for (i, r) in results.iter().enumerate() { - if i > 0 { - println!("---"); - } - print_post_order_result(r, output)?; - } - } - OutputFormat::Json => { - let data: Vec<_> = results.iter().map(post_order_to_json).collect(); - super::print_json(&data)?; - } - } - Ok(()) -} - -pub fn print_cancel_result( - result: &CancelOrdersResponse, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if !result.canceled.is_empty() { - println!("Canceled: {}", result.canceled.join(", ")); - } - if !result.not_canceled.is_empty() { - println!("Not canceled:"); - for (id, reason) in &result.not_canceled { - println!(" {id}: {reason}"); - } - } - if result.canceled.is_empty() && result.not_canceled.is_empty() { - println!("No orders to cancel."); - } - } - OutputFormat::Json => { - let data = json!({ - "canceled": result.canceled, - "not_canceled": result.not_canceled, - }); - super::print_json(&data)?; - } - } - Ok(()) -} - -pub fn print_trades(result: &Page, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.data.is_empty() { - println!("No trades found."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "ID")] - id: String, - #[tabled(rename = "Side")] - side: String, - #[tabled(rename = "Price")] - price: String, - #[tabled(rename = "Size")] - size: String, - #[tabled(rename = "Status")] - status: String, - #[tabled(rename = "Time")] - match_time: String, - } - let rows: Vec = result - .data - .iter() - .map(|t| Row { - id: truncate(&t.id, 12), - side: t.side.to_string(), - price: t.price.to_string(), - size: t.size.to_string(), - status: t.status.to_string(), - match_time: t.match_time.format("%Y-%m-%d %H:%M").to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - if result.next_cursor != END_CURSOR { - println!("Next cursor: {}", result.next_cursor); - } - } - OutputFormat::Json => { - let data: Vec<_> = result - .data - .iter() - .map(|t| { - json!({ - "id": t.id, - "taker_order_id": t.taker_order_id, - "market": t.market.to_string(), - "asset_id": t.asset_id.to_string(), - "side": t.side.to_string(), - "size": t.size.to_string(), - "price": t.price.to_string(), - "fee_rate_bps": t.fee_rate_bps.to_string(), - "status": t.status.to_string(), - "match_time": t.match_time.to_rfc3339(), - "outcome": t.outcome, - "trader_side": format!("{:?}", t.trader_side), - "transaction_hash": t.transaction_hash.to_string(), - }) - }) - .collect(); - let wrapper = json!({"data": data, "next_cursor": result.next_cursor}); - super::print_json(&wrapper)?; - } - } - Ok(()) -} - -/// USDC uses 6 decimal places on-chain. -const USDC_DECIMALS: u32 = 6; - -pub fn print_balance( - result: &BalanceAllowanceResponse, - is_collateral: bool, - output: &OutputFormat, -) -> anyhow::Result<()> { - let divisor = Decimal::from(10u64.pow(USDC_DECIMALS)); - let human_balance = result.balance / divisor; - match output { - OutputFormat::Table => { - if is_collateral { - println!("Balance: {}", format_decimal(human_balance)); - } else { - println!("Balance: {human_balance} shares"); - } - if !result.allowances.is_empty() { - println!("Allowances:"); - for (addr, allowance) in &result.allowances { - println!(" {}: {allowance}", truncate(&addr.to_string(), 14)); - } - } - } - OutputFormat::Json => { - let allowances: serde_json::Map = result - .allowances - .iter() - .map(|(addr, val)| (addr.to_string(), json!(val))) - .collect(); - let data = json!({ - "balance": human_balance.to_string(), - "allowances": allowances, - }); - super::print_json(&data)?; - } - } - Ok(()) -} - -pub fn print_notifications( - result: &[NotificationResponse], - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.is_empty() { - println!("No notifications."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Type")] - notif_type: String, - #[tabled(rename = "Question")] - question: String, - #[tabled(rename = "Side")] - side: String, - #[tabled(rename = "Price")] - price: String, - #[tabled(rename = "Size")] - size: String, - } - let rows: Vec = result - .iter() - .map(|n| Row { - notif_type: n.r#type.to_string(), - question: truncate(&n.payload.question, 40), - side: n.payload.side.to_string(), - price: n.payload.price.to_string(), - size: n.payload.matched_size.to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - } - OutputFormat::Json => { - let data: Vec<_> = result - .iter() - .map(|n| { - json!({ - "type": n.r#type, - "question": n.payload.question, - "side": n.payload.side.to_string(), - "price": n.payload.price.to_string(), - "outcome": n.payload.outcome, - "matched_size": n.payload.matched_size.to_string(), - "original_size": n.payload.original_size.to_string(), - "order_id": n.payload.order_id, - "trade_id": n.payload.trade_id, - "market": n.payload.market.to_string(), - }) - }) - .collect(); - super::print_json(&data)?; - } - } - Ok(()) -} - -pub fn print_rewards( - result: &Page, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.data.is_empty() { - println!("No reward earnings found."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Date")] - date: String, - #[tabled(rename = "Condition ID")] - condition_id: String, - #[tabled(rename = "Earnings")] - earnings: String, - #[tabled(rename = "Rate")] - rate: String, - } - let rows: Vec = result - .data - .iter() - .map(|e| Row { - date: e.date.to_string(), - condition_id: truncate(&e.condition_id.to_string(), 14), - earnings: format_decimal(e.earnings), - rate: e.asset_rate.to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - if result.next_cursor != END_CURSOR { - println!("Next cursor: {}", result.next_cursor); - } - } - OutputFormat::Json => { - let data: Vec<_> = result - .data - .iter() - .map(|e| { - json!({ - "date": e.date.to_string(), - "condition_id": e.condition_id.to_string(), - "asset_address": e.asset_address.to_string(), - "maker_address": e.maker_address.to_string(), - "earnings": e.earnings.to_string(), - "asset_rate": e.asset_rate.to_string(), - }) - }) - .collect(); - let wrapper = json!({"data": data, "next_cursor": result.next_cursor}); - super::print_json(&wrapper)?; - } - } - Ok(()) -} - -pub fn print_earnings( - result: &[TotalUserEarningResponse], - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.is_empty() { - println!("No earnings data found."); - return Ok(()); - } - for (i, e) in result.iter().enumerate() { - if i > 0 { - println!("---"); - } - println!("Date: {}", e.date); - println!("Earnings: {}", format_decimal(e.earnings)); - println!("Asset Rate: {}", e.asset_rate); - println!("Maker: {}", e.maker_address); - } - } - OutputFormat::Json => { - let data: Vec<_> = result - .iter() - .map(|e| { - json!({ - "date": e.date.to_string(), - "asset_address": e.asset_address.to_string(), - "maker_address": e.maker_address.to_string(), - "earnings": e.earnings.to_string(), - "asset_rate": e.asset_rate.to_string(), - }) - }) - .collect(); - super::print_json(&data)?; - } - } - Ok(()) -} - -pub fn print_user_earnings_markets( - result: &[UserRewardsEarningResponse], - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.is_empty() { - println!("No earnings data found."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Question")] - question: String, - #[tabled(rename = "Condition ID")] - condition_id: String, - #[tabled(rename = "Earn %")] - earning_pct: String, - #[tabled(rename = "Max Spread")] - max_spread: String, - #[tabled(rename = "Min Size")] - min_size: String, - } - let rows: Vec = result - .iter() - .map(|e| Row { - question: truncate(&e.question, 40), - condition_id: truncate(&e.condition_id.to_string(), 14), - earning_pct: format!("{}%", e.earning_percentage), - max_spread: e.rewards_max_spread.to_string(), - min_size: e.rewards_min_size.to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - } - OutputFormat::Json => { - let data: Vec<_> = result - .iter() - .map(|e| { - json!({ - "condition_id": e.condition_id.to_string(), - "question": e.question, - "market_slug": e.market_slug, - "event_slug": e.event_slug, - "earning_percentage": e.earning_percentage.to_string(), - "rewards_max_spread": e.rewards_max_spread.to_string(), - "rewards_min_size": e.rewards_min_size.to_string(), - "market_competitiveness": e.market_competitiveness.to_string(), - "maker_address": e.maker_address.to_string(), - "tokens": e.tokens.iter().map(|t| json!({ - "token_id": t.token_id.to_string(), - "outcome": t.outcome, - "price": t.price.to_string(), - "winner": t.winner, - })).collect::>(), - "rewards_config": e.rewards_config.iter().map(|r| json!({ - "asset_address": r.asset_address.to_string(), - "start_date": r.start_date.to_string(), - "end_date": r.end_date.to_string(), - "rate_per_day": r.rate_per_day.to_string(), - "total_rewards": r.total_rewards.to_string(), - })).collect::>(), - "earnings": e.earnings.iter().map(|ear| json!({ - "asset_address": ear.asset_address.to_string(), - "earnings": ear.earnings.to_string(), - "asset_rate": ear.asset_rate.to_string(), - })).collect::>(), - }) - }) - .collect(); - super::print_json(&data)?; - } - } - Ok(()) -} - -pub fn print_reward_percentages( - result: &RewardsPercentagesResponse, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.is_empty() { - println!("No reward percentages found."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Market")] - market: String, - #[tabled(rename = "Percentage")] - percentage: String, - } - let rows: Vec = result - .iter() - .map(|(market, pct)| Row { - market: truncate(market, 20), - percentage: format!("{pct}%"), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - } - OutputFormat::Json => { - let data: serde_json::Map = result - .iter() - .map(|(k, v)| (k.clone(), json!(v.to_string()))) - .collect(); - super::print_json(&data)?; - } - } - Ok(()) -} - -pub fn print_current_rewards( - result: &Page, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.data.is_empty() { - println!("No current rewards found."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Condition ID")] - condition_id: String, - #[tabled(rename = "Max Spread")] - max_spread: String, - #[tabled(rename = "Min Size")] - min_size: String, - #[tabled(rename = "Configs")] - configs: String, - } - let rows: Vec = result - .data - .iter() - .map(|r| Row { - condition_id: truncate(&r.condition_id.to_string(), 14), - max_spread: r.rewards_max_spread.to_string(), - min_size: r.rewards_min_size.to_string(), - configs: r.rewards_config.len().to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - if result.next_cursor != END_CURSOR { - println!("Next cursor: {}", result.next_cursor); - } - } - OutputFormat::Json => { - let data: Vec<_> = result - .data - .iter() - .map(|r| { - json!({ - "condition_id": r.condition_id.to_string(), - "rewards_max_spread": r.rewards_max_spread.to_string(), - "rewards_min_size": r.rewards_min_size.to_string(), - "rewards_config": r.rewards_config.iter().map(|c| json!({ - "asset_address": c.asset_address.to_string(), - "start_date": c.start_date.to_string(), - "end_date": c.end_date.to_string(), - "rate_per_day": c.rate_per_day.to_string(), - "total_rewards": c.total_rewards.to_string(), - })).collect::>(), - }) - }) - .collect(); - let wrapper = json!({"data": data, "next_cursor": result.next_cursor}); - super::print_json(&wrapper)?; - } - } - Ok(()) -} - -pub fn print_market_reward( - result: &Page, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.data.is_empty() { - println!("No market reward data found."); - return Ok(()); - } - for (i, r) in result.data.iter().enumerate() { - if i > 0 { - println!("---"); - } - println!("Question: {}", r.question); - println!("Condition ID: {}", r.condition_id); - println!("Slug: {}", r.market_slug); - println!("Max Spread: {}", r.rewards_max_spread); - println!("Min Size: {}", r.rewards_min_size); - println!("Competitiveness: {}", r.market_competitiveness); - for token in &r.tokens { - println!( - " Token ({}): {} | Price: {}", - token.outcome, token.token_id, token.price - ); - } - } - if result.next_cursor != END_CURSOR { - println!("Next cursor: {}", result.next_cursor); - } - } - OutputFormat::Json => { - let data: Vec<_> = result - .data - .iter() - .map(|r| { - json!({ - "condition_id": r.condition_id.to_string(), - "question": r.question, - "market_slug": r.market_slug, - "event_slug": r.event_slug, - "rewards_max_spread": r.rewards_max_spread.to_string(), - "rewards_min_size": r.rewards_min_size.to_string(), - "market_competitiveness": r.market_competitiveness.to_string(), - "tokens": r.tokens.iter().map(|t| json!({ - "token_id": t.token_id.to_string(), - "outcome": t.outcome, - "price": t.price.to_string(), - "winner": t.winner, - })).collect::>(), - "rewards_config": r.rewards_config.iter().map(|c| json!({ - "id": c.id, - "asset_address": c.asset_address.to_string(), - "start_date": c.start_date.to_string(), - "end_date": c.end_date.to_string(), - "rate_per_day": c.rate_per_day.to_string(), - "total_rewards": c.total_rewards.to_string(), - "total_days": c.total_days.to_string(), - })).collect::>(), - }) - }) - .collect(); - let wrapper = json!({"data": data, "next_cursor": result.next_cursor}); - super::print_json(&wrapper)?; - } - } - Ok(()) -} - -pub fn print_order_scoring( - result: &OrderScoringResponse, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => println!("Scoring: {}", result.scoring), - OutputFormat::Json => { - super::print_json(&json!({"scoring": result.scoring}))?; - } - } - Ok(()) -} - -pub fn print_orders_scoring( - result: &OrdersScoringResponse, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - if result.is_empty() { - println!("No scoring data."); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Order ID")] - order_id: String, - #[tabled(rename = "Scoring")] - scoring: String, - } - let rows: Vec = result - .iter() - .map(|(id, scoring)| Row { - order_id: truncate(id, 16), - scoring: scoring.to_string(), - }) - .collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); - } - OutputFormat::Json => { - super::print_json(result)?; - } - } - Ok(()) -} - -pub fn print_api_keys(result: &ApiKeysResponse, output: &OutputFormat) -> anyhow::Result<()> { - // SDK limitation: ApiKeysResponse.keys is private with no public accessor or Serialize impl. - // We use Debug output as the only available representation. - let debug = format!("{result:?}"); - match output { - OutputFormat::Table => { - println!("API Keys: {debug}"); - } - OutputFormat::Json => { - super::print_json(&json!({"api_keys": debug}))?; - } - } - Ok(()) -} - -pub fn print_delete_api_key( - result: &serde_json::Value, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => println!("API key deleted: {result}"), - OutputFormat::Json => { - super::print_json(result)?; - } - } - Ok(()) -} - -pub fn print_create_api_key(result: &Credentials, output: &OutputFormat) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - println!("API Key: {}", result.key()); - println!("Secret: [redacted]"); - println!("Passphrase: [redacted]"); - } - OutputFormat::Json => { - super::print_json(&json!({ - "api_key": result.key().to_string(), - "secret": "[redacted]", - "passphrase": "[redacted]", - }))?; - } - } - Ok(()) -} - -pub fn print_account_status( - result: &BanStatusResponse, - output: &OutputFormat, -) -> anyhow::Result<()> { - match output { - OutputFormat::Table => { - println!( - "Account status: {}", - if result.closed_only { - "Closed-only mode (restricted)" - } else { - "Active" - } - ); - } - OutputFormat::Json => { - super::print_json(&json!({"closed_only": result.closed_only}))?; - } - } - Ok(()) -} diff --git a/src/output/clob/account.rs b/src/output/clob/account.rs new file mode 100644 index 0000000..dd170a9 --- /dev/null +++ b/src/output/clob/account.rs @@ -0,0 +1,564 @@ +use polymarket_client_sdk::auth::Credentials; +use polymarket_client_sdk::clob::types::response::{ + ApiKeysResponse, BalanceAllowanceResponse, BanStatusResponse, CurrentRewardResponse, + GeoblockResponse, MarketRewardResponse, NotificationResponse, Page, RewardsPercentagesResponse, + TotalUserEarningResponse, UserEarningResponse, UserRewardsEarningResponse, +}; +use polymarket_client_sdk::types::Decimal; +use serde_json::json; +use tabled::settings::Style; +use tabled::{Table, Tabled}; + +use super::END_CURSOR; +use crate::output::{OutputFormat, format_decimal, truncate}; + +pub fn print_server_time(timestamp: i64, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + let dt = chrono::DateTime::from_timestamp(timestamp, 0); + match dt { + Some(dt) => { + println!( + "Server time: {} ({timestamp})", + dt.format("%Y-%m-%d %H:%M:%S UTC") + ); + } + None => println!("Server time: {timestamp}"), + } + } + OutputFormat::Json => { + crate::output::print_json(&json!({"timestamp": timestamp}))?; + } + } + Ok(()) +} + +pub fn print_geoblock(result: &GeoblockResponse, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + println!("Blocked: {}", result.blocked); + println!("IP: {}", result.ip); + println!("Country: {}", result.country); + println!("Region: {}", result.region); + } + OutputFormat::Json => { + crate::output::print_json(&json!({ + "blocked": result.blocked, + "ip": result.ip, + "country": result.country, + "region": result.region, + }))?; + } + } + Ok(()) +} + +pub fn print_balance( + result: &BalanceAllowanceResponse, + is_collateral: bool, + output: &OutputFormat, +) -> anyhow::Result<()> { + let divisor = Decimal::from(10u64.pow(crate::commands::USDC_DECIMALS)); + let human_balance = result.balance / divisor; + match output { + OutputFormat::Table => { + if is_collateral { + println!("Balance: {}", format_decimal(human_balance)); + } else { + println!("Balance: {human_balance} shares"); + } + if !result.allowances.is_empty() { + println!("Allowances:"); + for (addr, allowance) in &result.allowances { + println!(" {}: {allowance}", truncate(&addr.to_string(), 14)); + } + } + } + OutputFormat::Json => { + let allowances: serde_json::Map = result + .allowances + .iter() + .map(|(addr, val)| (addr.to_string(), json!(val))) + .collect(); + let data = json!({ + "balance": human_balance.to_string(), + "allowances": allowances, + }); + crate::output::print_json(&data)?; + } + } + Ok(()) +} + +pub fn print_notifications( + result: &[NotificationResponse], + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.is_empty() { + println!("No notifications."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Type")] + notif_type: String, + #[tabled(rename = "Question")] + question: String, + #[tabled(rename = "Side")] + side: String, + #[tabled(rename = "Price")] + price: String, + #[tabled(rename = "Size")] + size: String, + } + let rows: Vec = result + .iter() + .map(|n| Row { + notif_type: n.r#type.to_string(), + question: truncate(&n.payload.question, 40), + side: n.payload.side.to_string(), + price: n.payload.price.to_string(), + size: n.payload.matched_size.to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => { + let data: Vec<_> = result + .iter() + .map(|n| { + json!({ + "type": n.r#type, + "question": n.payload.question, + "side": n.payload.side.to_string(), + "price": n.payload.price.to_string(), + "outcome": n.payload.outcome, + "matched_size": n.payload.matched_size.to_string(), + "original_size": n.payload.original_size.to_string(), + "order_id": n.payload.order_id, + "trade_id": n.payload.trade_id, + "market": n.payload.market.to_string(), + }) + }) + .collect(); + crate::output::print_json(&data)?; + } + } + Ok(()) +} + +pub fn print_rewards( + result: &Page, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.data.is_empty() { + println!("No reward earnings found."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Date")] + date: String, + #[tabled(rename = "Condition ID")] + condition_id: String, + #[tabled(rename = "Earnings")] + earnings: String, + #[tabled(rename = "Rate")] + rate: String, + } + let rows: Vec = result + .data + .iter() + .map(|e| Row { + date: e.date.to_string(), + condition_id: truncate(&e.condition_id.to_string(), 14), + earnings: format_decimal(e.earnings), + rate: e.asset_rate.to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + if result.next_cursor != END_CURSOR { + println!("Next cursor: {}", result.next_cursor); + } + } + OutputFormat::Json => { + let data: Vec<_> = result + .data + .iter() + .map(|e| { + json!({ + "date": e.date.to_string(), + "condition_id": e.condition_id.to_string(), + "asset_address": e.asset_address.to_string(), + "maker_address": e.maker_address.to_string(), + "earnings": e.earnings.to_string(), + "asset_rate": e.asset_rate.to_string(), + }) + }) + .collect(); + let wrapper = json!({"data": data, "next_cursor": result.next_cursor}); + crate::output::print_json(&wrapper)?; + } + } + Ok(()) +} + +pub fn print_earnings( + result: &[TotalUserEarningResponse], + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.is_empty() { + println!("No earnings data found."); + return Ok(()); + } + for (i, e) in result.iter().enumerate() { + if i > 0 { + println!("---"); + } + println!("Date: {}", e.date); + println!("Earnings: {}", format_decimal(e.earnings)); + println!("Asset Rate: {}", e.asset_rate); + println!("Maker: {}", e.maker_address); + } + } + OutputFormat::Json => { + let data: Vec<_> = result + .iter() + .map(|e| { + json!({ + "date": e.date.to_string(), + "asset_address": e.asset_address.to_string(), + "maker_address": e.maker_address.to_string(), + "earnings": e.earnings.to_string(), + "asset_rate": e.asset_rate.to_string(), + }) + }) + .collect(); + crate::output::print_json(&data)?; + } + } + Ok(()) +} + +pub fn print_user_earnings_markets( + result: &[UserRewardsEarningResponse], + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.is_empty() { + println!("No earnings data found."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Question")] + question: String, + #[tabled(rename = "Condition ID")] + condition_id: String, + #[tabled(rename = "Earn %")] + earning_pct: String, + #[tabled(rename = "Max Spread")] + max_spread: String, + #[tabled(rename = "Min Size")] + min_size: String, + } + let rows: Vec = result + .iter() + .map(|e| Row { + question: truncate(&e.question, 40), + condition_id: truncate(&e.condition_id.to_string(), 14), + earning_pct: format!("{}%", e.earning_percentage), + max_spread: e.rewards_max_spread.to_string(), + min_size: e.rewards_min_size.to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => { + let data: Vec<_> = result + .iter() + .map(|e| { + json!({ + "condition_id": e.condition_id.to_string(), + "question": e.question, + "market_slug": e.market_slug, + "event_slug": e.event_slug, + "earning_percentage": e.earning_percentage.to_string(), + "rewards_max_spread": e.rewards_max_spread.to_string(), + "rewards_min_size": e.rewards_min_size.to_string(), + "market_competitiveness": e.market_competitiveness.to_string(), + "maker_address": e.maker_address.to_string(), + "tokens": e.tokens.iter().map(|t| json!({ + "token_id": t.token_id.to_string(), + "outcome": t.outcome, + "price": t.price.to_string(), + "winner": t.winner, + })).collect::>(), + "rewards_config": e.rewards_config.iter().map(|r| json!({ + "asset_address": r.asset_address.to_string(), + "start_date": r.start_date.to_string(), + "end_date": r.end_date.to_string(), + "rate_per_day": r.rate_per_day.to_string(), + "total_rewards": r.total_rewards.to_string(), + })).collect::>(), + "earnings": e.earnings.iter().map(|ear| json!({ + "asset_address": ear.asset_address.to_string(), + "earnings": ear.earnings.to_string(), + "asset_rate": ear.asset_rate.to_string(), + })).collect::>(), + }) + }) + .collect(); + crate::output::print_json(&data)?; + } + } + Ok(()) +} + +pub fn print_reward_percentages( + result: &RewardsPercentagesResponse, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.is_empty() { + println!("No reward percentages found."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Market")] + market: String, + #[tabled(rename = "Percentage")] + percentage: String, + } + let rows: Vec = result + .iter() + .map(|(market, pct)| Row { + market: truncate(market, 20), + percentage: format!("{pct}%"), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => { + let data: serde_json::Map = result + .iter() + .map(|(k, v)| (k.clone(), json!(v.to_string()))) + .collect(); + crate::output::print_json(&data)?; + } + } + Ok(()) +} + +pub fn print_current_rewards( + result: &Page, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.data.is_empty() { + println!("No current rewards found."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Condition ID")] + condition_id: String, + #[tabled(rename = "Max Spread")] + max_spread: String, + #[tabled(rename = "Min Size")] + min_size: String, + #[tabled(rename = "Configs")] + configs: String, + } + let rows: Vec = result + .data + .iter() + .map(|r| Row { + condition_id: truncate(&r.condition_id.to_string(), 14), + max_spread: r.rewards_max_spread.to_string(), + min_size: r.rewards_min_size.to_string(), + configs: r.rewards_config.len().to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + if result.next_cursor != END_CURSOR { + println!("Next cursor: {}", result.next_cursor); + } + } + OutputFormat::Json => { + let data: Vec<_> = result + .data + .iter() + .map(|r| { + json!({ + "condition_id": r.condition_id.to_string(), + "rewards_max_spread": r.rewards_max_spread.to_string(), + "rewards_min_size": r.rewards_min_size.to_string(), + "rewards_config": r.rewards_config.iter().map(|c| json!({ + "asset_address": c.asset_address.to_string(), + "start_date": c.start_date.to_string(), + "end_date": c.end_date.to_string(), + "rate_per_day": c.rate_per_day.to_string(), + "total_rewards": c.total_rewards.to_string(), + })).collect::>(), + }) + }) + .collect(); + let wrapper = json!({"data": data, "next_cursor": result.next_cursor}); + crate::output::print_json(&wrapper)?; + } + } + Ok(()) +} + +pub fn print_market_reward( + result: &Page, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.data.is_empty() { + println!("No market reward data found."); + return Ok(()); + } + for (i, r) in result.data.iter().enumerate() { + if i > 0 { + println!("---"); + } + println!("Question: {}", r.question); + println!("Condition ID: {}", r.condition_id); + println!("Slug: {}", r.market_slug); + println!("Max Spread: {}", r.rewards_max_spread); + println!("Min Size: {}", r.rewards_min_size); + println!("Competitiveness: {}", r.market_competitiveness); + for token in &r.tokens { + println!( + " Token ({}): {} | Price: {}", + token.outcome, token.token_id, token.price + ); + } + } + if result.next_cursor != END_CURSOR { + println!("Next cursor: {}", result.next_cursor); + } + } + OutputFormat::Json => { + let data: Vec<_> = result + .data + .iter() + .map(|r| { + json!({ + "condition_id": r.condition_id.to_string(), + "question": r.question, + "market_slug": r.market_slug, + "event_slug": r.event_slug, + "rewards_max_spread": r.rewards_max_spread.to_string(), + "rewards_min_size": r.rewards_min_size.to_string(), + "market_competitiveness": r.market_competitiveness.to_string(), + "tokens": r.tokens.iter().map(|t| json!({ + "token_id": t.token_id.to_string(), + "outcome": t.outcome, + "price": t.price.to_string(), + "winner": t.winner, + })).collect::>(), + "rewards_config": r.rewards_config.iter().map(|c| json!({ + "id": c.id, + "asset_address": c.asset_address.to_string(), + "start_date": c.start_date.to_string(), + "end_date": c.end_date.to_string(), + "rate_per_day": c.rate_per_day.to_string(), + "total_rewards": c.total_rewards.to_string(), + "total_days": c.total_days.to_string(), + })).collect::>(), + }) + }) + .collect(); + let wrapper = json!({"data": data, "next_cursor": result.next_cursor}); + crate::output::print_json(&wrapper)?; + } + } + Ok(()) +} + +pub fn print_api_keys(result: &ApiKeysResponse, output: &OutputFormat) -> anyhow::Result<()> { + // SDK limitation: ApiKeysResponse.keys is private with no public accessor or Serialize impl. + // We use Debug output as the only available representation. + let debug = format!("{result:?}"); + match output { + OutputFormat::Table => { + println!("API Keys: {debug}"); + } + OutputFormat::Json => { + crate::output::print_json(&json!({"api_keys": debug}))?; + } + } + Ok(()) +} + +pub fn print_delete_api_key( + result: &serde_json::Value, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => println!("API key deleted: {result}"), + OutputFormat::Json => { + crate::output::print_json(result)?; + } + } + Ok(()) +} + +pub fn print_create_api_key(result: &Credentials, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + println!("API Key: {}", result.key()); + println!("Secret: [redacted]"); + println!("Passphrase: [redacted]"); + } + OutputFormat::Json => { + crate::output::print_json(&json!({ + "api_key": result.key().to_string(), + "secret": "[redacted]", + "passphrase": "[redacted]", + }))?; + } + } + Ok(()) +} + +pub fn print_account_status( + result: &BanStatusResponse, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + println!( + "Account status: {}", + if result.closed_only { + "Closed-only mode (restricted)" + } else { + "Active" + } + ); + } + OutputFormat::Json => { + crate::output::print_json(&json!({"closed_only": result.closed_only}))?; + } + } + Ok(()) +} diff --git a/src/output/clob/books.rs b/src/output/clob/books.rs new file mode 100644 index 0000000..71a322b --- /dev/null +++ b/src/output/clob/books.rs @@ -0,0 +1,160 @@ +use polymarket_client_sdk::clob::types::response::{ + LastTradePriceResponse, LastTradesPricesResponse, OrderBookSummaryResponse, +}; +use serde_json::json; +use tabled::settings::Style; +use tabled::{Table, Tabled}; + +use crate::output::{DASH, OutputFormat, truncate}; + +pub fn print_order_book( + result: &OrderBookSummaryResponse, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + println!("Market: {}", result.market); + println!("Asset: {}", result.asset_id); + println!( + "Last Trade: {}", + result + .last_trade_price + .map_or(DASH.into(), |p| p.to_string()) + ); + println!(); + + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Price")] + price: String, + #[tabled(rename = "Size")] + size: String, + } + + if result.bids.is_empty() { + println!("No bids."); + } else { + println!("Bids:"); + let rows: Vec = result + .bids + .iter() + .map(|o| Row { + price: o.price.to_string(), + size: o.size.to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + + println!(); + + if result.asks.is_empty() { + println!("No asks."); + } else { + println!("Asks:"); + let rows: Vec = result + .asks + .iter() + .map(|o| Row { + price: o.price.to_string(), + size: o.size.to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + } + OutputFormat::Json => { + crate::output::print_json(result)?; + } + } + Ok(()) +} + +pub fn print_order_books( + result: &[OrderBookSummaryResponse], + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.is_empty() { + println!("No order books found."); + return Ok(()); + } + for (i, book) in result.iter().enumerate() { + if i > 0 { + println!(); + } + print_order_book(book, output)?; + } + } + OutputFormat::Json => { + crate::output::print_json(result)?; + } + } + Ok(()) +} + +pub fn print_last_trade( + result: &LastTradePriceResponse, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => println!("Last Trade: {} ({})", result.price, result.side), + OutputFormat::Json => { + crate::output::print_json(&json!({ + "price": result.price.to_string(), + "side": result.side.to_string(), + }))?; + } + } + Ok(()) +} + +pub fn print_last_trades_prices( + result: &[LastTradesPricesResponse], + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.is_empty() { + println!("No last trade prices found."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Token ID")] + token_id: String, + #[tabled(rename = "Price")] + price: String, + #[tabled(rename = "Side")] + side: String, + } + let rows: Vec = result + .iter() + .map(|t| Row { + token_id: truncate(&t.token_id.to_string(), 20), + price: t.price.to_string(), + side: t.side.to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => { + let data: Vec<_> = result + .iter() + .map(|t| { + json!({ + "token_id": t.token_id.to_string(), + "price": t.price.to_string(), + "side": t.side.to_string(), + }) + }) + .collect(); + crate::output::print_json(&data)?; + } + } + Ok(()) +} diff --git a/src/output/clob/markets.rs b/src/output/clob/markets.rs new file mode 100644 index 0000000..2251149 --- /dev/null +++ b/src/output/clob/markets.rs @@ -0,0 +1,230 @@ +use polymarket_client_sdk::clob::types::response::{ + FeeRateResponse, MarketResponse, NegRiskResponse, Page, PriceHistoryResponse, + SimplifiedMarketResponse, TickSizeResponse, +}; +use serde_json::json; +use tabled::settings::Style; +use tabled::{Table, Tabled}; + +use super::END_CURSOR; +use crate::output::{DASH, OutputFormat, truncate}; + +pub fn print_clob_market(result: &MarketResponse, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + let mut rows = vec![ + ["Question".into(), result.question.clone()], + ["Description".into(), truncate(&result.description, 80)], + ["Slug".into(), result.market_slug.clone()], + [ + "Condition ID".into(), + result.condition_id.map_or(DASH.into(), |c| c.to_string()), + ], + ["Active".into(), result.active.to_string()], + ["Closed".into(), result.closed.to_string()], + [ + "Accepting Orders".into(), + result.accepting_orders.to_string(), + ], + [ + "Min Order Size".into(), + result.minimum_order_size.to_string(), + ], + ["Min Tick Size".into(), result.minimum_tick_size.to_string()], + ["Neg Risk".into(), result.neg_risk.to_string()], + [ + "End Date".into(), + result.end_date_iso.map_or(DASH.into(), |d| d.to_rfc3339()), + ], + ]; + for token in &result.tokens { + rows.push([ + format!("Token ({})", token.outcome), + format!( + "ID: {} | Price: {} | Winner: {}", + token.token_id, token.price, token.winner + ), + ]); + } + crate::output::print_detail_table(rows); + } + OutputFormat::Json => { + crate::output::print_json(result)?; + } + } + Ok(()) +} + +pub fn print_clob_markets( + result: &Page, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.data.is_empty() { + println!("No markets found."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Question")] + question: String, + #[tabled(rename = "Active")] + active: String, + #[tabled(rename = "Tokens")] + tokens: String, + #[tabled(rename = "Min Tick")] + min_tick: String, + } + let rows: Vec = result + .data + .iter() + .map(|m| Row { + question: truncate(&m.question, 50), + active: if m.active { "Yes" } else { "No" }.into(), + tokens: m.tokens.len().to_string(), + min_tick: m.minimum_tick_size.to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + if result.next_cursor != END_CURSOR { + println!("Next cursor: {}", result.next_cursor); + } + } + OutputFormat::Json => { + crate::output::print_json(result)?; + } + } + Ok(()) +} + +pub fn print_simplified_markets( + result: &Page, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.data.is_empty() { + println!("No markets found."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Condition ID")] + condition_id: String, + #[tabled(rename = "Tokens")] + tokens: String, + #[tabled(rename = "Active")] + active: String, + #[tabled(rename = "Closed")] + closed: String, + #[tabled(rename = "Orders")] + accepting_orders: String, + } + let rows: Vec = result + .data + .iter() + .map(|m| Row { + condition_id: m + .condition_id + .map_or(DASH.into(), |c| truncate(&c.to_string(), 14)), + tokens: m.tokens.len().to_string(), + active: if m.active { "Yes" } else { "No" }.into(), + closed: if m.closed { "Yes" } else { "No" }.into(), + accepting_orders: if m.accepting_orders { "Yes" } else { "No" }.into(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + if result.next_cursor != END_CURSOR { + println!("Next cursor: {}", result.next_cursor); + } + } + OutputFormat::Json => { + crate::output::print_json(result)?; + } + } + Ok(()) +} + +pub fn print_tick_size(result: &TickSizeResponse, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + println!("Tick size: {}", result.minimum_tick_size.as_decimal()); + } + OutputFormat::Json => { + crate::output::print_json(&json!({ + "minimum_tick_size": result.minimum_tick_size.as_decimal().to_string(), + }))?; + } + } + Ok(()) +} + +pub fn print_fee_rate(result: &FeeRateResponse, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + println!("Fee rate: {} bps", result.base_fee); + } + OutputFormat::Json => { + crate::output::print_json(&json!({ + "base_fee_bps": result.base_fee, + }))?; + } + } + Ok(()) +} + +pub fn print_neg_risk(result: &NegRiskResponse, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => println!("Neg risk: {}", result.neg_risk), + OutputFormat::Json => { + crate::output::print_json(&json!({"neg_risk": result.neg_risk}))?; + } + } + Ok(()) +} + +pub fn print_price_history( + result: &PriceHistoryResponse, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.history.is_empty() { + println!("No price history found."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Timestamp")] + timestamp: String, + #[tabled(rename = "Price")] + price: String, + } + let rows: Vec = result + .history + .iter() + .map(|p| Row { + timestamp: chrono::DateTime::from_timestamp(p.t, 0) + .map_or(p.t.to_string(), |dt| { + dt.format("%Y-%m-%d %H:%M").to_string() + }), + price: p.p.to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => { + let data: Vec<_> = result + .history + .iter() + .map(|p| json!({"timestamp": p.t, "price": p.p.to_string()})) + .collect(); + crate::output::print_json(&data)?; + } + } + Ok(()) +} diff --git a/src/output/clob/mod.rs b/src/output/clob/mod.rs new file mode 100644 index 0000000..6651db3 --- /dev/null +++ b/src/output/clob/mod.rs @@ -0,0 +1,41 @@ +mod account; +mod books; +mod markets; +mod orders; +mod prices; + +/// Base64-encoded empty cursor returned by the CLOB API when there are no more pages. +const END_CURSOR: &str = "LTE="; + +pub(crate) use super::OutputFormat; + +pub use account::{ + print_account_status, print_api_keys, print_balance, print_create_api_key, + print_current_rewards, print_delete_api_key, print_earnings, print_geoblock, + print_market_reward, print_notifications, print_reward_percentages, print_rewards, + print_server_time, print_user_earnings_markets, +}; +pub use books::{print_last_trade, print_last_trades_prices, print_order_book, print_order_books}; +pub use markets::{ + print_clob_market, print_clob_markets, print_fee_rate, print_neg_risk, print_price_history, + print_simplified_markets, print_tick_size, +}; +pub use orders::{ + print_cancel_result, print_order_detail, print_order_scoring, print_orders, + print_orders_scoring, print_post_order_result, print_post_orders_result, print_trades, +}; +pub use prices::{ + print_batch_prices, print_midpoint, print_midpoints, print_price, print_spread, print_spreads, +}; + +use serde_json::json; + +pub fn print_ok(result: &str, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => println!("CLOB API: {result}"), + OutputFormat::Json => { + super::print_json(&json!({"status": result}))?; + } + } + Ok(()) +} diff --git a/src/output/clob/orders.rs b/src/output/clob/orders.rs new file mode 100644 index 0000000..2a5711d --- /dev/null +++ b/src/output/clob/orders.rs @@ -0,0 +1,334 @@ +use polymarket_client_sdk::clob::types::response::{ + CancelOrdersResponse, OpenOrderResponse, OrderScoringResponse, OrdersScoringResponse, Page, + PostOrderResponse, TradeResponse, +}; +use serde_json::json; +use tabled::settings::Style; +use tabled::{Table, Tabled}; + +use super::END_CURSOR; +use crate::output::{OutputFormat, truncate}; + +pub fn print_orders(result: &Page, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.data.is_empty() { + println!("No open orders."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "ID")] + id: String, + #[tabled(rename = "Side")] + side: String, + #[tabled(rename = "Price")] + price: String, + #[tabled(rename = "Size")] + original_size: String, + #[tabled(rename = "Matched")] + size_matched: String, + #[tabled(rename = "Status")] + status: String, + #[tabled(rename = "Type")] + order_type: String, + } + let rows: Vec = result + .data + .iter() + .map(|o| Row { + id: truncate(&o.id, 12), + side: o.side.to_string(), + price: o.price.to_string(), + original_size: o.original_size.to_string(), + size_matched: o.size_matched.to_string(), + status: o.status.to_string(), + order_type: o.order_type.to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + if result.next_cursor != END_CURSOR { + println!("Next cursor: {}", result.next_cursor); + } + } + OutputFormat::Json => { + let data: Vec<_> = result + .data + .iter() + .map(|o| { + json!({ + "id": o.id, + "status": o.status.to_string(), + "market": o.market.to_string(), + "asset_id": o.asset_id.to_string(), + "side": o.side.to_string(), + "price": o.price.to_string(), + "original_size": o.original_size.to_string(), + "size_matched": o.size_matched.to_string(), + "outcome": o.outcome, + "order_type": o.order_type.to_string(), + "created_at": o.created_at.to_rfc3339(), + "expiration": o.expiration.to_rfc3339(), + }) + }) + .collect(); + let wrapper = json!({"data": data, "next_cursor": result.next_cursor}); + crate::output::print_json(&wrapper)?; + } + } + Ok(()) +} + +pub fn print_order_detail(result: &OpenOrderResponse, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + let rows = vec![ + ["ID".into(), result.id.clone()], + ["Status".into(), result.status.to_string()], + ["Market".into(), result.market.to_string()], + ["Asset ID".into(), result.asset_id.to_string()], + ["Side".into(), result.side.to_string()], + ["Price".into(), result.price.to_string()], + ["Original Size".into(), result.original_size.to_string()], + ["Size Matched".into(), result.size_matched.to_string()], + ["Outcome".into(), result.outcome.clone()], + ["Order Type".into(), result.order_type.to_string()], + ["Created".into(), result.created_at.to_rfc3339()], + ["Expiration".into(), result.expiration.to_rfc3339()], + ["Trades".into(), result.associate_trades.join(", ")], + ]; + crate::output::print_detail_table(rows); + } + OutputFormat::Json => { + let data = json!({ + "id": result.id, + "status": result.status.to_string(), + "owner": result.owner.to_string(), + "maker_address": result.maker_address.to_string(), + "market": result.market.to_string(), + "asset_id": result.asset_id.to_string(), + "side": result.side.to_string(), + "price": result.price.to_string(), + "original_size": result.original_size.to_string(), + "size_matched": result.size_matched.to_string(), + "outcome": result.outcome, + "order_type": result.order_type.to_string(), + "created_at": result.created_at.to_rfc3339(), + "expiration": result.expiration.to_rfc3339(), + "associate_trades": result.associate_trades, + }); + crate::output::print_json(&data)?; + } + } + Ok(()) +} + +fn post_order_to_json(r: &PostOrderResponse) -> serde_json::Value { + let tx_hashes: Vec<_> = r + .transaction_hashes + .iter() + .map(std::string::ToString::to_string) + .collect(); + json!({ + "order_id": r.order_id, + "status": r.status.to_string(), + "success": r.success, + "error_msg": r.error_msg, + "making_amount": r.making_amount.to_string(), + "taking_amount": r.taking_amount.to_string(), + "transaction_hashes": tx_hashes, + "trade_ids": r.trade_ids, + }) +} + +pub fn print_post_order_result( + result: &PostOrderResponse, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + println!("Order ID: {}", result.order_id); + println!("Status: {}", result.status); + println!("Success: {}", result.success); + if let Some(err) = &result.error_msg + && !err.is_empty() + { + println!("Error: {err}"); + } + println!("Making: {}", result.making_amount); + println!("Taking: {}", result.taking_amount); + } + OutputFormat::Json => { + crate::output::print_json(&post_order_to_json(result))?; + } + } + Ok(()) +} + +pub fn print_post_orders_result( + results: &[PostOrderResponse], + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + for (i, r) in results.iter().enumerate() { + if i > 0 { + println!("---"); + } + print_post_order_result(r, output)?; + } + } + OutputFormat::Json => { + let data: Vec<_> = results.iter().map(post_order_to_json).collect(); + crate::output::print_json(&data)?; + } + } + Ok(()) +} + +pub fn print_cancel_result( + result: &CancelOrdersResponse, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if !result.canceled.is_empty() { + println!("Canceled: {}", result.canceled.join(", ")); + } + if !result.not_canceled.is_empty() { + println!("Not canceled:"); + for (id, reason) in &result.not_canceled { + println!(" {id}: {reason}"); + } + } + if result.canceled.is_empty() && result.not_canceled.is_empty() { + println!("No orders to cancel."); + } + } + OutputFormat::Json => { + let data = json!({ + "canceled": result.canceled, + "not_canceled": result.not_canceled, + }); + crate::output::print_json(&data)?; + } + } + Ok(()) +} + +pub fn print_trades(result: &Page, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.data.is_empty() { + println!("No trades found."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "ID")] + id: String, + #[tabled(rename = "Side")] + side: String, + #[tabled(rename = "Price")] + price: String, + #[tabled(rename = "Size")] + size: String, + #[tabled(rename = "Status")] + status: String, + #[tabled(rename = "Time")] + match_time: String, + } + let rows: Vec = result + .data + .iter() + .map(|t| Row { + id: truncate(&t.id, 12), + side: t.side.to_string(), + price: t.price.to_string(), + size: t.size.to_string(), + status: t.status.to_string(), + match_time: t.match_time.format("%Y-%m-%d %H:%M").to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + if result.next_cursor != END_CURSOR { + println!("Next cursor: {}", result.next_cursor); + } + } + OutputFormat::Json => { + let data: Vec<_> = result + .data + .iter() + .map(|t| { + json!({ + "id": t.id, + "taker_order_id": t.taker_order_id, + "market": t.market.to_string(), + "asset_id": t.asset_id.to_string(), + "side": t.side.to_string(), + "size": t.size.to_string(), + "price": t.price.to_string(), + "fee_rate_bps": t.fee_rate_bps.to_string(), + "status": t.status.to_string(), + "match_time": t.match_time.to_rfc3339(), + "outcome": t.outcome, + "trader_side": format!("{:?}", t.trader_side), + "transaction_hash": t.transaction_hash.to_string(), + }) + }) + .collect(); + let wrapper = json!({"data": data, "next_cursor": result.next_cursor}); + crate::output::print_json(&wrapper)?; + } + } + Ok(()) +} + +pub fn print_order_scoring( + result: &OrderScoringResponse, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => println!("Scoring: {}", result.scoring), + OutputFormat::Json => { + crate::output::print_json(&json!({"scoring": result.scoring}))?; + } + } + Ok(()) +} + +pub fn print_orders_scoring( + result: &OrdersScoringResponse, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.is_empty() { + println!("No scoring data."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Order ID")] + order_id: String, + #[tabled(rename = "Scoring")] + scoring: String, + } + let rows: Vec = result + .iter() + .map(|(id, scoring)| Row { + order_id: truncate(id, 16), + scoring: scoring.to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => { + crate::output::print_json(result)?; + } + } + Ok(()) +} diff --git a/src/output/clob/prices.rs b/src/output/clob/prices.rs new file mode 100644 index 0000000..00f7588 --- /dev/null +++ b/src/output/clob/prices.rs @@ -0,0 +1,169 @@ +use polymarket_client_sdk::clob::types::response::{ + MidpointResponse, MidpointsResponse, PriceResponse, PricesResponse, SpreadResponse, + SpreadsResponse, +}; +use serde_json::json; +use tabled::settings::Style; +use tabled::{Table, Tabled}; + +use crate::output::{OutputFormat, truncate}; + +pub fn print_price(result: &PriceResponse, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => println!("Price: {}", result.price), + OutputFormat::Json => { + crate::output::print_json(&json!({"price": result.price.to_string()}))?; + } + } + Ok(()) +} + +pub fn print_batch_prices(result: &PricesResponse, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + let Some(prices) = &result.prices else { + println!("No prices available."); + return Ok(()); + }; + if prices.is_empty() { + println!("No prices available."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Token ID")] + token_id: String, + #[tabled(rename = "Side")] + side: String, + #[tabled(rename = "Price")] + price: String, + } + let mut rows = Vec::new(); + for (token_id, sides) in prices { + for (side, price) in sides { + rows.push(Row { + token_id: truncate(&token_id.to_string(), 20), + side: side.to_string(), + price: price.to_string(), + }); + } + } + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => { + let data = result.prices.as_ref().map(|prices| { + prices + .iter() + .map(|(token_id, sides)| { + let side_map: serde_json::Map = sides + .iter() + .map(|(side, price)| (side.to_string(), json!(price.to_string()))) + .collect(); + (token_id.to_string(), json!(side_map)) + }) + .collect::>() + }); + crate::output::print_json(&data)?; + } + } + Ok(()) +} + +pub fn print_midpoint(result: &MidpointResponse, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => println!("Midpoint: {}", result.mid), + OutputFormat::Json => { + crate::output::print_json(&json!({"midpoint": result.mid.to_string()}))?; + } + } + Ok(()) +} + +pub fn print_midpoints(result: &MidpointsResponse, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if result.midpoints.is_empty() { + println!("No midpoints available."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Token ID")] + token_id: String, + #[tabled(rename = "Midpoint")] + midpoint: String, + } + let rows: Vec = result + .midpoints + .iter() + .map(|(id, mid)| Row { + token_id: truncate(&id.to_string(), 20), + midpoint: mid.to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => { + let data: serde_json::Map = result + .midpoints + .iter() + .map(|(id, mid)| (id.to_string(), json!(mid.to_string()))) + .collect(); + crate::output::print_json(&data)?; + } + } + Ok(()) +} + +pub fn print_spread(result: &SpreadResponse, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => println!("Spread: {}", result.spread), + OutputFormat::Json => { + crate::output::print_json(&json!({"spread": result.spread.to_string()}))?; + } + } + Ok(()) +} + +pub fn print_spreads(result: &SpreadsResponse, output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + let Some(spreads) = &result.spreads else { + println!("No spreads available."); + return Ok(()); + }; + if spreads.is_empty() { + println!("No spreads available."); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Token ID")] + token_id: String, + #[tabled(rename = "Spread")] + spread: String, + } + let rows: Vec = spreads + .iter() + .map(|(id, spread)| Row { + token_id: truncate(&id.to_string(), 20), + spread: spread.to_string(), + }) + .collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => { + let data = result.spreads.as_ref().map(|spreads| { + spreads + .iter() + .map(|(id, spread)| (id.to_string(), json!(spread.to_string()))) + .collect::>() + }); + crate::output::print_json(&data)?; + } + } + Ok(()) +} diff --git a/src/output/comments.rs b/src/output/comments.rs index a71572c..4204afb 100644 --- a/src/output/comments.rs +++ b/src/output/comments.rs @@ -2,7 +2,9 @@ use polymarket_client_sdk::gamma::types::response::Comment; use tabled::settings::Style; use tabled::{Table, Tabled}; -use super::{detail_field, print_detail_table, truncate}; +use super::{ + DASH, OutputFormat, detail_field, format_date, print_detail_table, print_json, truncate, +}; #[derive(Tabled)] struct CommentRow { @@ -24,34 +26,44 @@ fn comment_author(c: &Comment) -> String { .and_then(|p| p.name.as_deref().or(p.pseudonym.as_deref())) .map(String::from) .or_else(|| c.user_address.map(|a| truncate(&format!("{a}"), 10))) - .unwrap_or_else(|| "—".into()) + .unwrap_or_else(|| DASH.into()) } fn comment_to_row(c: &Comment) -> CommentRow { CommentRow { id: truncate(&c.id, 12), author: comment_author(c), - body: truncate(c.body.as_deref().unwrap_or("—"), 60), + body: truncate(c.body.as_deref().unwrap_or(DASH), 60), reactions: c .reaction_count - .map_or_else(|| "—".into(), |n| n.to_string()), + .map_or_else(|| DASH.into(), |n| n.to_string()), created: c .created_at - .map_or_else(|| "—".into(), |d| d.format("%Y-%m-%d %H:%M").to_string()), + .as_ref() + .map_or_else(|| DASH.into(), format_date), } } -pub fn print_comments_table(comments: &[Comment]) { - if comments.is_empty() { - println!("No comments found."); - return; +pub fn print_comments(comments: &[Comment], output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if comments.is_empty() { + println!("No comments found."); + return Ok(()); + } + let rows: Vec = comments.iter().map(comment_to_row).collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => print_json(comments)?, } - let rows: Vec = comments.iter().map(comment_to_row).collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); + Ok(()) } -pub fn print_comment_detail(c: &Comment) { +pub fn print_comment(c: &Comment, output: &OutputFormat) -> anyhow::Result<()> { + if matches!(output, OutputFormat::Json) { + return print_json(c); + } let mut rows: Vec<[String; 2]> = Vec::new(); detail_field!(rows, "ID", c.id.clone()); @@ -91,7 +103,7 @@ pub fn print_comment_detail(c: &Comment) { rows, "Reactions", c.reaction_count - .map_or_else(|| "—".into(), |n| n.to_string()) + .map_or_else(|| DASH.into(), |n| n.to_string()) ); detail_field!( rows, @@ -101,13 +113,14 @@ pub fn print_comment_detail(c: &Comment) { detail_field!( rows, "Created At", - c.created_at.map(|d| d.to_string()).unwrap_or_default() + c.created_at.as_ref().map(format_date).unwrap_or_default() ); detail_field!( rows, "Updated At", - c.updated_at.map(|d| d.to_string()).unwrap_or_default() + c.updated_at.as_ref().map(format_date).unwrap_or_default() ); print_detail_table(rows); + Ok(()) } diff --git a/src/output/data.rs b/src/output/data.rs index 9f46955..2b4b7c0 100644 --- a/src/output/data.rs +++ b/src/output/data.rs @@ -1,5 +1,3 @@ -#![allow(clippy::items_after_statements)] - use polymarket_client_sdk::data::types::response::{ Activity, BuilderLeaderboardEntry, BuilderVolumeEntry, ClosedPosition, LiveVolume, Market, MetaHolder, OpenInterest, Position, Trade, Traded, TraderLeaderboardEntry, Value, @@ -8,7 +6,7 @@ use serde_json::json; use tabled::settings::Style; use tabled::{Table, Tabled}; -use super::{OutputFormat, format_decimal, truncate}; +use super::{DASH, OutputFormat, format_decimal, truncate}; fn format_market(m: &Market) -> String { match m { @@ -272,7 +270,7 @@ pub fn print_activity(activity: &[Activity], output: &OutputFormat) -> anyhow::R .iter() .map(|a| Row { activity_type: a.activity_type.to_string(), - title: truncate(a.title.as_deref().unwrap_or("—"), 35), + title: truncate(a.title.as_deref().unwrap_or(DASH), 35), size: format!("{:.2}", a.size), usdc_size: format_decimal(a.usdc_size), tx: truncate(&a.transaction_hash.to_string(), 14), @@ -329,7 +327,7 @@ pub fn print_holders(meta_holders: &[MetaHolder], output: &OutputFormat) -> anyh .name .as_deref() .or(h.pseudonym.as_deref()) - .unwrap_or("—") + .unwrap_or(DASH) .into(), amount: format_decimal(h.amount), outcome_index: h.outcome_index.to_string(), @@ -471,7 +469,7 @@ pub fn print_leaderboard( .iter() .map(|e| Row { rank: e.rank.to_string(), - trader: truncate(e.user_name.as_deref().unwrap_or("—"), 20), + trader: truncate(e.user_name.as_deref().unwrap_or(DASH), 20), pnl: format_decimal(e.pnl), volume: format_decimal(e.vol), }) diff --git a/src/output/events.rs b/src/output/events.rs index 9167e9c..5b4def2 100644 --- a/src/output/events.rs +++ b/src/output/events.rs @@ -2,7 +2,10 @@ use polymarket_client_sdk::gamma::types::response::Event; use tabled::settings::Style; use tabled::{Table, Tabled}; -use super::{detail_field, format_decimal, print_detail_table, truncate}; +use super::{ + DASH, OutputFormat, active_status, detail_field, format_date, format_decimal, + print_detail_table, print_json, truncate, +}; #[derive(Tabled)] struct EventRow { @@ -18,44 +21,43 @@ struct EventRow { status: String, } -fn event_status(e: &Event) -> &'static str { - if e.closed == Some(true) { - "Closed" - } else if e.active == Some(true) { - "Active" - } else { - "Inactive" - } -} - fn event_to_row(e: &Event) -> EventRow { - let title = e.title.as_deref().unwrap_or("—"); + let title = e.title.as_deref().unwrap_or(DASH); let market_count = e .markets .as_ref() - .map_or_else(|| "—".into(), |m| m.len().to_string()); + .map_or_else(|| DASH.into(), |m| m.len().to_string()); EventRow { title: truncate(title, 60), market_count, - volume: e.volume.map_or_else(|| "—".into(), format_decimal), - liquidity: e.liquidity.map_or_else(|| "—".into(), format_decimal), - status: event_status(e).into(), + volume: e.volume.map_or_else(|| DASH.into(), format_decimal), + liquidity: e.liquidity.map_or_else(|| DASH.into(), format_decimal), + status: active_status(e.closed, e.active).into(), } } -pub fn print_events_table(events: &[Event]) { - if events.is_empty() { - println!("No events found."); - return; +pub fn print_events(events: &[Event], output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if events.is_empty() { + println!("No events found."); + return Ok(()); + } + let rows: Vec = events.iter().map(event_to_row).collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => print_json(events)?, } - let rows: Vec = events.iter().map(event_to_row).collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); + Ok(()) } #[allow(clippy::too_many_lines)] -pub fn print_event_detail(e: &Event) { +pub fn print_event(e: &Event, output: &OutputFormat) -> anyhow::Result<()> { + if matches!(output, OutputFormat::Json) { + return print_json(e); + } let mut rows: Vec<[String; 2]> = Vec::new(); detail_field!(rows, "ID", e.id.clone()); @@ -114,7 +116,7 @@ pub fn print_event_detail(e: &Event) { "Volume (1mo)", e.volume_1mo.map(format_decimal).unwrap_or_default() ); - detail_field!(rows, "Status", event_status(e).into()); + detail_field!(rows, "Status", active_status(e.closed, e.active).into()); detail_field!( rows, "Neg Risk", @@ -135,17 +137,17 @@ pub fn print_event_detail(e: &Event) { detail_field!( rows, "Start Date", - e.start_date.map(|d| d.to_string()).unwrap_or_default() + e.start_date.as_ref().map(format_date).unwrap_or_default() ); detail_field!( rows, "End Date", - e.end_date.map(|d| d.to_string()).unwrap_or_default() + e.end_date.as_ref().map(format_date).unwrap_or_default() ); detail_field!( rows, "Created At", - e.created_at.map(|d| d.to_string()).unwrap_or_default() + e.created_at.as_ref().map(format_date).unwrap_or_default() ); detail_field!( rows, @@ -167,6 +169,7 @@ pub fn print_event_detail(e: &Event) { ); print_detail_table(rows); + Ok(()) } #[cfg(test)] @@ -181,25 +184,25 @@ mod tests { #[test] fn status_closed_overrides_active() { let e = make_event(json!({"id": "1", "closed": true, "active": true})); - assert_eq!(event_status(&e), "Closed"); + assert_eq!(active_status(e.closed, e.active), "Closed"); } #[test] fn status_active_when_not_closed() { let e = make_event(json!({"id": "1", "closed": false, "active": true})); - assert_eq!(event_status(&e), "Active"); + assert_eq!(active_status(e.closed, e.active), "Active"); } #[test] fn status_inactive_when_fields_missing() { let e = make_event(json!({"id": "1"})); - assert_eq!(event_status(&e), "Inactive"); + assert_eq!(active_status(e.closed, e.active), "Inactive"); } #[test] fn status_inactive_when_both_false() { let e = make_event(json!({"id": "1", "closed": false, "active": false})); - assert_eq!(event_status(&e), "Inactive"); + assert_eq!(active_status(e.closed, e.active), "Inactive"); } #[test] diff --git a/src/output/markets.rs b/src/output/markets.rs index 1698a23..b9a3588 100644 --- a/src/output/markets.rs +++ b/src/output/markets.rs @@ -3,7 +3,10 @@ use polymarket_client_sdk::types::Decimal; use tabled::settings::Style; use tabled::{Table, Tabled}; -use super::{detail_field, format_decimal, print_detail_table, truncate}; +use super::{ + DASH, OutputFormat, active_status, detail_field, format_date, format_decimal, + print_detail_table, print_json, truncate, +}; #[derive(Tabled)] struct MarketRow { @@ -19,44 +22,46 @@ struct MarketRow { status: String, } -fn market_status(m: &Market) -> &'static str { - if m.closed == Some(true) { - "Closed" - } else if m.active == Some(true) { - "Active" - } else { - "Inactive" - } -} - fn market_to_row(m: &Market) -> MarketRow { - let question = m.question.as_deref().unwrap_or("—"); + 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(|| "—".into(), |p| format!("{:.2}¢", p * Decimal::from(100))); + .map_or_else( + || DASH.into(), + |p| format!("{:.2}¢", p * Decimal::from(100)), + ); MarketRow { question: truncate(question, 60), price_yes, - volume: m.volume_num.map_or_else(|| "—".into(), format_decimal), - liquidity: m.liquidity_num.map_or_else(|| "—".into(), format_decimal), - status: market_status(m).into(), + volume: m.volume_num.map_or_else(|| DASH.into(), format_decimal), + liquidity: m.liquidity_num.map_or_else(|| DASH.into(), format_decimal), + status: active_status(m.closed, m.active).into(), } } -pub fn print_markets_table(markets: &[Market]) { - if markets.is_empty() { - println!("No markets found."); - return; +pub fn print_markets(markets: &[Market], output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if markets.is_empty() { + println!("No markets found."); + return Ok(()); + } + let rows: Vec = markets.iter().map(market_to_row).collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => print_json(markets)?, } - let rows: Vec = markets.iter().map(market_to_row).collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); + Ok(()) } -pub fn print_market_detail(m: &Market) { +pub fn print_market(m: &Market, output: &OutputFormat) -> anyhow::Result<()> { + if matches!(output, OutputFormat::Json) { + return print_json(m); + } let mut rows: Vec<[String; 2]> = Vec::new(); detail_field!(rows, "ID", m.id.clone()); @@ -119,7 +124,7 @@ pub fn print_market_detail(m: &Market) { .map(|v| format!("{v:.4}")) .unwrap_or_default() ); - detail_field!(rows, "Status", market_status(m).into()); + detail_field!(rows, "Status", active_status(m.closed, m.active).into()); detail_field!( rows, "Condition ID", @@ -140,12 +145,12 @@ pub fn print_market_detail(m: &Market) { detail_field!( rows, "Start Date", - m.start_date.map(|d| d.to_string()).unwrap_or_default() + m.start_date.as_ref().map(format_date).unwrap_or_default() ); detail_field!( rows, "End Date", - m.end_date.map(|d| d.to_string()).unwrap_or_default() + m.end_date.as_ref().map(format_date).unwrap_or_default() ); detail_field!( rows, @@ -159,6 +164,7 @@ pub fn print_market_detail(m: &Market) { ); print_detail_table(rows); + Ok(()) } #[cfg(test)] @@ -173,25 +179,25 @@ mod tests { #[test] fn status_closed_overrides_active() { let m = make_market(json!({"id": "1", "closed": true, "active": true})); - assert_eq!(market_status(&m), "Closed"); + assert_eq!(active_status(m.closed, m.active), "Closed"); } #[test] fn status_active_when_not_closed() { let m = make_market(json!({"id": "1", "closed": false, "active": true})); - assert_eq!(market_status(&m), "Active"); + assert_eq!(active_status(m.closed, m.active), "Active"); } #[test] fn status_inactive_when_fields_missing() { let m = make_market(json!({"id": "1"})); - assert_eq!(market_status(&m), "Inactive"); + assert_eq!(active_status(m.closed, m.active), "Inactive"); } #[test] fn status_inactive_when_both_false() { let m = make_market(json!({"id": "1", "closed": false, "active": false})); - assert_eq!(market_status(&m), "Inactive"); + assert_eq!(active_status(m.closed, m.active), "Inactive"); } #[test] diff --git a/src/output/mod.rs b/src/output/mod.rs index cc76acd..05de836 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -1,29 +1,32 @@ -pub mod approve; -pub mod bridge; -pub mod clob; -pub mod comments; -pub mod ctf; -pub mod data; -pub mod events; -pub mod markets; -pub mod profiles; -pub mod series; -pub mod sports; -pub mod tags; - +pub(crate) mod approve; +pub(crate) mod bridge; +pub(crate) mod clob; +pub(crate) mod comments; +pub(crate) mod ctf; +pub(crate) mod data; +pub(crate) mod events; +pub(crate) mod markets; +pub(crate) mod profiles; +pub(crate) mod series; +pub(crate) mod sports; +pub(crate) mod tags; + +use chrono::{DateTime, Utc}; use polymarket_client_sdk::types::Decimal; use rust_decimal::prelude::ToPrimitive; use tabled::Table; use tabled::settings::object::Columns; use tabled::settings::{Modify, Style, Width}; +pub(crate) const DASH: &str = "—"; + #[derive(Clone, Copy, Debug, clap::ValueEnum)] -pub enum OutputFormat { +pub(crate) enum OutputFormat { Table, Json, } -pub fn truncate(s: &str, max: usize) -> String { +pub(crate) fn truncate(s: &str, max: usize) -> String { if s.chars().count() <= max { return s.to_string(); } @@ -32,23 +35,50 @@ pub fn truncate(s: &str, max: usize) -> String { truncated } -pub fn format_decimal(n: Decimal) -> String { +pub(crate) fn format_decimal(n: Decimal) -> String { let f = n.to_f64().unwrap_or(0.0); - if f >= 1_000_000.0 { - format!("${:.1}M", f / 1_000_000.0) - } else if f >= 1_000.0 { - format!("${:.1}K", f / 1_000.0) + let abs = f.abs(); + let sign = if f < 0.0 { "-" } else { "" }; + if abs >= 1_000_000.0 { + format!("{sign}${:.1}M", abs / 1_000_000.0) + } else if abs >= 1_000.0 { + format!("{sign}${:.1}K", abs / 1_000.0) + } else { + format!("{sign}${abs:.2}") + } +} + +pub(crate) fn format_date(d: &DateTime) -> String { + d.format("%Y-%m-%d %H:%M UTC").to_string() +} + +pub(crate) fn active_status(closed: Option, active: Option) -> &'static str { + if closed == Some(true) { + "Closed" + } else if active == Some(true) { + "Active" } else { - format!("${f:.2}") + "Inactive" } } -pub fn print_json(data: &impl serde::Serialize) -> anyhow::Result<()> { +pub(crate) fn print_json(data: &(impl serde::Serialize + ?Sized)) -> anyhow::Result<()> { println!("{}", serde_json::to_string_pretty(data)?); Ok(()) } -pub fn print_detail_table(rows: Vec<[String; 2]>) { +pub(crate) fn print_error(error: &anyhow::Error, format: OutputFormat) { + match format { + OutputFormat::Json => { + println!("{}", serde_json::json!({"error": error.to_string()})); + } + OutputFormat::Table => { + eprintln!("Error: {error}"); + } + } +} + +pub(crate) fn print_detail_table(rows: Vec<[String; 2]>) { let table = Table::from_iter(rows) .with(Style::rounded()) .with(Modify::new(Columns::first()).with(Width::wrap(20))) @@ -143,7 +173,12 @@ mod tests { #[test] fn format_decimal_negative() { - assert_eq!(format_decimal(dec!(-500)), "$-500.00"); + assert_eq!(format_decimal(dec!(-500)), "-$500.00"); + } + + #[test] + fn format_decimal_negative_thousands() { + assert_eq!(format_decimal(dec!(-1_500)), "-$1.5K"); } #[test] diff --git a/src/output/profiles.rs b/src/output/profiles.rs index f5d4348..6cf8c70 100644 --- a/src/output/profiles.rs +++ b/src/output/profiles.rs @@ -1,8 +1,11 @@ use polymarket_client_sdk::gamma::types::response::PublicProfile; -use super::{detail_field, print_detail_table}; +use super::{OutputFormat, detail_field, print_detail_table, print_json}; -pub fn print_profile_detail(p: &PublicProfile) { +pub fn print_profile(p: &PublicProfile, output: &OutputFormat) -> anyhow::Result<()> { + if matches!(output, OutputFormat::Json) { + return print_json(p); + } let mut rows: Vec<[String; 2]> = Vec::new(); detail_field!(rows, "Name", p.name.clone().unwrap_or_default()); @@ -38,4 +41,5 @@ pub fn print_profile_detail(p: &PublicProfile) { ); print_detail_table(rows); + Ok(()) } diff --git a/src/output/series.rs b/src/output/series.rs index 8c15095..99f1cfb 100644 --- a/src/output/series.rs +++ b/src/output/series.rs @@ -2,7 +2,10 @@ use polymarket_client_sdk::gamma::types::response::Series; use tabled::settings::Style; use tabled::{Table, Tabled}; -use super::{detail_field, format_decimal, print_detail_table, truncate}; +use super::{ + DASH, OutputFormat, active_status, detail_field, format_date, format_decimal, + print_detail_table, print_json, truncate, +}; #[derive(Tabled)] struct SeriesRow { @@ -18,37 +21,36 @@ struct SeriesRow { status: String, } -fn series_status(s: &Series) -> &'static str { - if s.closed == Some(true) { - "Closed" - } else if s.active == Some(true) { - "Active" - } else { - "Inactive" - } -} - fn series_to_row(s: &Series) -> SeriesRow { SeriesRow { - title: truncate(s.title.as_deref().unwrap_or("—"), 50), - series_type: s.series_type.as_deref().unwrap_or("—").into(), - volume: s.volume.map_or_else(|| "—".into(), format_decimal), - liquidity: s.liquidity.map_or_else(|| "—".into(), format_decimal), - status: series_status(s).into(), + title: truncate(s.title.as_deref().unwrap_or(DASH), 50), + series_type: s.series_type.as_deref().unwrap_or(DASH).into(), + volume: s.volume.map_or_else(|| DASH.into(), format_decimal), + liquidity: s.liquidity.map_or_else(|| DASH.into(), format_decimal), + status: active_status(s.closed, s.active).into(), } } -pub fn print_series_table(series: &[Series]) { - if series.is_empty() { - println!("No series found."); - return; +pub fn print_series(series: &[Series], output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if series.is_empty() { + println!("No series found."); + return Ok(()); + } + let rows: Vec = series.iter().map(series_to_row).collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => print_json(series)?, } - let rows: Vec = series.iter().map(series_to_row).collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); + Ok(()) } -pub fn print_series_detail(s: &Series) { +pub fn print_series_item(s: &Series, output: &OutputFormat) -> anyhow::Result<()> { + if matches!(output, OutputFormat::Json) { + return print_json(s); + } let mut rows: Vec<[String; 2]> = Vec::new(); detail_field!(rows, "ID", s.id.clone()); @@ -76,7 +78,7 @@ pub fn print_series_detail(s: &Series) { "Volume (24hr)", s.volume_24hr.map(format_decimal).unwrap_or_default() ); - detail_field!(rows, "Status", series_status(s).into()); + detail_field!(rows, "Status", active_status(s.closed, s.active).into()); detail_field!( rows, "Events", @@ -93,12 +95,12 @@ pub fn print_series_detail(s: &Series) { detail_field!( rows, "Start Date", - s.start_date.map(|d| d.to_string()).unwrap_or_default() + s.start_date.as_ref().map(format_date).unwrap_or_default() ); detail_field!( rows, "Created At", - s.created_at.map(|d| d.to_string()).unwrap_or_default() + s.created_at.as_ref().map(format_date).unwrap_or_default() ); detail_field!( rows, @@ -115,4 +117,5 @@ pub fn print_series_detail(s: &Series) { ); print_detail_table(rows); + Ok(()) } diff --git a/src/output/sports.rs b/src/output/sports.rs index 41d0305..0eae756 100644 --- a/src/output/sports.rs +++ b/src/output/sports.rs @@ -4,7 +4,7 @@ use polymarket_client_sdk::gamma::types::response::{ use tabled::settings::Style; use tabled::{Table, Tabled}; -use super::truncate; +use super::{DASH, OutputFormat, print_json, truncate}; #[derive(Tabled)] struct SportRow { @@ -27,24 +27,39 @@ fn sport_to_row(s: &SportsMetadata) -> SportRow { } } -pub fn print_sports_table(sports: &[SportsMetadata]) { - if sports.is_empty() { - println!("No sports found."); - return; +pub fn print_sports(sports: &[SportsMetadata], output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if sports.is_empty() { + println!("No sports found."); + return Ok(()); + } + let rows: Vec = sports.iter().map(sport_to_row).collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => print_json(sports)?, } - let rows: Vec = sports.iter().map(sport_to_row).collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); + Ok(()) } -pub fn print_sport_types(types: &SportsMarketTypesResponse) { - if types.market_types.is_empty() { - println!("No market types found."); - return; +pub fn print_sport_types( + types: &SportsMarketTypesResponse, + output: &OutputFormat, +) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if types.market_types.is_empty() { + println!("No market types found."); + return Ok(()); + } + let rows: Vec<[String; 1]> = types.market_types.iter().map(|t| [t.clone()]).collect(); + let table = Table::from_iter(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => print_json(types)?, } - let rows: Vec<[String; 1]> = types.market_types.iter().map(|t| [t.clone()]).collect(); - let table = Table::from_iter(rows).with(Style::rounded()).to_string(); - println!("{table}"); + Ok(()) } #[derive(Tabled)] @@ -64,19 +79,25 @@ struct TeamRow { fn team_to_row(t: &Team) -> TeamRow { TeamRow { id: t.id.to_string(), - name: t.name.as_deref().unwrap_or("—").into(), - league: t.league.as_deref().unwrap_or("—").into(), - record: t.record.as_deref().unwrap_or("—").into(), - abbreviation: t.abbreviation.as_deref().unwrap_or("—").into(), + name: t.name.as_deref().unwrap_or(DASH).into(), + league: t.league.as_deref().unwrap_or(DASH).into(), + record: t.record.as_deref().unwrap_or(DASH).into(), + abbreviation: t.abbreviation.as_deref().unwrap_or(DASH).into(), } } -pub fn print_teams_table(teams: &[Team]) { - if teams.is_empty() { - println!("No teams found."); - return; +pub fn print_teams(teams: &[Team], output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if teams.is_empty() { + println!("No teams found."); + return Ok(()); + } + let rows: Vec = teams.iter().map(team_to_row).collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => print_json(teams)?, } - let rows: Vec = teams.iter().map(team_to_row).collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); + Ok(()) } diff --git a/src/output/tags.rs b/src/output/tags.rs index d5e895f..3c57f55 100644 --- a/src/output/tags.rs +++ b/src/output/tags.rs @@ -2,7 +2,9 @@ use polymarket_client_sdk::gamma::types::response::{RelatedTag, Tag}; use tabled::settings::Style; use tabled::{Table, Tabled}; -use super::{detail_field, print_detail_table, truncate}; +use super::{ + DASH, OutputFormat, detail_field, format_date, print_detail_table, print_json, truncate, +}; #[derive(Tabled)] struct TagRow { @@ -19,20 +21,26 @@ struct TagRow { fn tag_to_row(t: &Tag) -> TagRow { TagRow { id: truncate(&t.id, 20), - label: t.label.as_deref().unwrap_or("—").into(), - slug: t.slug.as_deref().unwrap_or("—").into(), - carousel: t.is_carousel.map_or_else(|| "—".into(), |v| v.to_string()), + label: t.label.as_deref().unwrap_or(DASH).into(), + slug: t.slug.as_deref().unwrap_or(DASH).into(), + carousel: t.is_carousel.map_or_else(|| DASH.into(), |v| v.to_string()), } } -pub fn print_tags_table(tags: &[Tag]) { - if tags.is_empty() { - println!("No tags found."); - return; +pub fn print_tags(tags: &[Tag], output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if tags.is_empty() { + println!("No tags found."); + return Ok(()); + } + let rows: Vec = tags.iter().map(tag_to_row).collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => print_json(tags)?, } - let rows: Vec = tags.iter().map(tag_to_row).collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); + Ok(()) } #[derive(Tabled)] @@ -50,24 +58,33 @@ struct RelatedTagRow { fn related_tag_to_row(r: &RelatedTag) -> RelatedTagRow { RelatedTagRow { id: truncate(&r.id, 20), - tag_id: r.tag_id.as_deref().unwrap_or("—").into(), - related_tag_id: r.related_tag_id.as_deref().unwrap_or("—").into(), - rank: r.rank.map_or_else(|| "—".into(), |v| v.to_string()), + tag_id: r.tag_id.as_deref().unwrap_or(DASH).into(), + related_tag_id: r.related_tag_id.as_deref().unwrap_or(DASH).into(), + rank: r.rank.map_or_else(|| DASH.into(), |v| v.to_string()), } } -pub fn print_related_tags_table(tags: &[RelatedTag]) { - if tags.is_empty() { - println!("No related tags found."); - return; +pub fn print_related_tags(tags: &[RelatedTag], output: &OutputFormat) -> anyhow::Result<()> { + match output { + OutputFormat::Table => { + if tags.is_empty() { + println!("No related tags found."); + return Ok(()); + } + let rows: Vec = tags.iter().map(related_tag_to_row).collect(); + let table = Table::new(rows).with(Style::rounded()).to_string(); + println!("{table}"); + } + OutputFormat::Json => print_json(tags)?, } - let rows: Vec = tags.iter().map(related_tag_to_row).collect(); - let table = Table::new(rows).with(Style::rounded()).to_string(); - println!("{table}"); + Ok(()) } #[allow(clippy::vec_init_then_push)] -pub fn print_tag_detail(t: &Tag) { +pub fn print_tag(t: &Tag, output: &OutputFormat) -> anyhow::Result<()> { + if matches!(output, OutputFormat::Json) { + return print_json(t); + } let mut rows: Vec<[String; 2]> = Vec::new(); detail_field!(rows, "ID", t.id.clone()); @@ -91,13 +108,14 @@ pub fn print_tag_detail(t: &Tag) { detail_field!( rows, "Created At", - t.created_at.map(|d| d.to_string()).unwrap_or_default() + t.created_at.as_ref().map(format_date).unwrap_or_default() ); detail_field!( rows, "Updated At", - t.updated_at.map(|d| d.to_string()).unwrap_or_default() + t.updated_at.as_ref().map(format_date).unwrap_or_default() ); print_detail_table(rows); + Ok(()) } diff --git a/src/shell.rs b/src/shell.rs index b0db00d..0c2df7b 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -1,20 +1,12 @@ -use clap::Parser; +use clap::Parser as _; -use crate::output::OutputFormat; - -pub async fn run_shell() { +pub async fn run_shell() -> anyhow::Result<()> { println!(); println!(" Polymarket CLI · Interactive Shell"); println!(" Type 'help' for commands, 'exit' to quit."); println!(); - let mut rl = match rustyline::DefaultEditor::new() { - Ok(rl) => rl, - Err(e) => { - eprintln!("Failed to initialize shell: {e}"); - return; - } - }; + let mut rl = rustyline::DefaultEditor::new()?; loop { match rl.readline("polymarket> ") { @@ -48,14 +40,7 @@ pub async fn run_shell() { Ok(cli) => { let output = cli.output; if let Err(e) = crate::run(cli).await { - match output { - OutputFormat::Json => { - println!("{}", serde_json::json!({"error": e.to_string()})); - } - OutputFormat::Table => { - eprintln!("Error: {e}"); - } - } + crate::output::print_error(&e, output); } } Err(e) => { @@ -73,6 +58,7 @@ pub async fn run_shell() { } println!("Goodbye!"); + Ok(()) } fn split_args(input: &str) -> Vec {