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;
///
/// Finds profitable 10-input CS2 tradeup contracts over the live listings. It joins three
/// things: the catalogue-derived (which collections produce
/// what), the active s (what inputs cost and what outputs sell
/// for), and the exact . 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.
///
/// 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 ).
///
///
/// All economics live here, never in a frontend: the CLI and the future web UI both call
/// and only format the returned candidates.
///
///
public sealed class TradeupFinder
{
private readonly SkinTrackerDbContext _db;
private readonly TradeupGraphBuilder _graphBuilder;
private readonly TradeupOptions _options;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger _logger;
public TradeupFinder(
SkinTrackerDbContext db,
TradeupGraphBuilder graphBuilder,
IOptions options,
IServiceProvider serviceProvider,
ILogger logger)
{
_db = db;
_graphBuilder = graphBuilder;
_options = options.Value;
_serviceProvider = serviceProvider;
_logger = logger;
}
///
/// Runs the search and returns candidates ranked best-first.
/// caps the returned list; pass 0 or negative for "all".
///
public async Task> 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();
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;
}
///
/// 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.
///
private TradeupCandidate? EvaluateRecipe(
TradeupInputGroup group,
bool statTrak,
TradeupListingData listingData,
IReadOnlyDictionary 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 BuildPool(
TradeupInputGroup group,
bool statTrak,
TradeupListingData listingData,
IReadOnlyDictionary floatBounds)
{
var pool = new List();
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(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) });
}
///
/// 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.
///
private async Task> EnrichThinOutputsAsync(
List candidates,
IReadOnlyDictionary 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(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(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 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();
}
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 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 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 StatTrakUniverses(StatTrakMode mode) => mode switch
{
StatTrakMode.NonStatTrakOnly => new[] { false },
StatTrakMode.StatTrakOnly => new[] { true },
_ => new[] { false, true },
};
}