How REBUY priced used electronics: from live comparables to machine learning

By Lucas Espindola · · 11 min read

REBUY answered one question for people with a drawer full of old gadgets: how much is this actually worth? Type "iPhone 7 16GB", and a few seconds later you got a price, the number of listings it was based on, and roughly how long one takes to sell. Here is how that number was produced, how the same data told us which products were worth going after, and the machine-learning layer we built to replace the heuristics once enough data had built up.

The product

The flow was deliberately short. You searched for each device you owned, and a running total counted up: "Descubra quanto você pode ganhar!", "find out how much you can earn". Under the total sat two buttons, Quero vender (sell) and Quero doar (donate). Selling linked you straight to the marketplaces the price came from. Donating showed a map of e-waste collection points. The whole product depended on one thing: the price had to be believable, because people compare it with what they see on the marketplaces themselves.

Prices from comparables

We didn't set prices; the market did. For every search, the API went to the two places where used electronics actually change hands in Brazil, OLX and Mercado Livre, and read the current listings for that query:

Both were plain HTTP fetches parsed with jsdom and jQuery selectors, run in parallel with short timeouts so a slow marketplace couldn't hold the answer hostage. Nothing was stored: every search was priced from what was listed at that moment.

Matching: which listings are actually this product?

A search for "iPhone 7 16GB" returns cases, screen protectors, "iPhone 7 Plus" and broken units for parts alongside the phones. Before any statistics, every listing title was scored against the query with Levenshtein edit distance, the number of single-character edits needed to turn one string into the other:

products = products.map(x => {
    x.levenstein = searchText.toLowerCase().levenstein(x.product.toLowerCase());
    return x;
});
products = products.sort((a, b) => a.levenstein - b.levenstein);

Sorting by distance puts listings whose titles read like the query first and pushes accessories and different models down. It needs no training data and no product catalogue, and it works across both marketplaces' wildly different title styles. That mattered, because we had neither.

From comparables to one number

The closest 100 listings with a plausible price became the comparables, and the headline price was their median:

const relevantResults = products
    .filter(x => x.price_value > 0 && x.price_value < 8000)
    .splice(0, 100);
const prices = relevantResults
    .sort((a, b) => a.price_value - b.price_value)
    .map(x => x.price_value);

price: {
    min: prices[0],
    max: prices[prices.length - 1],
    median: Math.round(statistics.medianSorted(prices)),
}

The median did most of the work. Marketplace listings are noisy in both directions: optimistic asking prices, "R$ 1" placeholders, bundles, parts-only units. A mean gets dragged around by every one of them; the median of a hundred close matches barely moves. The hard price cap removed only the worst nonsense, and the distance ranking had already filtered out most accessories before price was considered.

The response also carried how many listings the price was based on and a time-to-sell estimate. That estimate was the age of the oldest listing on OLX's last results page for the query, a rough measure of how long stock sits before it sells. The UI showed both next to the price ("this search covered N listings, and this product sells in about X days on average"), because a number shown with its evidence is easier to trust.

Targeting: which products are worth it?

Pricing one search is useful to a person. Deciding what REBUY itself should focus on needed a view of the whole market, so the API grew a second system: a daily job that mirrored Mercado Livre's public catalogue into MongoDB. It walked the category tree from a root category, paged through each category's listings, then enriched items with sold quantities and how many other sellers listed the same brand and model.

On top of that we computed, per listing, how much it actually moves:

diff_months = max(ceil((now - date_created) / 1 month), 1)
revenue     = sold_quantity × price / diff_months
sales       = floor(sold_quantity / diff_months)

With monthly revenue, monthly sales and the number of competing sellers on every listing, a filterable search over the catalogue answered the targeting questions directly. Which models sell fastest used? Where is the money per month? Which products have few sellers but steady demand? Filters covered price, condition, listing type, seller reputation, rating and title keywords, and results could be sorted by any metric.

The ML layer

The heuristics had clear ceilings. A median can't tell a boxed unit from a cracked one. Monthly sales computed from a listing's lifetime average can't tell a listing that sells every day from one that sold a burst in its first week. Exact brand-and-model matching misses every listing that doesn't fill in those attributes. With the catalogue in MongoDB, a learned layer could answer three questions the raw data couldn't:

  1. What is this item worth? Price estimation.
  2. How fast does it sell? Sales velocity and time-to-sell.
  3. Which listings are the same product? Product matching.

