Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ use {
display_trait::TradeDisplay,
polymarket_api::{
ClobClient, DataClient, GammaClient, MarketUpdateFormatter, PolymarketWebSocket,
RTDSClient, default_cache_dir, lock_mutex,
RTDSClient,
data::{ClosedPosition, Position},
default_cache_dir, lock_mutex,
},
std::{
collections::HashMap,
Expand Down Expand Up @@ -139,6 +141,12 @@ enum Commands {
#[arg(long)]
expires_in: Option<String>,
},
/// Fetch info about a specific user
User {
/// User address, starting with "0x"
#[arg(value_name = "USER")]
user: String,
},
}

fn extract_event_slug(event_input: &str) -> String {
Expand Down Expand Up @@ -484,6 +492,7 @@ async fn main() -> Result<()> {
min_volume,
expires_in,
}) => run_yield(min_prob, limit, min_volume, expires_in).await,
Some(Commands::User { user }) => run_user(user).await,
}
}

Expand Down Expand Up @@ -975,6 +984,74 @@ async fn run_yield(
Ok(())
}

/// Handler for the `user` subcommand, which summarizes info about a user.
async fn run_user(user: String) -> Result<()> {
use polymarket_api::DataClient;
println!("Fetching info for user: {}\n", user);
let data_client = DataClient::new();

// Fetch all positions for the user
let open_positions: Vec<Position> = data_client
.get_positions(&user)
.await
.context("Failed to fetch open user positions")?;
let closed_positions: Vec<ClosedPosition> = data_client
.get_closed_positions(&user)
.await
.context("Failed to fetch closed user positions")?;

// Total Position Value (TPV)
let open_tpv: f64 = open_positions.iter().filter_map(|p| p.current_value).sum();

// Biggest Win (Closed Positions), based on the amount extra received.
let biggest_win = closed_positions.iter().max_by(|a, b| {
a.realized_pnl
.partial_cmp(&b.realized_pnl)
.unwrap_or(std::cmp::Ordering::Equal)
});

// Profit / Loss Calculations
let open_pnl: f64 = open_positions
.iter()
.map(|p| p.cash_pnl.unwrap_or(0.0))
.sum();

let closed_pnl: f64 = closed_positions.iter().map(|p| p.realized_pnl).sum();
let total_pnl = open_pnl + closed_pnl;

// Output
println!("User: {}\n", user);

println!("Total Position Value: ${:.2}", open_tpv);

if let Some(win) = biggest_win {
let amount_paid = win.total_bought - win.realized_pnl;
println!(
"Biggest Win: ${:.2} -> ${:.2} ({:+.2}%) ({})",
amount_paid,
win.realized_pnl,
(win.realized_pnl / amount_paid) * 100.0,
win.title.trim()
);
} else {
println!("Biggest Win: N/A");
}

println!(
"Total Predictions: {} (Open: {}, Closed: {})",
open_positions.len() + closed_positions.len(),
open_positions.len(),
closed_positions.len()
);

println!("Current Profit/Loss:");
println!(" All Positions: ${:.2}", total_pnl);
println!(" Open Positions: ${:.2}", open_pnl);
println!(" Closed Positions: ${:.2}", closed_pnl);
// TODO: Need to integrate P&L from trades as well.
Ok(())
}

