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,409 @@
using System.Collections.Concurrent;
using BlueLaminate.Core.Options;
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// The multi-collection tradeup search. Where the single-collection pass keeps all ten
/// inputs in one collection, this mixes inputs from any collections sharing a rarity tier —
/// the pattern behind most genuinely profitable contracts (cheap inputs from one collection,
/// a valuable output rolled from another).
/// <para>
/// It exploits two facts: an output's probability is linear in how many inputs came from its
/// collection (<c>n_C / size·k_C</c>), and the produced float depends only on the single
/// global average input fraction. So for a FIXED output-float target F, each input copy has an
/// independent reward — its collection's average output value share minus its price — and one
/// max-reward knapsack over the whole tier's pool finds the optimal mix without enumerating
/// collection subsets. The search sweeps F on a grid; each grid point is an independent chunk
/// run in parallel. Because the reward is a linear (expected-value) function, this optimises
/// EXPECTED profit; each winning selection is then evaluated exactly.
/// </para>
/// </summary>
public static class MultiCollectionSearch
{
private sealed record CollectionInfo(
int CollectionId,
string CollectionName,
WeaponRarity OutputRarity,
IReadOnlyList<TradeupOutputSkin> Outputs);
// An input copy already reduced to its collection, float bucket and price.
private readonly record struct PoolItem(int CollectionId, int Bucket, decimal Fraction, InputListing Listing);
public static List<TradeupCandidate> Evaluate(
TradeupGraph graph,
TradeupListingData listingData,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
TradeupOptions options,
CancellationToken ct)
{
var step = options.MultiCollectionFloatGrid;
var size = options.ContractSize;
var maxBucketPerItem = (int)Math.Ceiling(1m / step);
var results = new List<TradeupCandidate>();
// All recipes sharing an input rarity can be mixed (each collection still rolls into
// its own next tier). Group by (input rarity, ST universe).
var byTier = graph.Groups.GroupBy(g => g.InputRarity);
foreach (var tier in byTier)
{
var tierGroups = tier.ToList();
foreach (var statTrak in StatTrakUniverses(options.StatTrak))
{
ct.ThrowIfCancellationRequested();
EvaluateTier(tier.Key, tierGroups, statTrak, listingData, floatBounds, options, step, size,
maxBucketPerItem, results, ct);
}
}
return results;
}
private static void EvaluateTier(
WeaponRarity inputRarity,
List<TradeupInputGroup> tierGroups,
bool statTrak,
TradeupListingData listingData,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
TradeupOptions options,
decimal step,
int size,
int maxBucketPerItem,
List<TradeupCandidate> results,
CancellationToken ct)
{
var collections = tierGroups.ToDictionary(
g => g.CollectionId,
g => new CollectionInfo(g.CollectionId, g.CollectionName, g.OutputRarity, g.OutputSkins));
var skinCollection = new Dictionary<int, int>();
foreach (var g in tierGroups)
{
foreach (var skinId in g.InputSkinIds)
{
skinCollection[skinId] = g.CollectionId;
}
}
// Build the tier's input pool once, then trim to the cheapest `size` copies per
// (collection, float bucket) — within a cell every copy has the same float and value,
// so only the cheapest can ever be optimal. Bucketing/trim don't depend on the target,
// so this is done once and reused across all grid chunks.
var trimmed = BuildTrimmedPool(tierGroups, statTrak, listingData, floatBounds, step, maxBucketPerItem, size);
if (trimmed.Count < size)
{
return;
}
var priceBook = listingData.OutputPrices;
// One chunk per float-target grid point, run in parallel.
var grid = new List<decimal>();
for (var f = step; f <= 1m + 1e-9m; f += step)
{
grid.Add(Math.Min(f, 1m));
}
var chunkResults = new ConcurrentBag<TradeupCandidate>();
Parallel.ForEach(
grid,
new ParallelOptions { CancellationToken = ct },
target =>
{
var candidate = EvaluateChunk(
inputRarity, target, trimmed, collections, skinCollection, floatBounds, priceBook,
options, step, size, statTrak);
if (candidate is not null)
{
chunkResults.Add(candidate);
}
});
// Only genuine mixes belong here — single-collection selections are the dedicated
// pass's job, and emitting them too just duplicates rows. De-duplicate by collection
// mix (different float targets often converge on the same set), keep the best expected
// profit, then take the top few.
var deduped = chunkResults
.Where(c => c.CollectionCount >= 2)
.GroupBy(MixSignature)
.Select(grp => grp.MaxBy(c => c.ExpectedProfit)!)
.OrderByDescending(c => c.ExpectedProfit)
.Take(options.MultiCollectionPerTier);
lock (results)
{
results.AddRange(deduped);
}
}
private static List<PoolItem> BuildTrimmedPool(
List<TradeupInputGroup> tierGroups,
bool statTrak,
TradeupListingData listingData,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
decimal step,
int maxBucketPerItem,
int size)
{
// (collection, bucket) -> cheapest copies.
var cells = new Dictionary<(int Collection, int Bucket), List<PoolItem>>();
foreach (var group in tierGroups)
{
foreach (var skinId in group.InputSkinIds)
{
if (!floatBounds.TryGetValue(skinId, out var bounds))
{
continue;
}
foreach (var listing in listingData.InputsFor(skinId, statTrak))
{
var fraction = TradeupMath.NormalizedFraction(listing.FloatValue, bounds.Min, bounds.Max);
var bucket = Math.Clamp((int)Math.Ceiling(fraction / step), 0, maxBucketPerItem);
var key = (group.CollectionId, bucket);
if (!cells.TryGetValue(key, out var cell))
{
cell = new List<PoolItem>();
cells[key] = cell;
}
cell.Add(new PoolItem(group.CollectionId, bucket, fraction, listing));
}
}
}
var trimmed = new List<PoolItem>();
foreach (var cell in cells.Values)
{
cell.Sort(static (a, b) => a.Listing.Price.CompareTo(b.Listing.Price));
for (var i = 0; i < Math.Min(size, cell.Count); i++)
{
trimmed.Add(cell[i]);
}
}
return trimmed;
}
private static TradeupCandidate? EvaluateChunk(
WeaponRarity inputRarity,
decimal target,
List<PoolItem> trimmed,
IReadOnlyDictionary<int, CollectionInfo> collections,
IReadOnlyDictionary<int, int> skinCollection,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
OutputPriceBook priceBook,
TradeupOptions options,
decimal step,
int size,
bool statTrak)
{
// Each collection's average output value if the produced float averages `target`.
var valueByCollection = new Dictionary<int, decimal>(collections.Count);
foreach (var (id, info) in collections)
{
valueByCollection[id] = AverageOutputValue(info, target, priceBook, options, statTrak);
}
var capBucket = (int)Math.Floor(target * size / step);
var items = new List<TradeupSelector.RewardItem>(trimmed.Count);
foreach (var item in trimmed)
{
if (item.Bucket > capBucket)
{
continue;
}
// Reward = this copy's share of expected output value, minus what it costs.
var reward = (double)(valueByCollection[item.CollectionId] / size - item.Listing.Price);
items.Add(new TradeupSelector.RewardItem(item.Bucket, reward, item.Listing));
}
var picks = TradeupSelector.SolveMaxReward(items, size, capBucket);
return picks is null
? null
: BuildCandidate(inputRarity, picks, collections, skinCollection, floatBounds, priceBook, options, size, statTrak);
}
// The conservative average output value of a collection at a given input-float average:
// each next-tier skin is equally likely; an output with no comparable listing contributes 0.
private static decimal AverageOutputValue(
CollectionInfo info, decimal averageFraction, OutputPriceBook priceBook,
TradeupOptions options, bool statTrak)
{
if (info.Outputs.Count == 0)
{
return 0m;
}
decimal total = 0m;
foreach (var output in info.Outputs)
{
var outputFloat = TradeupMath.OutputFloat(averageFraction, output.FloatMin, output.FloatMax);
var resolved = priceBook.Resolve(output.SkinId, statTrak, outputFloat, options.CsFloatThinOutputThreshold);
if (resolved.LowestAsk is { } ask)
{
total += NetSell(ask, options);
}
}
return total / info.Outputs.Count;
}
private static TradeupCandidate BuildCandidate(
WeaponRarity inputRarity,
PickNode picks,
IReadOnlyDictionary<int, CollectionInfo> collections,
IReadOnlyDictionary<int, int> skinCollection,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
OutputPriceBook priceBook,
TradeupOptions options,
int size,
bool statTrak)
{
var inputs = picks.ToList();
// Realised average fraction from the actual copies (exact, not bucketed).
decimal fractionSum = 0m;
var counts = new Dictionary<int, int>();
decimal cost = 0m;
foreach (var input in inputs)
{
cost += input.Price;
if (floatBounds.TryGetValue(input.SkinId, out var bounds))
{
fractionSum += TradeupMath.NormalizedFraction(input.FloatValue, bounds.Min, bounds.Max);
}
var collectionId = skinCollection[input.SkinId];
counts[collectionId] = counts.GetValueOrDefault(collectionId) + 1;
}
var averageFraction = fractionSum / size;
var outcomes = new List<TradeupOutcome>();
var composition = new List<TradeupContribution>();
foreach (var (collectionId, n) in counts.OrderByDescending(kv => kv.Value))
{
var info = collections[collectionId];
composition.Add(new TradeupContribution(collectionId, info.CollectionName, info.OutputRarity, n));
var k = info.Outputs.Count;
if (k == 0)
{
continue;
}
var probability = (decimal)n / (size * k);
foreach (var output in info.Outputs)
{
var outputFloat = TradeupMath.OutputFloat(averageFraction, output.FloatMin, output.FloatMax);
var band = WearBands.FromFloat(outputFloat);
var resolved = priceBook.Resolve(output.SkinId, statTrak, outputFloat, options.CsFloatThinOutputThreshold);
outcomes.Add(new TradeupOutcome(
output.SkinId,
output.Name,
outputFloat,
band,
probability,
resolved.LowestAsk is { } ask ? NetSell(ask, options) : null,
resolved.BandLiquidity,
resolved.Basis == OutputPriceBasis.Floor ? "market-floor" : "market"));
}
}
var (expectedNet, worstCaseNet, guaranteed) = Economics(outcomes, cost);
var primary = composition[0];
return new TradeupCandidate(
primary.CollectionId,
SummariseMix(composition),
inputRarity,
primary.OutputRarity,
statTrak,
averageFraction,
cost,
expectedNet,
worstCaseNet,
guaranteed,
inputs,
outcomes,
composition);
}
private static string SummariseMix(IReadOnlyList<TradeupContribution> composition)
{
if (composition.Count == 1)
{
return composition[0].CollectionName;
}
var parts = composition.Take(3).Select(c => $"{Shorten(c.CollectionName)} ×{c.InputCount}");
var summary = string.Join(" + ", parts);
return composition.Count > 3 ? $"{summary} +{composition.Count - 3}" : summary;
}
private static string Shorten(string name)
{
// "The 2021 Mirage Collection" -> "Mirage" style trimming for compact summaries.
var trimmed = name;
if (trimmed.StartsWith("The ", StringComparison.Ordinal))
{
trimmed = trimmed[4..];
}
const string suffix = " Collection";
if (trimmed.EndsWith(suffix, StringComparison.Ordinal))
{
trimmed = trimmed[..^suffix.Length];
}
return trimmed;
}
private static string MixSignature(TradeupCandidate candidate)
=> string.Join(',', candidate.Composition
.OrderBy(c => c.CollectionId)
.Select(c => $"{c.CollectionId}:{c.InputCount}"));
private static decimal NetSell(decimal lowestAsk, TradeupOptions options)
=> lowestAsk * (1m - options.UndercutRate) * (1m - options.SellFeeRate);
private static (decimal Expected, decimal Worst, bool Guaranteed) Economics(
IReadOnlyList<TradeupOutcome> outcomes, decimal cost)
{
decimal expected = 0m;
decimal worst = decimal.MaxValue;
var allPriced = true;
foreach (var outcome in outcomes)
{
var realised = outcome.NetSellPrice ?? 0m;
expected += outcome.Probability * realised;
worst = Math.Min(worst, realised);
if (outcome.NetSellPrice is null)
{
allPriced = false;
}
}
if (outcomes.Count == 0)
{
worst = 0m;
}
return (expected, worst, allPriced && worst > cost);
}
private static IReadOnlyList<bool> StatTrakUniverses(StatTrakMode mode) => mode switch
{
StatTrakMode.NonStatTrakOnly => new[] { false },
StatTrakMode.StatTrakOnly => new[] { true },
_ => new[] { false, true },
};
}

