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