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