1use std::collections::HashMap;
2
3use serde::Serialize;
4
5use crate::models::skyblock::{Bazaar, BazaarProduct, SkyBlockAuction};
6
7#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
9pub struct BazaarSpread {
10 pub buy_order_price: f64,
12 pub sell_order_price: f64,
14}
15
16impl BazaarSpread {
17 pub fn margin(&self) -> f64 {
19 (self.sell_order_price - self.buy_order_price).abs()
20 }
21}
22
23pub 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
36pub 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
45pub 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#[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
96pub const BAZAAR_TAX: f64 = 0.0125;
98
99#[derive(Debug, Clone, PartialEq, Serialize)]
101pub struct BazaarFlip {
102 pub product_id: String,
103 pub buy_order_price: f64,
105 pub sell_order_price: f64,
107 pub profit_per_item: f64,
109 pub margin: f64,
111 pub buy_moving_week: i64,
113 pub sell_moving_week: i64,
115}
116
117pub 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}