View File

@@ -0,0 +1,67 @@
namespace BlueLaminate.Core.Tradeups;
/// <summary>One possible result of a contract and what it would net if it lands.</summary>
/// <param name="Probability">Chance this specific output is produced (single-collection: 1/k).</param>
/// <param name="NetSellPrice">
/// Realisable sale value after undercut + sell fee, or null when nothing comparable is
/// listed (treated as unsellable for the worst-case test).
/// </param>
/// <param name="Liquidity">Active listings backing the price, in the same wear band.</param>
/// <param name="PriceSource">Where the price came from: "market" (our stored listings) or
/// "csfloat-live" (re-priced from the CSFloat API because the stored liquidity was thin).</param>
public sealed record TradeupOutcome(
int SkinId,
string Name,
decimal OutputFloat,
WearBand Band,
decimal Probability,
decimal? NetSellPrice,
int Liquidity,
string PriceSource = "market");
/// <summary>
/// One collection's share of a (possibly multi-collection) contract: how many of the ten
/// inputs came from it, and which output tier those inputs roll into. Single-collection
/// contracts have exactly one of these.
/// </summary>
public sealed record TradeupContribution(
int CollectionId,
string CollectionName,
WeaponRarity OutputRarity,
int InputCount);
/// <summary>
/// A concrete, actionable tradeup: which ten copies to buy, what they cost, the output
/// distribution, and the resulting economics. The finder returns these ranked; a frontend
/// only formats them.
/// <para>
/// A contract may mix several collections (all inputs share the input rarity, but each
/// collection rolls into its own next tier). <see cref="Composition"/> records the per-
/// collection split; <see cref="CollectionCount"/> is its length. <see cref="OutputRarity"/>
/// is the tier of the largest contributor (a display convenience for the common case).
/// </para>
/// </summary>
public sealed record TradeupCandidate(
int CollectionId,
string CollectionName,
WeaponRarity InputRarity,
WeaponRarity OutputRarity,
bool StatTrak,
decimal AverageFraction,
decimal InputCost,
decimal ExpectedNet,
decimal WorstCaseNet,
bool Guaranteed,
IReadOnlyList<InputListing> Inputs,
IReadOnlyList<TradeupOutcome> Outcomes,
IReadOnlyList<TradeupContribution> Composition)
{
/// <summary>Number of distinct collections the inputs are drawn from (1 = single-collection).</summary>
public int CollectionCount => Composition.Count;
/// <summary>Expected profit across the output distribution, net of cost.</summary>
public decimal ExpectedProfit => ExpectedNet - InputCost;
/// <summary>Profit if the worst (lowest-value) output lands — negative unless guaranteed.</summary>
public decimal WorstCaseProfit => WorstCaseNet - InputCost;
}

