final
This commit is contained in:
267
BlueLaminate/BlueLaminate.Cli/Commands/FindTradeupsCommand.cs
Normal file
267
BlueLaminate/BlueLaminate.Cli/Commands/FindTradeupsCommand.cs
Normal file
@@ -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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
Reference in New Issue
Block a user