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 instant_buy_price: f64,
12 pub instant_sell_price: f64,
14}
15
16impl BazaarSpread {
17 pub fn margin(&self) -> f64 {
20 self.instant_buy_price - self.instant_sell_price
21 }
22}
23
24pub 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
40pub 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
49pub 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#[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
100pub const BAZAAR_TAX: f64 = 0.0125;
102
103#[derive(Debug, Clone, PartialEq, Serialize)]
105pub struct BazaarFlip {
106 pub product_id: String,
107 pub order_price: f64,
110 pub offer_price: f64,
113 pub profit_per_item: f64,
115 pub margin: f64,
117 pub buy_moving_week: i64,
119 pub sell_moving_week: i64,
121}
122
123pub 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 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 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}