View File

@@ -0,0 +1,476 @@
using BlueLaminate.Core.Options;
using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using BlueLaminate.Scraper.CsFloat;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// Finds profitable 10-input CS2 tradeup contracts over the live listings. It joins three
/// things: the catalogue-derived <see cref="TradeupGraph"/> (which collections produce
/// what), the active <see cref="MarketListing"/>s (what inputs cost and what outputs sell
/// for), and the exact <see cref="TradeupMath"/>. For each (collection-recipe, StatTrak)
/// universe it runs the cardinality-constrained selection DP and values every resulting
/// output distribution, keeping the best contract per recipe and ranking them.
/// <para>
/// When a proposed contract's output is thinly listed in our data, its stored lowest-ask is
/// fragile, so a follow-up pass re-prices that output from the live CSFloat API and
/// recomputes the economics (see <see cref="EnrichThinOutputsAsync"/>).
/// </para>
/// <para>
/// All economics live here, never in a frontend: the CLI and the future web UI both call
/// <see cref="FindAsync"/> and only format the returned candidates.
/// </para>
/// </summary>
public sealed class TradeupFinder
{
private readonly SkinTrackerDbContext _db;
private readonly TradeupGraphBuilder _graphBuilder;
private readonly TradeupOptions _options;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TradeupFinder> _logger;
public TradeupFinder(
SkinTrackerDbContext db,
TradeupGraphBuilder graphBuilder,
IOptions<TradeupOptions> options,
IServiceProvider serviceProvider,
ILogger<TradeupFinder> logger)
{
_db = db;
_graphBuilder = graphBuilder;
_options = options.Value;
_serviceProvider = serviceProvider;
_logger = logger;
}
/// <summary>
/// Runs the search and returns candidates ranked best-first. <paramref name="maxResults"/>
/// caps the returned list; pass 0 or negative for "all".
/// </summary>
public async Task<IReadOnlyList<TradeupCandidate>> FindAsync(
int maxResults = 50,
CancellationToken ct = default)
{
var graph = await _graphBuilder.BuildAsync(ct);
// Float bounds for input skins, needed to normalise each input copy's float to its
// own range before averaging. (Output bounds already live on the graph.)
var floatBounds = await _db.Skins
.Where(s => s.FloatMin != null && s.FloatMax != null)
.Select(s => new { s.Id, Min = s.FloatMin!.Value, Max = s.FloatMax!.Value })
.AsNoTracking()
.ToDictionaryAsync(s => s.Id, s => (s.Min, s.Max), ct);
// def_index/paint_index for output skins, so a thin output can be looked up live on
// CSFloat (which identifies items by these two indexes).
var indexes = await _db.Skins
.Where(s => s.DefIndex != null && s.PaintIndex != null)
.Select(s => new { s.Id, Def = s.DefIndex!.Value, Paint = s.PaintIndex!.Value })
.AsNoTracking()
.ToDictionaryAsync(s => s.Id, s => (s.Def, s.Paint), ct);
var listingData = await LoadListingsAsync(ct);
var universes = StatTrakUniverses(_options.StatTrak);
var candidates = new List<TradeupCandidate>();
foreach (var group in graph.Groups)
{
foreach (var statTrak in universes)
{
var candidate = EvaluateRecipe(group, statTrak, listingData, floatBounds);
if (candidate is not null)
{
candidates.Add(candidate);
}
}
}
// Multi-collection contracts (expected-profit optimised) on top of the single-collection
// pass. Skipped under StatTrak filters? No — it respects the same universes internally.
if (_options.MultiCollection)
{
var multi = MultiCollectionSearch.Evaluate(graph, listingData, floatBounds, _options, ct);
_logger.LogInformation("Multi-collection search produced {Count} candidate contracts.", multi.Count);
candidates.AddRange(multi);
}
var ranked = candidates.OrderByDescending(RankingMetric).ToList();
// Re-price thin outputs from the live CSFloat API. Only the top window is enriched
// (it's where results are shown, and it bounds the live lookups); the rest keep their
// stored pricing. Then re-filter and re-rank with the refreshed economics.
var window = maxResults > 0 ? Math.Max(maxResults * 3, 60) : ranked.Count;
var head = await EnrichThinOutputsAsync(ranked.Take(window).ToList(), indexes, ct);
ranked = head.Concat(ranked.Skip(window))
.Where(c => !_options.GuaranteedOnly || c.Guaranteed)
.Where(c => RankingMetric(c) >= _options.MinProfit)
.OrderByDescending(RankingMetric)
.ToList();
_logger.LogInformation(
"Tradeup search complete: {Surviving} qualifying contracts (guaranteedOnly={Guaranteed}, "
+ "minProfit={MinProfit}, statTrak={StatTrak}).",
ranked.Count, _options.GuaranteedOnly, _options.MinProfit, _options.StatTrak);
return maxResults > 0 ? ranked.Take(maxResults).ToList() : ranked;
}
/// <summary>
/// Evaluates one (recipe, StatTrak) universe: builds the input pool, solves the
/// selection DP, and returns the best qualifying contract — or null if the recipe can't
/// be filled or nothing clears the filters.
/// </summary>
private TradeupCandidate? EvaluateRecipe(
TradeupInputGroup group,
bool statTrak,
TradeupListingData listingData,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds)
{
var pool = BuildPool(group, statTrak, listingData, floatBounds);
if (pool.Count < _options.ContractSize)
{
return null;
}
var selection = TradeupSelector.Solve(pool, _options.ContractSize, _options.FractionBucket);
TradeupCandidate? best = null;
decimal bestMetric = decimal.MinValue;
foreach (var (averageFraction, cost, picks) in selection.Selections())
{
var candidate = BuildCandidate(
group, statTrak, averageFraction, cost, picks, listingData.OutputPrices);
if (_options.GuaranteedOnly && !candidate.Guaranteed)
{
continue;
}
var metric = RankingMetric(candidate);
if (metric < _options.MinProfit)
{
continue;
}
if (metric > bestMetric)
{
bestMetric = metric;
best = candidate;
}
}
return best;
}
private List<SelectableInput> BuildPool(
TradeupInputGroup group,
bool statTrak,
TradeupListingData listingData,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds)
{
var pool = new List<SelectableInput>();
foreach (var skinId in group.InputSkinIds)
{
if (!floatBounds.TryGetValue(skinId, out var bounds))
{
continue;
}
foreach (var listing in listingData.InputsFor(skinId, statTrak))
{
var fraction = TradeupMath.NormalizedFraction(listing.FloatValue, bounds.Min, bounds.Max);
pool.Add(new SelectableInput(fraction, listing));
}
}
return pool;
}
private TradeupCandidate BuildCandidate(
TradeupInputGroup group,
bool statTrak,
decimal averageFraction,
decimal cost,
PickNode picks,
OutputPriceBook priceBook)
{
var probability = 1m / group.OutputSkins.Count; // single-collection v1: equally likely.
var outcomes = new List<TradeupOutcome>(group.OutputSkins.Count);
foreach (var output in group.OutputSkins)
{
var outputFloat = TradeupMath.OutputFloat(averageFraction, output.FloatMin, output.FloatMax);
var band = WearBands.FromFloat(outputFloat);
var resolved = priceBook.Resolve(
output.SkinId, statTrak, outputFloat, _options.CsFloatThinOutputThreshold);
outcomes.Add(new TradeupOutcome(
output.SkinId,
output.Name,
outputFloat,
band,
probability,
resolved.LowestAsk is { } ask ? NetSell(ask) : null,
resolved.BandLiquidity,
resolved.Basis == OutputPriceBasis.Floor ? "market-floor" : "market"));
}
var (expectedNet, worstCaseNet, guaranteed) = Economics(outcomes, cost);
return new TradeupCandidate(
group.CollectionId,
group.CollectionName,
group.InputRarity,
group.OutputRarity,
statTrak,
averageFraction,
cost,
expectedNet,
worstCaseNet,
guaranteed,
picks.ToList(),
outcomes,
new[] { new TradeupContribution(group.CollectionId, group.CollectionName, group.OutputRarity, _options.ContractSize) });
}
/// <summary>
/// For each candidate with a thinly-listed output (liquidity below the configured
/// threshold), fetches that output's current lowest ask from the live CSFloat API and
/// recomputes the contract's economics. Distinct (skin, ST, band) lookups are cached and
/// the total is capped, so this stays within the API's rate-limit budget. Inert (returns
/// the input unchanged) when the feature is off or no CSFloat key is configured.
/// </summary>
private async Task<List<TradeupCandidate>> EnrichThinOutputsAsync(
List<TradeupCandidate> candidates,
IReadOnlyDictionary<int, (int Def, int Paint)> indexes,
CancellationToken ct)
{
if (!_options.UseCsFloatForThinOutputs || candidates.Count == 0)
{
return candidates;
}
var client = TryResolveCsFloatClient();
if (client is null)
{
return candidates;
}
var cache = new Dictionary<(int Def, int Paint, bool StatTrak, WearBand Band), BandPrice?>();
var lookups = 0;
var enriched = 0;
var stop = false;
var result = new List<TradeupCandidate>(candidates.Count);
foreach (var candidate in candidates)
{
var thin = candidate.Outcomes.Any(o =>
o.Liquidity < _options.CsFloatThinOutputThreshold && indexes.ContainsKey(o.SkinId));
if (!thin)
{
result.Add(candidate);
continue;
}
var newOutcomes = new List<TradeupOutcome>(candidate.Outcomes.Count);
var changed = false;
foreach (var outcome in candidate.Outcomes)
{
if (outcome.Liquidity >= _options.CsFloatThinOutputThreshold
|| !indexes.TryGetValue(outcome.SkinId, out var idx))
{
newOutcomes.Add(outcome);
continue;
}
var key = (idx.Def, idx.Paint, candidate.StatTrak, outcome.Band);
if (!cache.TryGetValue(key, out var live))
{
if (stop || lookups >= _options.CsFloatMaxLookups)
{
newOutcomes.Add(outcome);
continue;
}
lookups++;
try
{
live = await FetchCsFloatBandPriceAsync(
client, idx.Def, idx.Paint, candidate.StatTrak, outcome.Band, ct);
cache[key] = live;
}
catch (CsFloatApiException ex)
{
// Rate-limited or rejected — stop hitting the API and keep stored prices.
_logger.LogWarning("CSFloat re-pricing halted after {Lookups} lookups: {Message}",
lookups, ex.Message);
stop = true;
newOutcomes.Add(outcome);
continue;
}
}
if (live is { } bp)
{
newOutcomes.Add(outcome with
{
NetSellPrice = NetSell(bp.LowestAsk),
Liquidity = bp.Liquidity,
PriceSource = "csfloat-live",
});
changed = true;
}
else
{
newOutcomes.Add(outcome);
}
}
if (!changed)
{
result.Add(candidate);
continue;
}
var (expectedNet, worstCaseNet, guaranteed) = Economics(newOutcomes, candidate.InputCost);
result.Add(candidate with
{
Outcomes = newOutcomes,
ExpectedNet = expectedNet,
WorstCaseNet = worstCaseNet,
Guaranteed = guaranteed,
});
enriched++;
}
if (enriched > 0)
{
_logger.LogInformation(
"Re-priced {Enriched} contracts with thin outputs via CSFloat ({Lookups} live lookups).",
enriched, lookups);
}
return result;
}
private static async Task<BandPrice?> FetchCsFloatBandPriceAsync(
CsFloatListingsClient client, int defIndex, int paintIndex, bool statTrak, WearBand band, CancellationToken ct)
{
var (min, max) = band.Bounds();
// Sorted lowest_price ascending, scoped to the band — so the first listing matching the
// ST flag (and not a souvenir) is the lowest comparable ask.
var page = await client.FetchPageAsync(
defIndex, paintIndex, sortBy: "lowest_price", limit: 50, cursor: null,
type: "buy_now", minFloat: min, maxFloat: max, ct: ct);
decimal? lowest = null;
var count = 0;
foreach (var listing in page.Listings)
{
if (listing.IsSouvenir || listing.IsStatTrak != statTrak)
{
continue;
}
count++;
lowest ??= listing.Price;
}
return lowest is { } price ? new BandPrice(price, count) : null;
}
private CsFloatListingsClient? TryResolveCsFloatClient()
{
try
{
return _serviceProvider.GetRequiredService<CsFloatListingsClient>();
}
catch (Exception ex)
{
// No API key configured (the client's ctor throws): the feature is simply inert.
_logger.LogWarning("CSFloat re-pricing unavailable, using stored prices only: {Message}", ex.Message);
return null;
}
}
// Realisable sale value from a lowest ask: undercut to sell, then pay the marketplace fee.
private decimal NetSell(decimal lowestAsk)
=> lowestAsk * (1m - _options.UndercutRate) * (1m - _options.SellFeeRate);
private static (decimal Expected, decimal Worst, bool Guaranteed) Economics(
IReadOnlyList<TradeupOutcome> outcomes, decimal cost)
{
decimal expected = 0m;
decimal worst = decimal.MaxValue;
var allPriced = true;
foreach (var outcome in outcomes)
{
var realised = outcome.NetSellPrice ?? 0m;
expected += outcome.Probability * realised;
worst = Math.Min(worst, realised);
if (outcome.NetSellPrice is null)
{
allPriced = false;
}
}
if (outcomes.Count == 0)
{
worst = 0m;
}
// Guaranteed = every output is priced AND even the cheapest one clears input cost.
return (expected, worst, allPriced && worst > cost);
}
private async Task<TradeupListingData> LoadListingsAsync(CancellationToken ct)
{
var rows = await _db.MarketListings
.Where(l => l.Status == "Active"
&& l.Currency == _options.Currency
&& l.SkinId != null
&& l.Price > 0m)
.Select(l => new TradeupListingRow(
l.SkinId!.Value,
l.MarketHashName,
l.Marketplace,
l.InspectLink,
l.ExternalId,
l.IsStatTrak,
l.IsSouvenir,
l.FloatValue,
l.Price))
.ToListAsync(ct);
_logger.LogInformation("Loaded {Count} active {Currency} listings for tradeup search.",
rows.Count, _options.Currency);
return TradeupListingData.Build(rows);
}
private decimal RankingMetric(TradeupCandidate candidate) => _options.Ranking switch
{
TradeupRanking.ExpectedProfit => candidate.ExpectedProfit,
_ => candidate.WorstCaseProfit,
};
private static IReadOnlyList<bool> StatTrakUniverses(StatTrakMode mode) => mode switch
{
StatTrakMode.NonStatTrakOnly => new[] { false },
StatTrakMode.StatTrakOnly => new[] { true },
_ => new[] { false, true },
};
}

