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

@@ -11,6 +11,10 @@
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" /> <None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="BlueLaminate.Tests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\BlueLaminate.Core\BlueLaminate.Core.csproj" /> <ProjectReference Include="..\BlueLaminate.Core\BlueLaminate.Core.csproj" />
<ProjectReference Include="..\BlueLaminate.Scraper\BlueLaminate.Scraper.csproj" /> <ProjectReference Include="..\BlueLaminate.Scraper\BlueLaminate.Scraper.csproj" />
@@ -20,6 +24,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting" /> <PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="System.CommandLine" /> <PackageReference Include="System.CommandLine" />
<PackageReference Include="OpenTelemetry" /> <PackageReference Include="OpenTelemetry" />
<PackageReference Include="Spectre.Console" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,267 @@
using BlueLaminate.Cli.Tui;
using BlueLaminate.Core.Options;
using BlueLaminate.Core.Tradeups;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using System.CommandLine;
namespace BlueLaminate.Cli.Commands;
/// <summary>
/// <c>find-tradeups</c>: surfaces the most profitable 10-input tradeup contracts over the
/// live listings. Pure presentation over <see cref="TradeupFinder.FindAsync"/> — all the
/// economics live in the Core engine so the future web UI shares them verbatim. The CLI
/// flags only override <see cref="TradeupOptions"/> for the run.
/// <para>
/// In an interactive terminal it opens the <see cref="TradeupBrowser"/> TUI; pipe the
/// output or pass <c>--plain</c> for the scriptable table dump.
/// </para>
/// </summary>
internal static class FindTradeupsCommand
{
public static Command Build(IHost host)
{
var topOption = new Option<int>("--top")
{
Description = "How many contracts to show.",
DefaultValueFactory = _ => 20,
};
var minProfitOption = new Option<decimal?>("--min-profit")
{
Description = "Only show contracts whose ranking profit clears this amount (USD).",
};
var statTrakOption = new Option<StatTrakMode?>("--stattrak")
{
Description = "Which universes to search: Both, NonStatTrakOnly, or StatTrakOnly.",
};
var rankingOption = new Option<TradeupRanking?>("--rank")
{
Description = "Rank by WorstCaseProfit (guaranteed) or ExpectedProfit.",
};
var allowRiskyOption = new Option<bool>("--allow-risky")
{
Description = "Include contracts that aren't guaranteed-profit (off by default).",
};
var detailOption = new Option<bool>("--detail")
{
Description = "Plain mode only: show the per-output distribution and the copies to buy.",
};
var plainOption = new Option<bool>("--plain")
{
Description = "Force the non-interactive table dump instead of the TUI.",
};
var command = new Command(
"find-tradeups",
"Find profitable 10-input CS2 tradeup contracts from live listings, ranked best-first.")
{
topOption,
minProfitOption,
statTrakOption,
rankingOption,
allowRiskyOption,
detailOption,
plainOption,
};
command.SetAction((parseResult, ct) => RunAsync(
host,
parseResult.GetValue(topOption),
parseResult.GetValue(minProfitOption),
parseResult.GetValue(statTrakOption),
parseResult.GetValue(rankingOption),
parseResult.GetValue(allowRiskyOption),
parseResult.GetValue(detailOption),
parseResult.GetValue(plainOption),
ct));
return command;
}
private static async Task<int> RunAsync(
IHost host,
int top,
decimal? minProfit,
StatTrakMode? statTrak,
TradeupRanking? ranking,
bool allowRisky,
bool detail,
bool plain,
CancellationToken ct)
{
using var scope = host.Services.CreateScope();
// Apply per-run overrides on top of the configured TradeupOptions. The finder reads
// IOptions, so mutate the resolved instance for this scope only.
var options = scope.ServiceProvider.GetRequiredService<IOptions<TradeupOptions>>().Value;
if (minProfit is { } mp)
{
options.MinProfit = mp;
}
if (statTrak is { } st)
{
options.StatTrak = st;
}
if (ranking is { } r)
{
options.Ranking = r;
}
if (allowRisky)
{
options.GuaranteedOnly = false;
}
var interactive = !plain && TradeupBrowser.IsSupported;
// When launched bare (no search-policy flags), open the TUI on its settings screen so
// the options are tweakable in-app. Passing any policy flag skips straight to results
// (the settings screen is still reachable from there via "Adjust & re-run").
var noPolicyFlags = minProfit is null && statTrak is null && ranking is null && !allowRisky;
var showSettings = interactive && noPolicyFlags;
try
{
var finder = scope.ServiceProvider.GetRequiredService<TradeupFinder>();
while (true)
{
if (showSettings && TradeupBrowser.PromptSettings(options, ref top) == SettingsAction.Quit)
{
return 0;
}
Console.WriteLine(
$"Searching tradeups ({options.StatTrak}, rank by {options.Ranking}, "
+ $"{(options.GuaranteedOnly ? "guaranteed-only" : "incl. risky")}, "
+ $"min profit {options.MinProfit:C})…");
Console.WriteLine();
var candidates = await finder.FindAsync(maxResults: top, ct: ct);
if (!interactive)
{
if (candidates.Count == 0)
{
Console.WriteLine("No qualifying contracts found.");
}
else
{
Print(candidates, options.Ranking, detail);
}
return 0;
}
if (candidates.Count == 0)
{
Console.WriteLine("No qualifying contracts found — adjusting settings.");
Console.WriteLine();
showSettings = true;
continue;
}
if (TradeupBrowser.Run(candidates, options) == BrowseAction.Quit)
{
return 0;
}
// BrowseAction.AdjustSettings → loop back to the settings screen and re-search.
showSettings = true;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Tradeup search failed: {ex.Message}");
return 1;
}
}
private static void Print(
IReadOnlyList<TradeupCandidate> candidates, TradeupRanking ranking, bool detail)
{
Console.WriteLine(
$"{"#",-3} {"Collection",-34} {"Recipe",-22} {"ST",-3} "
+ $"{"Cost",10} {"E[net]",10} {"Worst",10} {"E[profit]",11} {"Worst P/L",11} {"G",2}");
Console.WriteLine(new string('-', 140));
var rank = 1;
foreach (var c in candidates)
{
var recipe = c.CollectionCount > 1
? $"{c.InputRarity}→mix×{c.CollectionCount}"
: $"{c.InputRarity}→{c.OutputRarity}";
var guaranteed = c.Guaranteed ? "✓" : " ";
Console.WriteLine(
$"{rank,-3} {Truncate(c.CollectionName, 34),-34} {recipe,-22} "
+ $"{(c.StatTrak ? "ST" : ""),-3} "
+ $"{c.InputCost,10:C} {c.ExpectedNet,10:C} {c.WorstCaseNet,10:C} "
+ $"{c.ExpectedProfit,11:C} {c.WorstCaseProfit,11:C} {guaranteed,2}");
if (detail)
{
PrintDetail(c);
}
rank++;
}
Console.WriteLine();
Console.WriteLine(
$"Ranked by {ranking}. 'G' marks guaranteed (every output clears input cost). "
+ "Sell prices are net of undercut + fee; outputs with no comparable listing are unpriced.");
}
private static void PrintDetail(TradeupCandidate c)
{
if (c.CollectionCount > 1)
{
var mix = string.Join(", ", c.Composition.Select(p =>
$"{p.InputCount}× {p.CollectionName} → {p.OutputRarity}"));
Console.WriteLine($" mix: {mix}");
}
Console.WriteLine(
$" avg input fraction {c.AverageFraction:F4} — "
+ $"possible outputs ({c.Outcomes.Count}):");
foreach (var o in c.Outcomes.OrderByDescending(o => o.NetSellPrice ?? -1m))
{
var price = o.NetSellPrice is { } net ? net.ToString("C") : "(unpriced)";
var source = o.PriceSource switch
{
"csfloat-live" => " (csfloat live)",
"market-floor" => " (floor est)",
_ => string.Empty,
};
Console.WriteLine(
$" {o.Probability,6:P1} {Truncate(o.Name, 44),-44} "
+ $"float {o.OutputFloat:F4} {o.Band,-14} {price,10} liq {o.Liquidity}{source}");
}
// The actionable buy list: each of the ten copies on its own line — exactly what to
// search for, on which market, at what float and price. Sorted cheapest-first.
Console.WriteLine($" buy ({c.Inputs.Count} inputs, total {c.InputCost:C}):");
var n = 1;
foreach (var input in c.Inputs.OrderBy(i => i.Price))
{
var locator = string.IsNullOrWhiteSpace(input.InspectLink)
? $"id {input.ExternalId}"
: input.InspectLink;
Console.WriteLine(
$" {n,2}. {Truncate(input.MarketHashName, 52),-52} "
+ $"float {FullFloat(input.FloatValue),-20} {input.Price,9:C} @ {input.Marketplace,-9} {locator}");
n++;
}
}
private static string Truncate(string value, int max)
=> value.Length <= max ? value : value[..(max - 1)] + "…";
// Full stored listing float (trailing zeros dropped) so a copy is matchable exactly.
private static string FullFloat(decimal value)
=> value.ToString("0.##################", System.Globalization.CultureInfo.InvariantCulture);
}

View File

@@ -72,6 +72,7 @@ var root = new RootCommand("BlueLaminate CLI — Counter-Strike skin tracker too
FetchListingsCommand.Build(host), FetchListingsCommand.Build(host),
SweepListingsCommand.Build(host), SweepListingsCommand.Build(host),
SweepCatalogCommand.Build(host), SweepCatalogCommand.Build(host),
FindTradeupsCommand.Build(host),
}; };
// Ctrl+C → cancel the action's token so long-running commands (e.g. sweep-catalog, // Ctrl+C → cancel the action's token so long-running commands (e.g. sweep-catalog,

