hypixel-sdk 0.2.1

Async client for the Hypixel Public API with typed models and SkyBlock helpers
Documentation
use std::collections::HashMap;

use serde::Serialize;

use crate::models::skyblock::{Bazaar, BazaarProduct, SkyBlockAuction};

/// A concise view of a single bazaar product's order book.
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct BazaarSpread {
    /// Highest price a buy order is currently offering (instant-sell price).
    pub buy_order_price: f64,
    /// Lowest price a sell order is currently asking (instant-buy price).
    pub sell_order_price: f64,
}

impl BazaarSpread {
    /// Absolute difference between the instant-buy and instant-sell prices.
    pub fn margin(&self) -> f64 {
        (self.sell_order_price - self.buy_order_price).abs()
    }
}

/// Compute the top-of-book spread for a bazaar product, if both sides have orders.
///
/// On the bazaar, `buy_summary` holds outstanding buy orders and `sell_summary`
/// holds outstanding sell offers; the best of each defines the spread.
pub fn bazaar_spread(product: &BazaarProduct) -> Option<BazaarSpread> {
    let sell_order_price = product.sell_summary.first()?.price_per_unit;
    let buy_order_price = product.buy_summary.first()?.price_per_unit;
    Some(BazaarSpread {
        buy_order_price,
        sell_order_price,
    })
}

/// Compute spreads for every product in a bazaar snapshot.
pub fn bazaar_spreads(bazaar: &Bazaar) -> HashMap<String, BazaarSpread> {
    bazaar
        .products
        .iter()
        .filter_map(|(id, product)| bazaar_spread(product).map(|s| (id.clone(), s)))
        .collect()
}

/// Aggregate the lowest Buy-It-Now price per item name across a set of auctions.
///
/// Only active BIN auctions are considered. The returned map is keyed by the
/// auction `item_name`.
pub fn lowest_bin(auctions: &[SkyBlockAuction]) -> HashMap<String, i64> {
    let mut lowest: HashMap<String, i64> = HashMap::new();
    for auction in auctions {
        if !auction.bin {
            continue;
        }
        lowest
            .entry(auction.item_name.clone())
            .and_modify(|price| {
                if auction.starting_bid < *price {
                    *price = auction.starting_bid;
                }
            })
            .or_insert(auction.starting_bid);
    }
    lowest
}

/// Aggregate the lowest Buy-It-Now price per SkyBlock item id.
///
/// Like [`lowest_bin`], but decodes each auction's `item_bytes` NBT to key by
/// the internal item id (e.g. `HYPERION`) instead of the display name, which
/// is what a [`PriceBook`](crate::util::networth::PriceBook) expects. Auctions
/// whose blobs fail to decode are skipped.
#[cfg(feature = "nbt")]
pub fn lowest_bin_by_id(auctions: &[SkyBlockAuction]) -> HashMap<String, i64> {
    use crate::util::{nbt, networth};

    let mut lowest: HashMap<String, i64> = HashMap::new();
    for auction in auctions {
        if !auction.bin {
            continue;
        }
        let Ok(decoded) = nbt::decode_inventory(&auction.item_bytes) else {
            continue;
        };
        let Some(stack) = networth::extract_item_stacks(&decoded).into_iter().next() else {
            continue;
        };
        lowest
            .entry(stack.item_id)
            .and_modify(|price| *price = (*price).min(auction.starting_bid))
            .or_insert(auction.starting_bid);
    }
    lowest
}

/// The bazaar's sales tax, applied to sell offers and instant-sells.
pub const BAZAAR_TAX: f64 = 0.0125;

