204 lines
8.1 KiB
C#
204 lines
8.1 KiB
C#
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);
|
||
}
|