View File

@@ -0,0 +1,352 @@
using BlueLaminate.Core.Options;
using BlueLaminate.Core.Tradeups;
using Spectre.Console;
using System.Globalization;
namespace BlueLaminate.Cli.Tui;
/// <summary>What the user chose to do next after browsing results.</summary>
internal enum BrowseAction
{
/// <summary>Leave the finder.</summary>
Quit,
/// <summary>Return to the settings screen and run a fresh search.</summary>
AdjustSettings,
}
/// <summary>What the user chose on the settings screen.</summary>
internal enum SettingsAction
{
/// <summary>Run a search with the current settings.</summary>
RunSearch,
/// <summary>Leave the finder.</summary>
Quit,
}
/// <summary>
/// Interactive terminal browser for tradeup candidates: a settings screen, then an
/// arrow-key navigable list of contracts that drills into a detail view with the output
/// distribution and a line-by-line buy list (each copy linked to its source listing).
/// Pure presentation over the <see cref="TradeupCandidate"/>s the Core engine produces; it
/// only mutates a <see cref="TradeupOptions"/> the command then feeds back to the finder.
/// <para>
/// The render/prompt methods take an <see cref="IAnsiConsole"/> so they can be exercised
/// against a recording console in tests; only the live loop needs the real terminal.
/// </para>
/// </summary>
internal static class TradeupBrowser
{
private const int QuitSentinel = -1;
private const int AdjustSentinel = -2;
/// <summary>Whether the current terminal can host the interactive prompts.</summary>
public static bool IsSupported => AnsiConsole.Profile.Capabilities.Interactive;
/// <summary>
/// Settings screen: shows the current search options and lets the user tweak any of them
/// before running. Mutates <paramref name="options"/> and <paramref name="top"/> in place;
/// returns whether to run a search or quit.
/// </summary>
public static SettingsAction PromptSettings(TradeupOptions options, ref int top)
=> PromptSettings(AnsiConsole.Console, options, ref top);
public static SettingsAction PromptSettings(IAnsiConsole console, TradeupOptions options, ref int top)
{
while (true)
{
console.Clear();
console.Write(new Rule("[bold yellow]Tradeup Finder[/] — settings")
{
Justification = Justify.Left,
});
console.MarkupLine("[grey]Pick a setting to change it, then [green]Run search[/]. (↑/↓ + Enter)[/]");
console.WriteLine();
const string run = "[green]▶ Run search[/]";
const string quit = "[red]✕ Quit[/]";
var stItem = $"StatTrak universe · [aqua]{options.StatTrak}[/]";
var rankItem = $"Rank by · [aqua]{options.Ranking}[/]";
var guarItem = "Profit filter · "
+ (options.GuaranteedOnly ? "[green]guaranteed only[/]" : "[yellow]include risky[/]");
var minItem = $"Min profit · [aqua]{options.MinProfit:C}[/]";
var topItem = $"Show top · [aqua]{top}[/]";
var choice = console.Prompt(
new SelectionPrompt<string>()
.Title("Settings")
.PageSize(12)
.AddChoices(run, stItem, rankItem, guarItem, minItem, topItem, quit));
if (choice == run)
{
return SettingsAction.RunSearch;
}
if (choice == quit)
{
console.Clear();
return SettingsAction.Quit;
}
if (choice == stItem)
{
options.StatTrak = console.Prompt(
new SelectionPrompt<StatTrakMode>()
.Title("StatTrak universe to search")
.AddChoices(StatTrakMode.Both, StatTrakMode.NonStatTrakOnly, StatTrakMode.StatTrakOnly));
}
else if (choice == rankItem)
{
options.Ranking = console.Prompt(
new SelectionPrompt<TradeupRanking>()
.Title("Rank surviving contracts by")
.AddChoices(TradeupRanking.WorstCaseProfit, TradeupRanking.ExpectedProfit));
}
else if (choice == guarItem)
{
options.GuaranteedOnly = console.Prompt(
new SelectionPrompt<string>()
.Title("Which contracts should survive?")
.AddChoices("Guaranteed only (worst output still profits)", "Include risky (any positive EV)"))
.StartsWith("Guaranteed");
}
else if (choice == minItem)
{
options.MinProfit = console.Prompt(
new TextPrompt<decimal>("Minimum ranking profit (USD):")
.DefaultValue(options.MinProfit)
.ShowDefaultValue());
}
else if (choice == topItem)
{
top = console.Prompt(
new TextPrompt<int>("Show how many contracts:")
.DefaultValue(top)
.ShowDefaultValue()
.Validate(v => v > 0 ? ValidationResult.Success() : ValidationResult.Error("must be > 0")));
}
}
}
public static BrowseAction Run(IReadOnlyList<TradeupCandidate> candidates, TradeupOptions options)
=> Run(AnsiConsole.Console, candidates, options);
public static BrowseAction Run(
IAnsiConsole console, IReadOnlyList<TradeupCandidate> candidates, TradeupOptions options)
{
while (true)
{
console.Clear();
RenderHeader(console, candidates.Count, options);
var indices = Enumerable.Range(0, candidates.Count)
.Append(AdjustSentinel)
.Append(QuitSentinel)
.ToList();
var selected = console.Prompt(
new SelectionPrompt<int>()
.Title("Select a contract to inspect its [green]buy list[/]:")
.PageSize(18)
.WrapAround()
.MoreChoicesText("[grey](↑/↓ to scroll, type to filter)[/]")
.AddChoices(indices)
.UseConverter(ListChoiceLabel(candidates)));
switch (selected)
{
case QuitSentinel:
console.Clear();
return BrowseAction.Quit;
case AdjustSentinel:
return BrowseAction.AdjustSettings;
}
RenderDetail(console, candidates[selected], selected);
const string back = "← Back to list";
const string adjust = "⚙ Adjust settings & re-run";
const string quit = "Quit";
var next = console.Prompt(
new SelectionPrompt<string>()
.Title(string.Empty)
.AddChoices(back, adjust, quit));
if (next == quit)
{
console.Clear();
return BrowseAction.Quit;
}
if (next == adjust)
{
return BrowseAction.AdjustSettings;
}
}
}
private static Func<int, string> ListChoiceLabel(IReadOnlyList<TradeupCandidate> candidates) => i => i switch
{
QuitSentinel => "[red]Quit[/]",
AdjustSentinel => "[yellow]⚙ Adjust settings & re-run[/]",
_ => SummaryLine(candidates[i], i),
};
private static void RenderHeader(IAnsiConsole console, int count, TradeupOptions options)
{
console.Write(new Rule($"[bold yellow]Tradeup Finder[/] — {count} contracts")
{
Justification = Justify.Left,
});
console.MarkupLine(
$"[grey]{options.StatTrak} · rank by {options.Ranking} · "
+ $"{(options.GuaranteedOnly ? "guaranteed-only" : "incl. risky")} · "
+ $"sell net of {options.UndercutRate:P0} undercut + {options.SellFeeRate:P0} fee[/]");
console.WriteLine();
}
// One aligned, lightly-coloured line per contract for the selection list. Padding is
// applied to the raw text before markup so columns stay aligned (skin/collection names
// contain no markup brackets in practice; escaped defensively anyway).
internal static string SummaryLine(TradeupCandidate c, int index)
{
var rank = $"{index + 1,3}.";
var collection = Truncate(c.CollectionName, 30).PadRight(30);
var recipeText = c.CollectionCount > 1
? $"{c.InputRarity}→mix×{c.CollectionCount}"
: $"{c.InputRarity}→{c.OutputRarity}";
var recipe = recipeText.PadRight(21);
var st = c.StatTrak ? "ST " : " ";
var worst = Money(c.WorstCaseProfit).PadLeft(10);
var expected = Money(c.ExpectedProfit).PadLeft(10);
var flag = c.Guaranteed ? "[green]✓[/]" : " ";
var worstColor = c.WorstCaseProfit > 0 ? "green" : "red";
return $"{rank} {Markup.Escape(collection)} [aqua]{Markup.Escape(recipe)}[/] {st} "
+ $"cost [silver]{Money(c.InputCost),10}[/] worst [{worstColor}]{worst}[/] "
+ $"exp [green]{expected}[/] {flag}";
}
internal static void RenderDetail(IAnsiConsole console, TradeupCandidate c, int index)
{
console.Clear();
var recipe = c.CollectionCount > 1 ? $"{c.InputRarity}→mix" : $"{c.InputRarity}→{c.OutputRarity}";
var title = $"#{index + 1} [bold]{Markup.Escape(c.CollectionName)}[/] "
+ $"[aqua]{recipe}[/]{(c.StatTrak ? " [orange1]StatTrak[/]" : string.Empty)}";
console.Write(new Rule(title) { Justification = Justify.Left });
if (c.CollectionCount > 1)
{
var mix = string.Join(" ", c.Composition.Select(p =>
$"[silver]{p.InputCount}×[/] {Markup.Escape(p.CollectionName)} [grey]→ {p.OutputRarity}[/]"));
console.MarkupLine($"[grey]mix:[/] {mix}");
}
var worstColor = c.WorstCaseProfit > 0 ? "green" : "red";
var economics = new Grid();
economics.AddColumns(6);
economics.AddRow(
"[grey]Input cost[/]", "[grey]E(net)[/]", "[grey]Worst net[/]",
"[grey]E(profit)[/]", "[grey]Worst P/L[/]", "[grey]Avg float frac[/]");
economics.AddRow(
$"[silver]{Money(c.InputCost)}[/]",
$"{Money(c.ExpectedNet)}",
$"{Money(c.WorstCaseNet)}",
$"[green]{Money(c.ExpectedProfit)}[/]",
$"[{worstColor}]{Money(c.WorstCaseProfit)}[/]",
$"{c.AverageFraction:F4}");
console.Write(new Panel(economics)
{
Header = new PanelHeader(c.Guaranteed ? " [green]GUARANTEED[/] " : " [yellow]not guaranteed[/] "),
Border = BoxBorder.Rounded,
});
RenderOutcomes(console, c);
RenderBuyList(console, c);
}
private static void RenderOutcomes(IAnsiConsole console, TradeupCandidate c)
{
var table = new Table().Border(TableBorder.Minimal).Title("[bold]Possible outputs[/]");
table.AddColumn(new TableColumn("Chance").RightAligned());
table.AddColumn("Output");
table.AddColumn(new TableColumn("Float").RightAligned());
table.AddColumn("Wear");
table.AddColumn(new TableColumn("Net sell").RightAligned());
table.AddColumn(new TableColumn("Liq").RightAligned());
foreach (var o in c.Outcomes.OrderByDescending(o => o.NetSellPrice ?? -1m))
{
var price = o.NetSellPrice is { } net ? $"[green]{Money(net)}[/]" : "[grey](unpriced)[/]";
var liquidity = o.PriceSource switch
{
"csfloat-live" => $"{o.Liquidity} [blue]· csfloat live[/]",
"market-floor" => $"{o.Liquidity} [yellow]· floor est[/]",
_ => o.Liquidity.ToString(),
};
table.AddRow(
$"{o.Probability:P1}",
Markup.Escape(o.Name),
$"{o.OutputFloat:F4}",
Markup.Escape(o.Band.ToName()),
price,
liquidity);
}
console.Write(table);
}
private static void RenderBuyList(IAnsiConsole console, TradeupCandidate c)
{
var table = new Table().Border(TableBorder.Minimal)
.Title($"[bold]Buy list[/] — {c.Inputs.Count} inputs, total [silver]{Money(c.InputCost)}[/]");
table.AddColumn(new TableColumn("#").RightAligned());
table.AddColumn("Item");
table.AddColumn(new TableColumn("Float").RightAligned().NoWrap());
table.AddColumn(new TableColumn("Price").RightAligned());
table.AddColumn("Market");
table.AddColumn("Listing");
var n = 1;
foreach (var input in c.Inputs.OrderBy(i => i.Price))
{
table.AddRow(
n.ToString(),
Markup.Escape(input.MarketHashName),
FullFloat(input.FloatValue),
$"[silver]{Money(input.Price)}[/]",
Markup.Escape(input.Marketplace),
ListingLink(input));
n++;
}
console.Write(table);
}
// A clickable inspect link in terminals that support OSC-8 hyperlinks; otherwise the
// external listing id, so the copy is still traceable.
private static string ListingLink(InputListing input)
{
if (!string.IsNullOrWhiteSpace(input.InspectLink)
&& !input.InspectLink.Contains('[')
&& !input.InspectLink.Contains(']'))
{
return $"[link={input.InspectLink}]inspect[/]";
}
return $"[grey]{Markup.Escape(input.ExternalId)}[/]";
}
private static string Money(decimal value) => value.ToString("C");
// The real listing float at full stored precision (trailing zeros dropped), so a copy can
// be matched exactly on the market. Invariant culture keeps the decimal point a dot.
private static string FullFloat(decimal value)
=> value.ToString("0.##################", CultureInfo.InvariantCulture);
private static string Truncate(string value, int max)
=> value.Length <= max ? value : value[..(max - 1)] + "…";
}