fn display_trades(trades: &[polymarket_api::data::DataTrade]) {
use chrono::DateTime;
for _trade in trades {
Expand Down
176 changes: 147 additions & 29 deletions crates/polymarket-api/src/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub struct DataTrade {
pub transaction_hash: String,
}

/// User position with comprehensive fields
/// User position (current, open).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Position {
#[serde(rename = "proxyWallet", default)]
Expand All @@ -51,14 +51,19 @@ pub struct Position {
#[serde(rename = "currentValue", default)]
pub current_value: Option<f64>,
#[serde(rename = "cashPnl", default)]
/// Cash profit and loss (profit minus loss)
pub cash_pnl: Option<f64>,
#[serde(rename = "percentPnl", default)]
/// Percentage profit and loss (profit minus loss as a percentage)
pub percent_pnl: Option<f64>,
#[serde(rename = "totalBought", default)]
/// Total amount bought
pub total_bought: Option<f64>,
#[serde(rename = "realizedPnl", default)]
/// Realized profit and loss (profit minus loss for closed positions)
pub realized_pnl: Option<f64>,
#[serde(rename = "percentRealizedPnl", default)]
/// Percentage of realized profit and loss (profit minus loss for closed positions as a percentage)
pub percent_realized_pnl: Option<f64>,
#[serde(rename = "curPrice", default)]
pub cur_price: Option<f64>,
Expand Down Expand Up @@ -90,11 +95,55 @@ pub struct Position {
pub value: Option<String>,
}

/// Portfolio summary
/// User position (closed).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Portfolio {
pub total_value: Option<String>,
pub positions: Vec<Position>,
pub struct ClosedPosition {
#[serde(rename = "proxyWallet", default)]
pub proxy_wallet: Option<String>,

pub asset: String,

#[serde(rename = "conditionId", alias = "condition_id")]
pub condition_id: String,

#[serde(rename = "avgPrice")]
pub avg_price: f64,

#[serde(rename = "totalBought")]
pub total_bought: f64,

#[serde(rename = "realizedPnl")]
/// Realized profit and loss (profit minus loss for closed positions)
pub realized_pnl: f64,

#[serde(rename = "curPrice")]
pub cur_price: f64,

pub timestamp: i64,

pub title: String,

pub slug: String,

#[serde(default)]
pub icon: Option<String>,

#[serde(rename = "eventSlug", alias = "event_slug")]
pub event_slug: String,

pub outcome: String,

#[serde(rename = "outcomeIndex", alias = "outcome_index")]
pub outcome_index: i32,

#[serde(rename = "oppositeOutcome", default)]
pub opposite_outcome: Option<String>,

#[serde(rename = "oppositeAsset", default)]
pub opposite_asset: Option<String>,

#[serde(rename = "endDate", default)]
pub end_date: Option<String>,
}

/// Activity type enum
Expand Down Expand Up @@ -328,34 +377,103 @@ impl DataClient {
Ok(trades)
}

/// Get user positions (requires authentication)
/// Get current (open) user positions.
///
/// Does not require authentication.
///
/// # Arguments
/// * `user_address` - The user's wallet address (0x-prefixed, 40 hex chars)
///
/// # Returns
/// A vector of `Position` structs representing all of the user's current (open) positions, performing
/// pagination in the request as required to fetch all results.
///
/// # Notes
/// See also: `get_positions_filtered` for fine-grained control over pagination and filtering.
pub async fn get_positions(&self, user_address: &str) -> Result<Vec<Position>> {
let url = format!("{}/positions", DATA_API_BASE);
let params = [("user", user_address)];
let positions: Vec<Position> = self
.client
.get(&url)
.query(&params)
.send()
.await?
.json()
.await?;
Ok(positions)
let limit = 500; // Valid range: 0-500.
let mut all_positions: Vec<Position> = Vec::new();

// Cartesian product of mergeable and redeemable (true/false).
let combos = [(true, true), (true, false), (false, true), (false, false)];
for (mergeable, redeemable) in combos {
let mut offset = 0;
loop {
let params = [
("user", user_address),
("limit", &limit.to_string()),
("offset", &offset.to_string()),
("mergeable", &mergeable.to_string()),
("redeemable", &redeemable.to_string()),
];

let positions: Vec<Position> = self
.client
.get(&url)
.query(&params)
.send()
.await?
.json()
.await?;

let count = positions.len();
all_positions.extend(positions);

if count < (limit as usize) {
break;
}

offset += limit;
}
}

Ok(all_positions)
}

/// Get portfolio for a user (requires authentication)
pub async fn get_portfolio(&self, user_address: &str) -> Result<Portfolio> {
let url = format!("{}/portfolio", DATA_API_BASE);
let params = [("user", user_address)];
let portfolio: Portfolio = self
.client
.get(&url)
.query(&params)
.send()
.await?
.json()
.await?;
Ok(portfolio)
/// Get closed user positions.
///
/// Does not require authentication.
///
/// # Arguments
/// * `user_address` - The user's wallet address (0x-prefixed, 40 hex chars)
///
/// # Returns
/// A vector of `ClosedPosition` structs representing all of the user's closed positions, performing
/// pagination in the request as required to fetch all results.
pub async fn get_closed_positions(&self, user_address: &str) -> Result<Vec<ClosedPosition>> {
let url = format!("{}/closed-positions", DATA_API_BASE);
let limit = 50; // Valid range: 0-50.
let mut all_positions: Vec<ClosedPosition> = Vec::new();

let mut offset = 0;
loop {
let params = [
("user", user_address),
("limit", &limit.to_string()),
("offset", &offset.to_string()),
];

let positions: Vec<ClosedPosition> = self
.client
.get(&url)
.query(&params)
.send()
.await?
.json()
.await?;

let count = positions.len();
all_positions.extend(positions);

if count < (limit as usize) {
break;
}

offset += limit;
}

Ok(all_positions)
}

/// Get user activity (trades, splits, merges, redeems, rewards, conversions)
Expand Down
16 changes: 2 additions & 14 deletions crates/polymarket-api/tests/data_test.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use polymarket_api::data::{
Activity, ActivitySortBy, ActivityType, DataClient, DataTrade, Portfolio, Position,
SortDirection, TradeSide,
Activity, ActivitySortBy, ActivityType, DataClient, DataTrade, Position, SortDirection,
TradeSide,
};

// ============================================================================
Expand Down Expand Up @@ -154,18 +154,6 @@ fn test_data_trade_deserialization() {
assert_eq!(trade.price, 0.55);
}

#[test]
fn test_portfolio_deserialization() {
let json = r#"{
"total_value": "1000.50",
"positions": []
}"#;

let portfolio: Portfolio = serde_json::from_str(json).expect("Should deserialize");
assert_eq!(portfolio.total_value, Some("1000.50".to_string()));
assert!(portfolio.positions.is_empty());
}

// ============================================================================
// Integration Tests (require network)
// ============================================================================
Expand Down