This commit is contained in:
bob
2026-06-02 13:31:27 -05:00
parent 15310f0fd0
commit edc649fc36
33 changed files with 6407 additions and 8 deletions

View 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);
}