205 lines
7.5 KiB
C#
205 lines
7.5 KiB
C#
using BlueLaminate.Core.Tradeups;
|
|
using Xunit;
|
|
|
|
namespace BlueLaminate.Tests.Tradeups;
|
|
|
|
public class TradeupSelectorTests
|
|
{
|
|
private const decimal Bucket = 0.005m;
|
|
|
|
private static SelectableInput Item(decimal fraction, decimal price)
|
|
=> new(fraction, new InputListing(
|
|
SkinId: 1,
|
|
MarketHashName: "Test Skin",
|
|
Marketplace: "test",
|
|
InspectLink: null,
|
|
ExternalId: "0",
|
|
FloatValue: fraction,
|
|
Price: price));
|
|
|
|
[Fact]
|
|
public void Cheapest_full_selection_is_the_ten_cheapest_copies()
|
|
{
|
|
// 12 copies priced 1..12 at assorted fractions. With no binding float target the
|
|
// cheapest ten (cost 55) must be attainable at some summed-fraction bucket.
|
|
var pool = new List<SelectableInput>();
|
|
for (var i = 1; i <= 12; i++)
|
|
{
|
|
pool.Add(Item(fraction: 0.01m * i, price: i));
|
|
}
|
|
|
|
var selection = TradeupSelector.Solve(pool, contractSize: 10, Bucket);
|
|
var selections = selection.Selections().ToList();
|
|
|
|
Assert.NotEmpty(selections);
|
|
|
|
var cheapest = selections.MinBy(s => s.Cost);
|
|
Assert.Equal(55m, cheapest.Cost);
|
|
|
|
var picks = cheapest.Picks.ToList();
|
|
Assert.Equal(10, picks.Count);
|
|
Assert.Equal(Enumerable.Range(1, 10).Select(i => (decimal)i), picks.Select(p => p.Price).OrderBy(p => p));
|
|
}
|
|
|
|
[Fact]
|
|
public void Reported_average_fraction_is_a_conservative_upper_bound()
|
|
{
|
|
var pool = new List<SelectableInput>();
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
pool.Add(Item(fraction: 0.123m, price: 1m));
|
|
}
|
|
|
|
var only = Assert.Single(TradeupSelector.Solve(pool, 10, Bucket).Selections());
|
|
|
|
var trueAverage = only.Picks.ToList().Average(p => p.FloatValue);
|
|
Assert.True(only.AverageFraction >= trueAverage,
|
|
$"bucketed average {only.AverageFraction} should round up from true {trueAverage}");
|
|
// 0.123 rounds up to the 0.125 bucket (0.005 grid).
|
|
Assert.Equal(0.125m, only.AverageFraction);
|
|
}
|
|
|
|
[Fact]
|
|
public void Selecting_cheaper_low_float_copies_lowers_the_attainable_average()
|
|
{
|
|
// Cheap high-float copies vs. pricier low-float copies. A lower average is only
|
|
// reachable by paying for the low-float set, so a low-average selection must cost
|
|
// more than the unconstrained cheapest.
|
|
var pool = new List<SelectableInput>();
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
pool.Add(Item(fraction: 0.60m, price: 1m)); // cheap, bad float
|
|
}
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
pool.Add(Item(fraction: 0.10m, price: 5m)); // pricey, good float
|
|
}
|
|
|
|
var selections = TradeupSelector.Solve(pool, 10, Bucket).Selections().ToList();
|
|
|
|
var cheapest = selections.MinBy(s => s.Cost);
|
|
Assert.Equal(10m, cheapest.Cost); // ten cheap copies
|
|
Assert.Equal(0.60m, cheapest.AverageFraction);
|
|
|
|
var lowestFloat = selections.MinBy(s => s.AverageFraction);
|
|
Assert.Equal(0.10m, lowestFloat.AverageFraction);
|
|
Assert.Equal(50m, lowestFloat.Cost); // forced onto the pricey low-float set
|
|
}
|
|
|
|
[Fact]
|
|
public void No_full_selection_when_pool_is_too_small()
|
|
{
|
|
var pool = Enumerable.Range(0, 5).Select(i => Item(0.2m, i + 1)).ToList();
|
|
Assert.Empty(TradeupSelector.Solve(pool, 10, Bucket).Selections());
|
|
}
|
|
|
|
private static TradeupSelector.RewardItem Reward(int bucket, double reward, decimal price = 0m)
|
|
=> new(bucket, reward, new InputListing(1, "x", "m", null, "0", 0.1m, price));
|
|
|
|
[Fact]
|
|
public void MaxReward_picks_the_highest_reward_set_within_the_cap()
|
|
{
|
|
// Six items; pick 3 maximising reward with bucket sum ≤ 6. The three best rewards
|
|
// (9, 8, 7) sit at buckets 2,2,2 = 6 ≤ cap, so they win.
|
|
var items = new[]
|
|
{
|
|
Reward(2, 9), Reward(2, 8), Reward(2, 7),
|
|
Reward(1, 1), Reward(1, 2), Reward(1, 3),
|
|
};
|
|
|
|
var picks = TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 6);
|
|
|
|
Assert.NotNull(picks);
|
|
Assert.Equal(3, picks!.ToList().Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void MaxReward_respects_the_float_cap_even_at_the_cost_of_reward()
|
|
{
|
|
// The fat-reward items sit at high buckets; the cap forces the low-bucket set.
|
|
var items = new[]
|
|
{
|
|
Reward(5, 100, price: 50m), // too high-float to fit under a tight cap
|
|
Reward(1, 5, price: 1m),
|
|
Reward(1, 4, price: 1m),
|
|
Reward(1, 3, price: 1m),
|
|
};
|
|
|
|
var picks = TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 3);
|
|
|
|
Assert.NotNull(picks);
|
|
var chosen = picks!.ToList();
|
|
Assert.Equal(3, chosen.Count);
|
|
Assert.All(chosen, p => Assert.Equal(1m, p.Price)); // the three low-float copies
|
|
}
|
|
|
|
[Fact]
|
|
public void MaxReward_returns_null_when_the_contract_cannot_be_filled_under_the_cap()
|
|
{
|
|
// Only two items fit under the cap, but three are required.
|
|
var items = new[] { Reward(1, 5), Reward(1, 4), Reward(9, 100) };
|
|
Assert.Null(TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 3));
|
|
}
|
|
}
|
|
|
|
public class OutputPriceBookTests
|
|
{
|
|
private static TradeupListingRow Row(WearBand band, bool st, decimal price)
|
|
{
|
|
// A float squarely inside the band, so Build files it where we expect.
|
|
var f = band switch
|
|
{
|
|
WearBand.FactoryNew => 0.03m,
|
|
WearBand.MinimalWear => 0.10m,
|
|
WearBand.FieldTested => 0.25m,
|
|
WearBand.WellWorn => 0.40m,
|
|
_ => 0.60m,
|
|
};
|
|
return new TradeupListingRow(1, "M4A4 | X-Ray", "csmoney", null, "0", st, false, f, price);
|
|
}
|
|
|
|
[Fact]
|
|
public void Liquid_band_uses_its_own_price()
|
|
{
|
|
// Ten MW listings -> the MW band is trusted at its own lowest ask.
|
|
var rows = Enumerable.Range(0, 10).Select(i => Row(WearBand.MinimalWear, true, 90m + i));
|
|
var book = TradeupListingData.Build(rows).OutputPrices;
|
|
|
|
var r = book.Resolve(1, statTrak: true, outputFloat: 0.10m, thinThreshold: 10);
|
|
|
|
Assert.Equal(OutputPriceBasis.Band, r.Basis);
|
|
Assert.Equal(90m, r.LowestAsk);
|
|
}
|
|
|
|
[Fact]
|
|
public void Thin_band_falls_back_to_the_skin_overall_floor()
|
|
{
|
|
// The X-Ray case: two outlier FN listings ($1287) but a liquid MW market (~$90).
|
|
// A produced FN output must price off the $90 floor, not the $1287 band outlier.
|
|
var rows = new List<TradeupListingRow>
|
|
{
|
|
Row(WearBand.FactoryNew, true, 1287m),
|
|
Row(WearBand.FactoryNew, true, 1290m),
|
|
};
|
|
rows.AddRange(Enumerable.Range(0, 10).Select(i => Row(WearBand.MinimalWear, true, 90m + i)));
|
|
|
|
var book = TradeupListingData.Build(rows).OutputPrices;
|
|
|
|
var r = book.Resolve(1, statTrak: true, outputFloat: 0.0695m, thinThreshold: 10);
|
|
|
|
Assert.Equal(OutputPriceBasis.Floor, r.Basis);
|
|
Assert.Equal(90m, r.LowestAsk); // skin-wide floor, not the $1287 FN outlier
|
|
Assert.Equal(2, r.BandLiquidity); // still reports the thin band count → triggers CSFloat
|
|
}
|
|
|
|
[Fact]
|
|
public void Unlisted_skin_resolves_to_none()
|
|
{
|
|
var book = TradeupListingData.Build(Array.Empty<TradeupListingRow>()).OutputPrices;
|
|
var r = book.Resolve(1, statTrak: false, outputFloat: 0.1m, thinThreshold: 10);
|
|
Assert.Equal(OutputPriceBasis.None, r.Basis);
|
|
Assert.Null(r.LowestAsk);
|
|
}
|
|
}
|