final
This commit is contained in:
476
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupFinder.cs
Normal file
476
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupFinder.cs
Normal 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 },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user