View File

@@ -0,0 +1,37 @@
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// A skin that can come OUT of a tradeup, carrying the float bounds needed to map an
/// input average float onto this skin's own wear range. <see cref="StatTrakAvailable"/>
/// is recorded so the listing-side query (Phase C) can filter ST vs non-ST outputs;
/// the graph itself is ST-agnostic.
/// </summary>
public sealed record TradeupOutputSkin(
int SkinId,
string Name,
decimal FloatMin,
decimal FloatMax,
bool StatTrakAvailable);
/// <summary>
/// One tradeup "recipe slot": all eligible input skins of a single rarity within one
/// collection, and the set of output skins they produce (the next rarity tier present
/// in that collection). For a single-collection contract, ten inputs are drawn from
/// <see cref="InputSkinIds"/> and each of <see cref="OutputSkins"/> is an equally likely
/// outcome, so k_C = <c>OutputSkins.Count</c>.
/// </summary>
public sealed record TradeupInputGroup(
int CollectionId,
string CollectionName,
WeaponRarity InputRarity,
WeaponRarity OutputRarity,
IReadOnlyList<int> InputSkinIds,
IReadOnlyList<TradeupOutputSkin> OutputSkins);
/// <summary>
/// The full tradeup reference graph derived from the static catalogue: every
/// (collection, input rarity) → (output rarity, output skins) edge that yields a
/// 10-input weapon tradeup. Built once per process from the monthly-synced catalogue
/// (see <see cref="TradeupGraphBuilder"/>); contains no pricing or listing data.
/// </summary>
public sealed record TradeupGraph(IReadOnlyList<TradeupInputGroup> Groups);

