From edc649fc36f3e7d674e18053eea538165ad6eab7 Mon Sep 17 00:00:00 2001 From: bob Date: Tue, 2 Jun 2026 13:31:27 -0500 Subject: [PATCH] final --- .../BlueLaminate.Cli/BlueLaminate.Cli.csproj | 5 + .../Commands/FindTradeupsCommand.cs | 267 ++++ BlueLaminate/BlueLaminate.Cli/Program.cs | 1 + .../BlueLaminate.Cli/Tui/TradeupBrowser.cs | 352 +++++ .../ServiceCollectionExtensions.cs | 4 + .../Options/TradeupOptions.cs | 122 ++ .../SkinLand/SkinLandSlug.cs | 16 +- .../Tradeups/MultiCollectionSearch.cs | 409 +++++ .../Tradeups/TradeupCandidate.cs | 67 + .../Tradeups/TradeupFinder.cs | 476 ++++++ .../Tradeups/TradeupGraph.cs | 37 + .../Tradeups/TradeupGraphBuilder.cs | 197 +++ .../Tradeups/TradeupListingData.cs | 203 +++ .../BlueLaminate.Core/Tradeups/TradeupMath.cs | 38 + .../Tradeups/TradeupSelector.cs | 278 ++++ .../Tradeups/WeaponRarity.cs | 75 + .../BlueLaminate.Core/Tradeups/WearBand.cs | 58 + ...riceBeforeDiscountInMarketView.Designer.cs | 1347 +++++++++++++++++ ...eCsMoneyPriceBeforeDiscountInMarketView.cs | 187 +++ ...MoneyComputedPriceInMarketView.Designer.cs | 1347 +++++++++++++++++ ...328_UseCsMoneyComputedPriceInMarketView.cs | 186 +++ .../BlueLaminate.Tests.csproj | 25 + .../Tradeups/TradeupGraphBuilderTests.cs | 205 +++ .../Tradeups/TradeupMathTests.cs | 52 + .../Tradeups/TradeupSelectorTests.cs | 204 +++ .../Tradeups/WeaponRarityTests.cs | 42 + .../Tradeups/WearBandTests.cs | 31 + .../Tui/TradeupBrowserTests.cs | 113 ++ BlueLaminate/BlueLaminate.slnx | 1 + Directory.Packages.props | 8 + docker-compose.yml | 8 +- worker/blworker/config.py | 10 + worker/blworker/runtime.py | 44 +- 33 files changed, 6407 insertions(+), 8 deletions(-) create mode 100644 BlueLaminate/BlueLaminate.Cli/Commands/FindTradeupsCommand.cs create mode 100644 BlueLaminate/BlueLaminate.Cli/Tui/TradeupBrowser.cs create mode 100644 BlueLaminate/BlueLaminate.Core/Options/TradeupOptions.cs create mode 100644 BlueLaminate/BlueLaminate.Core/Tradeups/MultiCollectionSearch.cs create mode 100644 BlueLaminate/BlueLaminate.Core/Tradeups/TradeupCandidate.cs create mode 100644 BlueLaminate/BlueLaminate.Core/Tradeups/TradeupFinder.cs create mode 100644 BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraph.cs create mode 100644 BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraphBuilder.cs create mode 100644 BlueLaminate/BlueLaminate.Core/Tradeups/TradeupListingData.cs create mode 100644 BlueLaminate/BlueLaminate.Core/Tradeups/TradeupMath.cs create mode 100644 BlueLaminate/BlueLaminate.Core/Tradeups/TradeupSelector.cs create mode 100644 BlueLaminate/BlueLaminate.Core/Tradeups/WeaponRarity.cs create mode 100644 BlueLaminate/BlueLaminate.Core/Tradeups/WearBand.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Migrations/20260602031407_UseCsMoneyPriceBeforeDiscountInMarketView.Designer.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Migrations/20260602031407_UseCsMoneyPriceBeforeDiscountInMarketView.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Migrations/20260602034328_UseCsMoneyComputedPriceInMarketView.Designer.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Migrations/20260602034328_UseCsMoneyComputedPriceInMarketView.cs create mode 100644 BlueLaminate/BlueLaminate.Tests/BlueLaminate.Tests.csproj create mode 100644 BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupGraphBuilderTests.cs create mode 100644 BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupMathTests.cs create mode 100644 BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupSelectorTests.cs create mode 100644 BlueLaminate/BlueLaminate.Tests/Tradeups/WeaponRarityTests.cs create mode 100644 BlueLaminate/BlueLaminate.Tests/Tradeups/WearBandTests.cs create mode 100644 BlueLaminate/BlueLaminate.Tests/Tui/TradeupBrowserTests.cs 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("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("ListingId") + .HasColumnType("bigint") + .HasColumnName("listing_id"); + + b.Property("MarketHashName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("market_hash_name"); + + b.Property("NameTag") + .HasColumnType("text") + .HasColumnName("name_tag"); + + 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("SkinId") + .HasColumnType("integer") + .HasColumnName("skin_id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("StickerCount") + .HasColumnType("integer") + .HasColumnName("sticker_count"); + + b.HasKey("Id") + .HasName("pk_skin_land_listings"); + + b.HasIndex("ConditionId") + .HasDatabaseName("ix_skin_land_listings_condition_id"); + + b.HasIndex("ListingId") + .IsUnique() + .HasDatabaseName("ix_skin_land_listings_listing_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_skin_land_listings_status"); + + b.HasIndex("SkinId", "ConditionId") + .HasDatabaseName("ix_skin_land_listings_skin_id_condition_id"); + + b.ToTable("skin_land_listings", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinSweep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SkinId") + .HasColumnType("integer") + .HasColumnName("skin_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_sweeps"); + + b.HasIndex("SkinId", "Source") + .IsUnique() + .HasDatabaseName("ix_skin_sweeps_skin_id_source"); + + b.HasIndex("Source", "SweptAt") + .HasDatabaseName("ix_skin_sweeps_source_swept_at"); + + b.ToTable("skin_sweeps", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_synced_at"); + + b.Property("SteamId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("steam_id"); + + b.HasKey("Id") + .HasName("pk_steam_users"); + + b.HasIndex("SteamId") + .IsUnique() + .HasDatabaseName("ix_steam_users_steam_id"); + + b.ToTable("steam_users", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FromUserId") + .HasColumnType("integer") + .HasColumnName("from_user_id"); + + b.Property("SteamTradeId") + .HasColumnType("text") + .HasColumnName("steam_trade_id"); + + b.Property("ToUserId") + .HasColumnType("integer") + .HasColumnName("to_user_id"); + + b.Property("TradedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("traded_at"); + + b.HasKey("Id") + .HasName("pk_trades"); + + b.HasIndex("FromUserId") + .HasDatabaseName("ix_trades_from_user_id"); + + b.HasIndex("SteamTradeId") + .IsUnique() + .HasDatabaseName("ix_trades_steam_trade_id"); + + b.HasIndex("ToUserId") + .HasDatabaseName("ix_trades_to_user_id"); + + b.ToTable("trades", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("InventoryItemId") + .HasColumnType("integer") + .HasColumnName("inventory_item_id"); + + b.Property("TradeId") + .HasColumnType("integer") + .HasColumnName("trade_id"); + + b.HasKey("Id") + .HasName("pk_trade_items"); + + b.HasIndex("InventoryItemId") + .HasDatabaseName("ix_trade_items_inventory_item_id"); + + b.HasIndex("TradeId") + .HasDatabaseName("ix_trade_items_trade_id"); + + b.ToTable("trade_items", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Team") + .IsRequired() + .HasColumnType("text") + .HasColumnName("team"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_weapons"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_weapons_name"); + + b.ToTable("weapons", "skintracker"); + }); + + modelBuilder.Entity("CollectionSkin", b => + { + b.Property("CollectionsId") + .HasColumnType("integer") + .HasColumnName("collections_id"); + + b.Property("SkinsId") + .HasColumnType("integer") + .HasColumnName("skins_id"); + + b.HasKey("CollectionsId", "SkinsId") + .HasName("pk_skin_collections"); + + b.HasIndex("SkinsId") + .HasDatabaseName("ix_skin_collections_skins_id"); + + b.ToTable("skin_collections", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.CsMoneyListing", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition") + .WithMany() + .HasForeignKey("ConditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_cs_money_listings_skin_conditions_condition_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany() + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_cs_money_listings_skins_skin_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance") + .WithMany() + .HasForeignKey("SkinInstanceId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_cs_money_listings_skin_instances_skin_instance_id"); + + b.Navigation("Condition"); + + b.Navigation("Skin"); + + b.Navigation("SkinInstance"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance") + .WithMany("InventoryItems") + .HasForeignKey("SkinInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User") + .WithMany("InventoryItems") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_inventory_items_steam_users_user_id"); + + b.Navigation("SkinInstance"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition") + .WithMany() + .HasForeignKey("ConditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_listings_skin_conditions_condition_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany() + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_listings_skins_skin_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance") + .WithMany("Listings") + .HasForeignKey("SkinInstanceId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_listings_skin_instances_skin_instance_id"); + + b.Navigation("Condition"); + + b.Navigation("Skin"); + + b.Navigation("SkinInstance"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition") + .WithMany("PriceHistories") + .HasForeignKey("ConditionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_price_histories_skin_conditions_condition_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany("PriceHistories") + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_price_histories_skins_skin_id"); + + b.Navigation("Condition"); + + b.Navigation("Skin"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon") + .WithMany("Skins") + .HasForeignKey("WeaponId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skins_weapons_weapon_id"); + + b.Navigation("Weapon"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany("Conditions") + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_conditions_skins_skin_id"); + + b.Navigation("Skin"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinConditionSweep", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "SkinCondition") + .WithMany("Sweeps") + .HasForeignKey("SkinConditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_condition_sweeps_skin_conditions_skin_condition_id"); + + b.Navigation("SkinCondition"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition") + .WithMany("Instances") + .HasForeignKey("ConditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_skin_instances_skin_conditions_condition_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany("Instances") + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_instances_skins_skin_id"); + + b.Navigation("Condition"); + + b.Navigation("Skin"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinLandListing", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition") + .WithMany() + .HasForeignKey("ConditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_skin_land_listings_skin_conditions_condition_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany() + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_skin_land_listings_skins_skin_id"); + + b.Navigation("Condition"); + + b.Navigation("Skin"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinSweep", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany("Sweeps") + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_sweeps_skins_skin_id"); + + b.Navigation("Skin"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser") + .WithMany("TradesSent") + .HasForeignKey("FromUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_trades_steam_users_from_user_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser") + .WithMany("TradesReceived") + .HasForeignKey("ToUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_trades_steam_users_to_user_id"); + + b.Navigation("FromUser"); + + b.Navigation("ToUser"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem") + .WithMany("TradeItems") + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_trade_items_inventory_items_inventory_item_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade") + .WithMany("TradeItems") + .HasForeignKey("TradeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_trade_items_trades_trade_id"); + + b.Navigation("InventoryItem"); + + b.Navigation("Trade"); + }); + + modelBuilder.Entity("CollectionSkin", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.Collection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_collections_collections_collections_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", null) + .WithMany() + .HasForeignKey("SkinsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_collections_skins_skins_id"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b => + { + b.Navigation("TradeItems"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b => + { + b.Navigation("Conditions"); + + b.Navigation("Instances"); + + b.Navigation("PriceHistories"); + + b.Navigation("Sweeps"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b => + { + b.Navigation("Instances"); + + b.Navigation("PriceHistories"); + + b.Navigation("Sweeps"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b => + { + b.Navigation("InventoryItems"); + + b.Navigation("Listings"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b => + { + b.Navigation("InventoryItems"); + + b.Navigation("TradesReceived"); + + b.Navigation("TradesSent"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b => + { + b.Navigation("TradeItems"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b => + { + b.Navigation("Skins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260602031407_UseCsMoneyPriceBeforeDiscountInMarketView.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260602031407_UseCsMoneyPriceBeforeDiscountInMarketView.cs new file mode 100644 index 0000000..17146b0 --- /dev/null +++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260602031407_UseCsMoneyPriceBeforeDiscountInMarketView.cs @@ -0,0 +1,187 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BlueLaminate.EFCore.Migrations +{ + /// + /// cs.money's pricing.default is the post-discount sticker price, which understates + /// what a copy actually costs to buy — so tradeup input costs (and cross-market price + /// comparisons) read low and conjure phantom profit. Switch the csmoney arm of the + /// cross-market view to price_before_discount, falling back to price when no + /// discount was recorded. View-only change; the underlying table keeps both columns. + /// + public partial class UseCsMoneyPriceBeforeDiscountInMarketView : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + CREATE OR REPLACE VIEW skintracker.market_listings AS + SELECT + 'csfloat'::text AS marketplace, + l.cs_float_listing_id AS external_id, + l.skin_id AS skin_id, + l.condition_id AS condition_id, + l.skin_instance_id AS skin_instance_id, + l.market_hash_name AS market_hash_name, + l.wear_name AS wear, + l.float_value AS float_value, + l.paint_seed AS paint_seed, + l.is_stat_trak AS is_stat_trak, + l.is_souvenir AS is_souvenir, + l.sticker_count AS sticker_count, + l.price AS price, + l.currency AS currency, + l.inspect_link AS inspect_link, + l.asset_id AS asset_id, + l.status AS status, + l.first_seen_at AS first_seen_at, + l.last_seen_at AS last_seen_at, + l.removed_at AS removed_at + FROM skintracker.listings l + UNION ALL + SELECT + 'csmoney'::text, + c.sell_order_id::text, + c.skin_id, + c.condition_id, + c.skin_instance_id, + c.market_hash_name, + CASE lower(c.quality) + WHEN 'fn' THEN 'Factory New' + WHEN 'mw' THEN 'Minimal Wear' + WHEN 'ft' THEN 'Field-Tested' + WHEN 'ww' THEN 'Well-Worn' + WHEN 'bs' THEN 'Battle-Scarred' + ELSE c.quality + END, + c.float_value, + c.paint_seed, + c.is_stat_trak, + c.is_souvenir, + c.sticker_count, + -- Undiscounted price is the real cost to buy; fall back to price when no + -- discount was recorded. + COALESCE(c.price_before_discount, c.price), + c.currency, + c.inspect_link, + c.asset_id, + c.status, + c.first_seen_at, + c.last_seen_at, + c.removed_at + FROM skintracker.cs_money_listings c + UNION ALL + SELECT + 'skinland'::text, + s.listing_id::text, + s.skin_id, + s.condition_id, + NULL::integer, + s.market_hash_name, + sc.condition, + s.float_value, + NULL::integer, + s.is_stat_trak, + s.is_souvenir, + s.sticker_count, + s.price, + s.currency, + s.inspect_link, + NULL::text, + s.status, + s.first_seen_at, + s.last_seen_at, + s.removed_at + FROM skintracker.skin_land_listings s + LEFT JOIN skintracker.skin_conditions sc ON sc.id = s.condition_id; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Restore the csmoney arm to the post-discount pricing.default price. + migrationBuilder.Sql(""" + CREATE OR REPLACE VIEW skintracker.market_listings AS + SELECT + 'csfloat'::text AS marketplace, + l.cs_float_listing_id AS external_id, + l.skin_id AS skin_id, + l.condition_id AS condition_id, + l.skin_instance_id AS skin_instance_id, + l.market_hash_name AS market_hash_name, + l.wear_name AS wear, + l.float_value AS float_value, + l.paint_seed AS paint_seed, + l.is_stat_trak AS is_stat_trak, + l.is_souvenir AS is_souvenir, + l.sticker_count AS sticker_count, + l.price AS price, + l.currency AS currency, + l.inspect_link AS inspect_link, + l.asset_id AS asset_id, + l.status AS status, + l.first_seen_at AS first_seen_at, + l.last_seen_at AS last_seen_at, + l.removed_at AS removed_at + FROM skintracker.listings l + UNION ALL + SELECT + 'csmoney'::text, + c.sell_order_id::text, + c.skin_id, + c.condition_id, + c.skin_instance_id, + c.market_hash_name, + CASE lower(c.quality) + WHEN 'fn' THEN 'Factory New' + WHEN 'mw' THEN 'Minimal Wear' + WHEN 'ft' THEN 'Field-Tested' + WHEN 'ww' THEN 'Well-Worn' + WHEN 'bs' THEN 'Battle-Scarred' + ELSE c.quality + END, + c.float_value, + c.paint_seed, + c.is_stat_trak, + c.is_souvenir, + c.sticker_count, + c.price, + c.currency, + c.inspect_link, + c.asset_id, + c.status, + c.first_seen_at, + c.last_seen_at, + c.removed_at + FROM skintracker.cs_money_listings c + UNION ALL + SELECT + 'skinland'::text, + s.listing_id::text, + s.skin_id, + s.condition_id, + NULL::integer, + s.market_hash_name, + sc.condition, + s.float_value, + NULL::integer, + s.is_stat_trak, + s.is_souvenir, + s.sticker_count, + s.price, + s.currency, + s.inspect_link, + NULL::text, + s.status, + s.first_seen_at, + s.last_seen_at, + s.removed_at + FROM skintracker.skin_land_listings s + LEFT JOIN skintracker.skin_conditions sc ON sc.id = s.condition_id; + """); + } + } +} diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260602034328_UseCsMoneyComputedPriceInMarketView.Designer.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260602034328_UseCsMoneyComputedPriceInMarketView.Designer.cs new file mode 100644 index 0000000..cc333b7 --- /dev/null +++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260602034328_UseCsMoneyComputedPriceInMarketView.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("20260602034328_UseCsMoneyComputedPriceInMarketView")] + partial class UseCsMoneyComputedPriceInMarketView + { + /// + 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("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("ListingId") + .HasColumnType("bigint") + .HasColumnName("listing_id"); + + b.Property("MarketHashName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("market_hash_name"); + + b.Property("NameTag") + .HasColumnType("text") + .HasColumnName("name_tag"); + + 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("SkinId") + .HasColumnType("integer") + .HasColumnName("skin_id"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("StickerCount") + .HasColumnType("integer") + .HasColumnName("sticker_count"); + + b.HasKey("Id") + .HasName("pk_skin_land_listings"); + + b.HasIndex("ConditionId") + .HasDatabaseName("ix_skin_land_listings_condition_id"); + + b.HasIndex("ListingId") + .IsUnique() + .HasDatabaseName("ix_skin_land_listings_listing_id"); + + b.HasIndex("Status") + .HasDatabaseName("ix_skin_land_listings_status"); + + b.HasIndex("SkinId", "ConditionId") + .HasDatabaseName("ix_skin_land_listings_skin_id_condition_id"); + + b.ToTable("skin_land_listings", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinSweep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("SkinId") + .HasColumnType("integer") + .HasColumnName("skin_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_sweeps"); + + b.HasIndex("SkinId", "Source") + .IsUnique() + .HasDatabaseName("ix_skin_sweeps_skin_id_source"); + + b.HasIndex("Source", "SweptAt") + .HasDatabaseName("ix_skin_sweeps_source_swept_at"); + + b.ToTable("skin_sweeps", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_synced_at"); + + b.Property("SteamId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("steam_id"); + + b.HasKey("Id") + .HasName("pk_steam_users"); + + b.HasIndex("SteamId") + .IsUnique() + .HasDatabaseName("ix_steam_users_steam_id"); + + b.ToTable("steam_users", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FromUserId") + .HasColumnType("integer") + .HasColumnName("from_user_id"); + + b.Property("SteamTradeId") + .HasColumnType("text") + .HasColumnName("steam_trade_id"); + + b.Property("ToUserId") + .HasColumnType("integer") + .HasColumnName("to_user_id"); + + b.Property("TradedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("traded_at"); + + b.HasKey("Id") + .HasName("pk_trades"); + + b.HasIndex("FromUserId") + .HasDatabaseName("ix_trades_from_user_id"); + + b.HasIndex("SteamTradeId") + .IsUnique() + .HasDatabaseName("ix_trades_steam_trade_id"); + + b.HasIndex("ToUserId") + .HasDatabaseName("ix_trades_to_user_id"); + + b.ToTable("trades", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("InventoryItemId") + .HasColumnType("integer") + .HasColumnName("inventory_item_id"); + + b.Property("TradeId") + .HasColumnType("integer") + .HasColumnName("trade_id"); + + b.HasKey("Id") + .HasName("pk_trade_items"); + + b.HasIndex("InventoryItemId") + .HasDatabaseName("ix_trade_items_inventory_item_id"); + + b.HasIndex("TradeId") + .HasDatabaseName("ix_trade_items_trade_id"); + + b.ToTable("trade_items", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Team") + .IsRequired() + .HasColumnType("text") + .HasColumnName("team"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_weapons"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_weapons_name"); + + b.ToTable("weapons", "skintracker"); + }); + + modelBuilder.Entity("CollectionSkin", b => + { + b.Property("CollectionsId") + .HasColumnType("integer") + .HasColumnName("collections_id"); + + b.Property("SkinsId") + .HasColumnType("integer") + .HasColumnName("skins_id"); + + b.HasKey("CollectionsId", "SkinsId") + .HasName("pk_skin_collections"); + + b.HasIndex("SkinsId") + .HasDatabaseName("ix_skin_collections_skins_id"); + + b.ToTable("skin_collections", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.CsMoneyListing", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition") + .WithMany() + .HasForeignKey("ConditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_cs_money_listings_skin_conditions_condition_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany() + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_cs_money_listings_skins_skin_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance") + .WithMany() + .HasForeignKey("SkinInstanceId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_cs_money_listings_skin_instances_skin_instance_id"); + + b.Navigation("Condition"); + + b.Navigation("Skin"); + + b.Navigation("SkinInstance"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance") + .WithMany("InventoryItems") + .HasForeignKey("SkinInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User") + .WithMany("InventoryItems") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_inventory_items_steam_users_user_id"); + + b.Navigation("SkinInstance"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition") + .WithMany() + .HasForeignKey("ConditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_listings_skin_conditions_condition_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany() + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_listings_skins_skin_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance") + .WithMany("Listings") + .HasForeignKey("SkinInstanceId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_listings_skin_instances_skin_instance_id"); + + b.Navigation("Condition"); + + b.Navigation("Skin"); + + b.Navigation("SkinInstance"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition") + .WithMany("PriceHistories") + .HasForeignKey("ConditionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_price_histories_skin_conditions_condition_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany("PriceHistories") + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_price_histories_skins_skin_id"); + + b.Navigation("Condition"); + + b.Navigation("Skin"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon") + .WithMany("Skins") + .HasForeignKey("WeaponId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skins_weapons_weapon_id"); + + b.Navigation("Weapon"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany("Conditions") + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_conditions_skins_skin_id"); + + b.Navigation("Skin"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinConditionSweep", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "SkinCondition") + .WithMany("Sweeps") + .HasForeignKey("SkinConditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_condition_sweeps_skin_conditions_skin_condition_id"); + + b.Navigation("SkinCondition"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition") + .WithMany("Instances") + .HasForeignKey("ConditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_skin_instances_skin_conditions_condition_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany("Instances") + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_instances_skins_skin_id"); + + b.Navigation("Condition"); + + b.Navigation("Skin"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinLandListing", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition") + .WithMany() + .HasForeignKey("ConditionId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_skin_land_listings_skin_conditions_condition_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany() + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_skin_land_listings_skins_skin_id"); + + b.Navigation("Condition"); + + b.Navigation("Skin"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinSweep", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin") + .WithMany("Sweeps") + .HasForeignKey("SkinId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_sweeps_skins_skin_id"); + + b.Navigation("Skin"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser") + .WithMany("TradesSent") + .HasForeignKey("FromUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_trades_steam_users_from_user_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser") + .WithMany("TradesReceived") + .HasForeignKey("ToUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_trades_steam_users_to_user_id"); + + b.Navigation("FromUser"); + + b.Navigation("ToUser"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem") + .WithMany("TradeItems") + .HasForeignKey("InventoryItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_trade_items_inventory_items_inventory_item_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade") + .WithMany("TradeItems") + .HasForeignKey("TradeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_trade_items_trades_trade_id"); + + b.Navigation("InventoryItem"); + + b.Navigation("Trade"); + }); + + modelBuilder.Entity("CollectionSkin", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.Collection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_collections_collections_collections_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", null) + .WithMany() + .HasForeignKey("SkinsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_collections_skins_skins_id"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b => + { + b.Navigation("TradeItems"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b => + { + b.Navigation("Conditions"); + + b.Navigation("Instances"); + + b.Navigation("PriceHistories"); + + b.Navigation("Sweeps"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b => + { + b.Navigation("Instances"); + + b.Navigation("PriceHistories"); + + b.Navigation("Sweeps"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b => + { + b.Navigation("InventoryItems"); + + b.Navigation("Listings"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b => + { + b.Navigation("InventoryItems"); + + b.Navigation("TradesReceived"); + + b.Navigation("TradesSent"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b => + { + b.Navigation("TradeItems"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b => + { + b.Navigation("Skins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260602034328_UseCsMoneyComputedPriceInMarketView.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260602034328_UseCsMoneyComputedPriceInMarketView.cs new file mode 100644 index 0000000..106c3b1 --- /dev/null +++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260602034328_UseCsMoneyComputedPriceInMarketView.cs @@ -0,0 +1,186 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BlueLaminate.EFCore.Migrations +{ + /// + /// Spot-checking tradeups against cs.money's live site showed pricing.computed (the + /// reference/market price) — not the post-discount price nor price_before_discount + /// — is the true value of a copy. Switch the csmoney arm of the cross-market view to + /// computed_price, falling back to price_before_discount then price when it's null. + /// View-only change; the underlying table keeps all three price columns. + /// + public partial class UseCsMoneyComputedPriceInMarketView : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + CREATE OR REPLACE VIEW skintracker.market_listings AS + SELECT + 'csfloat'::text AS marketplace, + l.cs_float_listing_id AS external_id, + l.skin_id AS skin_id, + l.condition_id AS condition_id, + l.skin_instance_id AS skin_instance_id, + l.market_hash_name AS market_hash_name, + l.wear_name AS wear, + l.float_value AS float_value, + l.paint_seed AS paint_seed, + l.is_stat_trak AS is_stat_trak, + l.is_souvenir AS is_souvenir, + l.sticker_count AS sticker_count, + l.price AS price, + l.currency AS currency, + l.inspect_link AS inspect_link, + l.asset_id AS asset_id, + l.status AS status, + l.first_seen_at AS first_seen_at, + l.last_seen_at AS last_seen_at, + l.removed_at AS removed_at + FROM skintracker.listings l + UNION ALL + SELECT + 'csmoney'::text, + c.sell_order_id::text, + c.skin_id, + c.condition_id, + c.skin_instance_id, + c.market_hash_name, + CASE lower(c.quality) + WHEN 'fn' THEN 'Factory New' + WHEN 'mw' THEN 'Minimal Wear' + WHEN 'ft' THEN 'Field-Tested' + WHEN 'ww' THEN 'Well-Worn' + WHEN 'bs' THEN 'Battle-Scarred' + ELSE c.quality + END, + c.float_value, + c.paint_seed, + c.is_stat_trak, + c.is_souvenir, + c.sticker_count, + -- computed_price is the true market value; fall back when it's missing. + COALESCE(c.computed_price, c.price_before_discount, c.price), + c.currency, + c.inspect_link, + c.asset_id, + c.status, + c.first_seen_at, + c.last_seen_at, + c.removed_at + FROM skintracker.cs_money_listings c + UNION ALL + SELECT + 'skinland'::text, + s.listing_id::text, + s.skin_id, + s.condition_id, + NULL::integer, + s.market_hash_name, + sc.condition, + s.float_value, + NULL::integer, + s.is_stat_trak, + s.is_souvenir, + s.sticker_count, + s.price, + s.currency, + s.inspect_link, + NULL::text, + s.status, + s.first_seen_at, + s.last_seen_at, + s.removed_at + FROM skintracker.skin_land_listings s + LEFT JOIN skintracker.skin_conditions sc ON sc.id = s.condition_id; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Restore the csmoney arm to price_before_discount (with price fallback). + migrationBuilder.Sql(""" + CREATE OR REPLACE VIEW skintracker.market_listings AS + SELECT + 'csfloat'::text AS marketplace, + l.cs_float_listing_id AS external_id, + l.skin_id AS skin_id, + l.condition_id AS condition_id, + l.skin_instance_id AS skin_instance_id, + l.market_hash_name AS market_hash_name, + l.wear_name AS wear, + l.float_value AS float_value, + l.paint_seed AS paint_seed, + l.is_stat_trak AS is_stat_trak, + l.is_souvenir AS is_souvenir, + l.sticker_count AS sticker_count, + l.price AS price, + l.currency AS currency, + l.inspect_link AS inspect_link, + l.asset_id AS asset_id, + l.status AS status, + l.first_seen_at AS first_seen_at, + l.last_seen_at AS last_seen_at, + l.removed_at AS removed_at + FROM skintracker.listings l + UNION ALL + SELECT + 'csmoney'::text, + c.sell_order_id::text, + c.skin_id, + c.condition_id, + c.skin_instance_id, + c.market_hash_name, + CASE lower(c.quality) + WHEN 'fn' THEN 'Factory New' + WHEN 'mw' THEN 'Minimal Wear' + WHEN 'ft' THEN 'Field-Tested' + WHEN 'ww' THEN 'Well-Worn' + WHEN 'bs' THEN 'Battle-Scarred' + ELSE c.quality + END, + c.float_value, + c.paint_seed, + c.is_stat_trak, + c.is_souvenir, + c.sticker_count, + COALESCE(c.price_before_discount, c.price), + c.currency, + c.inspect_link, + c.asset_id, + c.status, + c.first_seen_at, + c.last_seen_at, + c.removed_at + FROM skintracker.cs_money_listings c + UNION ALL + SELECT + 'skinland'::text, + s.listing_id::text, + s.skin_id, + s.condition_id, + NULL::integer, + s.market_hash_name, + sc.condition, + s.float_value, + NULL::integer, + s.is_stat_trak, + s.is_souvenir, + s.sticker_count, + s.price, + s.currency, + s.inspect_link, + NULL::text, + s.status, + s.first_seen_at, + s.last_seen_at, + s.removed_at + FROM skintracker.skin_land_listings s + LEFT JOIN skintracker.skin_conditions sc ON sc.id = s.condition_id; + """); + } + } +} diff --git a/BlueLaminate/BlueLaminate.Tests/BlueLaminate.Tests.csproj b/BlueLaminate/BlueLaminate.Tests/BlueLaminate.Tests.csproj new file mode 100644 index 0000000..4abf69c --- /dev/null +++ b/BlueLaminate/BlueLaminate.Tests/BlueLaminate.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + diff --git a/BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupGraphBuilderTests.cs b/BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupGraphBuilderTests.cs new file mode 100644 index 0000000..75f785d --- /dev/null +++ b/BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupGraphBuilderTests.cs @@ -0,0 +1,205 @@ +using BlueLaminate.Core.Tradeups; +using BlueLaminate.EFCore.Data; +using BlueLaminate.EFCore.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace BlueLaminate.Tests.Tradeups; + +/// +/// Graph-derivation rules verified against a synthetic in-memory catalogue — never the +/// live database (see the no-live-DB-perturbation rule). Each test seeds the minimal +/// catalogue shape it needs. +/// +public class TradeupGraphBuilderTests +{ + private static SkinTrackerDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"tradeups-{Guid.NewGuid()}") + .Options; + return new SkinTrackerDbContext(options); + } + + private static async Task BuildAsync(SkinTrackerDbContext db) + { + await db.SaveChangesAsync(); + var builder = new TradeupGraphBuilder(db, NullLogger.Instance); + return await builder.BuildAsync(); + } + + private sealed class Catalogue + { + private int _nextId = 1; + public Weapon Rifle { get; } = new() { Id = 1000, Name = "AK-47", Type = "Rifle", Team = "Both" }; + public Weapon Knife { get; } = new() { Id = 1001, Name = "Karambit", Type = "Knives", Team = "Both" }; + public Weapon Glove { get; } = new() { Id = 1002, Name = "Sport Gloves", Type = "Gloves", Team = "Both" }; + + public List Skins { get; } = new(); + + public Skin Add( + Collection collection, + string rarity, + Weapon? weapon = null, + decimal? floatMin = 0.0m, + decimal? floatMax = 1.0m) + { + var skin = new Skin + { + Id = _nextId, + Slug = $"skin-{_nextId}", + Name = $"Skin {_nextId}", + Rarity = rarity, + Weapon = weapon ?? Rifle, + FloatMin = floatMin, + FloatMax = floatMax, + Collections = new List { collection }, + }; + _nextId++; + Skins.Add(skin); + return skin; + } + } + + private static Collection NewCollection(string name, string type = "Collection") + => new() { Name = name, Slug = $"col-{name}", Type = type }; + + [Fact] + public async Task Resolves_across_a_rarity_gap() + { + // MilSpec + Classified present, Restricted absent → MilSpec must resolve to Classified. + await using var db = NewContext(); + var cat = new Catalogue(); + var col = NewCollection("Gap"); + cat.Add(col, "Mil-Spec Grade"); + cat.Add(col, "Classified"); + db.Skins.AddRange(cat.Skins); + + var graph = await BuildAsync(db); + + var group = Assert.Single(graph.Groups); + Assert.Equal(WeaponRarity.MilSpec, group.InputRarity); + Assert.Equal(WeaponRarity.Classified, group.OutputRarity); + } + + [Fact] + public async Task Covert_is_an_output_but_never_an_input() + { + await using var db = NewContext(); + var cat = new Catalogue(); + var col = NewCollection("Tops at Covert"); + cat.Add(col, "Restricted"); + cat.Add(col, "Classified"); + cat.Add(col, "Covert"); // a weapon Covert (eligible output) + db.Skins.AddRange(cat.Skins); + + var graph = await BuildAsync(db); + + Assert.Contains(graph.Groups, g => g.InputRarity == WeaponRarity.Restricted && g.OutputRarity == WeaponRarity.Classified); + Assert.Contains(graph.Groups, g => g.InputRarity == WeaponRarity.Classified && g.OutputRarity == WeaponRarity.Covert); + Assert.DoesNotContain(graph.Groups, g => g.InputRarity == WeaponRarity.Covert); + } + + [Fact] + public async Task Knife_with_covert_rarity_is_excluded_as_output() + { + // The only "Covert" in the collection is a knife → the Covert tier has no eligible + // output, so Classified resolves to nothing and yields no group. + await using var db = NewContext(); + var cat = new Catalogue(); + var col = NewCollection("Knife Covert"); + cat.Add(col, "Classified"); + cat.Add(col, "Covert", weapon: cat.Knife); + db.Skins.AddRange(cat.Skins); + + var graph = await BuildAsync(db); + + Assert.Empty(graph.Groups); + } + + [Fact] + public async Task Contraband_and_gloves_are_not_weapon_tiers() + { + await using var db = NewContext(); + var cat = new Catalogue(); + var col = NewCollection("Howl"); + cat.Add(col, "Classified"); + cat.Add(col, "Contraband"); // The Howl + cat.Add(col, "Extraordinary", weapon: cat.Glove); // a glove + db.Skins.AddRange(cat.Skins); + + var graph = await BuildAsync(db); + + // Classified has no higher weapon tier present → no tradeup. + Assert.Empty(graph.Groups); + } + + [Fact] + public async Task Skins_without_float_bounds_are_excluded() + { + await using var db = NewContext(); + var cat = new Catalogue(); + var col = NewCollection("No Float"); + cat.Add(col, "Mil-Spec Grade", floatMin: null, floatMax: null); // floatless → not a tier + cat.Add(col, "Classified"); + db.Skins.AddRange(cat.Skins); + + var graph = await BuildAsync(db); + + // The only would-be input tier is excluded, so nothing resolves. + Assert.Empty(graph.Groups); + } + + [Fact] + public async Task Limited_edition_pseudo_collection_is_skipped() + { + await using var db = NewContext(); + var cat = new Catalogue(); + var col = NewCollection("Limited Edition Item"); + cat.Add(col, "Mil-Spec Grade"); + cat.Add(col, "Classified"); + db.Skins.AddRange(cat.Skins); + + var graph = await BuildAsync(db); + + Assert.Empty(graph.Groups); + } + + [Fact] + public async Task Containers_are_not_treated_as_collections() + { + await using var db = NewContext(); + var cat = new Catalogue(); + var crate = NewCollection("Some Case", type: "Container"); + cat.Add(crate, "Mil-Spec Grade"); + cat.Add(crate, "Classified"); + db.Skins.AddRange(cat.Skins); + + var graph = await BuildAsync(db); + + // Grouping is by Type='Collection' only; case weapons carry a separate Collection + // source in reality, but a Container source on its own yields nothing. + Assert.Empty(graph.Groups); + } + + [Fact] + public async Task Output_skins_carry_float_bounds_and_stattrak_flag() + { + await using var db = NewContext(); + var cat = new Catalogue(); + var col = NewCollection("Bounds"); + cat.Add(col, "Classified"); + var covert = cat.Add(col, "Covert", floatMin: 0.0m, floatMax: 0.8m); + covert.StatTrakAvailable = true; + db.Skins.AddRange(cat.Skins); + + var graph = await BuildAsync(db); + + var group = Assert.Single(graph.Groups); + var output = Assert.Single(group.OutputSkins); + Assert.Equal(0.0m, output.FloatMin); + Assert.Equal(0.8m, output.FloatMax); + Assert.True(output.StatTrakAvailable); + } +} diff --git a/BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupMathTests.cs b/BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupMathTests.cs new file mode 100644 index 0000000..1a45dfc --- /dev/null +++ b/BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupMathTests.cs @@ -0,0 +1,52 @@ +using BlueLaminate.Core.Tradeups; +using Xunit; + +namespace BlueLaminate.Tests.Tradeups; + +public class TradeupMathTests +{ + [Fact] + public void NormalizedFraction_maps_value_into_its_own_range() + { + // Mid-point of a 0.06–0.80 range. + var frac = TradeupMath.NormalizedFraction(0.43m, 0.06m, 0.80m); + Assert.Equal(0.5m, frac, precision: 6); + } + + [Theory] + [InlineData(-0.5)] // below min + [InlineData(2.0)] // above max + public void NormalizedFraction_clamps_out_of_range_values(double value) + { + var frac = TradeupMath.NormalizedFraction((decimal)value, 0.0m, 1.0m); + Assert.InRange(frac, 0m, 1m); + } + + [Fact] + public void NormalizedFraction_returns_zero_for_zero_width_range() + { + Assert.Equal(0m, TradeupMath.NormalizedFraction(0.3m, 0.3m, 0.3m)); + } + + [Fact] + public void OutputFloat_maps_average_fraction_onto_output_range() + { + // avg 0.10 onto a 0.00–0.70 output range → 0.07 (the FN/MW boundary). + var outFloat = TradeupMath.OutputFloat(0.10m, 0.00m, 0.70m); + Assert.Equal(0.07m, outFloat, precision: 6); + } + + [Fact] + public void Full_contract_float_math_matches_hand_calculation() + { + // Ten inputs, each normalised to its own range, then averaged and mapped onto the + // output's 0.00–0.80 range. Five inputs at fraction 0.2 and five at 0.4 → avg 0.3. + var fractions = new[] { 0.2m, 0.2m, 0.2m, 0.2m, 0.2m, 0.4m, 0.4m, 0.4m, 0.4m, 0.4m }; + var avg = fractions.Sum() / fractions.Length; + Assert.Equal(0.3m, avg, precision: 6); + + var outFloat = TradeupMath.OutputFloat(avg, 0.00m, 0.80m); + Assert.Equal(0.24m, outFloat, precision: 6); + Assert.Equal(WearBand.FieldTested, WearBands.FromFloat(outFloat)); + } +} diff --git a/BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupSelectorTests.cs b/BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupSelectorTests.cs new file mode 100644 index 0000000..db81cc9 --- /dev/null +++ b/BlueLaminate/BlueLaminate.Tests/Tradeups/TradeupSelectorTests.cs @@ -0,0 +1,204 @@ +using BlueLaminate.Core.Tradeups; +using Xunit; + +namespace BlueLaminate.Tests.Tradeups; + +public class TradeupSelectorTests +{ + private const decimal Bucket = 0.005m; + + private static SelectableInput Item(decimal fraction, decimal price) + => new(fraction, new InputListing( + SkinId: 1, + MarketHashName: "Test Skin", + Marketplace: "test", + InspectLink: null, + ExternalId: "0", + FloatValue: fraction, + Price: price)); + + [Fact] + public void Cheapest_full_selection_is_the_ten_cheapest_copies() + { + // 12 copies priced 1..12 at assorted fractions. With no binding float target the + // cheapest ten (cost 55) must be attainable at some summed-fraction bucket. + var pool = new List(); + for (var i = 1; i <= 12; i++) + { + pool.Add(Item(fraction: 0.01m * i, price: i)); + } + + var selection = TradeupSelector.Solve(pool, contractSize: 10, Bucket); + var selections = selection.Selections().ToList(); + + Assert.NotEmpty(selections); + + var cheapest = selections.MinBy(s => s.Cost); + Assert.Equal(55m, cheapest.Cost); + + var picks = cheapest.Picks.ToList(); + Assert.Equal(10, picks.Count); + Assert.Equal(Enumerable.Range(1, 10).Select(i => (decimal)i), picks.Select(p => p.Price).OrderBy(p => p)); + } + + [Fact] + public void Reported_average_fraction_is_a_conservative_upper_bound() + { + var pool = new List(); + for (var i = 0; i < 10; i++) + { + pool.Add(Item(fraction: 0.123m, price: 1m)); + } + + var only = Assert.Single(TradeupSelector.Solve(pool, 10, Bucket).Selections()); + + var trueAverage = only.Picks.ToList().Average(p => p.FloatValue); + Assert.True(only.AverageFraction >= trueAverage, + $"bucketed average {only.AverageFraction} should round up from true {trueAverage}"); + // 0.123 rounds up to the 0.125 bucket (0.005 grid). + Assert.Equal(0.125m, only.AverageFraction); + } + + [Fact] + public void Selecting_cheaper_low_float_copies_lowers_the_attainable_average() + { + // Cheap high-float copies vs. pricier low-float copies. A lower average is only + // reachable by paying for the low-float set, so a low-average selection must cost + // more than the unconstrained cheapest. + var pool = new List(); + for (var i = 0; i < 10; i++) + { + pool.Add(Item(fraction: 0.60m, price: 1m)); // cheap, bad float + } + + for (var i = 0; i < 10; i++) + { + pool.Add(Item(fraction: 0.10m, price: 5m)); // pricey, good float + } + + var selections = TradeupSelector.Solve(pool, 10, Bucket).Selections().ToList(); + + var cheapest = selections.MinBy(s => s.Cost); + Assert.Equal(10m, cheapest.Cost); // ten cheap copies + Assert.Equal(0.60m, cheapest.AverageFraction); + + var lowestFloat = selections.MinBy(s => s.AverageFraction); + Assert.Equal(0.10m, lowestFloat.AverageFraction); + Assert.Equal(50m, lowestFloat.Cost); // forced onto the pricey low-float set + } + + [Fact] + public void No_full_selection_when_pool_is_too_small() + { + var pool = Enumerable.Range(0, 5).Select(i => Item(0.2m, i + 1)).ToList(); + Assert.Empty(TradeupSelector.Solve(pool, 10, Bucket).Selections()); + } + + private static TradeupSelector.RewardItem Reward(int bucket, double reward, decimal price = 0m) + => new(bucket, reward, new InputListing(1, "x", "m", null, "0", 0.1m, price)); + + [Fact] + public void MaxReward_picks_the_highest_reward_set_within_the_cap() + { + // Six items; pick 3 maximising reward with bucket sum ≤ 6. The three best rewards + // (9, 8, 7) sit at buckets 2,2,2 = 6 ≤ cap, so they win. + var items = new[] + { + Reward(2, 9), Reward(2, 8), Reward(2, 7), + Reward(1, 1), Reward(1, 2), Reward(1, 3), + }; + + var picks = TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 6); + + Assert.NotNull(picks); + Assert.Equal(3, picks!.ToList().Count); + } + + [Fact] + public void MaxReward_respects_the_float_cap_even_at_the_cost_of_reward() + { + // The fat-reward items sit at high buckets; the cap forces the low-bucket set. + var items = new[] + { + Reward(5, 100, price: 50m), // too high-float to fit under a tight cap + Reward(1, 5, price: 1m), + Reward(1, 4, price: 1m), + Reward(1, 3, price: 1m), + }; + + var picks = TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 3); + + Assert.NotNull(picks); + var chosen = picks!.ToList(); + Assert.Equal(3, chosen.Count); + Assert.All(chosen, p => Assert.Equal(1m, p.Price)); // the three low-float copies + } + + [Fact] + public void MaxReward_returns_null_when_the_contract_cannot_be_filled_under_the_cap() + { + // Only two items fit under the cap, but three are required. + var items = new[] { Reward(1, 5), Reward(1, 4), Reward(9, 100) }; + Assert.Null(TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 3)); + } +} + +public class OutputPriceBookTests +{ + private static TradeupListingRow Row(WearBand band, bool st, decimal price) + { + // A float squarely inside the band, so Build files it where we expect. + var f = band switch + { + WearBand.FactoryNew => 0.03m, + WearBand.MinimalWear => 0.10m, + WearBand.FieldTested => 0.25m, + WearBand.WellWorn => 0.40m, + _ => 0.60m, + }; + return new TradeupListingRow(1, "M4A4 | X-Ray", "csmoney", null, "0", st, false, f, price); + } + + [Fact] + public void Liquid_band_uses_its_own_price() + { + // Ten MW listings -> the MW band is trusted at its own lowest ask. + var rows = Enumerable.Range(0, 10).Select(i => Row(WearBand.MinimalWear, true, 90m + i)); + var book = TradeupListingData.Build(rows).OutputPrices; + + var r = book.Resolve(1, statTrak: true, outputFloat: 0.10m, thinThreshold: 10); + + Assert.Equal(OutputPriceBasis.Band, r.Basis); + Assert.Equal(90m, r.LowestAsk); + } + + [Fact] + public void Thin_band_falls_back_to_the_skin_overall_floor() + { + // The X-Ray case: two outlier FN listings ($1287) but a liquid MW market (~$90). + // A produced FN output must price off the $90 floor, not the $1287 band outlier. + var rows = new List + { + Row(WearBand.FactoryNew, true, 1287m), + Row(WearBand.FactoryNew, true, 1290m), + }; + rows.AddRange(Enumerable.Range(0, 10).Select(i => Row(WearBand.MinimalWear, true, 90m + i))); + + var book = TradeupListingData.Build(rows).OutputPrices; + + var r = book.Resolve(1, statTrak: true, outputFloat: 0.0695m, thinThreshold: 10); + + Assert.Equal(OutputPriceBasis.Floor, r.Basis); + Assert.Equal(90m, r.LowestAsk); // skin-wide floor, not the $1287 FN outlier + Assert.Equal(2, r.BandLiquidity); // still reports the thin band count → triggers CSFloat + } + + [Fact] + public void Unlisted_skin_resolves_to_none() + { + var book = TradeupListingData.Build(Array.Empty()).OutputPrices; + var r = book.Resolve(1, statTrak: false, outputFloat: 0.1m, thinThreshold: 10); + Assert.Equal(OutputPriceBasis.None, r.Basis); + Assert.Null(r.LowestAsk); + } +} diff --git a/BlueLaminate/BlueLaminate.Tests/Tradeups/WeaponRarityTests.cs b/BlueLaminate/BlueLaminate.Tests/Tradeups/WeaponRarityTests.cs new file mode 100644 index 0000000..98af21d --- /dev/null +++ b/BlueLaminate/BlueLaminate.Tests/Tradeups/WeaponRarityTests.cs @@ -0,0 +1,42 @@ +using BlueLaminate.Core.Tradeups; +using Xunit; + +namespace BlueLaminate.Tests.Tradeups; + +public class WeaponRarityTests +{ + [Theory] + [InlineData("Consumer Grade", WeaponRarity.Consumer)] + [InlineData("Industrial Grade", WeaponRarity.Industrial)] + [InlineData("Mil-Spec Grade", WeaponRarity.MilSpec)] + [InlineData("Restricted", WeaponRarity.Restricted)] + [InlineData("Classified", WeaponRarity.Classified)] + [InlineData("Covert", WeaponRarity.Covert)] + public void Maps_each_weapon_tier_literal(string literal, WeaponRarity expected) + { + Assert.True(WeaponRarityExtensions.TryParse(literal, out var rarity)); + Assert.Equal(expected, rarity); + } + + [Theory] + [InlineData("Contraband")] // The Howl + [InlineData("Extraordinary")] // Gloves + public void Reports_non_weapon_rarities_as_not_a_tier(string literal) + { + Assert.False(WeaponRarityExtensions.TryParse(literal, out _)); + } + + [Fact] + public void Throws_on_unknown_literal_so_a_catalogue_rename_is_loud() + { + Assert.Throws(() => WeaponRarityExtensions.TryParse("Mythical", out _)); + } + + [Fact] + public void Tiers_are_strictly_ordered() + { + Assert.True(WeaponRarity.Consumer < WeaponRarity.Industrial); + Assert.True(WeaponRarity.MilSpec < WeaponRarity.Restricted); + Assert.True(WeaponRarity.Classified < WeaponRarity.Covert); + } +} diff --git a/BlueLaminate/BlueLaminate.Tests/Tradeups/WearBandTests.cs b/BlueLaminate/BlueLaminate.Tests/Tradeups/WearBandTests.cs new file mode 100644 index 0000000..6756626 --- /dev/null +++ b/BlueLaminate/BlueLaminate.Tests/Tradeups/WearBandTests.cs @@ -0,0 +1,31 @@ +using BlueLaminate.Core.Tradeups; +using Xunit; + +namespace BlueLaminate.Tests.Tradeups; + +public class WearBandTests +{ + [Theory] + [InlineData(0.00, WearBand.FactoryNew)] + [InlineData(0.0699, WearBand.FactoryNew)] + [InlineData(0.07, WearBand.MinimalWear)] // boundary is upper-exclusive + [InlineData(0.1499, WearBand.MinimalWear)] + [InlineData(0.15, WearBand.FieldTested)] + [InlineData(0.3799, WearBand.FieldTested)] + [InlineData(0.38, WearBand.WellWorn)] + [InlineData(0.4499, WearBand.WellWorn)] + [InlineData(0.45, WearBand.BattleScarred)] + [InlineData(1.00, WearBand.BattleScarred)] + public void FromFloat_classifies_on_absolute_thresholds(double value, WearBand expected) + { + Assert.Equal(expected, WearBands.FromFloat((decimal)value)); + } + + [Fact] + public void ToName_matches_listing_wear_strings() + { + Assert.Equal("Factory New", WearBand.FactoryNew.ToName()); + Assert.Equal("Field-Tested", WearBand.FieldTested.ToName()); + Assert.Equal("Battle-Scarred", WearBand.BattleScarred.ToName()); + } +} diff --git a/BlueLaminate/BlueLaminate.Tests/Tui/TradeupBrowserTests.cs b/BlueLaminate/BlueLaminate.Tests/Tui/TradeupBrowserTests.cs new file mode 100644 index 0000000..06a4e40 --- /dev/null +++ b/BlueLaminate/BlueLaminate.Tests/Tui/TradeupBrowserTests.cs @@ -0,0 +1,113 @@ +using BlueLaminate.Cli.Tui; +using BlueLaminate.Core.Tradeups; +using Spectre.Console; +using Spectre.Console.Testing; +using Xunit; + +namespace BlueLaminate.Tests.Tui; + +/// +/// Render-path smoke tests for the TUI: the navigation loop needs a live terminal, but the +/// rendering (and its Spectre markup) is exercised against a recording console so a stray +/// markup tag or unescaped name fails the build instead of crashing on first launch. +/// +public class TradeupBrowserTests +{ + private static InputListing Input(string name, string market, string? inspect, decimal price) + => new(SkinId: 1, MarketHashName: name, Marketplace: market, + InspectLink: inspect, ExternalId: "999", FloatValue: 0.1234m, Price: price); + + private static TradeupCandidate Candidate( + string collection = "The Sample Collection", + bool statTrak = false, + bool guaranteed = true, + decimal worstNet = 120m, + IReadOnlyList? outcomes = null, + IReadOnlyList? inputs = null) + { + outcomes ??= new[] + { + new TradeupOutcome(10, "Output Alpha", 0.0672m, WearBand.FactoryNew, 0.5m, 175.75m, 26), + new TradeupOutcome(11, "Output Beta", 0.0695m, WearBand.FactoryNew, 0.5m, null, 0), + }; + inputs ??= new[] + { + Input("AK-47 | Sample (Field-Tested)", "csfloat", + "steam://rungame/730/0/+csgo_econ_action_preview%20ABC123", 1.94m), + Input("MP9 | Sample (Minimal Wear)", "csmoney", null, 6.52m), + }; + + return new TradeupCandidate( + CollectionId: 1, + CollectionName: collection, + InputRarity: WeaponRarity.Classified, + OutputRarity: WeaponRarity.Covert, + StatTrak: statTrak, + AverageFraction: 0.0695m, + InputCost: 33.16m, + ExpectedNet: 120.53m, + WorstCaseNet: worstNet, + Guaranteed: guaranteed, + Inputs: inputs, + Outcomes: outcomes, + Composition: new[] { new TradeupContribution(1, collection, WeaponRarity.Covert, 10) }); + } + + [Fact] + public void RenderDetail_emits_the_key_sections_without_markup_errors() + { + var console = new TestConsole(); + + TradeupBrowser.RenderDetail(console, Candidate(), index: 0); + + var output = console.Output; + Assert.Contains("The Sample Collection", output); + Assert.Contains("Possible outputs", output); + Assert.Contains("Buy list", output); + Assert.Contains("Output Alpha", output); + Assert.Contains("unpriced", output); // the null-priced output is shown, not dropped + } + + [Theory] + [InlineData(false, true, 120)] // guaranteed, positive worst-case (green) + [InlineData(true, true, 120)] // StatTrak variant + [InlineData(false, false, -40)] // not guaranteed, negative worst-case (red) + public void SummaryLine_is_always_valid_markup(bool statTrak, bool guaranteed, int worstNet) + { + var line = TradeupBrowser.SummaryLine( + Candidate(statTrak: statTrak, guaranteed: guaranteed, worstNet: worstNet), index: 3); + + // The Markup ctor parses the string and throws on a malformed tag. + _ = new Markup(line); + } + + [Fact] + public void Settings_screen_renders_and_runs_on_enter() + { + var console = new TestConsole().Interactive(); + console.Input.PushKey(ConsoleKey.Enter); // first choice is "Run search" + + var options = new BlueLaminate.Core.Options.TradeupOptions(); + var top = 20; + var action = TradeupBrowser.PromptSettings(console, options, ref top); + + Assert.Equal(SettingsAction.RunSearch, action); + Assert.Contains("StatTrak universe", console.Output); + Assert.Contains("Run search", console.Output); + } + + [Fact] + public void Names_and_links_with_markup_characters_are_escaped() + { + var console = new TestConsole(); + var hostile = Candidate( + collection: "Danger [Collection]", + outcomes: new[] { new TradeupOutcome(10, "Skin [X]", 0.1m, WearBand.MinimalWear, 1m, 5m, 3) }, + inputs: new[] { Input("Weapon [Y] (FT)", "market", inspect: null, price: 1m) }); + + // Would throw a markup-parse exception if any bracketed field reached Spectre unescaped. + TradeupBrowser.RenderDetail(console, hostile, index: 0); + + Assert.Contains("Danger", console.Output); + } +} diff --git a/BlueLaminate/BlueLaminate.slnx b/BlueLaminate/BlueLaminate.slnx index 965f6bf..9d0ed33 100644 --- a/BlueLaminate/BlueLaminate.slnx +++ b/BlueLaminate/BlueLaminate.slnx @@ -4,4 +4,5 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 832e031..46f4138 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,6 +29,14 @@ + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml index d76842f..84c84c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,7 @@ services: - "host.docker.internal:host-gateway" restart: unless-stopped - worker: + csmoney-worker: build: context: . dockerfile: worker/Dockerfile @@ -42,7 +42,7 @@ services: deploy: replicas: ${CSMONEY_WORKERS:-1} environment: - WORKER_SCRIPT: csmoney_worker.py # (also the image default; explicit for symmetry) + WORKER_SCRIPT: csmoney_worker.py # (also the image default; explicit for symmetry) C2_URL: http://c2:5080 WORKER_TOKEN: ${WORKER_TOKEN:-dev-worker-token} # IPRoyal residential proxy: each replica self-assigns a unique sticky session @@ -51,9 +51,9 @@ services: IPROYAL_PASSWORD: ${IPROYAL_PASSWORD:-} IPROYAL_COUNTRY: ${IPROYAL_COUNTRY:-us} IPROYAL_LIFETIME_MIN: ${IPROYAL_LIFETIME_MIN:-60} - PROXY: ${PROXY:-} # auth-free host:port fallback (used only when IPRoyal creds are unset) + PROXY: ${PROXY:-} # auth-free host:port fallback (used only when IPRoyal creds are unset) SOLVE_SECONDS: ${SOLVE_SECONDS:-45} - LOAD_IMAGES: ${LOAD_IMAGES:-} # set to 1 to re-enable images (debugging) + LOAD_IMAGES: ${LOAD_IMAGES:-} # set to 1 to re-enable images (debugging) depends_on: - c2 ports: diff --git a/worker/blworker/config.py b/worker/blworker/config.py index 965ccff..3a48c0a 100644 --- a/worker/blworker/config.py +++ b/worker/blworker/config.py @@ -36,6 +36,13 @@ class Settings: browser_path: str | None load_images: bool chrome_no_sandbox: bool + # Browser bring-up resilience. nodriver gives Chromium only ~2.75s to open its + # DevTools port before raising "Failed to connect to browser"; when many replicas + # cold-start at once on a CPU-bound host they blow that window. A randomized pre-launch + # delay de-synchronizes the herd, and a few retries cover the residual slow starts. + startup_jitter: float + browser_start_retries: int + browser_start_backoff: float # Proxy (auth-free fallback) proxy: str | None # IPRoyal residential gateway @@ -69,6 +76,9 @@ class Settings: # the market APIs are pure JSON — so block images unless explicitly debugging. load_images=_flag("LOAD_IMAGES"), chrome_no_sandbox=_flag("CHROME_NO_SANDBOX"), + startup_jitter=_float("STARTUP_JITTER", 8.0), + browser_start_retries=_int("BROWSER_START_RETRIES", 4), + browser_start_backoff=_float("BROWSER_START_BACKOFF", 2.0), proxy=os.environ.get("PROXY") or None, iproyal_host=os.environ.get("IPROYAL_HOST", "geo.iproyal.com"), iproyal_port=_int("IPROYAL_PORT", 12321), diff --git a/worker/blworker/runtime.py b/worker/blworker/runtime.py index 85a9812..29c7dc8 100644 --- a/worker/blworker/runtime.py +++ b/worker/blworker/runtime.py @@ -147,6 +147,46 @@ class Worker(ABC): args += ["--no-sandbox", "--disable-dev-shm-usage"] return args + async def _kill_stray_chromium(self) -> None: + """Reap a half-launched Chromium left behind by a failed `uc.start()` so retries + (and steady-state memory) don't pile up dead browsers. One browser per container, + so a blanket pkill is safe here.""" + try: + proc = await asyncio.create_subprocess_exec( + "pkill", "-f", "chromium", + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL) + await proc.wait() + except Exception: + pass + + async def _start_browser(self, proxy: str | None): + """Bring up Chromium with a randomized pre-launch stagger and bounded retries. + + nodriver only polls the DevTools port for ~2.75s before giving up, so when many + replicas cold-start simultaneously on a busy host some launches lose the race and + the worker would otherwise exit(1). Staggering spreads the herd; retries (with a + fresh process each time) absorb the rest.""" + s = self.settings + if s.startup_jitter > 0: + delay = random.uniform(0, s.startup_jitter) + self.log.info("staggering browser launch by %.1fs", delay) + await asyncio.sleep(delay) + + attempts = max(1, s.browser_start_retries) + for attempt in range(1, attempts + 1): + try: + return await uc.start( + headless=False, browser_executable_path=s.browser_path, + browser_args=self._browser_args(proxy)) + except Exception as e: + if attempt == attempts: + raise + backoff = s.browser_start_backoff * attempt + random.uniform(0, 1) + self.log.warning("browser launch failed (attempt %d/%d): %s — retrying in %.1fs", + attempt, attempts, e, backoff) + await self._kill_stray_chromium() + await asyncio.sleep(backoff) + async def _on_challenge(self, page) -> None: """The exit IP is likely flagged. On IPRoyal, rotate to a fresh sticky session (new IP) before re-warming; otherwise just re-solve in place.""" @@ -192,9 +232,7 @@ class Worker(ABC): proxy, proxy_label = await self._setup_proxy() self.log.info("starting (C2=%s, proxy=%s, images=%s)", s.c2_url, proxy_label, "on" if s.load_images else "off") - browser = await uc.start( - headless=False, browser_executable_path=s.browser_path, - browser_args=self._browser_args(proxy)) + browser = await self._start_browser(proxy) try: page = await browser.get("about:blank") await self.warm(page)