/// A candidate bazaar order flip: buy via buy order, resell via sell offer.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct BazaarFlip {
    pub product_id: String,
    /// Acquisition price via the top buy order.
    pub buy_order_price: f64,
    /// Disposal price via the top sell offer, before tax.
    pub sell_order_price: f64,
    /// Per-item profit after [`BAZAAR_TAX`] on the sale.
    pub profit_per_item: f64,
    /// Profit as a fraction of the acquisition price.
    pub margin: f64,
    /// Items instant-bought from sell offers over the past week (demand).
    pub buy_moving_week: i64,
    /// Items instant-sold to buy orders over the past week (supply).
    pub sell_moving_week: i64,
}

/// Find profitable bazaar order flips, sorted by per-item profit descending.
///
/// A flip buys through a buy order at the top of the book and resells through
/// a sell offer, paying [`BAZAAR_TAX`] on the sale. Products moving fewer than
/// `min_weekly_volume` items per week on either side are skipped, which
/// filters out illiquid order books with misleading spreads.
pub fn bazaar_flips(bazaar: &Bazaar, min_weekly_volume: i64) -> Vec<BazaarFlip> {
    let mut flips: Vec<BazaarFlip> = bazaar
        .products
        .iter()
        .filter_map(|(id, product)| {
            let spread = bazaar_spread(product)?;
            let status = product.quick_status.as_ref()?;
            if status.buy_moving_week < min_weekly_volume
                || status.sell_moving_week < min_weekly_volume
            {
                return None;
            }
            let profit = spread.sell_order_price * (1.0 - BAZAAR_TAX) - spread.buy_order_price;
            if profit <= 0.0 || spread.buy_order_price <= 0.0 {
                return None;
            }
            Some(BazaarFlip {
                product_id: id.clone(),
                buy_order_price: spread.buy_order_price,
                sell_order_price: spread.sell_order_price,
                profit_per_item: profit,
                margin: profit / spread.buy_order_price,
                buy_moving_week: status.buy_moving_week,
                sell_moving_week: status.sell_moving_week,
            })
        })
        .collect();
    flips.sort_by(|a, b| {
        b.profit_per_item
            .partial_cmp(&a.profit_per_item)
            .unwrap_or(std::cmp::Ordering::Equal)
    });
    flips
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::skyblock::{BazaarOrder, BazaarProduct};

    fn product(sell: f64, buy: f64) -> BazaarProduct {
        BazaarProduct {
            product_id: "X".into(),
            sell_summary: vec![BazaarOrder {
                amount: 1,
                price_per_unit: sell,
                orders: 1,
            }],
            buy_summary: vec![BazaarOrder {
                amount: 1,
                price_per_unit: buy,
                orders: 1,
            }],
            quick_status: None,
        }
    }

    #[test]
    fn computes_spread_margin() {
        let spread = bazaar_spread(&product(5.0, 4.2)).unwrap();
        assert_eq!(spread.sell_order_price, 5.0);
        assert_eq!(spread.buy_order_price, 4.2);
        assert!((spread.margin() - 0.8).abs() < 1e-9);
    }

    #[test]
    fn flips_filter_volume_and_apply_tax() {
        use crate::models::skyblock::BazaarQuickStatus;

        let mut liquid = product(100.0, 50.0);
        liquid.quick_status = Some(BazaarQuickStatus {
            buy_moving_week: 100_000,
            sell_moving_week: 100_000,
            ..serde_json::from_str("{}").unwrap()
        });
        let mut illiquid = product(1_000.0, 1.0);
        illiquid.quick_status = Some(BazaarQuickStatus {
            buy_moving_week: 10,
            sell_moving_week: 100_000,
            ..serde_json::from_str("{}").unwrap()
        });

        let bazaar = Bazaar {
            last_updated: 0,
            products: [("LIQUID".into(), liquid), ("ILLIQUID".into(), illiquid)]
                .into_iter()
                .collect(),
        };
        let flips = bazaar_flips(&bazaar, 1_000);
        assert_eq!(flips.len(), 1);
        assert_eq!(flips[0].product_id, "LIQUID");
        assert!((flips[0].profit_per_item - (100.0 * (1.0 - BAZAAR_TAX) - 50.0)).abs() < 1e-9);
    }
}