feat: add shared unstable listing classifier
This commit is contained in:
@@ -41,3 +41,4 @@ export * from "./utils/cookies";
|
|||||||
export * from "./utils/delay";
|
export * from "./utils/delay";
|
||||||
export * from "./utils/format";
|
export * from "./utils/format";
|
||||||
export * from "./utils/http";
|
export * from "./utils/http";
|
||||||
|
export * from "./utils/unstable";
|
||||||
|
|||||||
@@ -18,3 +18,12 @@ export interface ListingDetails {
|
|||||||
address?: string | null;
|
address?: string | null;
|
||||||
creationDate?: string;
|
creationDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UnstableListingBuckets<T> {
|
||||||
|
results: T[];
|
||||||
|
unstableResults: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnstableListingModeOptions {
|
||||||
|
hideUnstableResults?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
46
packages/core/src/utils/unstable.ts
Normal file
46
packages/core/src/utils/unstable.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { ListingDetails, UnstableListingBuckets } from "../types/common";
|
||||||
|
|
||||||
|
function getMedian(values: number[]): number {
|
||||||
|
const middleIndex = Math.floor(values.length / 2);
|
||||||
|
|
||||||
|
if (values.length % 2 === 0) {
|
||||||
|
return (values[middleIndex - 1] + values[middleIndex]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return values[middleIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyUnstableListings<T extends ListingDetails>(
|
||||||
|
listings: T[],
|
||||||
|
): UnstableListingBuckets<T> {
|
||||||
|
const validPrices = listings
|
||||||
|
.map((listing) => listing.listingPrice.cents)
|
||||||
|
.filter((price) => Number.isFinite(price) && price > 0)
|
||||||
|
.sort((left, right) => left - right);
|
||||||
|
|
||||||
|
if (validPrices.length < 2) {
|
||||||
|
return {
|
||||||
|
results: [...listings],
|
||||||
|
unstableResults: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold = getMedian(validPrices) * 0.8;
|
||||||
|
const buckets: UnstableListingBuckets<T> = {
|
||||||
|
results: [],
|
||||||
|
unstableResults: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const listing of listings) {
|
||||||
|
const price = listing.listingPrice.cents;
|
||||||
|
|
||||||
|
if (Number.isFinite(price) && price > 0 && price < threshold) {
|
||||||
|
buckets.unstableResults.push(listing);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets.results.push(listing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
84
packages/core/test/unstable-listing-mode.test.ts
Normal file
84
packages/core/test/unstable-listing-mode.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type { ListingDetails } from "../src/types/common";
|
||||||
|
import { classifyUnstableListings } from "../src/utils/unstable";
|
||||||
|
|
||||||
|
interface TestListing extends ListingDetails {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeListing(id: string, cents: number): TestListing {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
url: `https://example.com/${id}`,
|
||||||
|
title: id,
|
||||||
|
listingPrice: {
|
||||||
|
amountFormatted: `$${(cents / 100).toFixed(2)}`,
|
||||||
|
cents,
|
||||||
|
currency: "CAD",
|
||||||
|
},
|
||||||
|
listingType: "test",
|
||||||
|
listingStatus: "active",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("classifyUnstableListings", () => {
|
||||||
|
test("moves listings below 80% of median into unstableResults", () => {
|
||||||
|
const listings = [
|
||||||
|
makeListing("stable-1", 100_00),
|
||||||
|
makeListing("stable-2", 110_00),
|
||||||
|
makeListing("unstable", 70_00),
|
||||||
|
];
|
||||||
|
|
||||||
|
const buckets = classifyUnstableListings(listings);
|
||||||
|
|
||||||
|
expect(buckets.results.map((listing) => listing.id)).toEqual(["stable-1", "stable-2"]);
|
||||||
|
expect(buckets.unstableResults.map((listing) => listing.id)).toEqual(["unstable"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the midpoint median for even-sized priced inputs", () => {
|
||||||
|
const listings = [
|
||||||
|
makeListing("low", 79_00),
|
||||||
|
makeListing("mid-low", 100_00),
|
||||||
|
makeListing("mid-high", 120_00),
|
||||||
|
makeListing("high", 140_00),
|
||||||
|
];
|
||||||
|
|
||||||
|
const buckets = classifyUnstableListings(listings);
|
||||||
|
|
||||||
|
expect(buckets.results.map((listing) => listing.id)).toEqual(["mid-low", "mid-high", "high"]);
|
||||||
|
expect(buckets.unstableResults.map((listing) => listing.id)).toEqual(["low"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps non-positive prices in results and excludes them from the median input", () => {
|
||||||
|
const listings = [
|
||||||
|
makeListing("zero", 0),
|
||||||
|
makeListing("negative", -500),
|
||||||
|
makeListing("stable-1", 100_00),
|
||||||
|
makeListing("stable-2", 120_00),
|
||||||
|
makeListing("unstable", 70_00),
|
||||||
|
];
|
||||||
|
|
||||||
|
const buckets = classifyUnstableListings(listings);
|
||||||
|
|
||||||
|
expect(buckets.results.map((listing) => listing.id)).toEqual([
|
||||||
|
"zero",
|
||||||
|
"negative",
|
||||||
|
"stable-1",
|
||||||
|
"stable-2",
|
||||||
|
]);
|
||||||
|
expect(buckets.unstableResults.map((listing) => listing.id)).toEqual(["unstable"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns all listings in results when fewer than two valid prices are present", () => {
|
||||||
|
const listings = [
|
||||||
|
makeListing("zero", 0),
|
||||||
|
makeListing("negative", -100),
|
||||||
|
makeListing("only-valid", 150_00),
|
||||||
|
];
|
||||||
|
|
||||||
|
const buckets = classifyUnstableListings(listings);
|
||||||
|
|
||||||
|
expect(buckets.results.map((listing) => listing.id)).toEqual(["zero", "negative", "only-valid"]);
|
||||||
|
expect(buckets.unstableResults).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user