๐Ÿ“ฆ FTSeller Wholesale Market Log in ยท Register

The wholesale market

XGM_KERNEL=wholesale runs every market of the desk as a wholesale marketplace: regular FMCG products โ€” single items, multipacks and bundles โ€” bought from suppliers at a landed cost, shipped in with a lead time, held in stock and sold through the buy box against competing sellers. Everything else on the site (carts, checkout, payments, agents, the record, the console, owned goods and the exchange) works as it does on the platform's other markets.

One session is one simulated week: every rate below is per session.

The listings

About a thousand listings (the platform's item families) in twelve categories of different sizes, in the mix of a real wholesale seller's assortment โ€” Grocery & Gourmet Food (190 listings), Beauty & Personal Care (165), Health & Household (125), Tools & Home Improvement (110), Arts, Crafts & Sewing (70), Office Products (60), Pet Supplies (60), Home & Kitchen (55), Patio, Lawn & Garden (35), Automotive (30), Sports & Outdoors (30), Baby Products (25). About 10 % of the listings are bundles of two or three products of the category and about 22 % multipacks.

The products are real FMCG SKUs: the built-in catalogue holds ~5,700 products of a real wholesale seller's assortment with their real titles, brands, product types, reference prices, size tiers and case packs. Their identifiers are not real: every ASIN, MSKU, FNSKU, UPC, supplier item number and part number (in the titles too) is lightly scrambled โ€” deterministically and in its own format: an ASIN is still B0 and eight letters and digits, an MSKU still ASIN-SUPPLIER-SEQUENCE built on its ASIN's scramble (every MSKU of one supplier shares one code), a UPC still has a valid check digit and shares its prefix with the manufacturer's other products. No product brings its real sales, sellers or wholesale cost: those are the market's own (below). A listing is labelled by its product type ("Ground Coffee ยท GRO-0012") and owns a handful of the category's products of that type.

The product on a listing changes over time. Every product has a SKU in the market (GRO-0012-G7: the listing's seventh product), an ASIN and an MSKU, and a life of 300โ€“1,200 sessions: it ramps up, sells, and at the end of its life phases out โ€” over 16โ€“60 sessions its demand fades, its reference price falls, sellers leave, and none of it recovers. Then the listing's next product takes over under a new SKU (a product that comes back is relisted under a new MSKU). A few listings in a hundred are phasing out at any time, and suppliers clear such products at deep discounts: a trap, since they will not sell again.

Sales follow the real assortment, category by category (all sellers together, per session): a few fast movers โ€” the top tenth of a category sells from about 11 units a session (sports, tools) to 140 (grocery) and more, its top 1 % hundreds to a thousand โ€” a median listing selling 3โ€“9, and a long slow tail: about a fifth sell under one unit a session, many under one a month. Sales velocities are never published, in any form.

What a deal shows

field meaning
unit_cost the supplier's landed cost per unit (deals and closeouts come cheaper)
list_price the buy box price of the session
sales_rank the listing's place among every product ranked in its marketplace category by recent sales (1 = the category's best seller), as published by the gate
rank_as_of the session whose close that rank is from
sales_rank_avg, rank_drops, rank_drops_long the rank's mean and the rank improvements ("drops": a sale) over 4 and 13 sessions, up to rank_as_of
nsellers competing sellers on the listing in the session (you would be one more)
sku, asin, msku, fnsku, upc, family, listing_type, category, brand, pack, case_pack, size_tier what is sold (sku is the market's id of the product on the listing; asin/msku/fnsku/upc its marketplace identifiers, scrambled)
category_size, category_listings products ranked in the marketplace category (the worst rank), and how many of them are listings of this market
referral_rate, fba_fee, fees_at_price, storage_per_session the marketplace fees: a share of the sale price, a fulfilment fee per unit sold (by size tier), storage per unit on hand and session
net_margin list_price - fees_at_price - unit_cost - inbound fee โ€” before storage and before anything goes unsold
max_qty units the supplier has (cases of case_pack)

The rank and its gate

Ranks are a real marketplace's: a listing is ranked among every product of its marketplace category โ€” 1.7 million in Grocery, 7 million in Home & Kitchen โ€” so a fast mover sits in the hundreds or thousands, the median listing around 100,000โ€“400,000 and a listing that has not sold for months near the bottom. Every unit sold lifts a listing's fading sales score, silence lets it fade, and the score's rate of sales goes through its category's curve from sales to rank (the real category's: a listing selling what the category's q-quantile listing sells is ranked where its q-quantile listing is), set against the category's own season and the market's tide โ€” a rank is relative: when the whole category sells more, nobody moves โ€” with a little scatter from the rest of the category. Ranks move instantly; they are published through a gate: a listing selling at least 10 units a session lately is published at once (rank_as_of = the last closed session), a slower one a session late (rank_delay_tiers). GET /api/v1/markets/{id}/families/{cluster}/signals serves a listing's published series โ€” rank, the session it is as of, the drops of that session, the sellers, the buy box price and the SKU โ€” whether or not a supplier offers the listing this session.

Sellers and the buy box

A listing carries its category's typical crowd of competing sellers (5 in Grocery, 10 in Sports & Outdoors at the median; from 2 to 18 across listings), a few more on busier listings, wandering over months; now and then an attractive listing (margin after fees, velocity) draws a wave of new sellers, who sell out and leave with a half-life.

Every seller runs a repricer. Alone on a listing the buy box sits a little above the product's reference price. Each seller beyond the listing's usual crowd (busier listings carry more sellers before it is a glut) pushes the price towards a floor below the wholesale cost: the more sellers, the more aggressive the pricing โ€” and the smaller everyone's share. The repricers follow gradually, faster down than up, and the buy box is sticky: in about half the sessions it does not move at all. The listing's price level also wanders on its own (the brand's price moves, sellers out of stock), and now and then a promotion cuts it 8โ€“30 % for a session or two. So a crowded listing's price sinks โ€” below cost in a glut โ€” and recovers as the sellers leave. A phasing-out product never recovers: its reference price itself falls.

Your share of a listing's units is one rotating share against the competing sellers (1 / (nsellers + 1) on average: your repricer has average-market efficiency), with some luck from session to session. Every account is one more seller against the simulated competition โ€” accounts never compete with each other.

Lots, stock and cash

A paid line fills at the session close as a lot with its own lead time: 2 sessions typically, sometimes 3 or 4, rarely 5 (62 / 24 / 11 / 3 %), drawn per line and revealed when the lot arrives.

  • Outstanding โ€” bought and paid, in transit: it sells nothing and pays no storage. You know the window it arrives in (arrives_window), not the session.
  • On hand โ€” it joins the account's stock of the SKU. The stock sells the account's share of the buy box at the buy box price, first in, first out across the account's lots (the oldest lot sells first; a new lot sells what the older ones leave), pays the referral and fulfilment fees on every sale (marketplace_fee in the ledger) and storage per unit on hand and session (by size tier, plus the cost of the money tied up: holding_fee).
  • A lot is held at most 16 sessions from its purchase. What it has not sold by then is removed: it is not converted to cash (written off); its whole units come back to the account holder as owned goods, as on every market.

The order debit is the units at unit_cost plus an inbound fee per unit (shipping and prep). A lot's result โ€” revenue - fees - storage - cost basis - write-off โ€” is fixed when it fills (the market's own future and the account's earlier lots of the SKU decide it) and is exactly what its trade on record shows.

Where to see it:

  • GET /api/v1/stock โ€” per SKU: qty_on_hand, qty_outstanding, their cost, what the lots sold, the fees and storage paid, the listing now (buy box, sellers, published rank) and every lot with its stage, lead time (once arrived), arrival window (while outstanding) and expiry; totals per market. The console's Stock page shows the same.
  • Trade-cart lines (GET /api/v1/cart, /cart) carry the stock of their SKU on the account they are ordered on: what is on hand and outstanding already, before you buy more.
  • Positions carry stage (inbound = outstanding, on_hand, settled), qty_outstanding, qty_on_hand, fees, and lead_time once arrived. The record's trades carry sku, arrived, lead_time (once arrived) and fees_so_far; the labelled dataset has sku and lead_time columns, the menu dataset sku and category.
  • GET /api/v1/markets/{id} has stock: true and rules โ€” the market's public rules: lead-time distribution, categories (their listings and the products ranked in them), fees, the rank gate, the horizon.

Configuration

Every mechanism is configurable: XGM_WHOLESALE_CONFIG=/path/to/wholesale.json overrides any field of xgames/wholesale/config.py (lists stand for tuples). For example:

{
  "categories": [{"code": "GRO", "name": "Grocery & Gourmet Food", "slots": 300, "referral_rate": 0.12},
                 {"code": "PET", "name": "Pet Supplies", "slots": 80, "sellers": 5.4, "season_amp": 0.05}],
  "catalogue_path": "",
  "lead_time_weights": [[2, 0.62], [3, 0.24], [4, 0.11], [5, 0.03]],
  "rank_delay_tiers": [[10.0, 0], [0.0, 1]],
  "crowd_floor_vs_cost": 0.8,
  "repricer_efficiency": 1.0,
  "horizon_ticks": 16
}

A category is code, name, slots (its listings), referral_rate and its calibration: velocity (units a session at the quantile levels velocity_levels: 5 %, 10 %, 25 %, 50 %, 75 %, 90 %, 95 %, 99 %), ranks (the rank a listing selling each of those is at), rank_size (products ranked in the category), sellers (its typical crowd), cost_ratio (wholesale cost / buy box: p10, p50, p90) and season_amp, season_peak (its yearly cycle and when it peaks, as a share of the year). A category without its own calibration takes the whole assortment's.

group knobs
catalogue categories, catalogue_path ("" = the built-in real SKUs, "synthetic" = invented products, or a file), bundle_share, multipack_share, lifecycle_ticks, ramp_up_ticks, phase_out_ticks, phase_out_price_drop
demand velocity_levels, velocity_floor, velocity_cap, generation_velocity_log_sd, multipack_velocity_factor, bundle_velocity_factor, demand_theta, demand_log_sd, burst_theta, burst_log_sd, tide_theta, tide_log_sd, season_period, season_origin, season_listing_spread, season_listing_shift, price_elasticity
sellers sellers_spread, sellers_velocity_elasticity, bundle_sellers, sellers_theta, sellers_log_sd, influx_rate, influx_size, influx_half_life, sellers_cap
buy box price price_premium, crowd_free, crowd_typical_share, crowd_scale, crowd_scale_per_typical, crowd_floor_vs_cost, repricer_speed_down, repricer_speed_up, price_walk_theta, price_walk_log_sd, reprice_prob, reprice_band, price_noise_sd, promo_prob, promo_depth, promo_sessions, undercut_prob, undercut_depth, repricer_efficiency, rotation_log_sd
rank rank_memory_decay, rank_samples_per_tick, rank_noise_sd, rank_delay_tiers, drops_window, drops_window_long, expose_rank_age
supply availability, offers_per_listing, cost_ratio, supplier_theta, supplier_log_sd, cost_scatter, deal_prob, deal_discount, closeout_availability, closeout_discount, max_qty, case_packs
lots and costs lead_time_weights, horizon_ticks, inbound_cost_per_unit, fba_fee_small / _standard / _bulky, storage_small / _standard / _bulky, capital_holding_rate, in_transit_cost_per_unit, leftover_writeoff_fraction, inbound_issue_prob, inbound_issue_cost, inbound_issue_demand
the desk desk_threshold, desk_threshold_amplitude, desk_period, desk_noise, desk_explore, desk_coverage_ticks, desk_velocity_floor

The configuration is part of the market's frozen identity: a database records the engine and configuration its history was generated with, so switching to the wholesale engine, or changing its configuration or catalogue, needs a fresh XGM_DB_PATH.

xgames wholesale report [--config wholesale.json] prints what a configuration produces โ€” the sales mix, the ranks, the crowds, how often prices sit below cost, the share of listings phasing out, the menu, the desk, lead times and rank delays โ€” from the hidden state, for the operator to tune by (none of it is served).

Calibration: series that look like a real marketplace's

The market is calibrated on a real wholesale seller's FMCG assortment โ€” in aggregate only: no listing takes a real product's sales. The sales distributions per category come from the assortment's ~150,000 SKUs; the dynamics from weekly Keepa series of ~5,900 of its listings over three years โ€” the sales rank, the number of sellers and the buy box โ€” measured by xgames.wholesale.calibration.panel_stats and kept as targets (xgames/wholesale/data/targets.json). xgames wholesale calibrate measures the market's own public series the same way โ€” the session's rank as a tracker averages it, a product per listing, as a tracker follows ASINs โ€” and prints both side by side:

statistic (weekly) real market
listing's median rank (log10): p10 / p50 / p90 4.34 / 5.25 / 5.81 4.28 / 5.29 / 5.87
rank memory (autocorrelation at 1 / 4 / 13 / 26 weeks) 0.90 / 0.72 / 0.42 / 0.14 0.91 / 0.76 / 0.39 / 0.09
rank moves (log10): median / p90 / p99 0.054 / 0.18 / 0.37 0.062 / 0.16 / 0.29
listings 3x worse after a year 5.3 % 7.0 %
listing's median sellers: p10 / p50 / p90 2 / 6.9 / 17.7 2 / 6 / 18
sellers' memory (1 / 4 / 13 weeks) 0.93 / 0.73 / 0.40 0.93 / 0.79 / 0.44
buy box: weeks without a move / p75 move / p99 move 42 % / 4.1 % / 36 % 42 % / 4.4 % / 31 %
buy box memory (1 / 4 / 13 weeks) 0.88 / 0.65 / 0.32 0.90 / 0.78 / 0.39
buy box against the crowd (log price on log sellers) -0.12 -0.09
weeks below cost / listings ever below cost 1.7 % / 15 % 1.2 % / 19 %

The market's sellers are whole counts (a tracker's weekly averages are not), so their small moves differ. The lead times follow the design brief (2 / 3 / 4 / 5 sessions) rather than real suppliers', which run longer (a median of about three weeks, 90 % within six): lead_time_weights is the knob.

The catalogue: real SKUs, scrambled identifiers

The built-in catalogue (xgames/wholesale/data/catalogue.json.gz) was made from a seller's export with

xgames wholesale catalogue skus.tsv --out xgames/wholesale/data/catalogue.json.gz --category-map categories.json

which keeps titles, brands, product types, reference prices, size tiers, case packs and pack counts, scrambles every identifier (--scramble-key or XGM_SCRAMBLE_KEY: the same export and key give the same identifiers, another key other ones) and never keeps sales or sellers; wholesale costs are dropped unless --keep-costs (a distributor's prices are usually confidential, and the menus show every deal's cost). Recognised columns: category, type, title, brand, price (or retail_price), case_pack (or units_per_carton), size_tier (any spelling), asin (or seller_sku), parent_asin, msku, fnsku, upc, vendor_sku, part_number, number_of_items. A catalogue of your own goes in catalogue_path (--config-out writes a configuration sized to it); keep private exports out of the repository (local/ is ignored).

Mapping to a purchasing model's inputs

decision-model field here
key (seller SKU) sku (the market's; msku is the listing's merchant SKU)
T, menu the session, its deals (the SKUs suppliers offer)
T_lead a lot's lead time (the distribution in rules.lead_time)
unit_cost, unit_price unit_cost, the buy box list_price (less fees_at_price)
qty_stock qty_on_hand in GET /stock
qty_outstanding qty_outstanding in GET /stock, with each lot's arrives_window
qty, profit the order's qty, the lot's realized_pnl

Determinism

Every series is generated for all listings at once in blocks of 128 sessions and is a pure function of the seed and the block: a block starts from truncated sums over the sessions before it (the demand, burst, seller, price-level and cost walks, the sales score, the seller waves) or re-runs a warm-up (the buy box price and its promotions), so any session is regenerated on its own, in any order, and the history is the same whenever it is recorded. A fresh history takes a few minutes to load in the background (a few seconds per 128 sessions per market).