final
This commit is contained in:
203
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupListingData.cs
Normal file
203
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupListingData.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user