View File

@@ -0,0 +1,197 @@
using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// Derives the <see cref="TradeupGraph"/> from the synced catalogue
/// (<see cref="Skin"/> + <see cref="Collection"/> + <see cref="Weapon"/>) with no
/// pricing, no listings, and no new tables. A single query loads the catalogue; the
/// graph is assembled in memory. Because the catalogue changes only when
/// <c>SkinSyncService</c> runs (monthly), callers can build this once and cache it for
/// the process lifetime.
/// </summary>
public sealed class TradeupGraphBuilder
{
/// <summary>
/// Pseudo-collections that are not real tradeup collections. "Limited Edition Item"
/// holds armory/non-tradeable skins (e.g. AK-47 Aphrodite) that must never be treated
/// as tradeup inputs or outputs.
/// </summary>
private static readonly HashSet<string> SkipCollectionNames = new(StringComparer.Ordinal)
{
"Limited Edition Item",
};
/// <summary>
/// Weapon categories that carry weapon-tier rarities but are never weapon tradeup
/// outputs: knives are stored as <c>Covert</c> and gloves as <c>Extraordinary</c>.
/// Excluded defensively even though the rarity/float filters already drop most.
/// </summary>
private static readonly HashSet<string> ExcludedWeaponTypes = new(StringComparer.Ordinal)
{
"Knives",
"Gloves",
};
private const string CollectionType = "Collection";
private readonly SkinTrackerDbContext _db;
private readonly ILogger<TradeupGraphBuilder> _logger;
public TradeupGraphBuilder(SkinTrackerDbContext db, ILogger<TradeupGraphBuilder> logger)
{
_db = db;
_logger = logger;
}
public async Task<TradeupGraph> BuildAsync(CancellationToken ct = default)
{
var skins = await _db.Skins
.Include(s => s.Collections)
.Include(s => s.Weapon)
.AsNoTracking()
.ToListAsync(ct);
// collectionId -> (collection, rarity -> eligible skins). Only Type='Collection'
// sources outside the skip-list participate; one skin can be filed under several
// collections, so it is added to each.
var byCollection = new Dictionary<int, CollectionBucket>();
foreach (var skin in skins)
{
if (!IsEligibleSkin(skin, out var rarity))
{
continue;
}
foreach (var collection in skin.Collections)
{
if (collection.Type != CollectionType || SkipCollectionNames.Contains(collection.Name))
{
continue;
}
if (!byCollection.TryGetValue(collection.Id, out var bucket))
{
bucket = new CollectionBucket(collection);
byCollection[collection.Id] = bucket;
}
bucket.Add(rarity, skin);
}
}
var groups = new List<TradeupInputGroup>();
foreach (var bucket in byCollection.Values)
{
// Tiers that actually have eligible skins in this collection, ascending.
var presentTiers = bucket.SkinsByRarity.Keys.OrderBy(r => (int)r).ToList();
foreach (var inputRarity in presentTiers)
{
// Covert is the v1 ceiling: it can be an output but never a 10-input source.
if (inputRarity >= WeaponRarity.Covert)
{
continue;
}
var outputRarity = NextPresentTier(presentTiers, inputRarity);
if (outputRarity is null)
{
// Collection tops out at this tier (or caps below Covert) — no tradeup.
continue;
}
var inputSkinIds = bucket.SkinsByRarity[inputRarity]
.Select(s => s.Id)
.ToList();
var outputSkins = bucket.SkinsByRarity[outputRarity.Value]
.Select(s => new TradeupOutputSkin(
s.Id,
s.Name,
s.FloatMin!.Value,
s.FloatMax!.Value,
s.StatTrakAvailable))
.ToList();
groups.Add(new TradeupInputGroup(
bucket.Collection.Id,
bucket.Collection.Name,
inputRarity,
outputRarity.Value,
inputSkinIds,
outputSkins));
}
}
_logger.LogInformation(
"Built tradeup graph: {Groups} input groups across {Collections} collections "
+ "from {Skins} catalogue skins.",
groups.Count, byCollection.Count, skins.Count);
return new TradeupGraph(groups);
}
/// <summary>
/// A skin is eligible to appear in the graph (as input or output) iff it parses to a
/// weapon tier, is not a knife/glove, and has both float bounds. The skip-list and
/// Type='Collection' filter are applied per-collection by the caller.
/// </summary>
private static bool IsEligibleSkin(Skin skin, out WeaponRarity rarity)
{
rarity = default;
if (skin.FloatMin is null || skin.FloatMax is null)
{
return false;
}
if (ExcludedWeaponTypes.Contains(skin.Weapon.Type))
{
return false;
}
// Throws on an unknown literal (catalogue rename); returns false for the
// non-weapon rarities (Contraband/Extraordinary).
return WeaponRarityExtensions.TryParse(skin.Rarity, out rarity);
}
/// <summary>The smallest present tier strictly greater than <paramref name="tier"/>, or null.</summary>
private static WeaponRarity? NextPresentTier(List<WeaponRarity> presentTiers, WeaponRarity tier)
{
foreach (var candidate in presentTiers)
{
if (candidate > tier)
{
return candidate;
}
}
return null;
}
private sealed class CollectionBucket
{
public CollectionBucket(Collection collection) => Collection = collection;
public Collection Collection { get; }
public Dictionary<WeaponRarity, List<Skin>> SkinsByRarity { get; } = new();
public void Add(WeaponRarity rarity, Skin skin)
{
if (!SkinsByRarity.TryGetValue(rarity, out var list))
{
list = new List<Skin>();
SkinsByRarity[rarity] = list;
}
list.Add(skin);
}
}
}

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

