polynode 0.13.11

Rust SDK for the PolyNode API — real-time Polymarket data
Documentation
//! Realized P&L computation using weighted average cost basis.

use super::storage::StorageBackend;
use super::types::{PositionSummary, RealizedPnlResult, TokenPnl, TradeRow};

/// Round to N decimal places.
fn round(value: f64, decimals: u32) -> f64 {
    let factor = 10f64.powi(decimals as i32);
    (value * factor).round() / factor
}

/// Compute realized P&L for a wallet across all tokens in the cache.
///
/// Algorithm: weighted average cost basis per token.
/// - BUY: accumulate cost_basis and position_size
/// - SELL: realize P&L as (sell_price * sell_size - fee) - (avg_cost * sell_size)
/// - Unrealized: (cur_price - avg_cost) * remaining_size
pub fn compute_realized_pnl(backend: &dyn StorageBackend, wallet: &str) -> RealizedPnlResult {
    let w = wallet.to_lowercase();
    let token_ids = backend.wallet_token_ids(&w);
    let positions = backend.wallet_positions(&w).unwrap_or_default();
    let backfill_complete = backend.wallet_backfill_complete(&w);

    // Build position lookup by token_id
    let pos_map: std::collections::HashMap<&str, &PositionSummary> =
        positions.iter().map(|p| (p.token_id.as_str(), p)).collect();

    let mut tokens: Vec<TokenPnl> = Vec::new();
    let mut total_trades: usize = 0;
    let mut processed_tokens = std::collections::HashSet::new();

    for token_id in &token_ids {
        let trades = backend.wallet_token_trades(&w, token_id);
        if trades.is_empty() {
            continue;
        }
        processed_tokens.insert(token_id.clone());

        let mut cost_basis: f64 = 0.0;
        let mut position_size: f64 = 0.0;
        let mut realized: f64 = 0.0;
        let mut buys: usize = 0;
        let mut sells: usize = 0;

        let condition_id = trades[0].condition_id.clone();
        let market_title = trades[0].market_title.clone();
        let outcome = trades[0].outcome.clone();

        for trade in &trades {
            let effective_side = effective_side_for_wallet(&w, trade);
            let fee = trade.fee.unwrap_or(0.0);

            match effective_side.as_str() {
                "BUY" => {
                    cost_basis += trade.price * trade.size + fee;
                    position_size += trade.size;
                    buys += 1;
                }
                "SELL" => {
                    let sell_size = trade.size.min(position_size);
                    if sell_size > 0.0 && position_size > 0.0 {
                        let avg_cost = cost_basis / position_size;
                        realized += (trade.price * sell_size - fee) - (avg_cost * sell_size);
                        cost_basis -= avg_cost * sell_size;
                        position_size -= sell_size;
                    }
                    sells += 1;
                }
                _ => {}
            }
        }

        // Prefer onchain realized_pnl (from PNL subgraph) when available — it's the source of truth.
        // Falls back to trade-based computation only when onchain data isn't present.
        let pos = pos_map.get(token_id.as_str());
        if let Some(p) = pos {
            if let Some(rpnl) = p.realized_pnl {
                if rpnl != 0.0 {
                    realized = rpnl;
                }
            }
        }

        if position_size < 1e-9 {
            position_size = 0.0;
            cost_basis = 0.0;
        }

        let avg_cost = if position_size > 0.0 {
            cost_basis / position_size
        } else {
            0.0
        };
        let cur_price = pos.and_then(|p| p.cur_price);

        let still_held = pos.map_or(false, |p| p.size > 0.0 && !p.redeemable);
        let unrealized = if still_held {
            cur_price.map_or(0.0, |cp| (cp - avg_cost) * position_size)
        } else {
            0.0
        };

        let trade_count = trades.len();
        total_trades += trade_count;

        tokens.push(TokenPnl {
            token_id: token_id.clone(),
            condition_id,
            market_title,
            outcome,
            realized_pnl: round(realized, 6),
            unrealized_pnl: round(unrealized, 6),
            remaining_size: round(position_size, 6),
            avg_cost: round(avg_cost, 6),
            cur_price,
            trades_analyzed: trade_count,
            buys,
            sells,
        });
    }

    // Include positions with realized_pnl but no trades in cache
    for pos in &positions {
        if processed_tokens.contains(&pos.token_id) {
            continue;
        }
        let rpnl = pos.realized_pnl.unwrap_or(0.0);
        if rpnl == 0.0 {
            continue;
        }

        tokens.push(TokenPnl {
            token_id: pos.token_id.clone(),
            condition_id: pos.condition_id.clone(),
            market_title: pos.market_title.clone(),
            outcome: pos.outcome.clone(),
            realized_pnl: rpnl,
            unrealized_pnl: 0.0,
            remaining_size: pos.size,
            avg_cost: pos.avg_price,
            cur_price: pos.cur_price,
            trades_analyzed: 0,
            buys: 0,
            sells: 0,
        });
    }

    let total_realized: f64 = tokens.iter().map(|t| t.realized_pnl).sum();
    let total_unrealized: f64 = tokens.iter().map(|t| t.unrealized_pnl).sum();

    // Confidence is 'full' if we have onchain data (any position has realized_pnl from subgraph)
    // or if backfill is complete. Onchain data from the PNL subgraph is the source of truth.
    let has_onchain = positions
        .iter()
        .any(|p| p.realized_pnl.unwrap_or(0.0) != 0.0 && p.total_bought.is_some());
    let confidence = if has_onchain || backfill_complete {
        "full"
    } else {
        "partial"
    };

    RealizedPnlResult {
        wallet: w,
        total_realized_pnl: round(total_realized, 2),
        total_unrealized_pnl: round(total_unrealized, 2),
        total_pnl: round(total_realized + total_unrealized, 2),
        tokens,
        trades_analyzed: total_trades,
        confidence: confidence.into(),
    }
}

/// Determine the effective side for a wallet in a given trade.
/// - If wallet is the taker, effective side = trade.side
/// - If wallet is the maker, effective side = opposite of trade.side
fn effective_side_for_wallet(wallet: &str, trade: &TradeRow) -> String {
    let taker = trade.taker.to_lowercase();
    let maker = trade.maker.to_lowercase();

    if taker == wallet {
        trade.side.to_uppercase()
    } else if maker == wallet {
        match trade.side.to_uppercase().as_str() {
            "BUY" => "SELL".into(),
            "SELL" => "BUY".into(),
            other => other.into(),
        }
    } else {
        // Shouldn't happen, but default to trade side
        trade.side.to_uppercase()
    }
}