View File

@@ -51,6 +51,8 @@ public static class ServiceCollectionExtensions
.Bind(configuration.GetSection(SkinCatalogOptions.SectionName)); .Bind(configuration.GetSection(SkinCatalogOptions.SectionName));
services.AddOptions<SweepOptions>() services.AddOptions<SweepOptions>()
.Bind(configuration.GetSection(SweepOptions.SectionName)); .Bind(configuration.GetSection(SweepOptions.SectionName));
services.AddOptions<TradeupOptions>()
.Bind(configuration.GetSection(TradeupOptions.SectionName));
// Typed-handler pooling via IHttpClientFactory; clients are scoped so a // Typed-handler pooling via IHttpClientFactory; clients are scoped so a
// command's handler and the service it drives share one instance (and thus // command's handler and the service it drives share one instance (and thus
@@ -73,6 +75,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<CsMoney.CsMoneyIngestService>(); services.AddScoped<CsMoney.CsMoneyIngestService>();
services.AddScoped<CsMoney.MarketPresenceService>(); services.AddScoped<CsMoney.MarketPresenceService>();
services.AddScoped<SkinLand.SkinLandIngestService>(); services.AddScoped<SkinLand.SkinLandIngestService>();
services.AddScoped<Tradeups.TradeupGraphBuilder>();
services.AddScoped<Tradeups.TradeupFinder>();
return services; return services;
} }

View File

@@ -0,0 +1,122 @@
namespace BlueLaminate.Core.Options;
/// <summary>
/// Which StatTrak universes the finder searches. The two input pools are disjoint and
/// never mix in a contract: non-ST inputs (normal souvenir) produce a normal output,
/// ST inputs produce an ST output.
/// </summary>
public enum StatTrakMode
{
/// <summary>Search both the non-ST and ST universes (default).</summary>
Both,
/// <summary>Only the non-ST universe (normal + souvenir inputs → normal output).</summary>
NonStatTrakOnly,
/// <summary>Only the StatTrak universe (ST inputs → ST output).</summary>
StatTrakOnly,
}
/// <summary>
/// How to rank surviving candidates.
/// </summary>
public enum TradeupRanking
{
/// <summary>By worst-case (minimum across outputs) net profit — low variance.</summary>
WorstCaseProfit,
/// <summary>By expected net profit across the output distribution.</summary>
ExpectedProfit,
}
/// <summary>
/// Tuning for the tradeup finder, bound from the <c>Tradeups</c> configuration section.
/// Defaults are sensible for CS2 marketplaces (15% sell fee) and a conservative v1
/// (guaranteed-profit only). Everything here is economics/policy — none of it lives in
/// the CLI.
/// </summary>
public sealed class TradeupOptions
{
public const string SectionName = "Tradeups";
/// <summary>Number of inputs per contract. v1 supports 10-input weapon tradeups only.</summary>
public int ContractSize { get; set; } = 10;
/// <summary>
/// Fraction of the sale price taken as marketplace commission when selling an output
/// (0.15 = 15%). Applied to the realised sell price.
/// </summary>
public decimal SellFeeRate { get; set; } = 0.15m;
/// <summary>
/// Fraction shaved off the lowest active ask to model undercutting it for a quick
/// sale (0.01 = list 1% under the cheapest competitor). Applied before the fee.
/// </summary>
public decimal UndercutRate { get; set; } = 0.01m;
/// <summary>
/// Bucket width used to discretise normalised input fractions for the
/// cardinality-constrained selection DP. Smaller = finer output-float resolution at
/// higher cost. 0.005 resolves the wear boundaries to within 0.005 of output float.
/// </summary>
public decimal FractionBucket { get; set; } = 0.005m;
/// <summary>
/// When true (v1 default) only contracts whose worst-case output still clears input
/// cost survive — a guaranteed profit. When false, any positive-EV contract survives.
/// </summary>
public bool GuaranteedOnly { get; set; } = true;
/// <summary>Minimum net profit (in the listing currency) for a candidate to be reported.</summary>
public decimal MinProfit { get; set; } = 0m;
/// <summary>How surviving candidates are ordered.</summary>
public TradeupRanking Ranking { get; set; } = TradeupRanking.WorstCaseProfit;
/// <summary>Which StatTrak universes to search.</summary>
public StatTrakMode StatTrak { get; set; } = StatTrakMode.Both;
/// <summary>
/// Currency listings must be in to be comparable. The finder ignores listings in
/// other currencies rather than converting (v1 keeps a single money space).
/// </summary>
public string Currency { get; set; } = "USD";
/// <summary>
/// When a proposed output has fewer than this many active listings in our data, its
/// stored lowest-ask is fragile, so the finder re-prices it from the live CSFloat API.
/// </summary>
public int CsFloatThinOutputThreshold { get; set; } = 10;
/// <summary>
/// Enables the live CSFloat re-pricing of thin outputs. Silently inert when no CSFloat
/// API key is configured.
/// </summary>
public bool UseCsFloatForThinOutputs { get; set; } = true;
/// <summary>
/// Hard cap on live CSFloat lookups per search, so the re-pricing pass can't blow the
/// API rate-limit budget. Distinct (skin, ST, wear band) lookups are cached within a run.
/// </summary>
public int CsFloatMaxLookups { get; set; } = 120;
/// <summary>
/// Enables the multi-collection search: alongside the single-collection pass, it mixes
/// inputs from any collections at a rarity tier to maximise expected profit. Off keeps the
/// finder single-collection only.
/// </summary>
public bool MultiCollection { get; set; } = true;
/// <summary>
/// Step of the output-float target grid the multi-collection search sweeps. Each grid
/// point is an independent, parallelised chunk (one knapsack over the tier's pool), so a
/// finer grid is more thorough but does more work. 0.02 ≈ 50 chunks per tier × ST.
/// </summary>
public decimal MultiCollectionFloatGrid { get; set; } = 0.02m;
/// <summary>
/// How many distinct multi-collection contracts to keep per (rarity tier, StatTrak), best
/// expected-profit first, after de-duplicating by collection mix. Caps result volume.
/// </summary>
public int MultiCollectionPerTier { get; set; } = 8;
}

