final
This commit is contained in:
@@ -11,6 +11,10 @@
|
||||
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="BlueLaminate.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BlueLaminate.Core\BlueLaminate.Core.csproj" />
|
||||
<ProjectReference Include="..\BlueLaminate.Scraper\BlueLaminate.Scraper.csproj" />
|
||||
@@ -20,6 +24,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -72,6 +72,7 @@ var root = new RootCommand("BlueLaminate CLI — Counter-Strike skin tracker too
|
||||
FetchListingsCommand.Build(host),
|
||||
SweepListingsCommand.Build(host),
|
||||
SweepCatalogCommand.Build(host),
|
||||
FindTradeupsCommand.Build(host),
|
||||
};
|
||||
|
||||
// Ctrl+C → cancel the action's token so long-running commands (e.g. sweep-catalog,
|
||||
|
||||
352
BlueLaminate/BlueLaminate.Cli/Tui/TradeupBrowser.cs
Normal file
352
BlueLaminate/BlueLaminate.Cli/Tui/TradeupBrowser.cs
Normal file
@@ -0,0 +1,352 @@
|
||||
using BlueLaminate.Core.Options;
|
||||
using BlueLaminate.Core.Tradeups;
|
||||
using Spectre.Console;
|
||||
using System.Globalization;
|
||||
|
||||
namespace BlueLaminate.Cli.Tui;
|
||||
|
||||
/// <summary>What the user chose to do next after browsing results.</summary>
|
||||
internal enum BrowseAction
|
||||
{
|
||||
/// <summary>Leave the finder.</summary>
|
||||
Quit,
|
||||
|
||||
/// <summary>Return to the settings screen and run a fresh search.</summary>
|
||||
AdjustSettings,
|
||||
}
|
||||
|
||||
/// <summary>What the user chose on the settings screen.</summary>
|
||||
internal enum SettingsAction
|
||||
{
|
||||
/// <summary>Run a search with the current settings.</summary>
|
||||
RunSearch,
|
||||
|
||||
/// <summary>Leave the finder.</summary>
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interactive terminal browser for tradeup candidates: a settings screen, then an
|
||||
/// arrow-key navigable list of contracts that drills into a detail view with the output
|
||||
/// distribution and a line-by-line buy list (each copy linked to its source listing).
|
||||
/// Pure presentation over the <see cref="TradeupCandidate"/>s the Core engine produces; it
|
||||
/// only mutates a <see cref="TradeupOptions"/> the command then feeds back to the finder.
|
||||
/// <para>
|
||||
/// The render/prompt methods take an <see cref="IAnsiConsole"/> so they can be exercised
|
||||
/// against a recording console in tests; only the live loop needs the real terminal.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class TradeupBrowser
|
||||
{
|
||||
private const int QuitSentinel = -1;
|
||||
private const int AdjustSentinel = -2;
|
||||
|
||||
/// <summary>Whether the current terminal can host the interactive prompts.</summary>
|
||||
public static bool IsSupported => AnsiConsole.Profile.Capabilities.Interactive;
|
||||
|
||||
/// <summary>
|
||||
/// Settings screen: shows the current search options and lets the user tweak any of them
|
||||
/// before running. Mutates <paramref name="options"/> and <paramref name="top"/> in place;
|
||||
/// returns whether to run a search or quit.
|
||||
/// </summary>
|
||||
public static SettingsAction PromptSettings(TradeupOptions options, ref int top)
|
||||
=> PromptSettings(AnsiConsole.Console, options, ref top);
|
||||
|
||||
public static SettingsAction PromptSettings(IAnsiConsole console, TradeupOptions options, ref int top)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
console.Clear();
|
||||
console.Write(new Rule("[bold yellow]Tradeup Finder[/] — settings")
|
||||
{
|
||||
Justification = Justify.Left,
|
||||
});
|
||||
console.MarkupLine("[grey]Pick a setting to change it, then [green]Run search[/]. (↑/↓ + Enter)[/]");
|
||||
console.WriteLine();
|
||||
|
||||
const string run = "[green]▶ Run search[/]";
|
||||
const string quit = "[red]✕ Quit[/]";
|
||||
var stItem = $"StatTrak universe · [aqua]{options.StatTrak}[/]";
|
||||
var rankItem = $"Rank by · [aqua]{options.Ranking}[/]";
|
||||
var guarItem = "Profit filter · "
|
||||
+ (options.GuaranteedOnly ? "[green]guaranteed only[/]" : "[yellow]include risky[/]");
|
||||
var minItem = $"Min profit · [aqua]{options.MinProfit:C}[/]";
|
||||
var topItem = $"Show top · [aqua]{top}[/]";
|
||||
|
||||
var choice = console.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("Settings")
|
||||
.PageSize(12)
|
||||
.AddChoices(run, stItem, rankItem, guarItem, minItem, topItem, quit));
|
||||
|
||||
if (choice == run)
|
||||
{
|
||||
return SettingsAction.RunSearch;
|
||||
}
|
||||
|
||||
if (choice == quit)
|
||||
{
|
||||
console.Clear();
|
||||
return SettingsAction.Quit;
|
||||
}
|
||||
|
||||
if (choice == stItem)
|
||||
{
|
||||
options.StatTrak = console.Prompt(
|
||||
new SelectionPrompt<StatTrakMode>()
|
||||
.Title("StatTrak universe to search")
|
||||
.AddChoices(StatTrakMode.Both, StatTrakMode.NonStatTrakOnly, StatTrakMode.StatTrakOnly));
|
||||
}
|
||||
else if (choice == rankItem)
|
||||
{
|
||||
options.Ranking = console.Prompt(
|
||||
new SelectionPrompt<TradeupRanking>()
|
||||
.Title("Rank surviving contracts by")
|
||||
.AddChoices(TradeupRanking.WorstCaseProfit, TradeupRanking.ExpectedProfit));
|
||||
}
|
||||
else if (choice == guarItem)
|
||||
{
|
||||
options.GuaranteedOnly = console.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title("Which contracts should survive?")
|
||||
.AddChoices("Guaranteed only (worst output still profits)", "Include risky (any positive EV)"))
|
||||
.StartsWith("Guaranteed");
|
||||
}
|
||||
else if (choice == minItem)
|
||||
{
|
||||
options.MinProfit = console.Prompt(
|
||||
new TextPrompt<decimal>("Minimum ranking profit (USD):")
|
||||
.DefaultValue(options.MinProfit)
|
||||
.ShowDefaultValue());
|
||||
}
|
||||
else if (choice == topItem)
|
||||
{
|
||||
top = console.Prompt(
|
||||
new TextPrompt<int>("Show how many contracts:")
|
||||
.DefaultValue(top)
|
||||
.ShowDefaultValue()
|
||||
.Validate(v => v > 0 ? ValidationResult.Success() : ValidationResult.Error("must be > 0")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static BrowseAction Run(IReadOnlyList<TradeupCandidate> candidates, TradeupOptions options)
|
||||
=> Run(AnsiConsole.Console, candidates, options);
|
||||
|
||||
public static BrowseAction Run(
|
||||
IAnsiConsole console, IReadOnlyList<TradeupCandidate> candidates, TradeupOptions options)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
console.Clear();
|
||||
RenderHeader(console, candidates.Count, options);
|
||||
|
||||
var indices = Enumerable.Range(0, candidates.Count)
|
||||
.Append(AdjustSentinel)
|
||||
.Append(QuitSentinel)
|
||||
.ToList();
|
||||
|
||||
var selected = console.Prompt(
|
||||
new SelectionPrompt<int>()
|
||||
.Title("Select a contract to inspect its [green]buy list[/]:")
|
||||
.PageSize(18)
|
||||
.WrapAround()
|
||||
.MoreChoicesText("[grey](↑/↓ to scroll, type to filter)[/]")
|
||||
.AddChoices(indices)
|
||||
.UseConverter(ListChoiceLabel(candidates)));
|
||||
|
||||
switch (selected)
|
||||
{
|
||||
case QuitSentinel:
|
||||
console.Clear();
|
||||
return BrowseAction.Quit;
|
||||
case AdjustSentinel:
|
||||
return BrowseAction.AdjustSettings;
|
||||
}
|
||||
|
||||
RenderDetail(console, candidates[selected], selected);
|
||||
|
||||
const string back = "← Back to list";
|
||||
const string adjust = "⚙ Adjust settings & re-run";
|
||||
const string quit = "Quit";
|
||||
var next = console.Prompt(
|
||||
new SelectionPrompt<string>()
|
||||
.Title(string.Empty)
|
||||
.AddChoices(back, adjust, quit));
|
||||
|
||||
if (next == quit)
|
||||
{
|
||||
console.Clear();
|
||||
return BrowseAction.Quit;
|
||||
}
|
||||
|
||||
if (next == adjust)
|
||||
{
|
||||
return BrowseAction.AdjustSettings;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Func<int, string> ListChoiceLabel(IReadOnlyList<TradeupCandidate> candidates) => i => i switch
|
||||
{
|
||||
QuitSentinel => "[red]Quit[/]",
|
||||
AdjustSentinel => "[yellow]⚙ Adjust settings & re-run[/]",
|
||||
_ => SummaryLine(candidates[i], i),
|
||||
};
|
||||
|
||||
private static void RenderHeader(IAnsiConsole console, int count, TradeupOptions options)
|
||||
{
|
||||
console.Write(new Rule($"[bold yellow]Tradeup Finder[/] — {count} contracts")
|
||||
{
|
||||
Justification = Justify.Left,
|
||||
});
|
||||
console.MarkupLine(
|
||||
$"[grey]{options.StatTrak} · rank by {options.Ranking} · "
|
||||
+ $"{(options.GuaranteedOnly ? "guaranteed-only" : "incl. risky")} · "
|
||||
+ $"sell net of {options.UndercutRate:P0} undercut + {options.SellFeeRate:P0} fee[/]");
|
||||
console.WriteLine();
|
||||
}
|
||||
|
||||
// One aligned, lightly-coloured line per contract for the selection list. Padding is
|
||||
// applied to the raw text before markup so columns stay aligned (skin/collection names
|
||||
// contain no markup brackets in practice; escaped defensively anyway).
|
||||
internal static string SummaryLine(TradeupCandidate c, int index)
|
||||
{
|
||||
var rank = $"{index + 1,3}.";
|
||||
var collection = Truncate(c.CollectionName, 30).PadRight(30);
|
||||
var recipeText = c.CollectionCount > 1
|
||||
? $"{c.InputRarity}→mix×{c.CollectionCount}"
|
||||
: $"{c.InputRarity}→{c.OutputRarity}";
|
||||
var recipe = recipeText.PadRight(21);
|
||||
var st = c.StatTrak ? "ST " : " ";
|
||||
var worst = Money(c.WorstCaseProfit).PadLeft(10);
|
||||
var expected = Money(c.ExpectedProfit).PadLeft(10);
|
||||
var flag = c.Guaranteed ? "[green]✓[/]" : " ";
|
||||
|
||||
var worstColor = c.WorstCaseProfit > 0 ? "green" : "red";
|
||||
return $"{rank} {Markup.Escape(collection)} [aqua]{Markup.Escape(recipe)}[/] {st} "
|
||||
+ $"cost [silver]{Money(c.InputCost),10}[/] worst [{worstColor}]{worst}[/] "
|
||||
+ $"exp [green]{expected}[/] {flag}";
|
||||
}
|
||||
|
||||
internal static void RenderDetail(IAnsiConsole console, TradeupCandidate c, int index)
|
||||
{
|
||||
console.Clear();
|
||||
|
||||
var recipe = c.CollectionCount > 1 ? $"{c.InputRarity}→mix" : $"{c.InputRarity}→{c.OutputRarity}";
|
||||
var title = $"#{index + 1} [bold]{Markup.Escape(c.CollectionName)}[/] "
|
||||
+ $"[aqua]{recipe}[/]{(c.StatTrak ? " [orange1]StatTrak™[/]" : string.Empty)}";
|
||||
console.Write(new Rule(title) { Justification = Justify.Left });
|
||||
|
||||
if (c.CollectionCount > 1)
|
||||
{
|
||||
var mix = string.Join(" ", c.Composition.Select(p =>
|
||||
$"[silver]{p.InputCount}×[/] {Markup.Escape(p.CollectionName)} [grey]→ {p.OutputRarity}[/]"));
|
||||
console.MarkupLine($"[grey]mix:[/] {mix}");
|
||||
}
|
||||
|
||||
var worstColor = c.WorstCaseProfit > 0 ? "green" : "red";
|
||||
var economics = new Grid();
|
||||
economics.AddColumns(6);
|
||||
economics.AddRow(
|
||||
"[grey]Input cost[/]", "[grey]E(net)[/]", "[grey]Worst net[/]",
|
||||
"[grey]E(profit)[/]", "[grey]Worst P/L[/]", "[grey]Avg float frac[/]");
|
||||
economics.AddRow(
|
||||
$"[silver]{Money(c.InputCost)}[/]",
|
||||
$"{Money(c.ExpectedNet)}",
|
||||
$"{Money(c.WorstCaseNet)}",
|
||||
$"[green]{Money(c.ExpectedProfit)}[/]",
|
||||
$"[{worstColor}]{Money(c.WorstCaseProfit)}[/]",
|
||||
$"{c.AverageFraction:F4}");
|
||||
console.Write(new Panel(economics)
|
||||
{
|
||||
Header = new PanelHeader(c.Guaranteed ? " [green]GUARANTEED[/] " : " [yellow]not guaranteed[/] "),
|
||||
Border = BoxBorder.Rounded,
|
||||
});
|
||||
|
||||
RenderOutcomes(console, c);
|
||||
RenderBuyList(console, c);
|
||||
}
|
||||
|
||||
private static void RenderOutcomes(IAnsiConsole console, TradeupCandidate c)
|
||||
{
|
||||
var table = new Table().Border(TableBorder.Minimal).Title("[bold]Possible outputs[/]");
|
||||
table.AddColumn(new TableColumn("Chance").RightAligned());
|
||||
table.AddColumn("Output");
|
||||
table.AddColumn(new TableColumn("Float").RightAligned());
|
||||
table.AddColumn("Wear");
|
||||
table.AddColumn(new TableColumn("Net sell").RightAligned());
|
||||
table.AddColumn(new TableColumn("Liq").RightAligned());
|
||||
|
||||
foreach (var o in c.Outcomes.OrderByDescending(o => o.NetSellPrice ?? -1m))
|
||||
{
|
||||
var price = o.NetSellPrice is { } net ? $"[green]{Money(net)}[/]" : "[grey](unpriced)[/]";
|
||||
var liquidity = o.PriceSource switch
|
||||
{
|
||||
"csfloat-live" => $"{o.Liquidity} [blue]· csfloat live[/]",
|
||||
"market-floor" => $"{o.Liquidity} [yellow]· floor est[/]",
|
||||
_ => o.Liquidity.ToString(),
|
||||
};
|
||||
table.AddRow(
|
||||
$"{o.Probability:P1}",
|
||||
Markup.Escape(o.Name),
|
||||
$"{o.OutputFloat:F4}",
|
||||
Markup.Escape(o.Band.ToName()),
|
||||
price,
|
||||
liquidity);
|
||||
}
|
||||
|
||||
console.Write(table);
|
||||
}
|
||||
|
||||
private static void RenderBuyList(IAnsiConsole console, TradeupCandidate c)
|
||||
{
|
||||
var table = new Table().Border(TableBorder.Minimal)
|
||||
.Title($"[bold]Buy list[/] — {c.Inputs.Count} inputs, total [silver]{Money(c.InputCost)}[/]");
|
||||
table.AddColumn(new TableColumn("#").RightAligned());
|
||||
table.AddColumn("Item");
|
||||
table.AddColumn(new TableColumn("Float").RightAligned().NoWrap());
|
||||
table.AddColumn(new TableColumn("Price").RightAligned());
|
||||
table.AddColumn("Market");
|
||||
table.AddColumn("Listing");
|
||||
|
||||
var n = 1;
|
||||
foreach (var input in c.Inputs.OrderBy(i => i.Price))
|
||||
{
|
||||
table.AddRow(
|
||||
n.ToString(),
|
||||
Markup.Escape(input.MarketHashName),
|
||||
FullFloat(input.FloatValue),
|
||||
$"[silver]{Money(input.Price)}[/]",
|
||||
Markup.Escape(input.Marketplace),
|
||||
ListingLink(input));
|
||||
n++;
|
||||
}
|
||||
|
||||
console.Write(table);
|
||||
}
|
||||
|
||||
// A clickable inspect link in terminals that support OSC-8 hyperlinks; otherwise the
|
||||
// external listing id, so the copy is still traceable.
|
||||
private static string ListingLink(InputListing input)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(input.InspectLink)
|
||||
&& !input.InspectLink.Contains('[')
|
||||
&& !input.InspectLink.Contains(']'))
|
||||
{
|
||||
return $"[link={input.InspectLink}]inspect[/]";
|
||||
}
|
||||
|
||||
return $"[grey]{Markup.Escape(input.ExternalId)}[/]";
|
||||
}
|
||||
|
||||
private static string Money(decimal value) => value.ToString("C");
|
||||
|
||||
// The real listing float at full stored precision (trailing zeros dropped), so a copy can
|
||||
// be matched exactly on the market. Invariant culture keeps the decimal point a dot.
|
||||
private static string FullFloat(decimal value)
|
||||
=> value.ToString("0.##################", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Truncate(string value, int max)
|
||||
=> value.Length <= max ? value : value[..(max - 1)] + "…";
|
||||
}
|
||||
Reference in New Issue
Block a user