Add market value: tiered prices and an eBay Browse provider

Researched the options first. PriceCharting is the standard for retro prices
but requires a paid subscription for both its API and its bulk download.
eBay's sold-price data sits behind the Marketplace Insights API, which is a
limited release closed to new developers. The free game-price APIs cover
current digital storefronts, not physical retro copies. So there is no free
route to sold prices, and this uses eBay Browse — active listings, which are
asking prices, labelled as such rather than presented as valuations.

Schema now holds three prices per game (loose, CIB, new), with marketValue
as whichever tier matches that copy's condition. Changing a condition
re-prices from the stored tiers with no further lookup, and the dashboard
can later show both actual value and what a collection would be worth
complete.

The judgement lives in classification and aggregation, both pure and both
tested without credentials:

  * listings are sorted into tiers from their titles, and accessories,
    reproductions and multi-game lots are discarded — a "box only" listing
    at $45 counted as a copy would halve the loose estimate for a $130 cart
  * the discard qualifier is required. The first version matched a bare
    "box", which threw out "complete in box" and "with box and manual",
    i.e. most of the CIB tier, while keeping exactly the listings the
    filter existed to remove. A test asserting on tiers rather than counts
    caught it.
  * median with an interquartile trim, since one optimist asking 50x moves
    a mean and not a median
  * sample counts travel with the estimate, because a tier drawn from two
    listings warrants less confidence than one drawn from thirty

Credentials are optional: with none set, /api/prices/status reports
configured=false and refresh answers 503 with instructions, while the rest
of the app is unaffected.

