diff --git a/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj b/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj
index 6eeb7fb..7627f0a 100644
--- a/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj
+++ b/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj
@@ -11,6 +11,10 @@
+
+
+
+
@@ -20,6 +24,7 @@
+
diff --git a/BlueLaminate/BlueLaminate.Cli/Commands/FindTradeupsCommand.cs b/BlueLaminate/BlueLaminate.Cli/Commands/FindTradeupsCommand.cs
new file mode 100644
index 0000000..c8e110a
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Cli/Commands/FindTradeupsCommand.cs
@@ -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;
+
+///
+/// find-tradeups: surfaces the most profitable 10-input tradeup contracts over the
+/// live listings. Pure presentation over — all the
+/// economics live in the Core engine so the future web UI shares them verbatim. The CLI
+/// flags only override for the run.
+///
+/// In an interactive terminal it opens the TUI; pipe the
+/// output or pass --plain for the scriptable table dump.
+///
+///
+internal static class FindTradeupsCommand
+{
+ public static Command Build(IHost host)
+ {
+ var topOption = new Option("--top")
+ {
+ Description = "How many contracts to show.",
+ DefaultValueFactory = _ => 20,
+ };
+ var minProfitOption = new Option("--min-profit")
+ {
+ Description = "Only show contracts whose ranking profit clears this amount (USD).",
+ };
+ var statTrakOption = new Option("--stattrak")
+ {
+ Description = "Which universes to search: Both, NonStatTrakOnly, or StatTrakOnly.",
+ };
+ var rankingOption = new Option("--rank")
+ {
+ Description = "Rank by WorstCaseProfit (guaranteed) or ExpectedProfit.",
+ };
+ var allowRiskyOption = new Option("--allow-risky")
+ {
+ Description = "Include contracts that aren't guaranteed-profit (off by default).",
+ };
+ var detailOption = new Option("--detail")
+ {
+ Description = "Plain mode only: show the per-output distribution and the copies to buy.",
+ };
+ var plainOption = new Option("--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 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>().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();
+
+ 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 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);
+}
diff --git a/BlueLaminate/BlueLaminate.Cli/Program.cs b/BlueLaminate/BlueLaminate.Cli/Program.cs
index 41eafa6..68019f9 100644
--- a/BlueLaminate/BlueLaminate.Cli/Program.cs
+++ b/BlueLaminate/BlueLaminate.Cli/Program.cs
@@ -72,6 +72,7 @@ var root = new RootCommand("BlueLaminate CLI — Counter-Strike skin tracker too
FetchListingsCommand.Build(host),
SweepListingsCommand.Build(host),
SweepCatalogCommand.Build(host),
+ FindTradeupsCommand.Build(host),
};
// Ctrl+C → cancel the action's token so long-running commands (e.g. sweep-catalog,
diff --git a/BlueLaminate/BlueLaminate.Cli/Tui/TradeupBrowser.cs b/BlueLaminate/BlueLaminate.Cli/Tui/TradeupBrowser.cs
new file mode 100644
index 0000000..5f43bdc
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Cli/Tui/TradeupBrowser.cs
@@ -0,0 +1,352 @@
+using BlueLaminate.Core.Options;
+using BlueLaminate.Core.Tradeups;
+using Spectre.Console;
+using System.Globalization;
+
+namespace BlueLaminate.Cli.Tui;
+
+/// What the user chose to do next after browsing results.
+internal enum BrowseAction
+{
+ /// Leave the finder.
+ Quit,
+
+ /// Return to the settings screen and run a fresh search.
+ AdjustSettings,
+}
+
+/// What the user chose on the settings screen.
+internal enum SettingsAction
+{
+ /// Run a search with the current settings.
+ RunSearch,
+
+ /// Leave the finder.
+ Quit,
+}
+
+///
+/// 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 s the Core engine produces; it
+/// only mutates a the command then feeds back to the finder.
+///
+/// The render/prompt methods take an so they can be exercised
+/// against a recording console in tests; only the live loop needs the real terminal.
+///
+///
+internal static class TradeupBrowser
+{
+ private const int QuitSentinel = -1;
+ private const int AdjustSentinel = -2;
+
+ /// Whether the current terminal can host the interactive prompts.
+ public static bool IsSupported => AnsiConsole.Profile.Capabilities.Interactive;
+
+ ///
+ /// Settings screen: shows the current search options and lets the user tweak any of them
+ /// before running. Mutates and in place;
+ /// returns whether to run a search or quit.
+ ///
+ 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()
+ .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()
+ .Title("StatTrak universe to search")
+ .AddChoices(StatTrakMode.Both, StatTrakMode.NonStatTrakOnly, StatTrakMode.StatTrakOnly));
+ }
+ else if (choice == rankItem)
+ {
+ options.Ranking = console.Prompt(
+ new SelectionPrompt()
+ .Title("Rank surviving contracts by")
+ .AddChoices(TradeupRanking.WorstCaseProfit, TradeupRanking.ExpectedProfit));
+ }
+ else if (choice == guarItem)
+ {
+ options.GuaranteedOnly = console.Prompt(
+ new SelectionPrompt()
+ .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("Minimum ranking profit (USD):")
+ .DefaultValue(options.MinProfit)
+ .ShowDefaultValue());
+ }
+ else if (choice == topItem)
+ {
+ top = console.Prompt(
+ new TextPrompt("Show how many contracts:")
+ .DefaultValue(top)
+ .ShowDefaultValue()
+ .Validate(v => v > 0 ? ValidationResult.Success() : ValidationResult.Error("must be > 0")));
+ }
+ }
+ }
+
+ public static BrowseAction Run(IReadOnlyList candidates, TradeupOptions options)
+ => Run(AnsiConsole.Console, candidates, options);
+
+ public static BrowseAction Run(
+ IAnsiConsole console, IReadOnlyList 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()
+ .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()
+ .Title(string.Empty)
+ .AddChoices(back, adjust, quit));
+
+ if (next == quit)
+ {
+ console.Clear();
+ return BrowseAction.Quit;
+ }
+
+ if (next == adjust)
+ {
+ return BrowseAction.AdjustSettings;
+ }
+ }
+ }
+
+ private static Func ListChoiceLabel(IReadOnlyList 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)] + "…";
+}
diff --git a/BlueLaminate/BlueLaminate.Core/DependencyInjection/ServiceCollectionExtensions.cs b/BlueLaminate/BlueLaminate.Core/DependencyInjection/ServiceCollectionExtensions.cs
index 18a5db5..cdfc885 100644
--- a/BlueLaminate/BlueLaminate.Core/DependencyInjection/ServiceCollectionExtensions.cs
+++ b/BlueLaminate/BlueLaminate.Core/DependencyInjection/ServiceCollectionExtensions.cs
@@ -51,6 +51,8 @@ public static class ServiceCollectionExtensions
.Bind(configuration.GetSection(SkinCatalogOptions.SectionName));
services.AddOptions()
.Bind(configuration.GetSection(SweepOptions.SectionName));
+ services.AddOptions()
+ .Bind(configuration.GetSection(TradeupOptions.SectionName));
// Typed-handler pooling via IHttpClientFactory; clients are scoped so a
// command's handler and the service it drives share one instance (and thus
@@ -73,6 +75,8 @@ public static class ServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
return services;
}
diff --git a/BlueLaminate/BlueLaminate.Core/Options/TradeupOptions.cs b/BlueLaminate/BlueLaminate.Core/Options/TradeupOptions.cs
new file mode 100644
index 0000000..cb1ddc3
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Options/TradeupOptions.cs
@@ -0,0 +1,122 @@
+namespace BlueLaminate.Core.Options;
+
+///
+/// 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.
+///
+public enum StatTrakMode
+{
+ /// Search both the non-ST and ST universes (default).
+ Both,
+
+ /// Only the non-ST universe (normal + souvenir inputs → normal output).
+ NonStatTrakOnly,
+
+ /// Only the StatTrak universe (ST inputs → ST output).
+ StatTrakOnly,
+}
+
+///
+/// How to rank surviving candidates.
+///
+public enum TradeupRanking
+{
+ /// By worst-case (minimum across outputs) net profit — low variance.
+ WorstCaseProfit,
+
+ /// By expected net profit across the output distribution.
+ ExpectedProfit,
+}
+
+///
+/// Tuning for the tradeup finder, bound from the Tradeups 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.
+///
+public sealed class TradeupOptions
+{
+ public const string SectionName = "Tradeups";
+
+ /// Number of inputs per contract. v1 supports 10-input weapon tradeups only.
+ public int ContractSize { get; set; } = 10;
+
+ ///
+ /// Fraction of the sale price taken as marketplace commission when selling an output
+ /// (0.15 = 15%). Applied to the realised sell price.
+ ///
+ public decimal SellFeeRate { get; set; } = 0.15m;
+
+ ///
+ /// 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.
+ ///
+ public decimal UndercutRate { get; set; } = 0.01m;
+
+ ///
+ /// 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.
+ ///
+ public decimal FractionBucket { get; set; } = 0.005m;
+
+ ///
+ /// 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.
+ ///
+ public bool GuaranteedOnly { get; set; } = true;
+
+ /// Minimum net profit (in the listing currency) for a candidate to be reported.
+ public decimal MinProfit { get; set; } = 0m;
+
+ /// How surviving candidates are ordered.
+ public TradeupRanking Ranking { get; set; } = TradeupRanking.WorstCaseProfit;
+
+ /// Which StatTrak universes to search.
+ public StatTrakMode StatTrak { get; set; } = StatTrakMode.Both;
+
+ ///
+ /// Currency listings must be in to be comparable. The finder ignores listings in
+ /// other currencies rather than converting (v1 keeps a single money space).
+ ///
+ public string Currency { get; set; } = "USD";
+
+ ///
+ /// 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.
+ ///
+ public int CsFloatThinOutputThreshold { get; set; } = 10;
+
+ ///
+ /// Enables the live CSFloat re-pricing of thin outputs. Silently inert when no CSFloat
+ /// API key is configured.
+ ///
+ public bool UseCsFloatForThinOutputs { get; set; } = true;
+
+ ///
+ /// 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.
+ ///
+ public int CsFloatMaxLookups { get; set; } = 120;
+
+ ///
+ /// 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.
+ ///
+ public bool MultiCollection { get; set; } = true;
+
+ ///
+ /// 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.
+ ///
+ public decimal MultiCollectionFloatGrid { get; set; } = 0.02m;
+
+ ///
+ /// 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.
+ ///
+ public int MultiCollectionPerTier { get; set; } = 8;
+}
diff --git a/BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandSlug.cs b/BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandSlug.cs
index dc5e9db..8180491 100644
--- a/BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandSlug.cs
+++ b/BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandSlug.cs
@@ -27,6 +27,13 @@ public static class SkinLandSlug
/// 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
/// catalogue's "AK-47 Redline Field-Tested" both reduce to "ak-47-redline-field-tested".
+ ///
+ /// 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" →
+ /// awp-man-o'-war, "AUG | Lil' Pig" → aug-lil'-pig; the collapsed
+ /// man-o-war/lil-pig forms 404). Both the ASCII (') and typographic (’)
+ /// apostrophe normalize to a literal ASCII apostrophe in the slug.
+ ///
///
public static string Slugify(string value)
{
@@ -34,7 +41,14 @@ public static class SkinLandSlug
var pendingHyphen = false;
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)
{
diff --git a/BlueLaminate/BlueLaminate.Core/Tradeups/MultiCollectionSearch.cs b/BlueLaminate/BlueLaminate.Core/Tradeups/MultiCollectionSearch.cs
new file mode 100644
index 0000000..66d5143
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Tradeups/MultiCollectionSearch.cs
@@ -0,0 +1,409 @@
+using System.Collections.Concurrent;
+using BlueLaminate.Core.Options;
+
+namespace BlueLaminate.Core.Tradeups;
+
+///
+/// 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).
+///
+/// It exploits two facts: an output's probability is linear in how many inputs came from its
+/// collection (n_C / size·k_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.
+///
+///
+public static class MultiCollectionSearch
+{
+ private sealed record CollectionInfo(
+ int CollectionId,
+ string CollectionName,
+ WeaponRarity OutputRarity,
+ IReadOnlyList 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 Evaluate(
+ TradeupGraph graph,
+ TradeupListingData listingData,
+ IReadOnlyDictionary floatBounds,
+ TradeupOptions options,
+ CancellationToken ct)
+ {
+ var step = options.MultiCollectionFloatGrid;
+ var size = options.ContractSize;
+ var maxBucketPerItem = (int)Math.Ceiling(1m / step);
+
+ var results = new List();
+
+ // 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 tierGroups,
+ bool statTrak,
+ TradeupListingData listingData,
+ IReadOnlyDictionary floatBounds,
+ TradeupOptions options,
+ decimal step,
+ int size,
+ int maxBucketPerItem,
+ List 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();
+ 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();
+ for (var f = step; f <= 1m + 1e-9m; f += step)
+ {
+ grid.Add(Math.Min(f, 1m));
+ }
+
+ var chunkResults = new ConcurrentBag();
+
+ 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 BuildTrimmedPool(
+ List tierGroups,
+ bool statTrak,
+ TradeupListingData listingData,
+ IReadOnlyDictionary floatBounds,
+ decimal step,
+ int maxBucketPerItem,
+ int size)
+ {
+ // (collection, bucket) -> cheapest copies.
+ var cells = new Dictionary<(int Collection, int Bucket), List>();
+
+ 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();
+ cells[key] = cell;
+ }
+
+ cell.Add(new PoolItem(group.CollectionId, bucket, fraction, listing));
+ }
+ }
+ }
+
+ var trimmed = new List();
+ 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 trimmed,
+ IReadOnlyDictionary collections,
+ IReadOnlyDictionary skinCollection,
+ IReadOnlyDictionary 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(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(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 collections,
+ IReadOnlyDictionary skinCollection,
+ IReadOnlyDictionary 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();
+ 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();
+ var composition = new List();
+
+ 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 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 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 StatTrakUniverses(StatTrakMode mode) => mode switch
+ {
+ StatTrakMode.NonStatTrakOnly => new[] { false },
+ StatTrakMode.StatTrakOnly => new[] { true },
+ _ => new[] { false, true },
+ };
+}
diff --git a/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupCandidate.cs b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupCandidate.cs
new file mode 100644
index 0000000..4e73273
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupCandidate.cs
@@ -0,0 +1,67 @@
+namespace BlueLaminate.Core.Tradeups;
+
+/// One possible result of a contract and what it would net if it lands.
+/// Chance this specific output is produced (single-collection: 1/k).
+///
+/// Realisable sale value after undercut + sell fee, or null when nothing comparable is
+/// listed (treated as unsellable for the worst-case test).
+///
+/// Active listings backing the price, in the same wear band.
+/// Where the price came from: "market" (our stored listings) or
+/// "csfloat-live" (re-priced from the CSFloat API because the stored liquidity was thin).
+public sealed record TradeupOutcome(
+ int SkinId,
+ string Name,
+ decimal OutputFloat,
+ WearBand Band,
+ decimal Probability,
+ decimal? NetSellPrice,
+ int Liquidity,
+ string PriceSource = "market");
+
+///
+/// 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.
+///
+public sealed record TradeupContribution(
+ int CollectionId,
+ string CollectionName,
+ WeaponRarity OutputRarity,
+ int InputCount);
+
+///
+/// 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.
+///
+/// A contract may mix several collections (all inputs share the input rarity, but each
+/// collection rolls into its own next tier). records the per-
+/// collection split; is its length.
+/// is the tier of the largest contributor (a display convenience for the common case).
+///
+///
+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 Inputs,
+ IReadOnlyList Outcomes,
+ IReadOnlyList Composition)
+{
+ /// Number of distinct collections the inputs are drawn from (1 = single-collection).
+ public int CollectionCount => Composition.Count;
+
+ /// Expected profit across the output distribution, net of cost.
+ public decimal ExpectedProfit => ExpectedNet - InputCost;
+
+ /// Profit if the worst (lowest-value) output lands — negative unless guaranteed.
+ public decimal WorstCaseProfit => WorstCaseNet - InputCost;
+}
diff --git a/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupFinder.cs b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupFinder.cs
new file mode 100644
index 0000000..ae21d89
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupFinder.cs
@@ -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;
+
+///
+/// Finds profitable 10-input CS2 tradeup contracts over the live listings. It joins three
+/// things: the catalogue-derived (which collections produce
+/// what), the active s (what inputs cost and what outputs sell
+/// for), and the exact . For each (collection-recipe, StatTrak)
+/// universe it runs the cardinality-constrained selection DP and values every resulting
+/// output distribution, keeping the best contract per recipe and ranking them.
+///
+/// When a proposed contract's output is thinly listed in our data, its stored lowest-ask is
+/// fragile, so a follow-up pass re-prices that output from the live CSFloat API and
+/// recomputes the economics (see ).
+///
+///
+/// All economics live here, never in a frontend: the CLI and the future web UI both call
+/// and only format the returned candidates.
+///
+///
+public sealed class TradeupFinder
+{
+ private readonly SkinTrackerDbContext _db;
+ private readonly TradeupGraphBuilder _graphBuilder;
+ private readonly TradeupOptions _options;
+ private readonly IServiceProvider _serviceProvider;
+ private readonly ILogger _logger;
+
+ public TradeupFinder(
+ SkinTrackerDbContext db,
+ TradeupGraphBuilder graphBuilder,
+ IOptions options,
+ IServiceProvider serviceProvider,
+ ILogger logger)
+ {
+ _db = db;
+ _graphBuilder = graphBuilder;
+ _options = options.Value;
+ _serviceProvider = serviceProvider;
+ _logger = logger;
+ }
+
+ ///
+ /// Runs the search and returns candidates ranked best-first.
+ /// caps the returned list; pass 0 or negative for "all".
+ ///
+ public async Task> FindAsync(
+ int maxResults = 50,
+ CancellationToken ct = default)
+ {
+ var graph = await _graphBuilder.BuildAsync(ct);
+
+ // Float bounds for input skins, needed to normalise each input copy's float to its
+ // own range before averaging. (Output bounds already live on the graph.)
+ var floatBounds = await _db.Skins
+ .Where(s => s.FloatMin != null && s.FloatMax != null)
+ .Select(s => new { s.Id, Min = s.FloatMin!.Value, Max = s.FloatMax!.Value })
+ .AsNoTracking()
+ .ToDictionaryAsync(s => s.Id, s => (s.Min, s.Max), ct);
+
+ // def_index/paint_index for output skins, so a thin output can be looked up live on
+ // CSFloat (which identifies items by these two indexes).
+ var indexes = await _db.Skins
+ .Where(s => s.DefIndex != null && s.PaintIndex != null)
+ .Select(s => new { s.Id, Def = s.DefIndex!.Value, Paint = s.PaintIndex!.Value })
+ .AsNoTracking()
+ .ToDictionaryAsync(s => s.Id, s => (s.Def, s.Paint), ct);
+
+ var listingData = await LoadListingsAsync(ct);
+
+ var universes = StatTrakUniverses(_options.StatTrak);
+ var candidates = new List();
+
+ foreach (var group in graph.Groups)
+ {
+ foreach (var statTrak in universes)
+ {
+ var candidate = EvaluateRecipe(group, statTrak, listingData, floatBounds);
+ if (candidate is not null)
+ {
+ candidates.Add(candidate);
+ }
+ }
+ }
+
+ // Multi-collection contracts (expected-profit optimised) on top of the single-collection
+ // pass. Skipped under StatTrak filters? No — it respects the same universes internally.
+ if (_options.MultiCollection)
+ {
+ var multi = MultiCollectionSearch.Evaluate(graph, listingData, floatBounds, _options, ct);
+ _logger.LogInformation("Multi-collection search produced {Count} candidate contracts.", multi.Count);
+ candidates.AddRange(multi);
+ }
+
+ var ranked = candidates.OrderByDescending(RankingMetric).ToList();
+
+ // Re-price thin outputs from the live CSFloat API. Only the top window is enriched
+ // (it's where results are shown, and it bounds the live lookups); the rest keep their
+ // stored pricing. Then re-filter and re-rank with the refreshed economics.
+ var window = maxResults > 0 ? Math.Max(maxResults * 3, 60) : ranked.Count;
+ var head = await EnrichThinOutputsAsync(ranked.Take(window).ToList(), indexes, ct);
+
+ ranked = head.Concat(ranked.Skip(window))
+ .Where(c => !_options.GuaranteedOnly || c.Guaranteed)
+ .Where(c => RankingMetric(c) >= _options.MinProfit)
+ .OrderByDescending(RankingMetric)
+ .ToList();
+
+ _logger.LogInformation(
+ "Tradeup search complete: {Surviving} qualifying contracts (guaranteedOnly={Guaranteed}, "
+ + "minProfit={MinProfit}, statTrak={StatTrak}).",
+ ranked.Count, _options.GuaranteedOnly, _options.MinProfit, _options.StatTrak);
+
+ return maxResults > 0 ? ranked.Take(maxResults).ToList() : ranked;
+ }
+
+ ///
+ /// Evaluates one (recipe, StatTrak) universe: builds the input pool, solves the
+ /// selection DP, and returns the best qualifying contract — or null if the recipe can't
+ /// be filled or nothing clears the filters.
+ ///
+ private TradeupCandidate? EvaluateRecipe(
+ TradeupInputGroup group,
+ bool statTrak,
+ TradeupListingData listingData,
+ IReadOnlyDictionary floatBounds)
+ {
+ var pool = BuildPool(group, statTrak, listingData, floatBounds);
+ if (pool.Count < _options.ContractSize)
+ {
+ return null;
+ }
+
+ var selection = TradeupSelector.Solve(pool, _options.ContractSize, _options.FractionBucket);
+
+ TradeupCandidate? best = null;
+ decimal bestMetric = decimal.MinValue;
+
+ foreach (var (averageFraction, cost, picks) in selection.Selections())
+ {
+ var candidate = BuildCandidate(
+ group, statTrak, averageFraction, cost, picks, listingData.OutputPrices);
+
+ if (_options.GuaranteedOnly && !candidate.Guaranteed)
+ {
+ continue;
+ }
+
+ var metric = RankingMetric(candidate);
+ if (metric < _options.MinProfit)
+ {
+ continue;
+ }
+
+ if (metric > bestMetric)
+ {
+ bestMetric = metric;
+ best = candidate;
+ }
+ }
+
+ return best;
+ }
+
+ private List BuildPool(
+ TradeupInputGroup group,
+ bool statTrak,
+ TradeupListingData listingData,
+ IReadOnlyDictionary floatBounds)
+ {
+ var pool = new List();
+
+ foreach (var skinId in group.InputSkinIds)
+ {
+ if (!floatBounds.TryGetValue(skinId, out var bounds))
+ {
+ continue;
+ }
+
+ foreach (var listing in listingData.InputsFor(skinId, statTrak))
+ {
+ var fraction = TradeupMath.NormalizedFraction(listing.FloatValue, bounds.Min, bounds.Max);
+ pool.Add(new SelectableInput(fraction, listing));
+ }
+ }
+
+ return pool;
+ }
+
+ private TradeupCandidate BuildCandidate(
+ TradeupInputGroup group,
+ bool statTrak,
+ decimal averageFraction,
+ decimal cost,
+ PickNode picks,
+ OutputPriceBook priceBook)
+ {
+ var probability = 1m / group.OutputSkins.Count; // single-collection v1: equally likely.
+
+ var outcomes = new List(group.OutputSkins.Count);
+ foreach (var output in group.OutputSkins)
+ {
+ var outputFloat = TradeupMath.OutputFloat(averageFraction, output.FloatMin, output.FloatMax);
+ var band = WearBands.FromFloat(outputFloat);
+ var resolved = priceBook.Resolve(
+ output.SkinId, statTrak, outputFloat, _options.CsFloatThinOutputThreshold);
+
+ outcomes.Add(new TradeupOutcome(
+ output.SkinId,
+ output.Name,
+ outputFloat,
+ band,
+ probability,
+ resolved.LowestAsk is { } ask ? NetSell(ask) : null,
+ resolved.BandLiquidity,
+ resolved.Basis == OutputPriceBasis.Floor ? "market-floor" : "market"));
+ }
+
+ var (expectedNet, worstCaseNet, guaranteed) = Economics(outcomes, cost);
+
+ return new TradeupCandidate(
+ group.CollectionId,
+ group.CollectionName,
+ group.InputRarity,
+ group.OutputRarity,
+ statTrak,
+ averageFraction,
+ cost,
+ expectedNet,
+ worstCaseNet,
+ guaranteed,
+ picks.ToList(),
+ outcomes,
+ new[] { new TradeupContribution(group.CollectionId, group.CollectionName, group.OutputRarity, _options.ContractSize) });
+ }
+
+ ///
+ /// For each candidate with a thinly-listed output (liquidity below the configured
+ /// threshold), fetches that output's current lowest ask from the live CSFloat API and
+ /// recomputes the contract's economics. Distinct (skin, ST, band) lookups are cached and
+ /// the total is capped, so this stays within the API's rate-limit budget. Inert (returns
+ /// the input unchanged) when the feature is off or no CSFloat key is configured.
+ ///
+ private async Task> EnrichThinOutputsAsync(
+ List candidates,
+ IReadOnlyDictionary indexes,
+ CancellationToken ct)
+ {
+ if (!_options.UseCsFloatForThinOutputs || candidates.Count == 0)
+ {
+ return candidates;
+ }
+
+ var client = TryResolveCsFloatClient();
+ if (client is null)
+ {
+ return candidates;
+ }
+
+ var cache = new Dictionary<(int Def, int Paint, bool StatTrak, WearBand Band), BandPrice?>();
+ var lookups = 0;
+ var enriched = 0;
+ var stop = false;
+
+ var result = new List(candidates.Count);
+
+ foreach (var candidate in candidates)
+ {
+ var thin = candidate.Outcomes.Any(o =>
+ o.Liquidity < _options.CsFloatThinOutputThreshold && indexes.ContainsKey(o.SkinId));
+ if (!thin)
+ {
+ result.Add(candidate);
+ continue;
+ }
+
+ var newOutcomes = new List(candidate.Outcomes.Count);
+ var changed = false;
+
+ foreach (var outcome in candidate.Outcomes)
+ {
+ if (outcome.Liquidity >= _options.CsFloatThinOutputThreshold
+ || !indexes.TryGetValue(outcome.SkinId, out var idx))
+ {
+ newOutcomes.Add(outcome);
+ continue;
+ }
+
+ var key = (idx.Def, idx.Paint, candidate.StatTrak, outcome.Band);
+ if (!cache.TryGetValue(key, out var live))
+ {
+ if (stop || lookups >= _options.CsFloatMaxLookups)
+ {
+ newOutcomes.Add(outcome);
+ continue;
+ }
+
+ lookups++;
+ try
+ {
+ live = await FetchCsFloatBandPriceAsync(
+ client, idx.Def, idx.Paint, candidate.StatTrak, outcome.Band, ct);
+ cache[key] = live;
+ }
+ catch (CsFloatApiException ex)
+ {
+ // Rate-limited or rejected — stop hitting the API and keep stored prices.
+ _logger.LogWarning("CSFloat re-pricing halted after {Lookups} lookups: {Message}",
+ lookups, ex.Message);
+ stop = true;
+ newOutcomes.Add(outcome);
+ continue;
+ }
+ }
+
+ if (live is { } bp)
+ {
+ newOutcomes.Add(outcome with
+ {
+ NetSellPrice = NetSell(bp.LowestAsk),
+ Liquidity = bp.Liquidity,
+ PriceSource = "csfloat-live",
+ });
+ changed = true;
+ }
+ else
+ {
+ newOutcomes.Add(outcome);
+ }
+ }
+
+ if (!changed)
+ {
+ result.Add(candidate);
+ continue;
+ }
+
+ var (expectedNet, worstCaseNet, guaranteed) = Economics(newOutcomes, candidate.InputCost);
+ result.Add(candidate with
+ {
+ Outcomes = newOutcomes,
+ ExpectedNet = expectedNet,
+ WorstCaseNet = worstCaseNet,
+ Guaranteed = guaranteed,
+ });
+ enriched++;
+ }
+
+ if (enriched > 0)
+ {
+ _logger.LogInformation(
+ "Re-priced {Enriched} contracts with thin outputs via CSFloat ({Lookups} live lookups).",
+ enriched, lookups);
+ }
+
+ return result;
+ }
+
+ private static async Task FetchCsFloatBandPriceAsync(
+ CsFloatListingsClient client, int defIndex, int paintIndex, bool statTrak, WearBand band, CancellationToken ct)
+ {
+ var (min, max) = band.Bounds();
+
+ // Sorted lowest_price ascending, scoped to the band — so the first listing matching the
+ // ST flag (and not a souvenir) is the lowest comparable ask.
+ var page = await client.FetchPageAsync(
+ defIndex, paintIndex, sortBy: "lowest_price", limit: 50, cursor: null,
+ type: "buy_now", minFloat: min, maxFloat: max, ct: ct);
+
+ decimal? lowest = null;
+ var count = 0;
+ foreach (var listing in page.Listings)
+ {
+ if (listing.IsSouvenir || listing.IsStatTrak != statTrak)
+ {
+ continue;
+ }
+
+ count++;
+ lowest ??= listing.Price;
+ }
+
+ return lowest is { } price ? new BandPrice(price, count) : null;
+ }
+
+ private CsFloatListingsClient? TryResolveCsFloatClient()
+ {
+ try
+ {
+ return _serviceProvider.GetRequiredService();
+ }
+ catch (Exception ex)
+ {
+ // No API key configured (the client's ctor throws): the feature is simply inert.
+ _logger.LogWarning("CSFloat re-pricing unavailable, using stored prices only: {Message}", ex.Message);
+ return null;
+ }
+ }
+
+ // Realisable sale value from a lowest ask: undercut to sell, then pay the marketplace fee.
+ private decimal NetSell(decimal lowestAsk)
+ => lowestAsk * (1m - _options.UndercutRate) * (1m - _options.SellFeeRate);
+
+ private static (decimal Expected, decimal Worst, bool Guaranteed) Economics(
+ IReadOnlyList outcomes, decimal cost)
+ {
+ decimal expected = 0m;
+ decimal worst = decimal.MaxValue;
+ var allPriced = true;
+
+ foreach (var outcome in outcomes)
+ {
+ var realised = outcome.NetSellPrice ?? 0m;
+ expected += outcome.Probability * realised;
+ worst = Math.Min(worst, realised);
+ if (outcome.NetSellPrice is null)
+ {
+ allPriced = false;
+ }
+ }
+
+ if (outcomes.Count == 0)
+ {
+ worst = 0m;
+ }
+
+ // Guaranteed = every output is priced AND even the cheapest one clears input cost.
+ return (expected, worst, allPriced && worst > cost);
+ }
+
+ private async Task LoadListingsAsync(CancellationToken ct)
+ {
+ var rows = await _db.MarketListings
+ .Where(l => l.Status == "Active"
+ && l.Currency == _options.Currency
+ && l.SkinId != null
+ && l.Price > 0m)
+ .Select(l => new TradeupListingRow(
+ l.SkinId!.Value,
+ l.MarketHashName,
+ l.Marketplace,
+ l.InspectLink,
+ l.ExternalId,
+ l.IsStatTrak,
+ l.IsSouvenir,
+ l.FloatValue,
+ l.Price))
+ .ToListAsync(ct);
+
+ _logger.LogInformation("Loaded {Count} active {Currency} listings for tradeup search.",
+ rows.Count, _options.Currency);
+
+ return TradeupListingData.Build(rows);
+ }
+
+ private decimal RankingMetric(TradeupCandidate candidate) => _options.Ranking switch
+ {
+ TradeupRanking.ExpectedProfit => candidate.ExpectedProfit,
+ _ => candidate.WorstCaseProfit,
+ };
+
+ private static IReadOnlyList StatTrakUniverses(StatTrakMode mode) => mode switch
+ {
+ StatTrakMode.NonStatTrakOnly => new[] { false },
+ StatTrakMode.StatTrakOnly => new[] { true },
+ _ => new[] { false, true },
+ };
+}
diff --git a/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraph.cs b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraph.cs
new file mode 100644
index 0000000..8f15556
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraph.cs
@@ -0,0 +1,37 @@
+namespace BlueLaminate.Core.Tradeups;
+
+///
+/// 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.
+/// is recorded so the listing-side query (Phase C) can filter ST vs non-ST outputs;
+/// the graph itself is ST-agnostic.
+///
+public sealed record TradeupOutputSkin(
+ int SkinId,
+ string Name,
+ decimal FloatMin,
+ decimal FloatMax,
+ bool StatTrakAvailable);
+
+///
+/// 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
+/// and each of is an equally likely
+/// outcome, so k_C = OutputSkins.Count.
+///
+public sealed record TradeupInputGroup(
+ int CollectionId,
+ string CollectionName,
+ WeaponRarity InputRarity,
+ WeaponRarity OutputRarity,
+ IReadOnlyList InputSkinIds,
+ IReadOnlyList OutputSkins);
+
+///
+/// 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 ); contains no pricing or listing data.
+///
+public sealed record TradeupGraph(IReadOnlyList Groups);
diff --git a/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraphBuilder.cs b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraphBuilder.cs
new file mode 100644
index 0000000..04b8c10
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraphBuilder.cs
@@ -0,0 +1,197 @@
+using BlueLaminate.EFCore.Data;
+using BlueLaminate.EFCore.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace BlueLaminate.Core.Tradeups;
+
+///
+/// Derives the from the synced catalogue
+/// ( + + ) 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
+/// SkinSyncService runs (monthly), callers can build this once and cache it for
+/// the process lifetime.
+///
+public sealed class TradeupGraphBuilder
+{
+ ///
+ /// 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.
+ ///
+ private static readonly HashSet SkipCollectionNames = new(StringComparer.Ordinal)
+ {
+ "Limited Edition Item",
+ };
+
+ ///
+ /// Weapon categories that carry weapon-tier rarities but are never weapon tradeup
+ /// outputs: knives are stored as Covert and gloves as Extraordinary.
+ /// Excluded defensively even though the rarity/float filters already drop most.
+ ///
+ private static readonly HashSet ExcludedWeaponTypes = new(StringComparer.Ordinal)
+ {
+ "Knives",
+ "Gloves",
+ };
+
+ private const string CollectionType = "Collection";
+
+ private readonly SkinTrackerDbContext _db;
+ private readonly ILogger _logger;
+
+ public TradeupGraphBuilder(SkinTrackerDbContext db, ILogger logger)
+ {
+ _db = db;
+ _logger = logger;
+ }
+
+ public async Task 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();
+
+ 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();
+
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
+ /// The smallest present tier strictly greater than , or null.
+ private static WeaponRarity? NextPresentTier(List 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> SkinsByRarity { get; } = new();
+
+ public void Add(WeaponRarity rarity, Skin skin)
+ {
+ if (!SkinsByRarity.TryGetValue(rarity, out var list))
+ {
+ list = new List();
+ SkinsByRarity[rarity] = list;
+ }
+
+ list.Add(skin);
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupListingData.cs b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupListingData.cs
new file mode 100644
index 0000000..2c66e11
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupListingData.cs
@@ -0,0 +1,203 @@
+namespace BlueLaminate.Core.Tradeups;
+
+/// One active listing reduced to the fields the finder needs.
+public readonly record struct TradeupListingRow(
+ int SkinId,
+ string MarketHashName,
+ string Marketplace,
+ string? InspectLink,
+ string ExternalId,
+ bool IsStatTrak,
+ bool IsSouvenir,
+ decimal? FloatValue,
+ decimal Price);
+
+///
+/// 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.
+///
+public readonly record struct InputListing(
+ int SkinId,
+ string MarketHashName,
+ string Marketplace,
+ string? InspectLink,
+ string ExternalId,
+ decimal FloatValue,
+ decimal Price);
+
+/// Lowest active ask and listing count for one (skin, ST, wear band).
+public readonly record struct BandPrice(decimal LowestAsk, int Liquidity);
+
+/// Where a resolved output price came from.
+public enum OutputPriceBasis
+{
+ /// Nothing comparable is listed anywhere — unpriceable.
+ None,
+
+ /// The wear band the output lands in, which is liquid enough to trust.
+ Band,
+
+ ///
+ /// 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.
+ ///
+ Floor,
+}
+
+/// An output price plus how thin its own band was and where the number came from.
+public readonly record struct ResolvedOutputPrice(decimal? LowestAsk, int BandLiquidity, OutputPriceBasis Basis);
+
+///
+/// The listing-side inputs to the finder (Phase B/C data), built once from a single scan
+/// of active listings:
+///
+/// - input pools — every floated input copy, split into the disjoint non-ST and ST
+/// universes (non-ST = normal ∪ souvenir);
+/// - an — the lowest non-souvenir ask per (skin, ST,
+/// wear band), used to value a produced output at its computed float.
+///
+/// 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.
+///
+public sealed class TradeupListingData
+{
+ private readonly IReadOnlyDictionary> _nonStatTrakInputs;
+ private readonly IReadOnlyDictionary> _statTrakInputs;
+ private readonly OutputPriceBook _outputPrices;
+
+ private TradeupListingData(
+ IReadOnlyDictionary> nonStatTrakInputs,
+ IReadOnlyDictionary> statTrakInputs,
+ OutputPriceBook outputPrices)
+ {
+ _nonStatTrakInputs = nonStatTrakInputs;
+ _statTrakInputs = statTrakInputs;
+ _outputPrices = outputPrices;
+ }
+
+ public OutputPriceBook OutputPrices => _outputPrices;
+
+ /// All purchasable input copies of in the given universe.
+ public IReadOnlyList InputsFor(int skinId, bool statTrak)
+ {
+ var pool = statTrak ? _statTrakInputs : _nonStatTrakInputs;
+ return pool.TryGetValue(skinId, out var listings) ? listings : Array.Empty();
+ }
+
+ public static TradeupListingData Build(IEnumerable rows)
+ {
+ var nonStInputs = new Dictionary>();
+ var stInputs = new Dictionary>();
+ 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();
+ 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);
+ }
+}
+
+///
+/// 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.
+///
+public sealed class OutputPriceBook
+{
+ private readonly Dictionary<(int SkinId, bool StatTrak), Dictionary> _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();
+ _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);
+ }
+
+ ///
+ /// The lowest ask for the given skin/ST in the wear band that
+ /// lands in, or null when nothing comparable is listed.
+ ///
+ 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;
+ }
+
+ ///
+ /// Resolves an output's value: the band price when the band is liquid enough
+ /// (≥ 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
+ /// is always the band's own count, so a thin result still triggers live CSFloat re-pricing.
+ ///
+ 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);
+}
diff --git a/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupMath.cs b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupMath.cs
new file mode 100644
index 0000000..388fff5
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupMath.cs
@@ -0,0 +1,38 @@
+namespace BlueLaminate.Core.Tradeups;
+
+///
+/// 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.
+///
+/// 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).
+///
+///
+public static class TradeupMath
+{
+ ///
+ /// Normalises an input float to the fraction of its own skin's wear range:
+ /// (value − min) / (max − min), clamped to [0,1]. A zero-width range
+ /// (min == max) has no meaningful fraction and yields 0.
+ ///
+ 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);
+ }
+
+ ///
+ /// Maps an average input fraction onto an output skin's wear range to get the exact
+ /// float the tradeup would produce: avgFraction × (max − min) + min.
+ ///
+ public static decimal OutputFloat(decimal averageFraction, decimal outputFloatMin, decimal outputFloatMax)
+ => averageFraction * (outputFloatMax - outputFloatMin) + outputFloatMin;
+}
diff --git a/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupSelector.cs b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupSelector.cs
new file mode 100644
index 0000000..c94658b
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Tradeups/TradeupSelector.cs
@@ -0,0 +1,278 @@
+namespace BlueLaminate.Core.Tradeups;
+
+/// One candidate input copy, with its normalised fraction precomputed.
+public readonly record struct SelectableInput(decimal Fraction, InputListing Listing);
+
+///
+/// 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.
+///
+public sealed record PickNode(InputListing Listing, PickNode? Previous)
+{
+ public IReadOnlyList ToList()
+ {
+ var items = new List();
+ for (var node = this; node is not null; node = node.Previous)
+ {
+ items.Add(node.Listing);
+ }
+
+ items.Reverse();
+ return items;
+ }
+
+ /// Exact total cost of the chosen copies (the DP minimises this in double).
+ public decimal TotalCost()
+ {
+ var total = 0m;
+ for (var node = this; node is not null; node = node.Previous)
+ {
+ total += node.Listing.Price;
+ }
+
+ return total;
+ }
+}
+
+///
+/// The result of the selection DP: for every reachable summed-fraction bucket, the
+/// cheapest way to pick exactly 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.
+///
+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; }
+
+ ///
+ /// Every feasible full selection: the (conservative) average input fraction, the total
+ /// input cost, and the exact copies to buy.
+ ///
+ 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);
+ }
+ }
+ }
+}
+
+///
+/// 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 double for speed; the exact decimal cost is
+/// recovered from the reconstructed picks.
+///
+public static class TradeupSelector
+{
+ public static TradeupSelection Solve(
+ IReadOnlyList 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 pool,
+ decimal bucketWidth,
+ int maxBucketPerItem,
+ int contractSize)
+ {
+ var byBucket = new Dictionary>();
+ foreach (var item in pool)
+ {
+ var bucket = BucketOf(item.Fraction, bucketWidth, maxBucketPerItem);
+ if (!byBucket.TryGetValue(bucket, out var list))
+ {
+ list = new List();
+ 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);
+ }
+
+ ///
+ /// 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).
+ ///
+ public readonly record struct RewardItem(int Bucket, double Reward, InputListing Listing);
+
+ ///
+ /// Picks exactly copies that MAXIMISE total reward subject
+ /// to the bucketed float sum not exceeding (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.
+ ///
+ public static PickNode? SolveMaxReward(
+ IReadOnlyList 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;
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Core/Tradeups/WeaponRarity.cs b/BlueLaminate/BlueLaminate.Core/Tradeups/WeaponRarity.cs
new file mode 100644
index 0000000..d10e9d2
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Tradeups/WeaponRarity.cs
@@ -0,0 +1,75 @@
+namespace BlueLaminate.Core.Tradeups;
+
+///
+/// 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.
+///
+/// Only the six weapon tiers are modelled. The catalogue also carries
+/// Contraband (the Howl) and Extraordinary (gloves), and knives are
+/// stored as Covert; none of those are weapon tradeup tiers, so
+/// reports them as "not a weapon tier" rather than mapping
+/// them. See the eligibility rules in .
+///
+///
+public enum WeaponRarity
+{
+ Consumer = 1,
+ Industrial = 2,
+ MilSpec = 3,
+ Restricted = 4,
+ Classified = 5,
+ Covert = 6,
+}
+
+public static class WeaponRarityExtensions
+{
+ ///
+ /// Maps a skins.rarity string literal to its weapon tier.
+ ///
+ ///
+ /// true with set when the literal is one of the
+ /// six weapon tiers; false for Contraband/Extraordinary
+ /// (valid catalogue rarities that are not weapon tradeup tiers).
+ ///
+ ///
+ /// The literal is none of the known catalogue rarities. Thrown deliberately so a
+ /// catalogue rename surfaces loudly instead of silently dropping a whole tier.
+ ///
+ 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));
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Core/Tradeups/WearBand.cs b/BlueLaminate/BlueLaminate.Core/Tradeups/WearBand.cs
new file mode 100644
index 0000000..a636f00
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Tradeups/WearBand.cs
@@ -0,0 +1,58 @@
+namespace BlueLaminate.Core.Tradeups;
+
+///
+/// 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.
+///
+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;
+
+ /// The wear band an absolute float value falls into.
+ public static WearBand FromFloat(decimal floatValue) => floatValue switch
+ {
+ < MinimalWearFloor => WearBand.FactoryNew,
+ < FieldTestedFloor => WearBand.MinimalWear,
+ < WellWornFloor => WearBand.FieldTested,
+ < BattleScarredFloor => WearBand.WellWorn,
+ _ => WearBand.BattleScarred,
+ };
+
+ /// The absolute float range [min, max) that defines this band — used to scope a
+ /// CSFloat query to the band the produced output lands in.
+ 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),
+ };
+
+ /// The full wear name as it appears in listing data ("Factory New", …).
+ 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),
+ };
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260602031407_UseCsMoneyPriceBeforeDiscountInMarketView.Designer.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260602031407_UseCsMoneyPriceBeforeDiscountInMarketView.Designer.cs
new file mode 100644
index 0000000..ebef2b2
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260602031407_UseCsMoneyPriceBeforeDiscountInMarketView.Designer.cs
@@ -0,0 +1,1347 @@
+//
+using System;
+using BlueLaminate.EFCore.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace BlueLaminate.EFCore.Migrations
+{
+ [DbContext(typeof(SkinTrackerDbContext))]
+ [Migration("20260602031407_UseCsMoneyPriceBeforeDiscountInMarketView")]
+ partial class UseCsMoneyPriceBeforeDiscountInMarketView
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("skintracker")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_collections");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_collections_slug");
+
+ b.ToTable("collections", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.CsMoneyListing", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AssetId")
+ .HasColumnType("text")
+ .HasColumnName("asset_id");
+
+ b.Property("ComputedPrice")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("computed_price");
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("currency");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("FloatValue")
+ .HasColumnType("numeric(20,18)")
+ .HasColumnName("float_value");
+
+ b.Property("InspectLink")
+ .HasColumnType("text")
+ .HasColumnName("inspect_link");
+
+ b.Property("IsSouvenir")
+ .HasColumnType("boolean")
+ .HasColumnName("is_souvenir");
+
+ b.Property("IsStatTrak")
+ .HasColumnType("boolean")
+ .HasColumnName("is_stat_trak");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at");
+
+ b.Property("MarketHashName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("market_hash_name");
+
+ b.Property("PaintSeed")
+ .HasColumnType("integer")
+ .HasColumnName("paint_seed");
+
+ b.Property("Phase")
+ .HasColumnType("text")
+ .HasColumnName("phase");
+
+ b.Property("Price")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price");
+
+ b.Property("PriceBeforeDiscount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price_before_discount");
+
+ b.Property("Quality")
+ .HasColumnType("text")
+ .HasColumnName("quality");
+
+ b.Property("RemovedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("removed_at");
+
+ b.Property("SellOrderId")
+ .HasColumnType("bigint")
+ .HasColumnName("sell_order_id");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("SkinInstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_instance_id");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("status");
+
+ b.Property("StickerCount")
+ .HasColumnType("integer")
+ .HasColumnName("sticker_count");
+
+ b.HasKey("Id")
+ .HasName("pk_cs_money_listings");
+
+ b.HasIndex("AssetId")
+ .HasDatabaseName("ix_cs_money_listings_asset_id");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_cs_money_listings_condition_id");
+
+ b.HasIndex("SellOrderId")
+ .IsUnique()
+ .HasDatabaseName("ix_cs_money_listings_sell_order_id");
+
+ b.HasIndex("SkinInstanceId")
+ .HasDatabaseName("ix_cs_money_listings_skin_instance_id");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("ix_cs_money_listings_status");
+
+ b.HasIndex("SkinId", "ConditionId")
+ .HasDatabaseName("ix_cs_money_listings_skin_id_condition_id");
+
+ b.ToTable("cs_money_listings", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AcquiredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("acquired_at");
+
+ b.Property("AssetId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("asset_id");
+
+ b.Property("SkinInstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_instance_id");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_inventory_items");
+
+ b.HasIndex("AssetId")
+ .IsUnique()
+ .HasDatabaseName("ix_inventory_items_asset_id");
+
+ b.HasIndex("SkinInstanceId")
+ .HasDatabaseName("ix_inventory_items_skin_instance_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_inventory_items_user_id");
+
+ b.ToTable("inventory_items", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AssetId")
+ .HasColumnType("text")
+ .HasColumnName("asset_id");
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("CsFloatListingId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("cs_float_listing_id");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("currency");
+
+ b.Property("DefIndex")
+ .HasColumnType("integer")
+ .HasColumnName("def_index");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("FloatValue")
+ .HasColumnType("numeric(20,18)")
+ .HasColumnName("float_value");
+
+ b.Property("InspectLink")
+ .HasColumnType("text")
+ .HasColumnName("inspect_link");
+
+ b.Property("IsSouvenir")
+ .HasColumnType("boolean")
+ .HasColumnName("is_souvenir");
+
+ b.Property("IsStatTrak")
+ .HasColumnType("boolean")
+ .HasColumnName("is_stat_trak");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at");
+
+ b.Property("ListedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("listed_at");
+
+ b.Property("MarketHashName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("market_hash_name");
+
+ b.Property("PaintIndex")
+ .HasColumnType("integer")
+ .HasColumnName("paint_index");
+
+ b.Property("PaintSeed")
+ .HasColumnType("integer")
+ .HasColumnName("paint_seed");
+
+ b.Property("Price")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price");
+
+ b.Property("RemovedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("removed_at");
+
+ b.Property("SellerSteamId")
+ .HasColumnType("text")
+ .HasColumnName("seller_steam_id");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("SkinInstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_instance_id");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("status");
+
+ b.Property("StickerCount")
+ .HasColumnType("integer")
+ .HasColumnName("sticker_count");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.Property("WearName")
+ .HasColumnType("text")
+ .HasColumnName("wear_name");
+
+ b.HasKey("Id")
+ .HasName("pk_listings");
+
+ b.HasIndex("AssetId")
+ .HasDatabaseName("ix_listings_asset_id");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_listings_condition_id");
+
+ b.HasIndex("CsFloatListingId")
+ .IsUnique()
+ .HasDatabaseName("ix_listings_cs_float_listing_id");
+
+ b.HasIndex("SkinId")
+ .HasDatabaseName("ix_listings_skin_id");
+
+ b.HasIndex("SkinInstanceId")
+ .HasDatabaseName("ix_listings_skin_instance_id");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("ix_listings_status");
+
+ b.HasIndex("DefIndex", "PaintIndex")
+ .HasDatabaseName("ix_listings_def_index_paint_index");
+
+ b.ToTable("listings", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.MarketListing", b =>
+ {
+ b.Property("AssetId")
+ .HasColumnType("text")
+ .HasColumnName("asset_id");
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("currency");
+
+ b.Property("ExternalId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("external_id");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("FloatValue")
+ .HasColumnType("numeric")
+ .HasColumnName("float_value");
+
+ b.Property("InspectLink")
+ .HasColumnType("text")
+ .HasColumnName("inspect_link");
+
+ b.Property("IsSouvenir")
+ .HasColumnType("boolean")
+ .HasColumnName("is_souvenir");
+
+ b.Property("IsStatTrak")
+ .HasColumnType("boolean")
+ .HasColumnName("is_stat_trak");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at");
+
+ b.Property("MarketHashName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("market_hash_name");
+
+ b.Property("Marketplace")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("marketplace");
+
+ b.Property("PaintSeed")
+ .HasColumnType("integer")
+ .HasColumnName("paint_seed");
+
+ b.Property("Price")
+ .HasColumnType("numeric")
+ .HasColumnName("price");
+
+ b.Property("RemovedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("removed_at");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("SkinInstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_instance_id");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("status");
+
+ b.Property("StickerCount")
+ .HasColumnType("integer")
+ .HasColumnName("sticker_count");
+
+ b.Property("Wear")
+ .HasColumnType("text")
+ .HasColumnName("wear");
+
+ b.ToTable((string)null);
+
+ b.ToView("market_listings", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("currency");
+
+ b.Property("Price")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price");
+
+ b.Property("RecordedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("recorded_at");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_price_histories");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_price_histories_condition_id");
+
+ b.HasIndex("SkinId", "ConditionId", "RecordedAt")
+ .HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
+
+ b.ToTable("price_histories", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ItemCount")
+ .HasColumnType("integer")
+ .HasColumnName("item_count");
+
+ b.Property("RanAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("ran_at");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_scrape_runs");
+
+ b.HasIndex("Source", "RanAt")
+ .HasDatabaseName("ix_scrape_runs_source_ran_at");
+
+ b.ToTable("scrape_runs", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DefIndex")
+ .HasColumnType("integer")
+ .HasColumnName("def_index");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("FloatMax")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_max");
+
+ b.Property("FloatMin")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_min");
+
+ b.Property("ImageUrl")
+ .HasColumnType("text")
+ .HasColumnName("image_url");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("PaintIndex")
+ .HasColumnType("integer")
+ .HasColumnName("paint_index");
+
+ b.Property("Rarity")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("rarity");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("SouvenirAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("souvenir_available");
+
+ b.Property("StatTrakAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("stat_trak_available");
+
+ b.Property("TrueFloat")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("boolean")
+ .HasColumnName("true_float")
+ .HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
+
+ b.Property("WeaponId")
+ .HasColumnType("integer")
+ .HasColumnName("weapon_id");
+
+ b.HasKey("Id")
+ .HasName("pk_skins");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_skins_slug");
+
+ b.HasIndex("TrueFloat")
+ .HasDatabaseName("ix_skins_true_float");
+
+ b.HasIndex("WeaponId")
+ .HasDatabaseName("ix_skins_weapon_id");
+
+ b.HasIndex("DefIndex", "PaintIndex")
+ .IsUnique()
+ .HasDatabaseName("ix_skins_def_index_paint_index")
+ .HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
+
+ b.ToTable("skins", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Condition")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("condition");
+
+ b.Property("FloatMax")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_max");
+
+ b.Property("FloatMin")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_min");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_conditions");
+
+ b.HasIndex("SkinId")
+ .HasDatabaseName("ix_skin_conditions_skin_id");
+
+ b.ToTable("skin_conditions", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinConditionSweep", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("SkinConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_condition_id");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.Property("SweptAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("swept_at");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_condition_sweeps");
+
+ b.HasIndex("SkinConditionId", "Source")
+ .IsUnique()
+ .HasDatabaseName("ix_skin_condition_sweeps_skin_condition_id_source");
+
+ b.HasIndex("Source", "SweptAt")
+ .HasDatabaseName("ix_skin_condition_sweeps_source_swept_at");
+
+ b.ToTable("skin_condition_sweeps", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("DupeFirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("dupe_first_seen_at");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("FloatValue")
+ .HasColumnType("numeric(20,18)")
+ .HasColumnName("float_value");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at");
+
+ b.Property("PaintSeed")
+ .HasColumnType("integer")
+ .HasColumnName("paint_seed");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Souvenir")
+ .HasColumnType("boolean")
+ .HasColumnName("souvenir");
+
+ b.Property("StatTrak")
+ .HasColumnType("boolean")
+ .HasColumnName("stat_trak");
+
+ b.Property("SuspectedDupe")
+ .HasColumnType("boolean")
+ .HasColumnName("suspected_dupe");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_instances");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_skin_instances_condition_id");
+
+ b.HasIndex("SuspectedDupe")
+ .HasDatabaseName("ix_skin_instances_suspected_dupe");
+
+ b.HasIndex("SkinId", "FloatValue", "PaintSeed", "StatTrak", "Souvenir")
+ .HasDatabaseName("ix_skin_instances_skin_id_float_value_paint_seed_stat_trak_sou");
+
+ b.ToTable("skin_instances", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinLandListing", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("currency");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("FloatValue")
+ .HasColumnType("numeric(20,18)")
+ .HasColumnName("float_value");
+
+ b.Property