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