101 backend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 15:21:32 -04:00
co-authored by Claude Opus 5
parent d5a0e42fed
commit ca70bcef34
19 changed files with 1475 additions and 9 deletions
+20
View File
@@ -31,3 +31,23 @@ JWT_LIFETIME_MINUTES=720
JWT_ISSUER=LudosData JWT_ISSUER=LudosData
JWT_AUDIENCE=LudosData JWT_AUDIENCE=LudosData
CORS_ORIGIN=http://localhost:8080 CORS_ORIGIN=http://localhost:8080
# --- Market value (optional) ------------------------------------------------
# Prices come from eBay's Browse API, which needs a free developer account.
#
# 1. Register at https://developer.ebay.com and create a developer account
# 2. Create an application keyset (Application Keys -> Production)
# 3. Copy the App ID (Client ID) and Cert ID (Client Secret) below
#
# Leave these blank and the pricing endpoints report 503 with an explanation;
# nothing else is affected.
#
# IMPORTANT: Browse returns ACTIVE LISTINGS, which are asking prices, not
# completed sales. eBay's sold-price data lives behind the Marketplace Insights
# API, which is a limited release not open to new developers. Expect these
# figures to read high — they are an upper bound, not a valuation.
EBAY_CLIENT_ID=
EBAY_CLIENT_SECRET=
# Set true to use eBay's sandbox while checking credentials.
EBAY_USE_SANDBOX=false
+40
View File
@@ -214,6 +214,46 @@ reinterpret every stored export.
New query parameters: `condition`, `region`, `minRating`, `hasValue`. New sort New query parameters: `condition`, `region`, `minRating`, `hasValue`. New sort
keys: `rating`, `value`, `price`, `purchased`. keys: `rating`, `value`, `price`, `purchased`.
### Market value
Prices come from **eBay's Browse API**, which needs a free developer account:
register at developer.ebay.com, create a production application keyset, and put
the App ID and Cert ID in `.env` as `EBAY_CLIENT_ID` / `EBAY_CLIENT_SECRET`.
Without them the pricing endpoints answer 503 with an explanation and nothing
else is affected.
```
GET /api/prices/status is a provider configured?
POST /api/prices/refresh {dryRun, limit} price some games
```
**These are asking prices, not sold prices.** Browse returns active listings.
eBay's completed-sales data lives behind the Marketplace Insights API, which is
a limited release closed to new developers, and PriceCharting — the usual
alternative — requires a paid subscription. Asking prices skew high: sellers
list optimistically and unsold listings linger. Treat the numbers as an upper
bound. The provider name (`ebay-asking`) is stored with every value it writes,
so the source is always visible next to the figure.
Deriving a price from listings takes more than an average:
- **Listings are classified into loose / CIB / new** from the title, because a
feed of mixed conditions has no single price. Accessories are discarded
outright — a "box only" listing at $45 counted as a copy would halve the loose
estimate for a $130 cartridge. So are reproductions and multi-game lots.
- **The qualifier is required when discarding.** An early version matched a bare
"box", which threw away "complete in box" and "with box and manual" — most of
the CIB tier — while keeping the cheap box-only listings the filter existed to
remove. Caught by a test asserting on the tier, not on the count.
- **Median, not mean,** with an interquartile trim. One optimist asking 50x drags
a mean past the point of usefulness; a median ignores them.
- **Sample counts travel with the estimate.** A tier from two listings deserves
less confidence than one from thirty.
Three prices are stored per game, and `marketValue` is whichever tier matches
that copy's condition. Changing a game's condition re-prices it from the stored
tiers without another lookup.
### Database changes ### Database changes
```bash ```bash
@@ -39,6 +39,9 @@ public record GameResponse(
decimal? MarketValue, decimal? MarketValue,
DateTimeOffset? MarketValueUpdatedAt, DateTimeOffset? MarketValueUpdatedAt,
string? MarketValueSource, string? MarketValueSource,
decimal? ValueLoose,
decimal? ValueCib,
decimal? ValueNew,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
@@ -81,6 +84,10 @@ public record GameRequest
[Range(0, 1_000_000)] public decimal? MarketValue { get; init; } [Range(0, 1_000_000)] public decimal? MarketValue { get; init; }
[MaxLength(100)] public string? MarketValueSource { get; init; } [MaxLength(100)] public string? MarketValueSource { get; init; }
[Range(0, 1_000_000)] public decimal? ValueLoose { get; init; }
[Range(0, 1_000_000)] public decimal? ValueCib { get; init; }
[Range(0, 1_000_000)] public decimal? ValueNew { get; init; }
} }
/// <summary>Query string for the library list, bound from [FromQuery].</summary> /// <summary>Query string for the library list, bound from [FromQuery].</summary>
@@ -42,6 +42,9 @@ public record ExportGame
public decimal? MarketValue { get; init; } public decimal? MarketValue { get; init; }
public DateTimeOffset? MarketValueUpdatedAt { get; init; } public DateTimeOffset? MarketValueUpdatedAt { get; init; }
public string? MarketValueSource { get; init; } public string? MarketValueSource { get; init; }
public decimal? ValueLoose { get; init; }
public decimal? ValueCib { get; init; }
public decimal? ValueNew { get; init; }
} }
/// <summary>Envelope written by the JSON exporter.</summary> /// <summary>Envelope written by the JSON exporter.</summary>
@@ -194,19 +194,38 @@ public class GamesController(
game.PurchasePrice = request.PurchasePrice; game.PurchasePrice = request.PurchasePrice;
game.PurchaseDate = request.PurchaseDate; game.PurchaseDate = request.PurchaseDate;
// Only stamp the valuation when the figure actually changes, so an var tiersChanged = request.ValueLoose != game.ValueLoose
|| request.ValueCib != game.ValueCib
|| request.ValueNew != game.ValueNew;
game.ValueLoose = request.ValueLoose;
game.ValueCib = request.ValueCib;
game.ValueNew = request.ValueNew;
// Only stamp the valuation when a figure actually changes, so an
// unrelated edit does not make a stale price look freshly checked. // unrelated edit does not make a stale price look freshly checked.
if (request.MarketValue != game.MarketValue) if (request.MarketValue != game.MarketValue || tiersChanged)
{ {
game.MarketValue = request.MarketValue; game.MarketValue = request.MarketValue;
game.MarketValueUpdatedAt = request.MarketValue is null ? null : DateTimeOffset.UtcNow; // Tiers win where they exist: they came from a source, and they
game.MarketValueSource = request.MarketValue is null // follow the copy's condition.
game.RecalculateEffectiveValue();
game.MarketValueUpdatedAt = game.MarketValue is null ? null : DateTimeOffset.UtcNow;
game.MarketValueSource = game.MarketValue is null
? null ? null
: request.MarketValueSource?.Trim() ?? "manual"; : request.MarketValueSource?.Trim() ?? "manual";
} }
else if (request.MarketValueSource is { } source && game.MarketValue is not null) else
{ {
game.MarketValueSource = source.Trim(); // Condition may have moved without any price changing, which puts a
// different tier in play.
game.RecalculateEffectiveValue();
if (request.MarketValueSource is { } source && game.MarketValue is not null)
{
game.MarketValueSource = source.Trim();
}
} }
} }
@@ -217,5 +236,6 @@ public class GamesController(
g.Rating, g.Notes, g.Condition, g.Region, g.Rating, g.Notes, g.Condition, g.Region,
g.PurchasePrice, g.PurchaseDate, g.PurchasePrice, g.PurchaseDate,
g.MarketValue, g.MarketValueUpdatedAt, g.MarketValueSource, g.MarketValue, g.MarketValueUpdatedAt, g.MarketValueSource,
g.ValueLoose, g.ValueCib, g.ValueNew,
g.CreatedAt, g.UpdatedAt); g.CreatedAt, g.UpdatedAt);
} }
@@ -33,7 +33,7 @@ public class LibraryController(
"description", "art", "own", "dumped", "played", "finished", "description", "art", "own", "dumped", "played", "finished",
"rating", "notes", "condition", "region", "rating", "notes", "condition", "region",
"purchasePrice", "purchaseDate", "marketValue", "marketValueUpdatedAt", "purchasePrice", "purchaseDate", "marketValue", "marketValueUpdatedAt",
"marketValueSource", "marketValueSource", "valueLoose", "valueCib", "valueNew",
]; ];
// Must match the converter registered on the controllers, so an export // Must match the converter registered on the controllers, so an export
@@ -75,6 +75,9 @@ public class LibraryController(
g.MarketValue?.ToString(CultureInfo.InvariantCulture), g.MarketValue?.ToString(CultureInfo.InvariantCulture),
g.MarketValueUpdatedAt?.ToString("O", CultureInfo.InvariantCulture), g.MarketValueUpdatedAt?.ToString("O", CultureInfo.InvariantCulture),
g.MarketValueSource, g.MarketValueSource,
g.ValueLoose?.ToString(CultureInfo.InvariantCulture),
g.ValueCib?.ToString(CultureInfo.InvariantCulture),
g.ValueNew?.ToString(CultureInfo.InvariantCulture),
})); }));
// A BOM keeps Excel from mangling non-ASCII titles such as Pokémon. // A BOM keeps Excel from mangling non-ASCII titles such as Pokémon.
@@ -294,6 +297,9 @@ public class LibraryController(
MarketValue = ParseMoney(Field(row, "marketvalue")), MarketValue = ParseMoney(Field(row, "marketvalue")),
MarketValueUpdatedAt = ParseTimestamp(Field(row, "marketvalueupdatedat")), MarketValueUpdatedAt = ParseTimestamp(Field(row, "marketvalueupdatedat")),
MarketValueSource = Field(row, "marketvaluesource"), MarketValueSource = Field(row, "marketvaluesource"),
ValueLoose = ParseMoney(Field(row, "valueloose")),
ValueCib = ParseMoney(Field(row, "valuecib")),
ValueNew = ParseMoney(Field(row, "valuenew")),
}); });
} }
@@ -353,9 +359,14 @@ public class LibraryController(
// The valuation's own timestamp is restored as recorded rather than // The valuation's own timestamp is restored as recorded rather than
// reset to now: an import is a restore, not a fresh price check. // reset to now: an import is a restore, not a fresh price check.
target.ValueLoose = source.ValueLoose;
target.ValueCib = source.ValueCib;
target.ValueNew = source.ValueNew;
target.MarketValue = source.MarketValue; target.MarketValue = source.MarketValue;
target.MarketValueUpdatedAt = source.MarketValue is null ? null : source.MarketValueUpdatedAt; target.RecalculateEffectiveValue();
target.MarketValueSource = source.MarketValue is null ? null : Blank(source.MarketValueSource); target.MarketValueUpdatedAt = target.MarketValue is null ? null : source.MarketValueUpdatedAt;
target.MarketValueSource = target.MarketValue is null ? null : Blank(source.MarketValueSource);
} }
private static string? Blank(string? value) => private static string? Blank(string? value) =>
@@ -384,5 +395,8 @@ public class LibraryController(
MarketValue = g.MarketValue, MarketValue = g.MarketValue,
MarketValueUpdatedAt = g.MarketValueUpdatedAt, MarketValueUpdatedAt = g.MarketValueUpdatedAt,
MarketValueSource = g.MarketValueSource, MarketValueSource = g.MarketValueSource,
ValueLoose = g.ValueLoose,
ValueCib = g.ValueCib,
ValueNew = g.ValueNew,
}; };
} }
@@ -0,0 +1,148 @@
using LudosData.Api.Auth;
using LudosData.Api.Data;
using LudosData.Api.Services.Pricing;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LudosData.Api.Controllers;
public record PriceRefreshRequest
{
/// <summary>Limit the run to specific games. Empty means the whole library.</summary>
public List<int>? GameIds { get; init; }
/// <summary>Re-price games that already have a figure.</summary>
public bool Overwrite { get; init; }
/// <summary>Report what would change without writing anything.</summary>
public bool DryRun { get; init; }
/// <summary>Ceiling on how many games one run will price.</summary>
public int Limit { get; init; } = 25;
}
public record PriceRefreshItem(
int GameId,
string Title,
decimal? Loose,
decimal? Cib,
decimal? New,
int Samples,
int Discarded,
string? Error);
public record PriceRefreshResult(
bool DryRun,
string Source,
int Considered,
int Priced,
int Failed,
IReadOnlyList<PriceRefreshItem> Items);
/// <summary>
/// Refreshes market values from a price provider.
///
/// The figures are asking prices from active listings, not completed sales —
/// see <see cref="EbayPriceProvider"/> — so they read high. The provider name
/// travels with every value it writes, so the dashboard can say where a number
/// came from and how old it is.
/// </summary>
[ApiController]
[Route("api/prices")]
[Authorize]
public class PricesController(
LudosDbContext db,
IPriceProvider provider,
ILogger<PricesController> logger) : ControllerBase
{
[HttpGet("status")]
public IActionResult Status() => Ok(new
{
provider = provider.Name,
configured = provider.IsConfigured,
// Stated plainly so a caller cannot mistake these for sold prices.
basis = "active listing asking prices, not completed sales",
});
[HttpPost("refresh")]
public async Task<ActionResult<PriceRefreshResult>> Refresh(
PriceRefreshRequest request, CancellationToken ct)
{
if (!provider.IsConfigured)
{
return StatusCode(StatusCodes.Status503ServiceUnavailable, new ProblemDetails
{
Title = "No price provider is configured.",
Detail = "Set Ebay:ClientId and Ebay:ClientSecret, then restart the API.",
});
}
var ownerId = User.GetUserId();
var query = db.Games.Where(g => g.OwnerId == ownerId);
if (request.GameIds is { Count: > 0 })
{
query = query.Where(g => request.GameIds.Contains(g.Id));
}
else if (!request.Overwrite)
{
query = query.Where(g => g.MarketValue == null);
}
var limit = Math.Clamp(request.Limit, 1, 200);
var games = await query.OrderBy(g => g.Title).Take(limit).ToListAsync(ct);
var items = new List<PriceRefreshItem>();
int priced = 0, failed = 0;
foreach (var game in games)
{
try
{
var estimate = await provider.EstimateAsync(game.Title, game.System, ct);
if (!estimate.HasAnyPrice)
{
failed++;
items.Add(new PriceRefreshItem(game.Id, game.Title, null, null, null,
0, estimate.Discarded, "No usable listings found"));
continue;
}
if (!request.DryRun)
{
game.ValueLoose = estimate.Loose;
game.ValueCib = estimate.Cib;
game.ValueNew = estimate.New;
game.RecalculateEffectiveValue();
game.MarketValueUpdatedAt = DateTimeOffset.UtcNow;
game.MarketValueSource = provider.Name;
}
priced++;
items.Add(new PriceRefreshItem(game.Id, game.Title,
estimate.Loose, estimate.Cib, estimate.New,
estimate.LooseSamples + estimate.CibSamples + estimate.NewSamples,
estimate.Discarded, null));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// One bad lookup should not abandon the rest of the batch.
logger.LogWarning(ex, "Pricing failed for game {GameId}", game.Id);
failed++;
items.Add(new PriceRefreshItem(game.Id, game.Title, null, null, null, 0, 0, ex.Message));
}
}
if (!request.DryRun && priced > 0)
{
await db.SaveChangesAsync(ct);
logger.LogInformation("User {OwnerId} priced {Count} games via {Source}",
ownerId, priced, provider.Name);
}
return Ok(new PriceRefreshResult(
request.DryRun, provider.Name, games.Count, priced, failed, items));
}
}
@@ -32,6 +32,9 @@ public class LudosDbContext(DbContextOptions<LudosDbContext> options)
game.Property(g => g.PurchasePrice).HasConversion(moneyToCents); game.Property(g => g.PurchasePrice).HasConversion(moneyToCents);
game.Property(g => g.MarketValue).HasConversion(moneyToCents); game.Property(g => g.MarketValue).HasConversion(moneyToCents);
game.Property(g => g.ValueLoose).HasConversion(moneyToCents);
game.Property(g => g.ValueCib).HasConversion(moneyToCents);
game.Property(g => g.ValueNew).HasConversion(moneyToCents);
// Stored as an enum's underlying int; readable names live in the API. // Stored as an enum's underlying int; readable names live in the API.
game.Property(g => g.Condition).HasConversion<int>(); game.Property(g => g.Condition).HasConversion<int>();
@@ -0,0 +1,406 @@
// <auto-generated />
using System;
using LudosData.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
[DbContext(typeof(LudosDbContext))]
[Migration("20260804191736_AddTieredPrices")]
partial class AddTieredPrices
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Condition")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("Developer")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("Dumped")
.HasColumnType("INTEGER");
b.Property<bool>("Finished")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long?>("MarketValue")
.HasColumnType("INTEGER");
b.Property<string>("MarketValueSource")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("MarketValueUpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<bool>("Own")
.HasColumnType("INTEGER");
b.Property<string>("OwnerId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Played")
.HasColumnType("INTEGER");
b.Property<string>("Publisher")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateOnly?>("PurchaseDate")
.HasColumnType("TEXT");
b.Property<long?>("PurchasePrice")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<int>("Region")
.HasColumnType("INTEGER");
b.Property<string>("System")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<long?>("ValueCib")
.HasColumnType("INTEGER");
b.Property<long?>("ValueLoose")
.HasColumnType("INTEGER");
b.Property<long?>("ValueNew")
.HasColumnType("INTEGER");
b.Property<string>("Year")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerId", "Genre");
b.HasIndex("OwnerId", "Rating");
b.HasIndex("OwnerId", "System");
b.HasIndex("OwnerId", "Title");
b.ToTable("Games");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", "Owner")
.WithMany("Games")
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Navigation("Games");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddTieredPrices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "ValueCib",
table: "Games",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "ValueLoose",
table: "Games",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "ValueNew",
table: "Games",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ValueCib",
table: "Games");
migrationBuilder.DropColumn(
name: "ValueLoose",
table: "Games");
migrationBuilder.DropColumn(
name: "ValueNew",
table: "Games");
}
}
}
@@ -177,6 +177,15 @@ namespace LudosData.Api.Data.Migrations
b.Property<DateTimeOffset>("UpdatedAt") b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long?>("ValueCib")
.HasColumnType("INTEGER");
b.Property<long?>("ValueLoose")
.HasColumnType("INTEGER");
b.Property<long?>("ValueNew")
.HasColumnType("INTEGER");
b.Property<string>("Year") b.Property<string>("Year")
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+40
View File
@@ -74,6 +74,13 @@ public class Game
// captured and where it came from, because a figure with neither is not // captured and where it came from, because a figure with neither is not
// something you can reason about — a total is only as good as its staleness. // something you can reason about — a total is only as good as its staleness.
/// <summary>
/// The figure used for totals, sorting and display: the tier matching this
/// copy's condition when tiers are known, otherwise whatever was entered by
/// hand. Denormalised deliberately — SQLite can sort and SUM a column, and
/// recomputing a CASE across three nullable columns in every query is worse
/// than keeping one value in step via <see cref="RecalculateEffectiveValue"/>.
/// </summary>
public decimal? MarketValue { get; set; } public decimal? MarketValue { get; set; }
public DateTimeOffset? MarketValueUpdatedAt { get; set; } public DateTimeOffset? MarketValueUpdatedAt { get; set; }
@@ -82,6 +89,39 @@ public class Game
[MaxLength(100)] [MaxLength(100)]
public string? MarketValueSource { get; set; } public string? MarketValueSource { get; set; }
// Price sources quote per condition, and the spread between them is
// routinely a multiple. Keeping all three means changing a copy's condition
// re-prices it without another lookup, and the dashboard can answer both
// "what is this worth" and "what would it be worth complete".
public decimal? ValueLoose { get; set; }
public decimal? ValueCib { get; set; }
public decimal? ValueNew { get; set; }
/// <summary>The tier that applies to a given condition, if it is known.</summary>
public decimal? TierFor(GameCondition condition) => condition switch
{
GameCondition.Sealed => ValueNew,
GameCondition.Cib => ValueCib,
GameCondition.Loose => ValueLoose,
// Digital has no physical tier, and an unspecified condition is most
// often a loose cart or disc, which is also the conservative estimate.
_ => ValueLoose,
};
/// <summary>
/// Brings <see cref="MarketValue"/> back in step with the tiers. A hand-typed
/// figure survives: it is only replaced once a source has supplied tiers.
/// </summary>
public void RecalculateEffectiveValue()
{
var tier = TierFor(Condition);
if (tier is not null)
{
MarketValue = tier;
}
}
/// <summary> /// <summary>
/// Owning user. Every query is filtered on this server-side, from the JWT subject — /// Owning user. Every query is filtered on this server-side, from the JWT subject —
/// it is never accepted from the client. /// it is never accepted from the client.
@@ -36,4 +36,11 @@
<Content Update="Data\Seed\games.json" CopyToOutputDirectory="PreserveNewest" /> <Content Update="Data\Seed\games.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Response parsing and query building are internal because nothing outside
the provider should call them, but they hold the logic most worth
testing — so the test assembly can see them. -->
<InternalsVisibleTo Include="LudosData.Api.Tests" />
</ItemGroup>
</Project> </Project>
+8
View File
@@ -4,6 +4,7 @@ using LudosData.Api.Auth;
using LudosData.Api.Data; using LudosData.Api.Data;
using LudosData.Api.Domain; using LudosData.Api.Domain;
using LudosData.Api.Services; using LudosData.Api.Services;
using LudosData.Api.Services.Pricing;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@@ -113,6 +114,13 @@ builder.Services.AddAuthorization();
builder.Services.AddScoped<ITokenService, TokenService>(); builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddSingleton<IImageStorage, ImageStorage>(); builder.Services.AddSingleton<IImageStorage, ImageStorage>();
// Pricing. The provider is registered whether or not credentials are present;
// it reports IsConfigured so the endpoint can answer 503 with a useful message
// rather than the app failing to start without an optional integration.
builder.Services.Configure<EbayOptions>(builder.Configuration.GetSection(EbayOptions.SectionName));
builder.Services.AddHttpClient("ebay", client => client.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddSingleton<IPriceProvider, EbayPriceProvider>();
builder.Services builder.Services
.AddControllers() .AddControllers()
.AddJsonOptions(options => .AddJsonOptions(options =>
@@ -0,0 +1,243 @@
using System.Net.Http.Headers;
using System.Text.Json;
using LudosData.Api.Domain;
using Microsoft.Extensions.Options;
namespace LudosData.Api.Services.Pricing;
public class EbayOptions
{
public const string SectionName = "Ebay";
/// <summary>App ID (Client ID) from the eBay developer portal.</summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>Cert ID (Client Secret).</summary>
public string ClientSecret { get; set; } = string.Empty;
/// <summary>Marketplace to price against. Changing this changes the currency.</summary>
public string Marketplace { get; set; } = "EBAY_US";
/// <summary>Video Games category, to keep guides and accessories out of the sample.</summary>
public string CategoryId { get; set; } = "139973";
/// <summary>Listings to consider per game. More is slower and rarely more accurate.</summary>
public int MaxListings { get; set; } = 50;
/// <summary>Sandbox endpoints, for trying credentials without touching production.</summary>
public bool UseSandbox { get; set; }
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(ClientId) && !string.IsNullOrWhiteSpace(ClientSecret);
}
public interface IPriceProvider
{
string Name { get; }
bool IsConfigured { get; }
Task<PriceEstimate> EstimateAsync(string title, string? system, CancellationToken ct = default);
}
/// <summary>
/// Estimates prices from eBay's Browse API.
///
/// An important caveat, carried through to the UI: Browse returns <em>active
/// listings</em>, which are asking prices. eBay's sold-item data lives behind the
/// Marketplace Insights API, which is a limited release not open to new
/// developers. Asking prices skew high — sellers list optimistically and
/// unsold listings persist — so these figures are an upper bound on what a copy
/// would actually fetch, and are labelled as such rather than presented as a
/// valuation.
/// </summary>
public class EbayPriceProvider(
IHttpClientFactory httpClientFactory,
IOptions<EbayOptions> options,
ILogger<EbayPriceProvider> logger) : IPriceProvider
{
private readonly EbayOptions _options = options.Value;
private string? _token;
private DateTimeOffset _tokenExpiresAt = DateTimeOffset.MinValue;
private readonly SemaphoreSlim _tokenLock = new(1, 1);
public string Name => "ebay-asking";
public bool IsConfigured => _options.IsConfigured;
private string ApiHost => _options.UseSandbox
? "https://api.sandbox.ebay.com"
: "https://api.ebay.com";
/// <summary>
/// Search terms that keep the sample on the right platform. Without the
/// console name, "Chrono Trigger" returns SNES, DS and PS1 copies together
/// and the median lands between three different markets.
/// </summary>
internal static string BuildQuery(string title, string? system) =>
string.IsNullOrWhiteSpace(system) ? title : $"{title} {SystemSearchTerm(system)}";
internal static string SystemSearchTerm(string system) => system.ToUpperInvariant() switch
{
"NES" => "Nintendo NES",
"SNES" => "Super Nintendo SNES",
"N64" => "Nintendo 64",
"GC" => "GameCube",
"WII" => "Nintendo Wii",
"GB" => "Game Boy",
"GBA" => "Game Boy Advance",
"DS" => "Nintendo DS",
"PS1" => "PlayStation 1 PS1",
"PS2" => "PlayStation 2 PS2",
"PSP" => "PSP",
"360" => "Xbox 360",
_ => system,
};
public async Task<PriceEstimate> EstimateAsync(
string title, string? system, CancellationToken ct = default)
{
if (!IsConfigured)
{
throw new InvalidOperationException(
"eBay credentials are not configured. Set Ebay:ClientId and Ebay:ClientSecret.");
}
var token = await GetTokenAsync(ct);
var client = httpClientFactory.CreateClient("ebay");
var query = Uri.EscapeDataString(BuildQuery(title, system));
var url = $"{ApiHost}/buy/browse/v1/item_summary/search"
+ $"?q={query}&limit={_options.MaxListings}"
+ $"&filter=buyingOptions:{{FIXED_PRICE}}"
+ (string.IsNullOrWhiteSpace(_options.CategoryId)
? string.Empty
: $"&category_ids={_options.CategoryId}");
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Add("X-EBAY-C-MARKETPLACE-ID", _options.Marketplace);
using var response = await client.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("eBay search for {Title} returned {Status}", title, response.StatusCode);
return PriceEstimate.Empty;
}
await using var stream = await response.Content.ReadAsStreamAsync(ct);
return Parse(await JsonDocument.ParseAsync(stream, cancellationToken: ct));
}
/// <summary>Turns a Browse search response into an estimate. Internal so it can be tested on fixtures.</summary>
internal static PriceEstimate Parse(JsonDocument document)
{
if (!document.RootElement.TryGetProperty("itemSummaries", out var summaries)
|| summaries.ValueKind != JsonValueKind.Array)
{
return PriceEstimate.Empty;
}
var listings = new List<PricedListing>();
var discarded = 0;
foreach (var item in summaries.EnumerateArray())
{
var title = item.TryGetProperty("title", out var t) ? t.GetString() : null;
var sellerCondition = item.TryGetProperty("condition", out var c) ? c.GetString() : null;
if (!TryReadPrice(item, out var price))
{
discarded++;
continue;
}
var tier = ListingCondition.Classify(title, sellerCondition);
if (tier is null)
{
discarded++;
continue;
}
listings.Add(new PricedListing(price, tier.Value));
}
return PriceMath.Summarise(listings, discarded);
}
private static bool TryReadPrice(JsonElement item, out decimal price)
{
price = 0m;
if (!item.TryGetProperty("price", out var priceElement)
|| !priceElement.TryGetProperty("value", out var valueElement))
{
return false;
}
// Browse reports the amount as a string.
var raw = valueElement.ValueKind == JsonValueKind.String
? valueElement.GetString()
: valueElement.ToString();
if (!decimal.TryParse(raw, System.Globalization.NumberStyles.Number,
System.Globalization.CultureInfo.InvariantCulture, out price))
{
return false;
}
// A listing at or near zero is a placeholder, not a price.
return price > 0.5m;
}
/// <summary>
/// Client-credentials token, cached until shortly before it expires. eBay
/// issues these for two hours and rate-limits the token endpoint, so
/// requesting one per game would fail long before the search quota did.
/// </summary>
private async Task<string> GetTokenAsync(CancellationToken ct)
{
if (_token is not null && DateTimeOffset.UtcNow < _tokenExpiresAt)
{
return _token;
}
await _tokenLock.WaitAsync(ct);
try
{
if (_token is not null && DateTimeOffset.UtcNow < _tokenExpiresAt)
{
return _token;
}
var client = httpClientFactory.CreateClient("ebay");
using var request = new HttpRequestMessage(HttpMethod.Post, $"{ApiHost}/identity/v1/oauth2/token");
var basic = Convert.ToBase64String(
System.Text.Encoding.UTF8.GetBytes($"{_options.ClientId}:{_options.ClientSecret}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic);
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["scope"] = "https://api.ebay.com/oauth/api_scope",
});
using var response = await client.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
_token = document.RootElement.GetProperty("access_token").GetString();
var seconds = document.RootElement.TryGetProperty("expires_in", out var e)
? e.GetInt32() : 7200;
// Retire it a minute early rather than discover expiry mid-batch.
_tokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(seconds - 60);
return _token!;
}
finally
{
_tokenLock.Release();
}
}
}
@@ -0,0 +1,106 @@
using System.Text.RegularExpressions;
using LudosData.Api.Domain;
namespace LudosData.Api.Services.Pricing;
/// <summary>
/// Sorts a marketplace listing into a condition tier from its title and the
/// seller's own condition flag.
///
/// This is the weakest link in deriving prices from active listings, and it is
/// isolated here so it can be tested on its own. Sellers do not use a controlled
/// vocabulary: "CIB", "complete in box", "w/ manual" and "boxed" all mean the
/// same tier, while "box only" and "manual only" mean there is no game at all
/// and the listing must be discarded rather than counted as cheap.
/// </summary>
public static partial class ListingCondition
{
/// <summary>
/// Listings that are not a copy of the game, at any condition.
///
/// The qualifier is required, not optional. Matching a bare "box" would
/// discard "complete in box" and "with box and manual" — that is, most of
/// the CIB tier — while leaving the cheap box-only listings that the filter
/// exists to remove.
/// </summary>
[GeneratedRegex(
@"\b(?:"
+ @"(?:box|case|manual|instructions?|insert|artwork|art\s*work|cover|label|"
+ @"poster|sticker|protector|display|shell)\s+only"
+ @"|only\s+(?:the\s+)?(?:box|case|manual|cover)"
+ @"|empty\s+(?:box|case)"
+ @"|no\s+(?:game|cart|cartridge|disc)"
+ @"|(?:custom|replacement|repro|reproduction)\s+(?:art|label|case|box|cover|manual)"
+ @"|(?:art|label|case|box|cover|manual)\s+(?:replacement|repro)"
+ @")\b",
RegexOptions.IgnoreCase)]
private static partial Regex AccessoryPattern();
/// <summary>Explicitly not a genuine retail copy.</summary>
[GeneratedRegex(@"\b(repro|reproduction|bootleg|fake|counterfeit|homebrew|aftermarket)\b",
RegexOptions.IgnoreCase)]
private static partial Regex CounterfeitPattern();
/// <summary>A bundle prices several games at once and would skew a median.</summary>
[GeneratedRegex(@"\b(lot|bundle|collection\s+of|\d+\s*games?|joblot|job\s+lot)\b",
RegexOptions.IgnoreCase)]
private static partial Regex LotPattern();
[GeneratedRegex(@"\b(sealed|factory\s*sealed|brand\s*new|bnib|nib|vga|wata|graded)\b",
RegexOptions.IgnoreCase)]
private static partial Regex SealedPattern();
[GeneratedRegex(
@"\b(cib|complete\s*in\s*box|complete|boxed|with\s*(box|manual|case)|"
+ @"w/\s*(box|manual|case)|box\s*and\s*manual)\b",
RegexOptions.IgnoreCase)]
private static partial Regex CompletePattern();
[GeneratedRegex(@"\b(loose|cart\s*only|cartridge\s*only|disc\s*only|game\s*only|unboxed)\b",
RegexOptions.IgnoreCase)]
private static partial Regex LoosePattern();
/// <summary>
/// The tier a listing belongs to, or null when it should not be counted —
/// an accessory, a reproduction, or a multi-game lot.
/// </summary>
public static GameCondition? Classify(string? title, string? sellerCondition)
{
var text = title ?? string.Empty;
// Discard first. A "box only" listing at $8 would otherwise drag a
// loose-cart median down to nonsense.
if (AccessoryPattern().IsMatch(text)
|| CounterfeitPattern().IsMatch(text)
|| LotPattern().IsMatch(text))
{
return null;
}
if (SealedPattern().IsMatch(text))
{
return GameCondition.Sealed;
}
if (CompletePattern().IsMatch(text))
{
return GameCondition.Cib;
}
if (LoosePattern().IsMatch(text))
{
return GameCondition.Loose;
}
// Nothing in the title said. Fall back to the seller's own flag, which
// only distinguishes new from used.
if (string.Equals(sellerCondition, "New", StringComparison.OrdinalIgnoreCase))
{
return GameCondition.Sealed;
}
// An unqualified used listing is most often a loose cart or disc, and
// that is also the conservative reading.
return GameCondition.Loose;
}
}
@@ -0,0 +1,100 @@
using LudosData.Api.Domain;
namespace LudosData.Api.Services.Pricing;
/// <summary>One priced listing, after classification.</summary>
public record PricedListing(decimal Price, GameCondition Tier);
/// <summary>
/// A per-condition estimate plus how much evidence sits behind it.
///
/// The sample counts are part of the result, not diagnostics: a tier derived
/// from two listings deserves less confidence than one derived from thirty, and
/// the caller needs to be able to say so.
/// </summary>
public record PriceEstimate(
decimal? Loose,
decimal? Cib,
decimal? New,
int LooseSamples,
int CibSamples,
int NewSamples,
int Discarded)
{
public bool HasAnyPrice => Loose is not null || Cib is not null || New is not null;
public static readonly PriceEstimate Empty = new(null, null, null, 0, 0, 0, 0);
}
public static class PriceMath
{
/// <summary>
/// Median, not mean. Marketplace listings carry outliers in both directions —
/// an optimist asking ten times the going rate, or a mispriced bargain — and
/// a mean chases them while a median does not.
/// </summary>
public static decimal? Median(IReadOnlyList<decimal> values)
{
if (values.Count == 0)
{
return null;
}
var sorted = values.OrderBy(v => v).ToArray();
var middle = sorted.Length / 2;
return sorted.Length % 2 == 1
? sorted[middle]
: Math.Round((sorted[middle - 1] + sorted[middle]) / 2m, 2);
}
/// <summary>
/// Drops prices far outside the bulk of the sample before taking a median.
///
/// Uses the interquartile range rather than standard deviations: listing
/// prices are not normally distributed, and a single graded copy at 50x
/// would widen a standard deviation enough to protect itself.
/// </summary>
public static List<decimal> RemoveOutliers(IReadOnlyList<decimal> values)
{
if (values.Count < 4)
{
// Too few points for quartiles to mean anything.
return [.. values];
}
var sorted = values.OrderBy(v => v).ToArray();
var q1 = sorted[sorted.Length / 4];
var q3 = sorted[sorted.Length * 3 / 4];
var iqr = q3 - q1;
if (iqr <= 0)
{
return [.. values];
}
var low = q1 - 1.5m * iqr;
var high = q3 + 1.5m * iqr;
return sorted.Where(v => v >= low && v <= high).ToList();
}
/// <summary>Aggregates classified listings into a per-tier estimate.</summary>
public static PriceEstimate Summarise(IReadOnlyList<PricedListing> listings, int discarded)
{
decimal? TierPrice(GameCondition tier, out int samples)
{
var prices = listings.Where(l => l.Tier == tier).Select(l => l.Price).ToList();
var kept = RemoveOutliers(prices);
samples = kept.Count;
return Median(kept);
}
var loose = TierPrice(GameCondition.Loose, out var looseSamples);
var cib = TierPrice(GameCondition.Cib, out var cibSamples);
var sealedPrice = TierPrice(GameCondition.Sealed, out var newSamples);
return new PriceEstimate(
loose, cib, sealedPrice, looseSamples, cibSamples, newSamples, discarded);
}
}
@@ -0,0 +1,238 @@
using System.Text.Json;
using LudosData.Api.Domain;
using LudosData.Api.Services.Pricing;
namespace LudosData.Api.Tests;
/// <summary>
/// The pricing logic, tested without touching eBay.
///
/// Everything that decides what a number means — which listings count, which
/// tier they land in, and how they aggregate — is pure and lives here. Only the
/// HTTP call itself needs credentials, and it is the least interesting part.
/// </summary>
public class ListingClassificationTests
{
[Theory]
[InlineData("Chrono Trigger SNES Cartridge Only", GameCondition.Loose)]
[InlineData("Super Metroid - loose cart, tested", GameCondition.Loose)]
[InlineData("Banjo-Kazooie N64 game only", GameCondition.Loose)]
[InlineData("Earthbound SNES CIB", GameCondition.Cib)]
[InlineData("Ocarina of Time complete in box", GameCondition.Cib)]
[InlineData("Mario Kart 64 with box and manual", GameCondition.Cib)]
[InlineData("Boxed Pokemon Yellow Game Boy", GameCondition.Cib)]
[InlineData("Metroid Prime FACTORY SEALED", GameCondition.Sealed)]
[InlineData("Halo 3 Xbox 360 Brand New Sealed", GameCondition.Sealed)]
[InlineData("Final Fantasy VII WATA 9.4 graded", GameCondition.Sealed)]
public void Titles_are_sorted_into_the_right_tier(string title, GameCondition expected)
{
Assert.Equal(expected, ListingCondition.Classify(title, "Used"));
}
[Theory]
// These are the dangerous ones: cheap, plentiful, and not the game.
[InlineData("Chrono Trigger SNES BOX ONLY no game")]
[InlineData("Super Mario World manual only")]
[InlineData("Zelda Ocarina of Time REPRODUCTION cartridge")]
[InlineData("N64 game case replacement")]
[InlineData("Custom art label for Earthbound")]
[InlineData("Lot of 12 SNES games")]
[InlineData("Nintendo 64 bundle 5 games")]
public void Accessories_reproductions_and_lots_are_discarded(string title)
{
// Counting a $6 "box only" listing as a copy of the game would drag a
// loose median to nonsense.
Assert.Null(ListingCondition.Classify(title, "Used"));
}
[Fact]
public void An_unqualified_listing_falls_back_to_the_sellers_flag()
{
Assert.Equal(GameCondition.Sealed, ListingCondition.Classify("Chrono Trigger", "New"));
// Unqualified and used reads as loose, the conservative assumption.
Assert.Equal(GameCondition.Loose, ListingCondition.Classify("Chrono Trigger", "Used"));
}
}
public class PriceMathTests
{
[Fact]
public void Median_of_an_odd_sample_is_the_middle_value()
{
// Sorted first: [10, 20, 30].
Assert.Equal(20m, PriceMath.Median([10m, 30m, 20m]));
}
[Fact]
public void Median_of_an_even_sample_averages_the_middle_pair()
{
Assert.Equal(25m, PriceMath.Median([10m, 20m, 30m, 40m]));
}
[Fact]
public void Median_of_nothing_is_null_rather_than_zero()
{
// A game with no listings is unpriced, which is not the same as free.
Assert.Null(PriceMath.Median([]));
}
[Fact]
public void A_wildly_optimistic_listing_does_not_move_the_estimate()
{
var withOutlier = new List<decimal> { 40m, 42m, 45m, 44m, 43m, 41m, 5000m };
var kept = PriceMath.RemoveOutliers(withOutlier);
Assert.DoesNotContain(5000m, kept);
// A mean would have been dragged past 750; the median holds.
Assert.InRange(PriceMath.Median(kept)!.Value, 40m, 45m);
}
[Fact]
public void Small_samples_are_left_alone()
{
// With three points, quartiles are meaningless and trimming would throw
// away most of the evidence.
var values = new List<decimal> { 10m, 20m, 900m };
Assert.Equal(3, PriceMath.RemoveOutliers(values).Count);
}
[Fact]
public void Summarise_reports_a_price_and_a_sample_count_per_tier()
{
var listings = new List<PricedListing>
{
new(20m, GameCondition.Loose),
new(24m, GameCondition.Loose),
new(22m, GameCondition.Loose),
new(80m, GameCondition.Cib),
new(90m, GameCondition.Cib),
new(400m, GameCondition.Sealed),
};
var estimate = PriceMath.Summarise(listings, discarded: 4);
Assert.Equal(22m, estimate.Loose);
Assert.Equal(85m, estimate.Cib);
Assert.Equal(400m, estimate.New);
Assert.Equal(3, estimate.LooseSamples);
Assert.Equal(2, estimate.CibSamples);
Assert.Equal(1, estimate.NewSamples);
Assert.Equal(4, estimate.Discarded);
Assert.True(estimate.HasAnyPrice);
}
[Fact]
public void A_tier_with_no_listings_stays_null()
{
var estimate = PriceMath.Summarise([new PricedListing(20m, GameCondition.Loose)], 0);
Assert.Equal(20m, estimate.Loose);
Assert.Null(estimate.Cib);
Assert.Null(estimate.New);
}
}
public class EbayResponseParsingTests
{
/// <summary>Shaped like a real Browse item_summary/search response.</summary>
private const string SampleResponse = """
{
"total": 8,
"itemSummaries": [
{ "title": "Chrono Trigger SNES Cartridge Only Authentic",
"condition": "Used", "price": { "value": "120.00", "currency": "USD" } },
{ "title": "Chrono Trigger Super Nintendo loose cart tested",
"condition": "Used", "price": { "value": "135.50", "currency": "USD" } },
{ "title": "Chrono Trigger SNES game only",
"condition": "Used", "price": { "value": "128.00", "currency": "USD" } },
{ "title": "Chrono Trigger SNES CIB complete in box",
"condition": "Used", "price": { "value": "650.00", "currency": "USD" } },
{ "title": "Chrono Trigger Super Nintendo with box and manual",
"condition": "Used", "price": { "value": "700.00", "currency": "USD" } },
{ "title": "Chrono Trigger SNES FACTORY SEALED WATA",
"condition": "New", "price": { "value": "12000.00", "currency": "USD" } },
{ "title": "Chrono Trigger SNES BOX ONLY no game",
"condition": "Used", "price": { "value": "45.00", "currency": "USD" } },
{ "title": "Lot of 6 SNES RPG games including Chrono Trigger",
"condition": "Used", "price": { "value": "300.00", "currency": "USD" } }
]
}
""";
[Fact]
public void A_search_response_is_split_into_tiers()
{
using var document = JsonDocument.Parse(SampleResponse);
var estimate = EbayPriceProvider.Parse(document);
Assert.Equal(128.00m, estimate.Loose); // median of 120, 128, 135.50
Assert.Equal(675.00m, estimate.Cib); // mean of the middle pair
Assert.Equal(12000.00m, estimate.New);
// The box-only listing and the multi-game lot are both thrown out. Left
// in, the $45 box would have halved the loose estimate.
Assert.Equal(2, estimate.Discarded);
Assert.Equal(3, estimate.LooseSamples);
}
[Fact]
public void A_response_with_no_results_yields_no_prices()
{
using var document = JsonDocument.Parse("""{ "total": 0, "itemSummaries": [] }""");
var estimate = EbayPriceProvider.Parse(document);
Assert.False(estimate.HasAnyPrice);
}
[Fact]
public void A_response_missing_the_results_array_does_not_throw()
{
using var document = JsonDocument.Parse("""{ "total": 0, "warnings": [] }""");
Assert.False(EbayPriceProvider.Parse(document).HasAnyPrice);
}
[Fact]
public void Listings_without_a_usable_price_are_discarded()
{
using var document = JsonDocument.Parse("""
{
"itemSummaries": [
{ "title": "Chrono Trigger SNES loose", "condition": "Used" },
{ "title": "Chrono Trigger SNES loose", "condition": "Used",
"price": { "value": "0.00", "currency": "USD" } },
{ "title": "Chrono Trigger SNES loose", "condition": "Used",
"price": { "value": "130.00", "currency": "USD" } }
]
}
""");
var estimate = EbayPriceProvider.Parse(document);
Assert.Equal(130.00m, estimate.Loose);
Assert.Equal(1, estimate.LooseSamples);
Assert.Equal(2, estimate.Discarded);
}
[Theory]
[InlineData("SNES", "Super Nintendo SNES")]
[InlineData("N64", "Nintendo 64")]
[InlineData("360", "Xbox 360")]
[InlineData("PS1", "PlayStation 1 PS1")]
public void The_console_name_is_added_to_the_search(string system, string expected)
{
// Without it, "Chrono Trigger" returns SNES, PS1 and DS copies together
// and the median lands between three different markets.
Assert.Equal($"Chrono Trigger {expected}",
EbayPriceProvider.BuildQuery("Chrono Trigger", system));
}
[Fact]
public void A_game_with_no_system_searches_on_title_alone()
{
Assert.Equal("Chrono Trigger", EbayPriceProvider.BuildQuery("Chrono Trigger", null));
}
}
+6
View File
@@ -30,6 +30,12 @@ services:
Seed__Email: ${SEED_EMAIL:-} Seed__Email: ${SEED_EMAIL:-}
Seed__Password: ${SEED_PASSWORD:-} Seed__Password: ${SEED_PASSWORD:-}
# Optional market-value lookups. Blank means the pricing endpoints report
# 503 and everything else carries on.
Ebay__ClientId: ${EBAY_CLIENT_ID:-}
Ebay__ClientSecret: ${EBAY_CLIENT_SECRET:-}
Ebay__UseSandbox: ${EBAY_USE_SANDBOX:-false}
# Only consulted when the SPA is served from somewhere other than nginx. # Only consulted when the SPA is served from somewhere other than nginx.
Cors__AllowedOrigins__0: ${CORS_ORIGIN:-http://localhost:8080} Cors__AllowedOrigins__0: ${CORS_ORIGIN:-http://localhost:8080}
Cors__AllowedOrigins__1: http://localhost:4200 Cors__AllowedOrigins__1: http://localhost:4200