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