Files
Operation-Blue-Laminate-v2/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupListingData.cs
2026-06-02 13:31:27 -05:00

204 lines
8.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
namespace BlueLaminate.Core.Tradeups;
/// <summary>One active listing reduced to the fields the finder needs.</summary>
public readonly record struct TradeupListingRow(
int SkinId,
string MarketHashName,
string Marketplace,
string? InspectLink,
string ExternalId,
bool IsStatTrak,
bool IsSouvenir,
decimal? FloatValue,
decimal Price);
/// <summary>
/// A purchasable input copy: a real listing the engine can pick into a contract. Carries
/// the market-hash name, marketplace, and the source listing's inspect link + external id
/// so the output is an actionable buy list that points at the exact listing.
/// </summary>
public readonly record struct InputListing(
int SkinId,
string MarketHashName,
string Marketplace,
string? InspectLink,
string ExternalId,
decimal FloatValue,
decimal Price);
/// <summary>Lowest active ask and listing count for one (skin, ST, wear band).</summary>
public readonly record struct BandPrice(decimal LowestAsk, int Liquidity);
/// <summary>Where a resolved output price came from.</summary>
public enum OutputPriceBasis
{
/// <summary>Nothing comparable is listed anywhere — unpriceable.</summary>
None,
/// <summary>The wear band the output lands in, which is liquid enough to trust.</summary>
Band,
/// <summary>
/// The band was too thin to trust, so this is the skin's cheapest comparable listing
/// across any wear — a conservative proxy. A live CSFloat lookup should refine it.
/// </summary>
Floor,
}
/// <summary>An output price plus how thin its own band was and where the number came from.</summary>
public readonly record struct ResolvedOutputPrice(decimal? LowestAsk, int BandLiquidity, OutputPriceBasis Basis);
/// <summary>
/// The listing-side inputs to the finder (Phase B/C data), built once from a single scan
/// of active listings:
/// <list type="bullet">
/// <item>input pools — every floated input copy, split into the disjoint non-ST and ST
/// universes (non-ST = normal souvenir);</item>
/// <item>an <see cref="OutputPriceBook"/> — the lowest non-souvenir ask per (skin, ST,
/// wear band), used to value a produced output at its computed float.</item>
/// </list>
/// Floatless listings are dropped: an input copy with no float can't be normalised, and
/// an output listing with no float can't be placed in a wear band.
/// </summary>
public sealed class TradeupListingData
{
private readonly IReadOnlyDictionary<int, List<InputListing>> _nonStatTrakInputs;
private readonly IReadOnlyDictionary<int, List<InputListing>> _statTrakInputs;
private readonly OutputPriceBook _outputPrices;
private TradeupListingData(
IReadOnlyDictionary<int, List<InputListing>> nonStatTrakInputs,
IReadOnlyDictionary<int, List<InputListing>> statTrakInputs,
OutputPriceBook outputPrices)
{
_nonStatTrakInputs = nonStatTrakInputs;
_statTrakInputs = statTrakInputs;
_outputPrices = outputPrices;
}
public OutputPriceBook OutputPrices => _outputPrices;
/// <summary>All purchasable input copies of <paramref name="skinId"/> in the given universe.</summary>
public IReadOnlyList<InputListing> InputsFor(int skinId, bool statTrak)
{
var pool = statTrak ? _statTrakInputs : _nonStatTrakInputs;
return pool.TryGetValue(skinId, out var listings) ? listings : Array.Empty<InputListing>();
}
public static TradeupListingData Build(IEnumerable<TradeupListingRow> rows)
{
var nonStInputs = new Dictionary<int, List<InputListing>>();
var stInputs = new Dictionary<int, List<InputListing>>();
var book = new OutputPriceBook();
foreach (var row in rows)
{
if (row.FloatValue is not { } floatValue || row.Price <= 0m)
{
continue;
}
// Input side: a copy can be used as input regardless of souvenir flag; only the
// ST flag splits the two disjoint universes.
var inputs = row.IsStatTrak ? stInputs : nonStInputs;
if (!inputs.TryGetValue(row.SkinId, out var list))
{
list = new List<InputListing>();
inputs[row.SkinId] = list;
}
list.Add(new InputListing(
row.SkinId, row.MarketHashName, row.Marketplace,
row.InspectLink, row.ExternalId, floatValue, row.Price));
// Output side: a tradeup never produces a souvenir, so souvenir listings don't
// price an output.
if (!row.IsSouvenir)
{
book.Add(row.SkinId, row.IsStatTrak, WearBands.FromFloat(floatValue), row.Price);
}
}
return new TradeupListingData(nonStInputs, stInputs, book);
}
}
/// <summary>
/// Phase B artifact: the lowest active ask (and liquidity count) for each
/// (skin, StatTrak, wear band). A produced output is valued by looking up the band its
/// computed float falls into — a conservative, listing-grounded estimate that never
/// invents a premium for a float no one is currently selling near.
/// </summary>
public sealed class OutputPriceBook
{
private readonly Dictionary<(int SkinId, bool StatTrak), Dictionary<WearBand, MutableBand>> _bands = new();
// Skin's cheapest comparable listing across ALL wears — the conservative floor used when a
// single band is too thin to trust.
private readonly Dictionary<(int SkinId, bool StatTrak), MutableBand> _floor = new();
internal void Add(int skinId, bool statTrak, WearBand band, decimal price)
{
var key = (skinId, statTrak);
if (!_bands.TryGetValue(key, out var byBand))
{
byBand = new Dictionary<WearBand, MutableBand>();
_bands[key] = byBand;
}
byBand[band] = byBand.TryGetValue(band, out var entry)
? new MutableBand(Math.Min(entry.LowestAsk, price), entry.Liquidity + 1)
: new MutableBand(price, 1);
_floor[key] = _floor.TryGetValue(key, out var f)
? new MutableBand(Math.Min(f.LowestAsk, price), f.Liquidity + 1)
: new MutableBand(price, 1);
}
/// <summary>
/// The lowest ask for the given skin/ST in the wear band that <paramref name="outputFloat"/>
/// lands in, or null when nothing comparable is listed.
/// </summary>
public BandPrice? PriceAt(int skinId, bool statTrak, decimal outputFloat)
{
if (_bands.TryGetValue((skinId, statTrak), out var byBand)
&& byBand.TryGetValue(WearBands.FromFloat(outputFloat), out var entry))
{
return new BandPrice(entry.LowestAsk, entry.Liquidity);
}
return null;
}
/// <summary>
/// Resolves an output's value: the band price when the band is liquid enough
/// (≥ <paramref name="thinThreshold"/> listings); otherwise the skin's overall floor, since
/// a one- or two-listing band is dominated by outliers (e.g. a lone over-priced FN sitting
/// just past a wear boundary). The reported <see cref="ResolvedOutputPrice.BandLiquidity"/>
/// is always the band's own count, so a thin result still triggers live CSFloat re-pricing.
/// </summary>
public ResolvedOutputPrice Resolve(int skinId, bool statTrak, decimal outputFloat, int thinThreshold)
{
var key = (skinId, statTrak);
var bandLiquidity = 0;
if (_bands.TryGetValue(key, out var byBand)
&& byBand.TryGetValue(WearBands.FromFloat(outputFloat), out var entry))
{
bandLiquidity = entry.Liquidity;
if (entry.Liquidity >= thinThreshold)
{
return new ResolvedOutputPrice(entry.LowestAsk, bandLiquidity, OutputPriceBasis.Band);
}
}
// Thin (or empty) band — fall back to the skin's cheapest comparable listing.
if (_floor.TryGetValue(key, out var floor))
{
return new ResolvedOutputPrice(floor.LowestAsk, bandLiquidity, OutputPriceBasis.Floor);
}
return new ResolvedOutputPrice(null, bandLiquidity, OutputPriceBasis.None);
}
private readonly record struct MutableBand(decimal LowestAsk, int Liquidity);
}