Skip to main content

hypixel/util/
market.rs

1use std::collections::HashMap;
2
3use serde::Serialize;
4
5use crate::models::skyblock::{Bazaar, BazaarProduct, SkyBlockAuction};
6
7/// A concise view of a single bazaar product's order book.
8#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
9pub struct BazaarSpread {
10    /// Highest price a buy order is currently offering (instant-sell price).
11    pub buy_order_price: f64,
12    /// Lowest price a sell order is currently asking (instant-buy price).
13    pub sell_order_price: f64,
14}
15
16impl BazaarSpread {
17    /// Absolute difference between the instant-buy and instant-sell prices.
18    pub fn margin(&self) -> f64 {
19        (self.sell_order_price - self.buy_order_price).abs()
20    }
21}
22
23/// Compute the top-of-book spread for a bazaar product, if both sides have orders.
24///
25/// On the bazaar, `buy_summary` holds outstanding buy orders and `sell_summary`
26/// holds outstanding sell offers; the best of each defines the spread.
27pub fn bazaar_spread(product: &BazaarProduct) -> Option<BazaarSpread> {
28    let sell_order_price = product.sell_summary.first()?.price_per_unit;
29    let buy_order_price = product.buy_summary.first()?.price_per_unit;
30    Some(BazaarSpread {
31        buy_order_price,
32        sell_order_price,
33    })
34}
35
36/// Compute spreads for every product in a bazaar snapshot.
37pub fn bazaar_spreads(bazaar: &Bazaar) -> HashMap<String, BazaarSpread> {
38    bazaar
39        .products
40        .iter()
41        .filter_map(|(id, product)| bazaar_spread(product).map(|s| (id.clone(), s)))
42        .collect()
43}
44
45/// Aggregate the lowest Buy-It-Now price per item name across a set of auctions.
46///
47/// Only active BIN auctions are considered. The returned map is keyed by the
48/// auction `item_name`.
49pub fn lowest_bin(auctions: &[SkyBlockAuction]) -> HashMap<String, i64> {
50    let mut lowest: HashMap<String, i64> = HashMap::new();
51    for auction in auctions {
52        if !auction.bin {
53            continue;
54        }
55        lowest
56            .entry(auction.item_name.clone())
57            .and_modify(|price| {
58                if auction.starting_bid < *price {
59                    *price = auction.starting_bid;
60                }
61            })
62            .or_insert(auction.starting_bid);
63    }
64    lowest
65}
66
67/// Aggregate the lowest Buy-It-Now price per SkyBlock item id.
68///
69/// Like [`lowest_bin`], but decodes each auction's `item_bytes` NBT to key by
70/// the internal item id (e.g. `HYPERION`) instead of the display name, which
71/// is what a [`PriceBook`](crate::util::networth::PriceBook) expects. Auctions
72/// whose blobs fail to decode are skipped.
73#[cfg(feature = "nbt")]
74pub fn lowest_bin_by_id(auctions: &[SkyBlockAuction]) -> HashMap<String, i64> {
75    use crate::util::{nbt, networth};
76
77    let mut lowest: HashMap<String, i64> = HashMap::new();
78    for auction in auctions {
79        if !auction.bin {
80            continue;
81        }
82        let Ok(decoded) = nbt::decode_inventory(&auction.item_bytes) else {
83            continue;
84        };
85        let Some(stack) = networth::extract_item_stacks(&decoded).into_iter().next() else {
86            continue;
87        };
88        lowest
89            .entry(stack.item_id)
90            .and_modify(|price| *price = (*price).min(auction.starting_bid))
91            .or_insert(auction.starting_bid);
92    }
93    lowest
94}
95
96/// The bazaar's sales tax, applied to sell offers and instant-sells.
97pub const BAZAAR_TAX: f64 = 0.0125;
98
99/// A candidate bazaar order flip: buy via buy order, resell via sell offer.
100#[derive(Debug, Clone, PartialEq, Serialize)]
101pub struct BazaarFlip {
102    pub product_id: String,
103    /// Acquisition price via the top buy order.
104    pub buy_order_price: f64,
105    /// Disposal price via the top sell offer, before tax.
106    pub sell_order_price: f64,
107    /// Per-item profit after [`BAZAAR_TAX`] on the sale.
108    pub profit_per_item: f64,
109    /// Profit as a fraction of the acquisition price.
110    pub margin: f64,
111    /// Items instant-bought from sell offers over the past week (demand).
112    pub buy_moving_week: i64,
113    /// Items instant-sold to buy orders over the past week (supply).
114    pub sell_moving_week: i64,
115}
116
117/// Find profitable bazaar order flips, sorted by per-item profit descending.
118///
119/// A flip buys through a buy order at the top of the book and resells through
120/// a sell offer, paying [`BAZAAR_TAX`] on the sale. Products moving fewer than
121/// `min_weekly_volume` items per week on either side are skipped, which
122/// filters out illiquid order books with misleading spreads.
123pub fn bazaar_flips(bazaar: &Bazaar, min_weekly_volume: i64) -> Vec<BazaarFlip> {
124    let mut flips: Vec<BazaarFlip> = bazaar
125        .products
126        .iter()
127        .filter_map(|(id, product)| {
128            let spread = bazaar_spread(product)?;
129            let status = product.quick_status.as_ref()?;
130            if status.buy_moving_week < min_weekly_volume
131                || status.sell_moving_week < min_weekly_volume
132            {
133                return None;
134            }
135            let profit = spread.sell_order_price * (1.0 - BAZAAR_TAX) - spread.buy_order_price;
136            if profit <= 0.0 || spread.buy_order_price <= 0.0 {
137                return None;
138            }
139            Some(BazaarFlip {
140                product_id: id.clone(),
141                buy_order_price: spread.buy_order_price,
142                sell_order_price: spread.sell_order_price,
143                profit_per_item: profit,
144                margin: profit / spread.buy_order_price,
145                buy_moving_week: status.buy_moving_week,
146                sell_moving_week: status.sell_moving_week,
147            })
148        })
149        .collect();
150    flips.sort_by(|a, b| {
151        b.profit_per_item
152            .partial_cmp(&a.profit_per_item)
153            .unwrap_or(std::cmp::Ordering::Equal)
154    });
155    flips
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::models::skyblock::{BazaarOrder, BazaarProduct};
162
163    fn product(sell: f64, buy: f64) -> BazaarProduct {
164        BazaarProduct {
165            product_id: "X".into(),
166            sell_summary: vec![BazaarOrder {
167                amount: 1,
168                price_per_unit: sell,
169                orders: 1,
170            }],
171            buy_summary: vec![BazaarOrder {
172                amount: 1,
173                price_per_unit: buy,
174                orders: 1,
175            }],
176            quick_status: None,
177        }
178    }
179
180    #[test]
181    fn computes_spread_margin() {
182        let spread = bazaar_spread(&product(5.0, 4.2)).unwrap();
183        assert_eq!(spread.sell_order_price, 5.0);
184        assert_eq!(spread.buy_order_price, 4.2);
185        assert!((spread.margin() - 0.8).abs() < 1e-9);
186    }
187
188    #[test]
189    fn flips_filter_volume_and_apply_tax() {
190        use crate::models::skyblock::BazaarQuickStatus;
191
192        let mut liquid = product(100.0, 50.0);
193        liquid.quick_status = Some(BazaarQuickStatus {
194            buy_moving_week: 100_000,
195            sell_moving_week: 100_000,
196            ..serde_json::from_str("{}").unwrap()
197        });
198        let mut illiquid = product(1_000.0, 1.0);
199        illiquid.quick_status = Some(BazaarQuickStatus {
200            buy_moving_week: 10,
201            sell_moving_week: 100_000,
202            ..serde_json::from_str("{}").unwrap()
203        });
204
205        let bazaar = Bazaar {
206            last_updated: 0,
207            products: [("LIQUID".into(), liquid), ("ILLIQUID".into(), illiquid)]
208                .into_iter()
209                .collect(),
210        };
211        let flips = bazaar_flips(&bazaar, 1_000);
212        assert_eq!(flips.len(), 1);
213        assert_eq!(flips[0].product_id, "LIQUID");
214        assert!((flips[0].profit_per_item - (100.0 * (1.0 - BAZAAR_TAX) - 50.0)).abs() < 1e-9);
215    }
216}