View File

@@ -0,0 +1,38 @@
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// The exact float arithmetic of a CS2 tradeup. Kept pure and dependency-free so it
/// can be unit-tested in isolation and reused verbatim by any frontend.
/// <para>
/// The contract: each input float is normalised to its own skin's range FIRST, those
/// fractions are averaged, and the average is mapped onto the OUTPUT skin's range. The
/// output float depends only on the average input fraction — a single scalar — which
/// is what makes the search tractable (see the engine design notes).
/// </para>
/// </summary>
public static class TradeupMath
{
/// <summary>
/// Normalises an input float to the fraction of its own skin's wear range:
/// <c>(value min) / (max min)</c>, clamped to [0,1]. A zero-width range
/// (min == max) has no meaningful fraction and yields 0.
/// </summary>
public static decimal NormalizedFraction(decimal floatValue, decimal skinFloatMin, decimal skinFloatMax)
{
var span = skinFloatMax - skinFloatMin;
if (span <= 0m)
{
return 0m;
}
var fraction = (floatValue - skinFloatMin) / span;
return Math.Clamp(fraction, 0m, 1m);
}
/// <summary>
/// Maps an average input fraction onto an output skin's wear range to get the exact
/// float the tradeup would produce: <c>avgFraction × (max min) + min</c>.
/// </summary>
public static decimal OutputFloat(decimal averageFraction, decimal outputFloatMin, decimal outputFloatMax)
=> averageFraction * (outputFloatMax - outputFloatMin) + outputFloatMin;
}

View File

