268 lines
9.7 KiB
C#
268 lines
9.7 KiB
C#
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);
|
||
}
|