use std::collections::HashMap;
use serde::Serialize;
use crate::models::skyblock::{Bazaar, BazaarProduct, SkyBlockAuction};
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct BazaarSpread {
pub instant_buy_price: f64,
pub instant_sell_price: f64,
}
impl BazaarSpread {
pub fn margin(&self) -> f64 {
self.instant_buy_price - self.instant_sell_price
}
}
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,
})
}
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()
}
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
}
#[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
}
pub const BAZAAR_TAX: f64 = 0.0125;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct BazaarFlip {
pub product_id: String,
pub order_price: f64,
pub offer_price: f64,
pub profit_per_item: f64,
pub margin: f64,
pub buy_moving_week: i64,
pub sell_moving_week: i64,
}
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};
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()
});
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);
}
}