477 lines
17 KiB
C#
477 lines
17 KiB
C#
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 },
|
|
};
|
|
}
|