The answers fed the "how much you can earn" email, the suggestedPrice field on user-submitted products, and new filters and sort keys in the targeting search.

Data first: snapshots

The catalogue job upserts each listing, so every run overwrote price and sold_quantity and the history was lost. We added one row per listing per run to an MLProductSnapshot collection:

{ product_id, category_id, captured_at, price, sold_quantity,
  available_quantity, reviews, listing_type_id, condition }

Real sales velocity then came from the change in sold_quantity between snapshots, replacing the lifetime average, and the snapshot history became the training data for every model below.

Price estimation

A gradient-boosted regressor (LightGBM) with quantile outputs, so it predicts a range (p10, p50, p90) rather than a single number. Features: category and its path from the root, the BRAND and MODEL attributes, condition and wear level, a TF-IDF or embedding representation of the title, and seller status, listing type, free shipping and state.

The important choice was the target: the log price of listings that actually sold, meaning their sold quantity went up between snapshots. Listings nobody buys don't get to set the price. That is the main thing the median of live listings could never do, since asking prices and selling prices are different numbers. In the email, the single total became a range like "R$ 850–1.100".

Sales velocity and time-to-sell

A regressor trained on the snapshot sales changes, predicting units sold per month for a listing and days-to-sell for a used item at a given price. Its features are the price relative to the median of the product's cluster, reviews, seller status, listing type and shipping. It replaced the "age of the oldest listing" heuristic, and its prediction, _ml.predicted_sales, became something the targeting search could filter and sort on.

Product matching

Multilingual sentence embeddings of each title plus its attributes, with nearest-neighbour clustering inside each category, so Portuguese titles that describe the same phone in different words land together. This replaced both the Levenshtein ranking and the exact brand-and-model match. The resulting _ml.cluster_id gave an honest competing-seller count and pooled price data across listings for the price model.

Category classification

Products users submit are free text with no marketplace category. A title classifier maps them to a category_id, which every other model needs before it can run.

Architecture

Agenda jobs (Nest/Node)                 rebuy-ml (Python, FastAPI)
─────────────────────────               ───────────────────────────
BUILD_CATEGORY_CATALOG                  POST /predict/price
BUILD_PRODUCT_CATALOG ──► snapshots     POST /predict/velocity
BUILD_PRODUCT_DETAILED_INFO             POST /match
SCORE_PRODUCT_CATALOG ──────────────►   POST /classify/category
        │                                     ▲
        ▼                                     │ nightly retrain
MLProduct._ml { predicted_sales,        MongoDB snapshots ──► model registry (S3)
  cluster_id, price_p50, model_version, scored_at }

Beating the baseline

Every model has to beat the heuristic it replaces before it ships, measured on a time-based split (train up to week t, test on week t+1) so it can't peek at the future:

ModelMetricBaseline it must beat
PriceMAPE / pinball lossCategory median price
VelocityMAE on units per monthsold_quantity / diff_months
MatchingPrecision@k on a labelled sample of pairsExact BRAND + MODEL match
CategoryTop-1 / top-3 accuracy—

Each prediction records the model_version that produced it, and price-distribution drift is tracked per category.

Rollout

We introduced it in phases, so there was always a working answer:

  1. Data: snapshots started recording. No models yet, because sales changes need a few weeks to build up.
  2. Baselines: the existing heuristics moved behind the ML service interface, becoming both the fallback and the benchmark.
  3. Price model: pricing for user-submitted items went live first, because it drives the email users see.
  4. Batch scoring: matching and velocity were written onto the catalogue and exposed in the targeting search.
  5. Feedback loop: the final sale price of items sold through REBUY went back into training as ground-truth labels.

What I'd change in the heuristics

Reading the heuristic code years later, a few things stand out:

The order mattered more than any single model. The heuristics shipped first because they were simple and explainable: "the median of the 100 closest live listings" is an answer anyone can check for themselves. When the ML layer arrived they didn't disappear. They became the fallback and the bar every model had to clear before it shipped.

More writing