hypixel-sdk 0.2.2

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, from the taker's side.
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct BazaarSpread {
    /// Lowest asking price, i.e. what you pay to instant-buy one item.
    pub instant_buy_price: f64,
    /// Highest bid, i.e. what you receive to instant-sell one item (before tax).
    pub instant_sell_price: f64,
}

impl BazaarSpread {
    /// The bid/ask spread: how much more an instant-buy costs than an
    /// instant-sell returns. Non-negative on a well-formed order book.
    pub fn margin(&self) -> f64 {
        self.instant_buy_price - self.instant_sell_price
    }
}

/// Compute the top-of-book spread for a bazaar product, if both sides have orders.
///
/// The bazaar's summaries are named from the *maker's* perspective, which is the
/// reverse of what a taker pays: `buy_summary` lists the sell offers you can buy
/// from (ascending, so `[0]` is the lowest ask) and `sell_summary` lists the buy
/// orders you can sell into (descending, so `[0]` is the highest bid). Index 0 is
/// the best price on both sides, and the ask always sits above the bid.
pub fn bazaar_spread(product: &BazaarProduct) -> Option<BazaarSpread> {
    let instant_sell_price = product.sell_summary.first()?.price_per_unit;
    let instant_buy_price = product.buy_summary.first()?.price_per_unit;
    Some(BazaarSpread {
        instant_buy_price,
        instant_sell_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 cost per item: the top of the bid side, where your buy order
    /// has to sit to get filled.
    pub order_price: f64,
    /// Gross proceeds per item, before tax: the top of the ask side, where your
    /// sell offer has to sit to get filled.
    pub offer_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 acquires items with a buy order at the top of the bid side and
/// resells them with a sell offer at the top of the ask side, paying
/// [`BAZAAR_TAX`] on the sale — so the gross edge is the bid/ask spread.
/// 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.instant_buy_price * (1.0 - BAZAAR_TAX) - spread.instant_sell_price;
            if profit <= 0.0 || spread.instant_sell_price <= 0.0 {
                return None;
            }
            Some(BazaarFlip {
                product_id: id.clone(),
                order_price: spread.instant_sell_price,
                offer_price: spread.instant_buy_price,
                profit_per_item: profit,
                margin: profit / spread.instant_sell_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};

    /// Build a one-level book. `bid` is the top of `sell_summary`, `ask` the top
    /// of `buy_summary`; on a real product the ask sits above the bid.
    fn product(bid: f64, ask: f64) -> BazaarProduct {
        BazaarProduct {
            product_id: "X".into(),
            sell_summary: vec![BazaarOrder {
                amount: 1,
                price_per_unit: bid,
                orders: 1,
            }],
            buy_summary: vec![BazaarOrder {
                amount: 1,
                price_per_unit: ask,
                orders: 1,
            }],
            quick_status: None,
        }
    }

    #[test]
    fn computes_spread_margin() {
        let spread = bazaar_spread(&product(4.2, 5.0)).unwrap();
        assert_eq!(spread.instant_sell_price, 4.2);
        assert_eq!(spread.instant_buy_price, 5.0);
        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(50.0, 100.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.0, 1_000.0);
        illiquid.quick_status = Some(BazaarQuickStatus {
            buy_moving_week: 10,
            sell_moving_week: 100_000,
            ..serde_json::from_str("{}").unwrap()
        });
        // Spread too thin to cover the tax on the sale.
        let mut tight = product(99.0, 100.0);
        tight.quick_status = Some(BazaarQuickStatus {
            buy_moving_week: 100_000,
            sell_moving_week: 100_000,
            ..serde_json::from_str("{}").unwrap()
        });

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