Files
Operation-Blue-Laminate-v2/BlueLaminate/BlueLaminate.Cli/Commands/FindTradeupsCommand.cs
2026-06-02 13:31:27 -05:00

268 lines
9.7 KiB
C#
Raw Blame History

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