View File

@@ -27,6 +27,13 @@ public static class SkinLandSlug
/// Lowercase, collapse every run of non-alphanumeric characters to a single hyphen, /// Lowercase, collapse every run of non-alphanumeric characters to a single hyphen,
/// and trim leading/trailing hyphens. So "AK-47 | Redline (Field-Tested)" and the /// and trim leading/trailing hyphens. So "AK-47 | Redline (Field-Tested)" and the
/// catalogue's "AK-47 Redline Field-Tested" both reduce to "ak-47-redline-field-tested". /// catalogue's "AK-47 Redline Field-Tested" both reduce to "ak-47-redline-field-tested".
/// <para>
/// The apostrophe is the one exception: skin.land keeps it literally in the slug rather
/// than collapsing it to a hyphen (verified live — "AWP | Man-o'-war" →
/// <c>awp-man-o'-war</c>, "AUG | Lil' Pig" → <c>aug-lil'-pig</c>; the collapsed
/// <c>man-o-war</c>/<c>lil-pig</c> forms 404). Both the ASCII (') and typographic ()
/// apostrophe normalize to a literal ASCII apostrophe in the slug.
/// </para>
/// </summary> /// </summary>
public static string Slugify(string value) public static string Slugify(string value)
{ {
@@ -34,7 +41,14 @@ public static class SkinLandSlug
var pendingHyphen = false; var pendingHyphen = false;
foreach (var ch in value) foreach (var ch in value)
{ {
if (char.IsLetterOrDigit(ch)) if (ch is '\'' or '')
{
// skin.land preserves the apostrophe as part of the word — emit it literally,
// and don't let it trigger a hyphen on either side.
sb.Append('\'');
pendingHyphen = false;
}
else if (char.IsLetterOrDigit(ch))
{ {
if (pendingHyphen && sb.Length > 0) if (pendingHyphen && sb.Length > 0)
{ {

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

View File

@@ -0,0 +1,187 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <summary>
/// cs.money's <c>pricing.default</c> is the post-discount sticker price, which understates
/// what a copy actually costs to buy — so tradeup input costs (and cross-market price
/// comparisons) read low and conjure phantom profit. Switch the csmoney arm of the
/// cross-market view to <c>price_before_discount</c>, falling back to <c>price</c> when no
/// discount was recorded. View-only change; the underlying table keeps both columns.
/// </summary>
public partial class UseCsMoneyPriceBeforeDiscountInMarketView : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
CREATE OR REPLACE VIEW skintracker.market_listings AS
SELECT
'csfloat'::text AS marketplace,
l.cs_float_listing_id AS external_id,
l.skin_id AS skin_id,
l.condition_id AS condition_id,
l.skin_instance_id AS skin_instance_id,
l.market_hash_name AS market_hash_name,
l.wear_name AS wear,
l.float_value AS float_value,
l.paint_seed AS paint_seed,
l.is_stat_trak AS is_stat_trak,
l.is_souvenir AS is_souvenir,
l.sticker_count AS sticker_count,
l.price AS price,
l.currency AS currency,
l.inspect_link AS inspect_link,
l.asset_id AS asset_id,
l.status AS status,
l.first_seen_at AS first_seen_at,
l.last_seen_at AS last_seen_at,
l.removed_at AS removed_at
FROM skintracker.listings l
UNION ALL
SELECT
'csmoney'::text,
c.sell_order_id::text,
c.skin_id,
c.condition_id,
c.skin_instance_id,
c.market_hash_name,
CASE lower(c.quality)
WHEN 'fn' THEN 'Factory New'
WHEN 'mw' THEN 'Minimal Wear'
WHEN 'ft' THEN 'Field-Tested'
WHEN 'ww' THEN 'Well-Worn'
WHEN 'bs' THEN 'Battle-Scarred'
ELSE c.quality
END,
c.float_value,
c.paint_seed,
c.is_stat_trak,
c.is_souvenir,
c.sticker_count,
-- Undiscounted price is the real cost to buy; fall back to price when no
-- discount was recorded.
COALESCE(c.price_before_discount, c.price),
c.currency,
c.inspect_link,
c.asset_id,
c.status,
c.first_seen_at,
c.last_seen_at,
c.removed_at
FROM skintracker.cs_money_listings c
UNION ALL
SELECT
'skinland'::text,
s.listing_id::text,
s.skin_id,
s.condition_id,
NULL::integer,
s.market_hash_name,
sc.condition,
s.float_value,
NULL::integer,
s.is_stat_trak,
s.is_souvenir,
s.sticker_count,
s.price,
s.currency,
s.inspect_link,
NULL::text,
s.status,
s.first_seen_at,
s.last_seen_at,
s.removed_at
FROM skintracker.skin_land_listings s
LEFT JOIN skintracker.skin_conditions sc ON sc.id = s.condition_id;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Restore the csmoney arm to the post-discount pricing.default price.
migrationBuilder.Sql("""
CREATE OR REPLACE VIEW skintracker.market_listings AS
SELECT
'csfloat'::text AS marketplace,
l.cs_float_listing_id AS external_id,
l.skin_id AS skin_id,
l.condition_id AS condition_id,
l.skin_instance_id AS skin_instance_id,
l.market_hash_name AS market_hash_name,
l.wear_name AS wear,
l.float_value AS float_value,
l.paint_seed AS paint_seed,
l.is_stat_trak AS is_stat_trak,
l.is_souvenir AS is_souvenir,
l.sticker_count AS sticker_count,
l.price AS price,
l.currency AS currency,
l.inspect_link AS inspect_link,
l.asset_id AS asset_id,
l.status AS status,
l.first_seen_at AS first_seen_at,
l.last_seen_at AS last_seen_at,
l.removed_at AS removed_at
FROM skintracker.listings l
UNION ALL
SELECT
'csmoney'::text,
c.sell_order_id::text,
c.skin_id,
c.condition_id,
c.skin_instance_id,
c.market_hash_name,
CASE lower(c.quality)
WHEN 'fn' THEN 'Factory New'
WHEN 'mw' THEN 'Minimal Wear'
WHEN 'ft' THEN 'Field-Tested'
WHEN 'ww' THEN 'Well-Worn'
WHEN 'bs' THEN 'Battle-Scarred'
ELSE c.quality
END,
c.float_value,
c.paint_seed,
c.is_stat_trak,
c.is_souvenir,
c.sticker_count,
c.price,
c.currency,
c.inspect_link,
c.asset_id,
c.status,
c.first_seen_at,
c.last_seen_at,
c.removed_at
FROM skintracker.cs_money_listings c
UNION ALL
SELECT
'skinland'::text,
s.listing_id::text,
s.skin_id,
s.condition_id,
NULL::integer,
s.market_hash_name,
sc.condition,
s.float_value,
NULL::integer,
s.is_stat_trak,
s.is_souvenir,
s.sticker_count,
s.price,
s.currency,
s.inspect_link,
NULL::text,
s.status,
s.first_seen_at,
s.last_seen_at,
s.removed_at
FROM skintracker.skin_land_listings s
LEFT JOIN skintracker.skin_conditions sc ON sc.id = s.condition_id;
""");
}
}
}

View File

@@ -0,0 +1,186 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <summary>
/// Spot-checking tradeups against cs.money's live site showed <c>pricing.computed</c> (the
/// reference/market price) — not the post-discount <c>price</c> nor <c>price_before_discount</c>
/// — is the true value of a copy. Switch the csmoney arm of the cross-market view to
/// <c>computed_price</c>, falling back to price_before_discount then price when it's null.
/// View-only change; the underlying table keeps all three price columns.
/// </summary>
public partial class UseCsMoneyComputedPriceInMarketView : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
CREATE OR REPLACE VIEW skintracker.market_listings AS
SELECT
'csfloat'::text AS marketplace,
l.cs_float_listing_id AS external_id,
l.skin_id AS skin_id,
l.condition_id AS condition_id,
l.skin_instance_id AS skin_instance_id,
l.market_hash_name AS market_hash_name,
l.wear_name AS wear,
l.float_value AS float_value,
l.paint_seed AS paint_seed,
l.is_stat_trak AS is_stat_trak,
l.is_souvenir AS is_souvenir,
l.sticker_count AS sticker_count,
l.price AS price,
l.currency AS currency,
l.inspect_link AS inspect_link,
l.asset_id AS asset_id,
l.status AS status,
l.first_seen_at AS first_seen_at,
l.last_seen_at AS last_seen_at,
l.removed_at AS removed_at
FROM skintracker.listings l
UNION ALL
SELECT
'csmoney'::text,
c.sell_order_id::text,
c.skin_id,
c.condition_id,
c.skin_instance_id,
c.market_hash_name,
CASE lower(c.quality)
WHEN 'fn' THEN 'Factory New'
WHEN 'mw' THEN 'Minimal Wear'
WHEN 'ft' THEN 'Field-Tested'
WHEN 'ww' THEN 'Well-Worn'
WHEN 'bs' THEN 'Battle-Scarred'
ELSE c.quality
END,
c.float_value,
c.paint_seed,
c.is_stat_trak,
c.is_souvenir,
c.sticker_count,
-- computed_price is the true market value; fall back when it's missing.
COALESCE(c.computed_price, c.price_before_discount, c.price),
c.currency,
c.inspect_link,
c.asset_id,
c.status,
c.first_seen_at,
c.last_seen_at,
c.removed_at
FROM skintracker.cs_money_listings c
UNION ALL
SELECT
'skinland'::text,
s.listing_id::text,
s.skin_id,
s.condition_id,
NULL::integer,
s.market_hash_name,
sc.condition,
s.float_value,
NULL::integer,
s.is_stat_trak,
s.is_souvenir,
s.sticker_count,
s.price,
s.currency,
s.inspect_link,
NULL::text,
s.status,
s.first_seen_at,
s.last_seen_at,
s.removed_at
FROM skintracker.skin_land_listings s
LEFT JOIN skintracker.skin_conditions sc ON sc.id = s.condition_id;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Restore the csmoney arm to price_before_discount (with price fallback).
migrationBuilder.Sql("""
CREATE OR REPLACE VIEW skintracker.market_listings AS
SELECT
'csfloat'::text AS marketplace,
l.cs_float_listing_id AS external_id,
l.skin_id AS skin_id,
l.condition_id AS condition_id,
l.skin_instance_id AS skin_instance_id,
l.market_hash_name AS market_hash_name,
l.wear_name AS wear,
l.float_value AS float_value,
l.paint_seed AS paint_seed,
l.is_stat_trak AS is_stat_trak,
l.is_souvenir AS is_souvenir,
l.sticker_count AS sticker_count,
l.price AS price,
l.currency AS currency,
l.inspect_link AS inspect_link,
l.asset_id AS asset_id,
l.status AS status,
l.first_seen_at AS first_seen_at,
l.last_seen_at AS last_seen_at,
l.removed_at AS removed_at
FROM skintracker.listings l
UNION ALL
SELECT
'csmoney'::text,
c.sell_order_id::text,
c.skin_id,
c.condition_id,
c.skin_instance_id,
c.market_hash_name,
CASE lower(c.quality)
WHEN 'fn' THEN 'Factory New'
WHEN 'mw' THEN 'Minimal Wear'
WHEN 'ft' THEN 'Field-Tested'
WHEN 'ww' THEN 'Well-Worn'
WHEN 'bs' THEN 'Battle-Scarred'
ELSE c.quality
END,
c.float_value,
c.paint_seed,
c.is_stat_trak,
c.is_souvenir,
c.sticker_count,
COALESCE(c.price_before_discount, c.price),
c.currency,
c.inspect_link,
c.asset_id,
c.status,
c.first_seen_at,
c.last_seen_at,
c.removed_at
FROM skintracker.cs_money_listings c
UNION ALL
SELECT
'skinland'::text,
s.listing_id::text,
s.skin_id,
s.condition_id,
NULL::integer,
s.market_hash_name,
sc.condition,
s.float_value,
NULL::integer,
s.is_stat_trak,
s.is_souvenir,
s.sticker_count,
s.price,
s.currency,
s.inspect_link,
NULL::text,
s.status,
s.first_seen_at,
s.last_seen_at,
s.removed_at
FROM skintracker.skin_land_listings s
LEFT JOIN skintracker.skin_conditions sc ON sc.id = s.condition_id;
""");
}
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\BlueLaminate.Core\BlueLaminate.Core.csproj" />
<ProjectReference Include="..\BlueLaminate.EFCore\BlueLaminate.EFCore.csproj" />
<ProjectReference Include="..\BlueLaminate.Cli\BlueLaminate.Cli.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Spectre.Console.Testing" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,205 @@
using BlueLaminate.Core.Tradeups;
using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace BlueLaminate.Tests.Tradeups;
/// <summary>
/// Graph-derivation rules verified against a synthetic in-memory catalogue — never the
/// live database (see the no-live-DB-perturbation rule). Each test seeds the minimal
/// catalogue shape it needs.
/// </summary>
public class TradeupGraphBuilderTests
{
private static SkinTrackerDbContext NewContext()
{
var options = new DbContextOptionsBuilder<SkinTrackerDbContext>()
.UseInMemoryDatabase($"tradeups-{Guid.NewGuid()}")
.Options;
return new SkinTrackerDbContext(options);
}
private static async Task<TradeupGraph> BuildAsync(SkinTrackerDbContext db)
{
await db.SaveChangesAsync();
var builder = new TradeupGraphBuilder(db, NullLogger<TradeupGraphBuilder>.Instance);
return await builder.BuildAsync();
}
private sealed class Catalogue
{
private int _nextId = 1;
public Weapon Rifle { get; } = new() { Id = 1000, Name = "AK-47", Type = "Rifle", Team = "Both" };
public Weapon Knife { get; } = new() { Id = 1001, Name = "Karambit", Type = "Knives", Team = "Both" };
public Weapon Glove { get; } = new() { Id = 1002, Name = "Sport Gloves", Type = "Gloves", Team = "Both" };
public List<Skin> Skins { get; } = new();
public Skin Add(
Collection collection,
string rarity,
Weapon? weapon = null,
decimal? floatMin = 0.0m,
decimal? floatMax = 1.0m)
{
var skin = new Skin
{
Id = _nextId,
Slug = $"skin-{_nextId}",
Name = $"Skin {_nextId}",
Rarity = rarity,
Weapon = weapon ?? Rifle,
FloatMin = floatMin,
FloatMax = floatMax,
Collections = new List<Collection> { collection },
};
_nextId++;
Skins.Add(skin);
return skin;
}
}
private static Collection NewCollection(string name, string type = "Collection")
=> new() { Name = name, Slug = $"col-{name}", Type = type };
[Fact]
public async Task Resolves_across_a_rarity_gap()
{
// MilSpec + Classified present, Restricted absent → MilSpec must resolve to Classified.
await using var db = NewContext();
var cat = new Catalogue();
var col = NewCollection("Gap");
cat.Add(col, "Mil-Spec Grade");
cat.Add(col, "Classified");
db.Skins.AddRange(cat.Skins);
var graph = await BuildAsync(db);
var group = Assert.Single(graph.Groups);
Assert.Equal(WeaponRarity.MilSpec, group.InputRarity);
Assert.Equal(WeaponRarity.Classified, group.OutputRarity);
}
[Fact]
public async Task Covert_is_an_output_but_never_an_input()
{
await using var db = NewContext();
var cat = new Catalogue();
var col = NewCollection("Tops at Covert");
cat.Add(col, "Restricted");
cat.Add(col, "Classified");
cat.Add(col, "Covert"); // a weapon Covert (eligible output)
db.Skins.AddRange(cat.Skins);
var graph = await BuildAsync(db);
Assert.Contains(graph.Groups, g => g.InputRarity == WeaponRarity.Restricted && g.OutputRarity == WeaponRarity.Classified);
Assert.Contains(graph.Groups, g => g.InputRarity == WeaponRarity.Classified && g.OutputRarity == WeaponRarity.Covert);
Assert.DoesNotContain(graph.Groups, g => g.InputRarity == WeaponRarity.Covert);
}
[Fact]
public async Task Knife_with_covert_rarity_is_excluded_as_output()
{
// The only "Covert" in the collection is a knife → the Covert tier has no eligible
// output, so Classified resolves to nothing and yields no group.
await using var db = NewContext();
var cat = new Catalogue();
var col = NewCollection("Knife Covert");
cat.Add(col, "Classified");
cat.Add(col, "Covert", weapon: cat.Knife);
db.Skins.AddRange(cat.Skins);
var graph = await BuildAsync(db);
Assert.Empty(graph.Groups);
}
[Fact]
public async Task Contraband_and_gloves_are_not_weapon_tiers()
{
await using var db = NewContext();
var cat = new Catalogue();
var col = NewCollection("Howl");
cat.Add(col, "Classified");
cat.Add(col, "Contraband"); // The Howl
cat.Add(col, "Extraordinary", weapon: cat.Glove); // a glove
db.Skins.AddRange(cat.Skins);
var graph = await BuildAsync(db);
// Classified has no higher weapon tier present → no tradeup.
Assert.Empty(graph.Groups);
}
[Fact]
public async Task Skins_without_float_bounds_are_excluded()
{
await using var db = NewContext();
var cat = new Catalogue();
var col = NewCollection("No Float");
cat.Add(col, "Mil-Spec Grade", floatMin: null, floatMax: null); // floatless → not a tier
cat.Add(col, "Classified");
db.Skins.AddRange(cat.Skins);
var graph = await BuildAsync(db);
// The only would-be input tier is excluded, so nothing resolves.
Assert.Empty(graph.Groups);
}
[Fact]
public async Task Limited_edition_pseudo_collection_is_skipped()
{
await using var db = NewContext();
var cat = new Catalogue();
var col = NewCollection("Limited Edition Item");
cat.Add(col, "Mil-Spec Grade");
cat.Add(col, "Classified");
db.Skins.AddRange(cat.Skins);
var graph = await BuildAsync(db);
Assert.Empty(graph.Groups);
}
[Fact]
public async Task Containers_are_not_treated_as_collections()
{
await using var db = NewContext();
var cat = new Catalogue();
var crate = NewCollection("Some Case", type: "Container");
cat.Add(crate, "Mil-Spec Grade");
cat.Add(crate, "Classified");
db.Skins.AddRange(cat.Skins);
var graph = await BuildAsync(db);
// Grouping is by Type='Collection' only; case weapons carry a separate Collection
// source in reality, but a Container source on its own yields nothing.
Assert.Empty(graph.Groups);
}
[Fact]
public async Task Output_skins_carry_float_bounds_and_stattrak_flag()
{
await using var db = NewContext();
var cat = new Catalogue();
var col = NewCollection("Bounds");
cat.Add(col, "Classified");
var covert = cat.Add(col, "Covert", floatMin: 0.0m, floatMax: 0.8m);
covert.StatTrakAvailable = true;
db.Skins.AddRange(cat.Skins);
var graph = await BuildAsync(db);
var group = Assert.Single(graph.Groups);
var output = Assert.Single(group.OutputSkins);
Assert.Equal(0.0m, output.FloatMin);
Assert.Equal(0.8m, output.FloatMax);
Assert.True(output.StatTrakAvailable);
}
}

View File

@@ -0,0 +1,52 @@
using BlueLaminate.Core.Tradeups;
using Xunit;
namespace BlueLaminate.Tests.Tradeups;
public class TradeupMathTests
{
[Fact]
public void NormalizedFraction_maps_value_into_its_own_range()
{
// Mid-point of a 0.060.80 range.
var frac = TradeupMath.NormalizedFraction(0.43m, 0.06m, 0.80m);
Assert.Equal(0.5m, frac, precision: 6);
}
[Theory]
[InlineData(-0.5)] // below min
[InlineData(2.0)] // above max
public void NormalizedFraction_clamps_out_of_range_values(double value)
{
var frac = TradeupMath.NormalizedFraction((decimal)value, 0.0m, 1.0m);
Assert.InRange(frac, 0m, 1m);
}
[Fact]
public void NormalizedFraction_returns_zero_for_zero_width_range()
{
Assert.Equal(0m, TradeupMath.NormalizedFraction(0.3m, 0.3m, 0.3m));
}
[Fact]
public void OutputFloat_maps_average_fraction_onto_output_range()
{
// avg 0.10 onto a 0.000.70 output range → 0.07 (the FN/MW boundary).
var outFloat = TradeupMath.OutputFloat(0.10m, 0.00m, 0.70m);
Assert.Equal(0.07m, outFloat, precision: 6);
}
[Fact]
public void Full_contract_float_math_matches_hand_calculation()
{
// Ten inputs, each normalised to its own range, then averaged and mapped onto the
// output's 0.000.80 range. Five inputs at fraction 0.2 and five at 0.4 → avg 0.3.
var fractions = new[] { 0.2m, 0.2m, 0.2m, 0.2m, 0.2m, 0.4m, 0.4m, 0.4m, 0.4m, 0.4m };
var avg = fractions.Sum() / fractions.Length;
Assert.Equal(0.3m, avg, precision: 6);
var outFloat = TradeupMath.OutputFloat(avg, 0.00m, 0.80m);
Assert.Equal(0.24m, outFloat, precision: 6);
Assert.Equal(WearBand.FieldTested, WearBands.FromFloat(outFloat));
}
}

View File

@@ -0,0 +1,204 @@
using BlueLaminate.Core.Tradeups;
using Xunit;
namespace BlueLaminate.Tests.Tradeups;
public class TradeupSelectorTests
{
private const decimal Bucket = 0.005m;
private static SelectableInput Item(decimal fraction, decimal price)
=> new(fraction, new InputListing(
SkinId: 1,
MarketHashName: "Test Skin",
Marketplace: "test",
InspectLink: null,
ExternalId: "0",
FloatValue: fraction,
Price: price));
[Fact]
public void Cheapest_full_selection_is_the_ten_cheapest_copies()
{
// 12 copies priced 1..12 at assorted fractions. With no binding float target the
// cheapest ten (cost 55) must be attainable at some summed-fraction bucket.
var pool = new List<SelectableInput>();
for (var i = 1; i <= 12; i++)
{
pool.Add(Item(fraction: 0.01m * i, price: i));
}
var selection = TradeupSelector.Solve(pool, contractSize: 10, Bucket);
var selections = selection.Selections().ToList();
Assert.NotEmpty(selections);
var cheapest = selections.MinBy(s => s.Cost);
Assert.Equal(55m, cheapest.Cost);
var picks = cheapest.Picks.ToList();
Assert.Equal(10, picks.Count);
Assert.Equal(Enumerable.Range(1, 10).Select(i => (decimal)i), picks.Select(p => p.Price).OrderBy(p => p));
}
[Fact]
public void Reported_average_fraction_is_a_conservative_upper_bound()
{
var pool = new List<SelectableInput>();
for (var i = 0; i < 10; i++)
{
pool.Add(Item(fraction: 0.123m, price: 1m));
}
var only = Assert.Single(TradeupSelector.Solve(pool, 10, Bucket).Selections());
var trueAverage = only.Picks.ToList().Average(p => p.FloatValue);
Assert.True(only.AverageFraction >= trueAverage,
$"bucketed average {only.AverageFraction} should round up from true {trueAverage}");
// 0.123 rounds up to the 0.125 bucket (0.005 grid).
Assert.Equal(0.125m, only.AverageFraction);
}
[Fact]
public void Selecting_cheaper_low_float_copies_lowers_the_attainable_average()
{
// Cheap high-float copies vs. pricier low-float copies. A lower average is only
// reachable by paying for the low-float set, so a low-average selection must cost
// more than the unconstrained cheapest.
var pool = new List<SelectableInput>();
for (var i = 0; i < 10; i++)
{
pool.Add(Item(fraction: 0.60m, price: 1m)); // cheap, bad float
}
for (var i = 0; i < 10; i++)
{
pool.Add(Item(fraction: 0.10m, price: 5m)); // pricey, good float
}
var selections = TradeupSelector.Solve(pool, 10, Bucket).Selections().ToList();
var cheapest = selections.MinBy(s => s.Cost);
Assert.Equal(10m, cheapest.Cost); // ten cheap copies
Assert.Equal(0.60m, cheapest.AverageFraction);
var lowestFloat = selections.MinBy(s => s.AverageFraction);
Assert.Equal(0.10m, lowestFloat.AverageFraction);
Assert.Equal(50m, lowestFloat.Cost); // forced onto the pricey low-float set
}
[Fact]
public void No_full_selection_when_pool_is_too_small()
{
var pool = Enumerable.Range(0, 5).Select(i => Item(0.2m, i + 1)).ToList();
Assert.Empty(TradeupSelector.Solve(pool, 10, Bucket).Selections());
}
private static TradeupSelector.RewardItem Reward(int bucket, double reward, decimal price = 0m)
=> new(bucket, reward, new InputListing(1, "x", "m", null, "0", 0.1m, price));
[Fact]
public void MaxReward_picks_the_highest_reward_set_within_the_cap()
{
// Six items; pick 3 maximising reward with bucket sum ≤ 6. The three best rewards
// (9, 8, 7) sit at buckets 2,2,2 = 6 ≤ cap, so they win.
var items = new[]
{
Reward(2, 9), Reward(2, 8), Reward(2, 7),
Reward(1, 1), Reward(1, 2), Reward(1, 3),
};
var picks = TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 6);
Assert.NotNull(picks);
Assert.Equal(3, picks!.ToList().Count);
}
[Fact]
public void MaxReward_respects_the_float_cap_even_at_the_cost_of_reward()
{
// The fat-reward items sit at high buckets; the cap forces the low-bucket set.
var items = new[]
{
Reward(5, 100, price: 50m), // too high-float to fit under a tight cap
Reward(1, 5, price: 1m),
Reward(1, 4, price: 1m),
Reward(1, 3, price: 1m),
};
var picks = TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 3);
Assert.NotNull(picks);
var chosen = picks!.ToList();
Assert.Equal(3, chosen.Count);
Assert.All(chosen, p => Assert.Equal(1m, p.Price)); // the three low-float copies
}
[Fact]
public void MaxReward_returns_null_when_the_contract_cannot_be_filled_under_the_cap()
{
// Only two items fit under the cap, but three are required.
var items = new[] { Reward(1, 5), Reward(1, 4), Reward(9, 100) };
Assert.Null(TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 3));
}
}
public class OutputPriceBookTests
{
private static TradeupListingRow Row(WearBand band, bool st, decimal price)
{
// A float squarely inside the band, so Build files it where we expect.
var f = band switch
{
WearBand.FactoryNew => 0.03m,
WearBand.MinimalWear => 0.10m,
WearBand.FieldTested => 0.25m,
WearBand.WellWorn => 0.40m,
_ => 0.60m,
};
return new TradeupListingRow(1, "M4A4 | X-Ray", "csmoney", null, "0", st, false, f, price);
}
[Fact]
public void Liquid_band_uses_its_own_price()
{
// Ten MW listings -> the MW band is trusted at its own lowest ask.
var rows = Enumerable.Range(0, 10).Select(i => Row(WearBand.MinimalWear, true, 90m + i));
var book = TradeupListingData.Build(rows).OutputPrices;
var r = book.Resolve(1, statTrak: true, outputFloat: 0.10m, thinThreshold: 10);
Assert.Equal(OutputPriceBasis.Band, r.Basis);
Assert.Equal(90m, r.LowestAsk);
}
[Fact]
public void Thin_band_falls_back_to_the_skin_overall_floor()
{
// The X-Ray case: two outlier FN listings ($1287) but a liquid MW market (~$90).
// A produced FN output must price off the $90 floor, not the $1287 band outlier.
var rows = new List<TradeupListingRow>
{
Row(WearBand.FactoryNew, true, 1287m),
Row(WearBand.FactoryNew, true, 1290m),
};
rows.AddRange(Enumerable.Range(0, 10).Select(i => Row(WearBand.MinimalWear, true, 90m + i)));
var book = TradeupListingData.Build(rows).OutputPrices;
var r = book.Resolve(1, statTrak: true, outputFloat: 0.0695m, thinThreshold: 10);
Assert.Equal(OutputPriceBasis.Floor, r.Basis);
Assert.Equal(90m, r.LowestAsk); // skin-wide floor, not the $1287 FN outlier
Assert.Equal(2, r.BandLiquidity); // still reports the thin band count → triggers CSFloat
}
[Fact]
public void Unlisted_skin_resolves_to_none()
{
var book = TradeupListingData.Build(Array.Empty<TradeupListingRow>()).OutputPrices;
var r = book.Resolve(1, statTrak: false, outputFloat: 0.1m, thinThreshold: 10);
Assert.Equal(OutputPriceBasis.None, r.Basis);
Assert.Null(r.LowestAsk);
}
}

View File

@@ -0,0 +1,42 @@
using BlueLaminate.Core.Tradeups;
using Xunit;
namespace BlueLaminate.Tests.Tradeups;
public class WeaponRarityTests
{
[Theory]
[InlineData("Consumer Grade", WeaponRarity.Consumer)]
[InlineData("Industrial Grade", WeaponRarity.Industrial)]
[InlineData("Mil-Spec Grade", WeaponRarity.MilSpec)]
[InlineData("Restricted", WeaponRarity.Restricted)]
[InlineData("Classified", WeaponRarity.Classified)]
[InlineData("Covert", WeaponRarity.Covert)]
public void Maps_each_weapon_tier_literal(string literal, WeaponRarity expected)
{
Assert.True(WeaponRarityExtensions.TryParse(literal, out var rarity));
Assert.Equal(expected, rarity);
}
[Theory]
[InlineData("Contraband")] // The Howl
[InlineData("Extraordinary")] // Gloves
public void Reports_non_weapon_rarities_as_not_a_tier(string literal)
{
Assert.False(WeaponRarityExtensions.TryParse(literal, out _));
}
[Fact]
public void Throws_on_unknown_literal_so_a_catalogue_rename_is_loud()
{
Assert.Throws<ArgumentException>(() => WeaponRarityExtensions.TryParse("Mythical", out _));
}
[Fact]
public void Tiers_are_strictly_ordered()
{
Assert.True(WeaponRarity.Consumer < WeaponRarity.Industrial);
Assert.True(WeaponRarity.MilSpec < WeaponRarity.Restricted);
Assert.True(WeaponRarity.Classified < WeaponRarity.Covert);
}
}

View File

@@ -0,0 +1,31 @@
using BlueLaminate.Core.Tradeups;
using Xunit;
namespace BlueLaminate.Tests.Tradeups;
public class WearBandTests
{
[Theory]
[InlineData(0.00, WearBand.FactoryNew)]
[InlineData(0.0699, WearBand.FactoryNew)]
[InlineData(0.07, WearBand.MinimalWear)] // boundary is upper-exclusive
[InlineData(0.1499, WearBand.MinimalWear)]
[InlineData(0.15, WearBand.FieldTested)]
[InlineData(0.3799, WearBand.FieldTested)]
[InlineData(0.38, WearBand.WellWorn)]
[InlineData(0.4499, WearBand.WellWorn)]
[InlineData(0.45, WearBand.BattleScarred)]
[InlineData(1.00, WearBand.BattleScarred)]
public void FromFloat_classifies_on_absolute_thresholds(double value, WearBand expected)
{
Assert.Equal(expected, WearBands.FromFloat((decimal)value));
}
[Fact]
public void ToName_matches_listing_wear_strings()
{
Assert.Equal("Factory New", WearBand.FactoryNew.ToName());
Assert.Equal("Field-Tested", WearBand.FieldTested.ToName());
Assert.Equal("Battle-Scarred", WearBand.BattleScarred.ToName());
}
}

View File

@@ -0,0 +1,113 @@
using BlueLaminate.Cli.Tui;
using BlueLaminate.Core.Tradeups;
using Spectre.Console;
using Spectre.Console.Testing;
using Xunit;
namespace BlueLaminate.Tests.Tui;
/// <summary>
/// Render-path smoke tests for the TUI: the navigation loop needs a live terminal, but the
/// rendering (and its Spectre markup) is exercised against a recording console so a stray
/// markup tag or unescaped name fails the build instead of crashing on first launch.
/// </summary>
public class TradeupBrowserTests
{
private static InputListing Input(string name, string market, string? inspect, decimal price)
=> new(SkinId: 1, MarketHashName: name, Marketplace: market,
InspectLink: inspect, ExternalId: "999", FloatValue: 0.1234m, Price: price);
private static TradeupCandidate Candidate(
string collection = "The Sample Collection",
bool statTrak = false,
bool guaranteed = true,
decimal worstNet = 120m,
IReadOnlyList<TradeupOutcome>? outcomes = null,
IReadOnlyList<InputListing>? inputs = null)
{
outcomes ??= new[]
{
new TradeupOutcome(10, "Output Alpha", 0.0672m, WearBand.FactoryNew, 0.5m, 175.75m, 26),
new TradeupOutcome(11, "Output Beta", 0.0695m, WearBand.FactoryNew, 0.5m, null, 0),
};
inputs ??= new[]
{
Input("AK-47 | Sample (Field-Tested)", "csfloat",
"steam://rungame/730/0/+csgo_econ_action_preview%20ABC123", 1.94m),
Input("MP9 | Sample (Minimal Wear)", "csmoney", null, 6.52m),
};
return new TradeupCandidate(
CollectionId: 1,
CollectionName: collection,
InputRarity: WeaponRarity.Classified,
OutputRarity: WeaponRarity.Covert,
StatTrak: statTrak,
AverageFraction: 0.0695m,
InputCost: 33.16m,
ExpectedNet: 120.53m,
WorstCaseNet: worstNet,
Guaranteed: guaranteed,
Inputs: inputs,
Outcomes: outcomes,
Composition: new[] { new TradeupContribution(1, collection, WeaponRarity.Covert, 10) });
}
[Fact]
public void RenderDetail_emits_the_key_sections_without_markup_errors()
{
var console = new TestConsole();
TradeupBrowser.RenderDetail(console, Candidate(), index: 0);
var output = console.Output;
Assert.Contains("The Sample Collection", output);
Assert.Contains("Possible outputs", output);
Assert.Contains("Buy list", output);
Assert.Contains("Output Alpha", output);
Assert.Contains("unpriced", output); // the null-priced output is shown, not dropped
}
[Theory]
[InlineData(false, true, 120)] // guaranteed, positive worst-case (green)
[InlineData(true, true, 120)] // StatTrak variant
[InlineData(false, false, -40)] // not guaranteed, negative worst-case (red)
public void SummaryLine_is_always_valid_markup(bool statTrak, bool guaranteed, int worstNet)
{
var line = TradeupBrowser.SummaryLine(
Candidate(statTrak: statTrak, guaranteed: guaranteed, worstNet: worstNet), index: 3);
// The Markup ctor parses the string and throws on a malformed tag.
_ = new Markup(line);
}
[Fact]
public void Settings_screen_renders_and_runs_on_enter()
{
var console = new TestConsole().Interactive();
console.Input.PushKey(ConsoleKey.Enter); // first choice is "Run search"
var options = new BlueLaminate.Core.Options.TradeupOptions();
var top = 20;
var action = TradeupBrowser.PromptSettings(console, options, ref top);
Assert.Equal(SettingsAction.RunSearch, action);
Assert.Contains("StatTrak universe", console.Output);
Assert.Contains("Run search", console.Output);
}
[Fact]
public void Names_and_links_with_markup_characters_are_escaped()
{
var console = new TestConsole();
var hostile = Candidate(
collection: "Danger [Collection]",
outcomes: new[] { new TradeupOutcome(10, "Skin [X]", 0.1m, WearBand.MinimalWear, 1m, 5m, 3) },
inputs: new[] { Input("Weapon [Y] (FT)", "market", inspect: null, price: 1m) });
// Would throw a markup-parse exception if any bracketed field reached Spectre unescaped.
TradeupBrowser.RenderDetail(console, hostile, index: 0);
Assert.Contains("Danger", console.Output);
}
}

View File

@@ -4,4 +4,5 @@
<Project Path="BlueLaminate.Core/BlueLaminate.Core.csproj" /> <Project Path="BlueLaminate.Core/BlueLaminate.Core.csproj" />
<Project Path="BlueLaminate.Cli/BlueLaminate.Cli.csproj" /> <Project Path="BlueLaminate.Cli/BlueLaminate.Cli.csproj" />
<Project Path="BlueLaminate.C2/BlueLaminate.C2.csproj" /> <Project Path="BlueLaminate.C2/BlueLaminate.C2.csproj" />
<Project Path="BlueLaminate.Tests/BlueLaminate.Tests.csproj" />
</Solution> </Solution>

View File

@@ -29,6 +29,14 @@
<!-- CLI / telemetry --> <!-- CLI / telemetry -->
<PackageVersion Include="System.CommandLine" Version="2.0.8" /> <PackageVersion Include="System.CommandLine" Version="2.0.8" />
<PackageVersion Include="OpenTelemetry" Version="1.15.3" /> <PackageVersion Include="OpenTelemetry" Version="1.15.3" />
<PackageVersion Include="Spectre.Console" Version="0.55.2" />
<!-- Testing -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="xunit" Version="2.9.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<PackageVersion Include="Spectre.Console.Testing" Version="0.55.2" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -34,7 +34,7 @@ services:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
restart: unless-stopped restart: unless-stopped
worker: csmoney-worker:
build: build:
context: . context: .
dockerfile: worker/Dockerfile dockerfile: worker/Dockerfile
@@ -42,7 +42,7 @@ services:
deploy: deploy:
replicas: ${CSMONEY_WORKERS:-1} replicas: ${CSMONEY_WORKERS:-1}
environment: environment:
WORKER_SCRIPT: csmoney_worker.py # (also the image default; explicit for symmetry) WORKER_SCRIPT: csmoney_worker.py # (also the image default; explicit for symmetry)
C2_URL: http://c2:5080 C2_URL: http://c2:5080
WORKER_TOKEN: ${WORKER_TOKEN:-dev-worker-token} WORKER_TOKEN: ${WORKER_TOKEN:-dev-worker-token}
# IPRoyal residential proxy: each replica self-assigns a unique sticky session # IPRoyal residential proxy: each replica self-assigns a unique sticky session
@@ -51,9 +51,9 @@ services:
IPROYAL_PASSWORD: ${IPROYAL_PASSWORD:-} IPROYAL_PASSWORD: ${IPROYAL_PASSWORD:-}
IPROYAL_COUNTRY: ${IPROYAL_COUNTRY:-us} IPROYAL_COUNTRY: ${IPROYAL_COUNTRY:-us}
IPROYAL_LIFETIME_MIN: ${IPROYAL_LIFETIME_MIN:-60} IPROYAL_LIFETIME_MIN: ${IPROYAL_LIFETIME_MIN:-60}
PROXY: ${PROXY:-} # auth-free host:port fallback (used only when IPRoyal creds are unset) PROXY: ${PROXY:-} # auth-free host:port fallback (used only when IPRoyal creds are unset)
SOLVE_SECONDS: ${SOLVE_SECONDS:-45} SOLVE_SECONDS: ${SOLVE_SECONDS:-45}
LOAD_IMAGES: ${LOAD_IMAGES:-} # set to 1 to re-enable images (debugging) LOAD_IMAGES: ${LOAD_IMAGES:-} # set to 1 to re-enable images (debugging)
depends_on: depends_on:
- c2 - c2
ports: ports:

View File

@@ -36,6 +36,13 @@ class Settings:
browser_path: str | None browser_path: str | None
load_images: bool load_images: bool
chrome_no_sandbox: bool chrome_no_sandbox: bool
# Browser bring-up resilience. nodriver gives Chromium only ~2.75s to open its
# DevTools port before raising "Failed to connect to browser"; when many replicas
# cold-start at once on a CPU-bound host they blow that window. A randomized pre-launch
# delay de-synchronizes the herd, and a few retries cover the residual slow starts.
startup_jitter: float
browser_start_retries: int
browser_start_backoff: float
# Proxy (auth-free fallback) # Proxy (auth-free fallback)
proxy: str | None proxy: str | None
# IPRoyal residential gateway # IPRoyal residential gateway
@@ -69,6 +76,9 @@ class Settings:
# the market APIs are pure JSON — so block images unless explicitly debugging. # the market APIs are pure JSON — so block images unless explicitly debugging.
load_images=_flag("LOAD_IMAGES"), load_images=_flag("LOAD_IMAGES"),
chrome_no_sandbox=_flag("CHROME_NO_SANDBOX"), chrome_no_sandbox=_flag("CHROME_NO_SANDBOX"),
startup_jitter=_float("STARTUP_JITTER", 8.0),
browser_start_retries=_int("BROWSER_START_RETRIES", 4),
browser_start_backoff=_float("BROWSER_START_BACKOFF", 2.0),
proxy=os.environ.get("PROXY") or None, proxy=os.environ.get("PROXY") or None,
iproyal_host=os.environ.get("IPROYAL_HOST", "geo.iproyal.com"), iproyal_host=os.environ.get("IPROYAL_HOST", "geo.iproyal.com"),
iproyal_port=_int("IPROYAL_PORT", 12321), iproyal_port=_int("IPROYAL_PORT", 12321),

View File

@@ -147,6 +147,46 @@ class Worker(ABC):
args += ["--no-sandbox", "--disable-dev-shm-usage"] args += ["--no-sandbox", "--disable-dev-shm-usage"]
return args return args
async def _kill_stray_chromium(self) -> None:
"""Reap a half-launched Chromium left behind by a failed `uc.start()` so retries
(and steady-state memory) don't pile up dead browsers. One browser per container,
so a blanket pkill is safe here."""
try:
proc = await asyncio.create_subprocess_exec(
"pkill", "-f", "chromium",
stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
await proc.wait()
except Exception:
pass
async def _start_browser(self, proxy: str | None):
"""Bring up Chromium with a randomized pre-launch stagger and bounded retries.
nodriver only polls the DevTools port for ~2.75s before giving up, so when many
replicas cold-start simultaneously on a busy host some launches lose the race and
the worker would otherwise exit(1). Staggering spreads the herd; retries (with a
fresh process each time) absorb the rest."""
s = self.settings
if s.startup_jitter > 0:
delay = random.uniform(0, s.startup_jitter)
self.log.info("staggering browser launch by %.1fs", delay)
await asyncio.sleep(delay)
attempts = max(1, s.browser_start_retries)
for attempt in range(1, attempts + 1):
try:
return await uc.start(
headless=False, browser_executable_path=s.browser_path,
browser_args=self._browser_args(proxy))
except Exception as e:
if attempt == attempts:
raise
backoff = s.browser_start_backoff * attempt + random.uniform(0, 1)
self.log.warning("browser launch failed (attempt %d/%d): %s — retrying in %.1fs",
attempt, attempts, e, backoff)
await self._kill_stray_chromium()
await asyncio.sleep(backoff)
async def _on_challenge(self, page) -> None: async def _on_challenge(self, page) -> None:
"""The exit IP is likely flagged. On IPRoyal, rotate to a fresh sticky session """The exit IP is likely flagged. On IPRoyal, rotate to a fresh sticky session
(new IP) before re-warming; otherwise just re-solve in place.""" (new IP) before re-warming; otherwise just re-solve in place."""
@@ -192,9 +232,7 @@ class Worker(ABC):
proxy, proxy_label = await self._setup_proxy() proxy, proxy_label = await self._setup_proxy()
self.log.info("starting (C2=%s, proxy=%s, images=%s)", self.log.info("starting (C2=%s, proxy=%s, images=%s)",
s.c2_url, proxy_label, "on" if s.load_images else "off") s.c2_url, proxy_label, "on" if s.load_images else "off")
browser = await uc.start( browser = await self._start_browser(proxy)
headless=False, browser_executable_path=s.browser_path,
browser_args=self._browser_args(proxy))
try: try:
page = await browser.get("about:blank") page = await browser.get("about:blank")
await self.warm(page) await self.warm(page)