@@ -0,0 +1,278 @@
namespace BlueLaminate.Core.Tradeups;
/// <summary>One candidate input copy, with its normalised fraction precomputed.</summary>
public readonly record struct SelectableInput(decimal Fraction, InputListing Listing);
/// <summary>
/// A persistent (shared-tail) singly-linked list of chosen listings. Travels with each
/// DP state so the winning selection is reconstructed for free — no fragile back-pointer
/// walk over a mutated cost table.
/// </summary>
public sealed record PickNode(InputListing Listing, PickNode? Previous)
{
public IReadOnlyList<InputListing> ToList()
{
var items = new List<InputListing>();
for (var node = this; node is not null; node = node.Previous)
{
items.Add(node.Listing);
}
items.Reverse();
return items;
}
/// <summary>Exact total cost of the chosen copies (the DP minimises this in double).</summary>
public decimal TotalCost()
{
var total = 0m;
for (var node = this; node is not null; node = node.Previous)
{
total += node.Listing.Price;
}
return total;
}
}
/// <summary>
/// The result of the selection DP: for every reachable summed-fraction bucket, the
/// cheapest way to pick exactly <see cref="ContractSize"/> distinct input copies whose
/// average fraction rounds (conservatively, upward) to that bucket. The finder reads
/// this once and evaluates output revenue across every bucket, which is equivalent to
/// solving the cheapest-inputs knapsack at every wear-boundary breakpoint at once.
/// </summary>
public sealed class TradeupSelection
{
private readonly PickNode?[] _pickBySum;
private readonly decimal _bucketWidth;
internal TradeupSelection(PickNode?[] pickBySum, int contractSize, decimal bucketWidth)
{
_pickBySum = pickBySum;
ContractSize = contractSize;
_bucketWidth = bucketWidth;
}
public int ContractSize { get; }
/// <summary>
/// Every feasible full selection: the (conservative) average input fraction, the total
/// input cost, and the exact copies to buy.
/// </summary>
public IEnumerable<(decimal AverageFraction, decimal Cost, PickNode Picks)> Selections()
{
for (var sum = 0; sum < _pickBySum.Length; sum++)
{
if (_pickBySum[sum] is { } picks)
{
var averageFraction = sum * _bucketWidth / ContractSize;
yield return (averageFraction, picks.TotalCost(), picks);
}
}
}
}
/// <summary>
/// Solves "pick exactly N distinct listings minimising total price, for each attainable
/// summed-fraction level". A bounded knapsack over the discretised fraction: O(items × N
/// × buckets). Fractions are bucketed by rounding UP, so the reported average float is an
/// upper bound — the conservative direction (it can only make an output look worse, never
/// better). Cost is minimised in <c>double</c> for speed; the exact decimal cost is
/// recovered from the reconstructed picks.
/// </summary>
public static class TradeupSelector
{
public static TradeupSelection Solve(
IReadOnlyList<SelectableInput> pool,
int contractSize,
decimal bucketWidth)
{
if (contractSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(contractSize));
}
if (bucketWidth <= 0m)
{
throw new ArgumentOutOfRangeException(nameof(bucketWidth));
}
// Buckets 0..maxBucketPerItem (fraction is clamped to [0,1]); summed across the
// contract gives the DP's second dimension.
var maxBucketPerItem = (int)Math.Ceiling(1m / bucketWidth);
var maxSum = maxBucketPerItem * contractSize;
var items = Trim(pool, bucketWidth, maxBucketPerItem, contractSize);
// dp[c][s] = cheapest cost (double) to choose exactly c items whose bucket sum is s;
// dpPick carries the actual copies so the winner is reconstructed exactly.
var dpCost = new double[contractSize + 1, maxSum + 1];
var dpPick = new PickNode?[contractSize + 1, maxSum + 1];
for (var c = 0; c <= contractSize; c++)
{
for (var s = 0; s <= maxSum; s++)
{
dpCost[c, s] = double.PositiveInfinity;
}
}
dpCost[0, 0] = 0d;
foreach (var (bucket, listing, price) in items)
{
// Descending count + sum so each item is used at most once (0/1 knapsack).
for (var c = contractSize - 1; c >= 0; c--)
{
for (var s = maxSum - bucket; s >= 0; s--)
{
var baseCost = dpCost[c, s];
if (double.IsPositiveInfinity(baseCost))
{
continue;
}
var newCost = baseCost + price;
var ns = s + bucket;
if (newCost < dpCost[c + 1, ns])
{
dpCost[c + 1, ns] = newCost;
dpPick[c + 1, ns] = new PickNode(listing, dpPick[c, s]);
}
}
}
}
var pickBySum = new PickNode?[maxSum + 1];
for (var s = 0; s <= maxSum; s++)
{
pickBySum[s] = dpPick[contractSize, s];
}
return new TradeupSelection(pickBySum, contractSize, bucketWidth);
}
// Within a single fraction bucket only the cheapest `contractSize` copies can ever be
// part of an optimal selection, so drop the rest up front. This bounds the item count
// regardless of how deep a popular skin's order book is.
private static List<(int Bucket, InputListing Listing, double Price)> Trim(
IReadOnlyList<SelectableInput> pool,
decimal bucketWidth,
int maxBucketPerItem,
int contractSize)
{
var byBucket = new Dictionary<int, List<InputListing>>();
foreach (var item in pool)
{
var bucket = BucketOf(item.Fraction, bucketWidth, maxBucketPerItem);
if (!byBucket.TryGetValue(bucket, out var list))
{
list = new List<InputListing>();
byBucket[bucket] = list;
}
list.Add(item.Listing);
}
var trimmed = new List<(int, InputListing, double)>();
foreach (var (bucket, listings) in byBucket)
{
listings.Sort(static (a, b) => a.Price.CompareTo(b.Price));
var take = Math.Min(contractSize, listings.Count);
for (var i = 0; i < take; i++)
{
trimmed.Add((bucket, listings[i], (double)listings[i].Price));
}
}
return trimmed;
}
// Round the fraction UP to a bucket index (conservative: never understates output float).
private static int BucketOf(decimal fraction, decimal bucketWidth, int maxBucketPerItem)
{
var clamped = Math.Clamp(fraction, 0m, 1m);
var bucket = (int)Math.Ceiling(clamped / bucketWidth);
return Math.Clamp(bucket, 0, maxBucketPerItem);
}
/// <summary>
/// One candidate input copy for the multi-collection search: its (already bucketed) float
/// contribution and a per-item reward (its collection's average output value share minus
/// its price, at a fixed output-float target).
/// </summary>
public readonly record struct RewardItem(int Bucket, double Reward, InputListing Listing);
/// <summary>
/// Picks exactly <paramref name="contractSize"/> copies that MAXIMISE total reward subject
/// to the bucketed float sum not exceeding <paramref name="capBucket"/> (i.e. mean float
/// ≤ the target). This is the multi-collection core: with each item's reward set to its
/// collection's value share minus price, the optimum naturally mixes whichever collections
/// pay off — no collection-subset enumeration. Returns the chosen copies, or null if the
/// contract can't be filled within the cap.
/// </summary>
public static PickNode? SolveMaxReward(
IReadOnlyList<RewardItem> items, int contractSize, int capBucket)
{
if (contractSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(contractSize));
}
var maxSum = Math.Max(0, capBucket);
// dp[c][s] = max total reward for exactly c items with bucket sum s (≤ cap).
var dpReward = new double[contractSize + 1, maxSum + 1];
var dpPick = new PickNode?[contractSize + 1, maxSum + 1];
for (var c = 0; c <= contractSize; c++)
{
for (var s = 0; s <= maxSum; s++)
{
dpReward[c, s] = double.NegativeInfinity;
}
}
dpReward[0, 0] = 0d;
foreach (var (bucket, reward, listing) in items)
{
if (bucket > maxSum)
{
continue; // a single copy already over the cap can't be in any valid contract
}
for (var c = contractSize - 1; c >= 0; c--)
{
for (var s = maxSum - bucket; s >= 0; s--)
{
var baseReward = dpReward[c, s];
if (double.IsNegativeInfinity(baseReward))
{
continue;
}
var newReward = baseReward + reward;
var ns = s + bucket;
if (newReward > dpReward[c + 1, ns])
{
dpReward[c + 1, ns] = newReward;
dpPick[c + 1, ns] = new PickNode(listing, dpPick[c, s]);
}
}
}
}
PickNode? best = null;
var bestReward = double.NegativeInfinity;
for (var s = 0; s <= maxSum; s++)
{
if (dpReward[contractSize, s] > bestReward && dpPick[contractSize, s] is { } pick)
{
bestReward = dpReward[contractSize, s];
best = pick;
}
}
return best;
}
}

View File

@@ -0,0 +1,75 @@
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// The ordered weapon-skin rarity tiers that participate in a 10-input tradeup.
/// The ordinal value IS the tier order: a tradeup consumes 10 inputs of tier T and
/// produces an output at the next tier present in the same collection.
/// <para>
/// Only the six weapon tiers are modelled. The catalogue also carries
/// <c>Contraband</c> (the Howl) and <c>Extraordinary</c> (gloves), and knives are
/// stored as <c>Covert</c>; none of those are weapon tradeup tiers, so
/// <see cref="TryParse"/> reports them as "not a weapon tier" rather than mapping
/// them. See the eligibility rules in <see cref="TradeupGraphBuilder"/>.
/// </para>
/// </summary>
public enum WeaponRarity
{
Consumer = 1,
Industrial = 2,
MilSpec = 3,
Restricted = 4,
Classified = 5,
Covert = 6,
}
public static class WeaponRarityExtensions
{
/// <summary>
/// Maps a <c>skins.rarity</c> string literal to its weapon tier.
/// </summary>
/// <returns>
/// <c>true</c> with <paramref name="rarity"/> set when the literal is one of the
/// six weapon tiers; <c>false</c> for <c>Contraband</c>/<c>Extraordinary</c>
/// (valid catalogue rarities that are not weapon tradeup tiers).
/// </returns>
/// <exception cref="ArgumentException">
/// The literal is none of the known catalogue rarities. Thrown deliberately so a
/// catalogue rename surfaces loudly instead of silently dropping a whole tier.
/// </exception>
public static bool TryParse(string rarity, out WeaponRarity result)
{
switch (rarity)
{
case "Consumer Grade":
result = WeaponRarity.Consumer;
return true;
case "Industrial Grade":
result = WeaponRarity.Industrial;
return true;
case "Mil-Spec Grade":
result = WeaponRarity.MilSpec;
return true;
case "Restricted":
result = WeaponRarity.Restricted;
return true;
case "Classified":
result = WeaponRarity.Classified;
return true;
case "Covert":
result = WeaponRarity.Covert;
return true;
// Known, valid catalogue rarities that are not weapon tradeup tiers.
case "Contraband": // The Howl
case "Extraordinary": // Gloves
result = default;
return false;
default:
throw new ArgumentException(
$"Unknown skin rarity literal '{rarity}'. The catalogue may have renamed a "
+ "rarity; update WeaponRarityExtensions.TryParse so a tier isn't silently dropped.",
nameof(rarity));
}
}
}

View File

@@ -0,0 +1,58 @@
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// The five CS2 wear bands, defined by absolute float thresholds (independent of any
/// individual skin's float range). A produced item's band — and therefore which
/// listings it competes with — is read straight off its absolute float.
/// </summary>
public enum WearBand
{
FactoryNew,
MinimalWear,
FieldTested,
WellWorn,
BattleScarred,
}
public static class WearBands
{
// Upper-exclusive boundaries: FN [0,0.07) MW [0.07,0.15) FT [0.15,0.38)
// WW [0.38,0.45) BS [0.45,1.0].
public const decimal MinimalWearFloor = 0.07m;
public const decimal FieldTestedFloor = 0.15m;
public const decimal WellWornFloor = 0.38m;
public const decimal BattleScarredFloor = 0.45m;
/// <summary>The wear band an absolute float value falls into.</summary>
public static WearBand FromFloat(decimal floatValue) => floatValue switch
{
< MinimalWearFloor => WearBand.FactoryNew,
< FieldTestedFloor => WearBand.MinimalWear,
< WellWornFloor => WearBand.FieldTested,
< BattleScarredFloor => WearBand.WellWorn,
_ => WearBand.BattleScarred,
};
/// <summary>The absolute float range [min, max) that defines this band — used to scope a
/// CSFloat query to the band the produced output lands in.</summary>
public static (decimal Min, decimal Max) Bounds(this WearBand band) => band switch
{
WearBand.FactoryNew => (0.00m, MinimalWearFloor),
WearBand.MinimalWear => (MinimalWearFloor, FieldTestedFloor),
WearBand.FieldTested => (FieldTestedFloor, WellWornFloor),
WearBand.WellWorn => (WellWornFloor, BattleScarredFloor),
WearBand.BattleScarred => (BattleScarredFloor, 1.00m),
_ => throw new ArgumentOutOfRangeException(nameof(band), band, null),
};
/// <summary>The full wear name as it appears in listing data ("Factory New", …).</summary>
public static string ToName(this WearBand band) => band switch
{
WearBand.FactoryNew => "Factory New",
WearBand.MinimalWear => "Minimal Wear",
WearBand.FieldTested => "Field-Tested",
WearBand.WellWorn => "Well-Worn",
WearBand.BattleScarred => "Battle-Scarred",
_ => throw new ArgumentOutOfRangeException(nameof(band), band, null),
};
}