Skip to main content

Query Analytics (Market)

mdmcp-market_data_table

Query precomputed market-data analytics in any aggregation shape: snapshot, single-dimension breakdown, multi-dimension cross-tab, numeric histogram, percentile summary, time-series trend, or period-over-period comparison. One tool covers every shape — pick via group_by, percentiles, trend, and compare:

  • Snapshot (one row, no buckets): group_by=[]
  • Breakdown by one dim: group_by=[\{"field": "retailer"}]
  • Cross-tab by two-three dims: group_by=[\{"field":"brand_slug"},\{"field":"market"}]
  • Histogram on a numeric field: group_by=[\{"field":"price","interval":1000}]
  • Percentile (median/quartile/p90): set percentiles
  • Trend (per-period time series, one row per period x group): set trend=true
  • Period comparison (WoW/MoM/vs prior): set compare

Full reference is on demand via the market_data_docs tool (topics: filter_fields, metrics, group_by_fields, dates_and_compare, text_search; pass ids=[...] for specific entries, e.g. topic="filter_fields", ids=["gender", "tier"]). This description is intentionally condensed — every filter field, metric, group-by / histogram / percentile field, date / snapping / compare rule, price-unit scale and text-search syntax is retrievable there, one section per call. Call it before building a query whenever you need a field id, metric id, valid value, operator, or price scale you do not already have.

Limits: up to 3 group-by/histogram fields, up to 8 primary metrics, row cap of 200 (default 50).

Drilldown: every aggregate this tool returns is backed by individual product options. When the user asks to see the products behind a stat or row, follow up with market_data_options_search: reuse the same filters (plus a filter pinning the row's group value, e.g. the retailer or brand) and set date to a day inside the aggregate window — typically its end_date. One exception: a synthetic row — "Unmapped" (the include_missing bucket) or the "others" roll-up — has no filter value behind its label, so it cannot be drilled into; say so instead of issuing a filter that silently returns nothing. The underlying products are always queryable; never direct the user to check retailer websites or other manual alternatives.

Inline result & truncation:

  • Results are returned inline, capped at limit rows. truncated says exactly whether the cap hid rows. total_row_count is exact-or-null: the exact count when the result is complete, and null whenever it is truncated (either include_others setting) — the table backend reports no population total, so the true count is unknown once rows are hidden. It is never a lower bound: read truncated for the "more rows exist" signal, and raise limit or set include_others=true for coverage (the rolled-up tail then sits in a trailing "others" row).
  • include_others defaults to true for queries grouped by a categorical dimension: rows past limit roll up into one trailing "others" row so the visible rows still sum to 100% — best for share / mix / distribution questions. Under trend the roll-up happens within each period (per-period top-N + others). Set false for a strict top-N (combine with sort). The "others" tail is itself bounded by the aggregation cap (<= 50,000 / 10,000 buckets), so on an extreme cross-tab even the rolled-up tail can under-count.

Not available inline: trend combined with multiple group_by dimensions (it exceeds the response-time budget) — use a single group-by dimension, a shorter date span, or drop trend. Discouraged (slow or silently partial): long-span trend on high-cardinality dims; multi-dim cross-tabs on high-cardinality dims (prefer include_others=true or fewer / coarser dims); percentiles across high-cardinality group-bys; composition crossed with a high-cardinality dim over a long range (each material bucket multiplies the bucket budget, so the backend may reject the query with a too-many-buckets error — narrow the range or the other dimension).

There is no SKU-level search or SKU-level pricing on this server, in any vertical. The sku_* metrics (sku_count, sku_count_retailer_avg, sku_availability, sku_sellout_pct, pct_mix_sku_count) are option-level AGGREGATES — counts and rates over an option's SKUs — not a way to address one SKU. size_options DOES filter to options offered in a size, but it selects whole options: it does not restrict the row or its metrics to that size, and there is no size/variant dimension to group or sort on. So "which dresses come in a size 16" is answerable, while "what did just the queen size sell for" is not — prices, counts and rates always describe the whole option. Answer at the option level and say the per-SKU breakdown is not available.

Resolve filter values BEFORE querying: most filters take canonical EDITED ids/slugs, not free text (the brand filter is the exception — see below). Get them from the lookup tools and copy the returned values verbatim — search_retailers -> retailer (retailers[].slug), search_brands -> brand_slug (slug), search_markets -> market (string code, e.g. "UK"), search_product_searches -> product_searches (integer id), search_size_group -> size_options (as "group-\{id}" strings), search_size_options -> size_options (integer ids; keep group strings and option ids in separate filters). When a user names a company ("Nike products"), default to resolving it as a retailer (search_retailers -> retailer); use brand_slug only for a brand within a retailer ("Nike at Foot Locker"). The brand filter takes a brand NAME as text — never a slug or id, and it never requires a lookup: for a confident search_brands match use that match's name (the canonical spelling, which also catches variant listings), otherwise use the user's string verbatim. A search_brands miss is never a reason to skip the filter — it means the brand is unmapped, which is exactly what brand is for (including a hit whose slug is null: its name is the value to use). brand and a POSITIVE (eq/in) brand_slug are OR-combined when both are supplied; a negated brand_slug (neq/not_in) stays an AND exclusion that always holds. When you take a brand_slug from search_brands, first confirm the returned name really matches the user's brand (ignore case, punctuation, ®/accents, hyphens vs spaces): the tool returns a best-effort list for almost any input, so a returned slug is NOT proof of a match — if none matches, treat the brand as unmapped and use the brand filter. At least one filter is required.

Brand strategy (this tool favours PRECISION): for a confident search_brands match, filter on brand_slug ALONE and group by brand_slug for normalised, deduped counts. For a brand with no slug, filter on brand and group by brand (raw) — which buckets every product, so no include_missing is needed (and it is not supported there) — and tell the user the counts come from raw brand text and may include variants/sub-brands. Keep the group-by dimension consistent with the resolved brand filter (mapped -> brand_slug; raw/unmapped -> brand).

Results assume healthy data coverage: a clean aggregate can be silently wrong if a retailer had a data gap or stale stretch in the window. Before trusting retailer-scoped figures, check get_retailer_coverage for the same retailers and date range, and caveat or narrow the analysis when it reports gap episodes overlapping the window.

vertical (apparel | beauty | homeware) selects the top-level data segment; omit to use the account's default vertical (see market_data_entitlements). This is NOT a product category — categories like dresses or mascara are a within-vertical filter found via search_product_searches. All three verticals are supported and return the SAME row grain: one row per product option. Homeware options bundle more SKUs per option (e.g. bed sizes) than apparel or beauty, so sku_count runs higher there — but no vertical returns per-SKU rows. Available verticals vary by account; requesting one you're not entitled to returns an error — call market_data_entitlements to see yours.

Date range snapping: start_date / end_date are snapped OUTWARD to whole calendar periods (weeks or months), so metric values describe the snapped window and not the literal dates you passed — the range is never a precise cutoff. The result reports the window actually measured as evaluated_window (start:end, end capped at today, so a final period can still be forming): read it and report figures against it. For the snapping rules, the weekly-vs-monthly boundary and partial-period caveats, call market_data_docs with topic="dates_and_compare".

Sort order: asc (lowest first) or desc (highest first). Sort by any field id (group-by, histogram, metric, or percentile id like price_p50); limit is a strict cap — combine with sort for top-N. A lone histogram group-by defaults to its band field ascending (a distribution is read in axis order, not by population), overriding the backend's metric-descending default; pass an explicit sort to change it. Note this interacts with limit: a histogram with more populated bands than limit is truncated from the TOP of the range (the high-value tail) under the ascending default — raise limit to keep the whole distribution. Empty bands are always omitted, so a distribution can still have gaps between the bands that are returned.

Retail-term routing — map common asks to the right shape before picking metrics:

  • "Discount", "markdown", "on sale", "full price" are ADVERTISED-discount concepts unless the user explicitly asks about raw price movement: depth -> avg_advertised_discount_pct; penetration / "% on sale" -> advertised_discounted_product_pct; "time to markdown" -> avg_days_to_first_discount; "deepest discount" is a histogram (group_by=[\{"field":"deepest_advertised_discount_percentage"}]), not a metric.
  • Entry / median / exit price points are PERCENTILES — set percentiles on price (current) or full_price (ticket/RRP), e.g. p10/p50/p90. They are NOT avg_min_price/avg_max_price, which average each product's own markdown-inclusive observed extremes (a clearance floor / per-product ceiling, not the assortment's price architecture).
  • "Full-price / ticket / RRP architecture" is about the full_price FIELD (percentiles or histogram) across the whole assortment — full_price is already pre-markdown, so do NOT add a discount filter. "Products currently selling at full price" is instead the POPULATION filter advertised_discounted=false.
  • "How many products / SKUs / options" -> a count metric (product_count, sku_count, or distinct_product_count_aggregate for a deduplicated distinct count — it requires a group_by), never the number of returned rows (rows are capped by limit).
  • "Bestsellers / top sellers / fastest selling" (individual products) -> market_data_options_search sorted by sellout_percentage desc; aggregate sell-out rate / speed -> first_majority_sellout_pct / avg_days_to_first_majority_sku_sellout.
  • "New in / new arrivals / launches / newness" -> new_arriving_products_count (add pct_mix_new_arriving_products_count for mix). New In methodology covers in + out of stock — omit the in_stock filter unless the user explicitly wants currently-available arrivals. To scope a population by launch timing instead ("launched in the last 90 days"), use a date_found filter with distinct_product_count_aggregate (deduplicated across the range; requires a group_by — with a single-retailer filter, grouping by retailer is a no-op) — over a multi-period range product_count would give the per-period average, not the population. See Date filters.

Default filters: is_second_hand=false and outlet=false are appended automatically when those fields are absent from filters. To include or target second-hand or outlet assortments, pass the field explicitly — an explicit filter always wins over the default.

Filter field table

fieldtypedefinition
retailerstringRegion-specific retailer slug (e.g. levi-uk, zara-us). Use values returned by search_retailers. This is a DIFFERENT id space from brand_slug: a retailer slug is not a brand slug. Both search_brands and search_retailers surface entries for a name like 'Levi's', but they return distinct values — retailer levi-uk vs. brand levis — that are not interchangeable between the two fields. There is no bare levis retailer slug. Default to retailer when a user names a company ('Nike products', 'the Adidas range'). Case-insensitive; display-name values (spaces or punctuation) are rejected with a pointer to search_retailers.
brand_slugstringNormalised brand slug — the label/manufacturer (e.g. nike, h-m, levis). Use values returned by search_brands (the entries that carry a slug). This is a DIFFERENT id space from retailer: a brand slug is not a retailer slug. Both search_brands and search_retailers surface entries for a name like 'Levi's', but they return distinct values — brand levis vs. retailer levi-uk — that are not interchangeable between the two fields. Use brand_slug for a brand within a retailer ('Nike at Foot Locker'); a bare company name defaults to retailer. Case-insensitive; display-name values (spaces or punctuation) are rejected with a pointer to search_brands — if the brand has no slug, use the free-text brand field instead. If you also supply a brand filter, a POSITIVE (eq/in) brand_slug is combined with it using OR (see the brand field); a negated one (neq/not_in) stays an AND exclusion that always holds.
brandtextFree-text brand match — the 'manual search' path for brands that have no normalised slug (only ~half of products carry a brand_slug). Matches the brand name as a case-insensitive phrase (e.g. bila77, Hugo Boss); a list matches ANY of the given names. Supports eq (one name) and in (a list) only, at most 50 names per request — each name is matched separately, so for a longer list resolve slugs and use brand_slug in instead. IMPORTANT: when brand and a POSITIVE (eq/in) brand_slug are both supplied they are combined with OR — a product matching EITHER field is returned (a deliberate exception to the otherwise-AND filter list). A negated brand_slug (neq/not_in) is NOT part of that OR: it stays an AND exclusion that always holds, so it still removes its brands from a brand match. A brand filter alongside a positive brand_slug CANNOT be broken down by brand_slug (rejected) — the backend would restrict the buckets to the filtered slug(s) and drop the volume brand added; group by brand (raw) for that breakdown. Tell the user you are matching either brand field rather than requiring both. The value is a brand NAME, never a slug — see the lookup workflow for which name to use. Prefer brand_slug when search_brands returns the brand with a slug; use brand for a name it has no slug for (or when the user asks for a literal brand name).
genderstringGender category. Valid values: women, men, unisex-adults, girls, boys, unisex-kids. Shorthand adult__all__ and child__all__ expand to all adult or all child genders. Case-insensitive (Unisex Kids normalises to unisex-kids); unknown values are rejected with an error.
marketstringRetailer market code — a 2-letter code like UK or US. Use the id values returned by search_markets. Case-insensitive; ISO GB is accepted for the UK. Country names (e.g. United Kingdom) are rejected with an error.
product_searchesinteger[]EDITED product-search category IDs. Use IDs returned by search_product_searches.
predominant_colourstringEDITED predominant colour classification. Valid values: black, grey, maroon, red, pink, fuchsia, purple, blue, navy, teal, aqua, green, lime, yellow, orange, copper, brown, gold, neutral, silver, white. The values unassigned and multicolour also appear in the data; when this field is used as a group_by, they merge into the others row only under a top-N roll-up; otherwise they appear as their own rows. Case-insensitive; unknown values are rejected with an error.
predominant_patternstringEDITED predominant pattern classification. Valid values: plain, abstract, animal, aztec, camouflage, checks, conversational, floral, geometric, graphics, lace, paisley, spots, stripes, tile. Shorthand pattern__all__ expands to every pattern except plain (i.e. patterned products only). Case-insensitive; unknown values are rejected with an error.
compositionstringFabric composition material. Supports eq and in only. Valid values (exact, lowercase): cotton, organic cotton, wool, silk, linen, cashmere, leather, calf leather, suede, viscose, acetate, rayon, polyester, recycled polyester, elastane, polyamide, nylon, spandex, polyurethane, acrylic, rubber. Matches any product CONTAINING the material — products usually have several, so material populations overlap.
in_stockbooleanProduct has at least one available SKU.
is_second_handbooleanWhether the product is second hand.
outletbooleanWhether the product comes from an outlet assortment.
advertised_discountedbooleanWhether the product is currently advertised as discounted.
pricenumberCurrent selling price in the requested currency. Filter thresholds use the value x 100 — a fixed scale the backend applies regardless of ISO 4217 exponent (GBP £49.99 = 4999; JPY ¥5,000 = 500000). Integer values only: a fractional value is rejected as un-multiplied major units. Returned prices are in major units. See market_data_docs topic="metrics" (without ids) for the price-units note.
full_pricenumberHighest observed selling price in the requested currency. Filter thresholds use the value x 100 (fixed; GBP 4999 = £49.99). Integer values only: a fractional value is rejected as un-multiplied major units. Returned prices are in major units. See market_data_docs topic="metrics" (without ids) for the price-units note.
advertised_discount_percentagenumberCurrent advertised discount percentage, on a 0-100 scale: 50 means 50% off — do NOT pass 0.5 for 50%.
deepest_advertised_discount_percentagenumberDeepest advertised discount percentage ever recorded on the option, on a 0-100 scale (50 = 50% off).
first_advertised_discount_percentagenumberFirst advertised discount percentage recorded on the option, on a 0-100 scale (50 = 50% off).
has_had_advertised_discountbooleanWhether the option has ever had an advertised discount.
sellout_percentagenumberPercentage of SKUs linked to the option that have sold out, on a 0-100 scale: 50 means 50% sold out — do NOT pass 0.5 for 50%.
tierstringRetailer market segment. Valid values: value, mass, premium, luxury. Case-insensitive; unknown values are rejected with an error.
sku_countintegerTotal number of SKUs linked to the product option.
option_idstringEDITED option identifier.
size_optionsinteger[] | string[]Size option IDs from search_size_options, or size group IDs as group-\{id} strings from search_size_group.
nametextFull-text search on product name. Elasticsearch query_string: default AND; UPPERCASE OR/NOT; trailing wildcards only. Wrap each multi-word term in escaped double quotes or it splits into independent AND-ed words instead of matching the phrase; inside an OR list a broad word then dominates and silently broadens the match — e.g. windbreaker OR \"shower jacket\" OR \"track jacket\", not windbreaker OR shower jacket OR track jacket (which collapses to a bare jacket match). See market_data_docs topic text_search.
descriptiontextFull-text search on product description. Elasticsearch query_string: default AND; UPPERCASE OR/NOT; trailing wildcards only. Wrap each multi-word term in escaped double quotes or it splits into independent AND-ed words instead of matching the phrase; inside an OR list a broad word then dominates and silently broadens the match — e.g. waterproof OR \"shower resistant\" OR \"wind resistant\", not waterproof OR shower resistant OR wind resistant (which collapses to a bare resistant match). See market_data_docs topic text_search.
date_founddateProduct launch date. Accepts literal dates (YYYY-MM-DD) and relative expressions — see market_data_docs topic dates_and_compare for the date-filter rules.
date_first_selloutdateDate all SKUs first went out of stock. Accepts literal and relative dates — see market_data_docs topic dates_and_compare for the date-filter rules.
date_first_majority_sku_selloutdateDate >=51% of SKUs first went out of stock (requires 2+ SKUs). Accepts literal and relative dates — see market_data_docs topic dates_and_compare for the date-filter rules.
normalised_average_ratingnumberAverage user review rating on a 5-point star scale (1.0 to 5.0). Despite the name it is NOT normalised to 0-1 — gte 4 means 4+ stars.
number_of_reviewsintegerNumber of user reviews on the product.
activewear_categorykeywordActivewear classification. Valid values: performance (sports/training activewear), athleisure (fashion-led activewear), none (not activewear). Supports eq and neq only.
is_licensed_activewearbooleanWhether the product is officially licensed activewear (e.g. NFL, NBA branded gear). Supports eq only.
sport_typekeywordSport the product is associated with. Valid values: american_football, baseball, basketball, boxing, cycling, football, golf, handball, hockey, hiking_and_outdoors, lacrosse, rugby, running, skateboarding, snowsports, softball, surfing, tennis, training, volleyball, yoga, other. Supports eq and in only.

Valid operators by field type

field typevalid operators
stringeq, neq, in, not_in
keywordeq, neq, in, not_in
text (name, description)eq only (query_string full-text)
text (brand)eq (one name), in (any of a list) — phrase match, not query_string
integer[] / integer[] | string[] (id lists)eq, neq, in, not_in
number / integereq, neq, gt, gte, lt, lte, between, in, not_in
dateeq, neq, gt, gte, lt, lte, between
booleaneq, neq

Parameters

NameTypeRequiredConstraintsDescription
namestringyesConcise name describing the type of data that you are trying to request.
metricsstring[]yesMetric IDs to request. Use the metric table to choose valid IDs.
start_datestringyesInclusive start date for the analysis period. Must be within the past two years (730 days).
end_datestringyesInclusive end date for the analysis period. Must be within the past two years (730 days).
filtersobject[]yesAND-combined filters. Nesting and OR groups are not supported, with one exception: brand and a POSITIVE (eq/in) brand_slug are OR-combined when both are supplied (a product matching either brand field is returned). A negated brand_slug (neq/not_in) stays an AND exclusion — it always holds, even alongside brand. At least one filter is required.
group_byobject[]no0-3 group-by dimensions. Empty list = global snapshot (one row of metric values). One entry = breakdown by that dim. Two-three entries = cross-tab. Use TableHistogram for numeric bucketing (price bands, discount bands).
percentilesobject[]noOptional percentile metrics on numeric fields. Each entry adds one column (e.g. price_p50, full_price_p90) to the response. Use for median / quartile / p90-p99 stats alongside metrics.
compareobjectnoOptional period-over-period comparison. When set, each metric gets a sibling \<metric>_compare column carrying the formatted comparison value (percent_change by default). Use mode='period' with explicit dates, or mode='relative' with an offset (previous_period, year_over_year). Use for WoW / MoM / vs-prior-period asks.
trendbooleannodefault: falseWhen True, splits the date range into sub-periods at the auto-selected granularity (W/M based on range length) and adds a dates column carrying each period as a start:end string. Output is long-format: one row per (period x group), re-ranked per period (top-N membership can vary period to period). Use for time-series questions. Compatible with compare using mode='relative'. Combining trend with multi-dimensional group_by is not available inline.
sortobject[]noOptional sort order. Each entry sorts by a group-by/histogram field id, a metric id, or a percentile id (e.g. price_p50). Applied in order; later entries break ties from earlier ones.
limitintegernodefault: 50 · 1–200Maximum number of rows in the response. Defaults to 50; combine with sort to get the top-N. Caps the group buckets, plus one trailing "others" row when include_others=true and one "Unmapped" row when a group-by sets include_missing. The "Unmapped" row rides outside limit on a strict top-N (include_others=false); with include_others=true it can occupy one of the limit slots, so a truncated result may show limit - 1 real buckets.
include_othersbooleannoRoll rows past limit into a single trailing row labelled "others" so the visible rows still sum to 100% of the population. When omitted, defaults to True for a categorical group_by (the roll-up both signals truncation and preserves 100% coverage) and False otherwise. Set True for share / mix / distribution / long-tail questions where coverage matters. Compatible with trend=true — the roll-up is computed within each period (per-period top-N + others). With multi-dim group_by only the first column is labelled "others"; the rest go blank.
verticalstringnoOptional. The market vertical (top-level data segment) to query: apparel | beauty | homeware. This is NOT a product category — product categories (e.g. dresses, mascara) are a within-vertical filter found via search_product_searches. All three verticals are supported and return the SAME row grain: one row per product option. Homeware options bundle more SKUs per option (e.g. bed sizes) than apparel or beauty, so sku_count runs higher there — but no vertical returns per-SKU rows, or exposes a size/variant dimension to group or sort on. When omitted it resolves to the account's default vertical (reported as default_vertical by market_data_entitlements). Available verticals vary by account — only pass this when the user explicitly asks about a different vertical; otherwise omit it and let the account default apply. Requesting a vertical you're not entitled to returns an error; call market_data_entitlements to see yours.
currencystringnoOptional ISO 4217 currency code (e.g. USD, EUR, GBP), case-insensitive. Converts every price-denominated value in the request to this currency. Price INPUTS — any price filter (price / full_price) threshold and, in market_data_table, the histogram interval — use a FIXED 'value x 100' scale, applied regardless of the currency's ISO 4217 exponent (GBP £49.99 = 4999, £1,600 = 160000, interval 1000 = a £10 band; JPY ¥5,000 = 500000, interval 1000 = a ¥10 band). All returned prices — market_data_table price metrics / percentiles and market_data_options_search per-option prices — are in whole (major) currency units, so price inputs and outputs differ by 100x. See the price-units note — in this tool's description, or market_data_docs topic="metrics" (unfiltered) when it is not — incl. the ISO deviation for zero-/3-decimal currencies. When omitted it is resolved from the caller's config (else USD). The backend validates the code; an unknown code is rejected upstream.

Try it

Loading interactive widget…

Code examples

curl -s https://mcp.edited.com/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2025-06-18" \
-H "x-api-key: $MCP_API_KEY" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "mdmcp-market_data_table",
"arguments": {
"name": "example",
"start_date": "example",
"end_date": "example",
"trend": false,
"limit": 50,
"include_others": false,
"vertical": "example",
"currency": "example"
}
}
}'

Input schema

{
"type": "object",
"properties": {
"name": {
"description": "Concise name describing the type of data that you are trying to request.",
"examples": [
"average price across the UK",
"product count by retailer in Q1 2026",
"price distribution for Zara dresses",
"average price trend monthly for Zara",
"median price for Zara dresses",
"Zara average price WoW change"
],
"type": "string"
},
"metrics": {
"description": "Metric IDs to request. Use the metric table to choose valid IDs.",
"examples": [
[
"avg_price",
"product_count"
]
],
"items": {
"enum": [
"avg_days_to_first_discount",
"avg_days_to_first_majority_sku_sellout",
"avg_advertised_discount_pct",
"avg_first_advertised_discount_pct",
"avg_first_price",
"avg_full_price",
"avg_max_price",
"avg_min_price",
"avg_normalised_average_rating",
"avg_number_of_reviews",
"avg_price",
"avg_price_increase_pct",
"advertised_discounted_product_count",
"advertised_discounted_product_pct",
"distinct_product_count_aggregate",
"first_majority_sellout_pct",
"new_arriving_products_count",
"new_arriving_products_count_retailer_avg",
"pct_mix",
"pct_mix_new_arriving_products_count",
"pct_mix_sku_count",
"price_increased_product_count",
"price_increased_product_pct",
"product_count",
"product_count_retailer_avg",
"replenished_products_count",
"replenished_products_pct",
"sku_availability",
"sku_count",
"sku_count_retailer_avg",
"sku_sellout_pct"
],
"type": "string"
},
"type": "array"
},
"start_date": {
"description": "Inclusive start date for the analysis period. Must be within the past two years (730 days).",
"examples": [
"2026-01-01"
],
"format": "date",
"type": "string"
},
"end_date": {
"description": "Inclusive end date for the analysis period. Must be within the past two years (730 days).",
"examples": [
"2026-01-31"
],
"format": "date",
"type": "string"
},
"filters": {
"description": "AND-combined filters. Nesting and OR groups are not supported, with one exception: `brand` and a POSITIVE (`eq`/`in`) `brand_slug` are OR-combined when both are supplied (a product matching either brand field is returned). A negated `brand_slug` (`neq`/`not_in`) stays an AND exclusion — it always holds, even alongside `brand`. At least one filter is required.",
"examples": [
[
{
"field": "brand_slug",
"op": "in",
"value": [
"nike",
"adidas"
]
},
{
"field": "market",
"op": "in",
"value": [
"DE",
"IT"
]
}
]
],
"items": {
"properties": {
"field": {
"description": "Field to filter on. Use only fields documented in the filter field table.",
"enum": [
"retailer",
"brand_slug",
"brand",
"gender",
"market",
"product_searches",
"predominant_colour",
"predominant_pattern",
"composition",
"in_stock",
"is_second_hand",
"outlet",
"advertised_discounted",
"price",
"full_price",
"advertised_discount_percentage",
"deepest_advertised_discount_percentage",
"first_advertised_discount_percentage",
"has_had_advertised_discount",
"sellout_percentage",
"tier",
"sku_count",
"option_id",
"size_options",
"name",
"description",
"date_found",
"date_first_sellout",
"date_first_majority_sku_sellout",
"normalised_average_rating",
"number_of_reviews",
"activewear_category",
"is_licensed_activewear",
"sport_type"
],
"examples": [
"retailer"
],
"type": "string"
},
"op": {
"description": "Comparison operator. Use 'between' for numeric or date range bounds. Use 'eq' for text-search fields (name, description). 'brand' supports 'eq' (one name) and 'in' (a list) only.",
"enum": [
"eq",
"neq",
"in",
"not_in",
"gt",
"gte",
"lt",
"lte",
"between"
],
"examples": [
"eq",
"in"
],
"type": "string"
},
"value": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"items": {
"type": "string"
},
"type": "array"
},
{
"items": {
"type": "integer"
},
"type": "array"
},
{
"items": {
"type": "number"
},
"type": "array"
}
],
"description": "Primary filter value. Use a list for 'in' and 'not_in'.",
"examples": [
"zara",
[
"zara",
"hm"
],
true
]
},
"value2": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
},
{
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"description": "Upper bound used only with 'between'.",
"examples": [
100
]
}
},
"required": [
"field",
"op",
"value"
],
"type": "object"
},
"minItems": 1,
"type": "array"
},
"group_by": {
"description": "0-3 group-by dimensions. Empty list = global snapshot (one row of metric values). One entry = breakdown by that dim. Two-three entries = cross-tab. Use TableHistogram for numeric bucketing (price bands, discount bands).",
"examples": [
[],
[
{
"field": "retailer"
}
],
[
{
"field": "brand_slug"
},
{
"field": "market"
}
],
[
{
"field": "price",
"interval": 1000
}
]
],
"items": {
"type": "object",
"properties": {
"field": {
"enum": [
"brand",
"brand_slug",
"composition",
"cs_subcategory",
"gender",
"market",
"predominant_colour",
"predominant_pattern",
"product_details",
"product_searches",
"retailer",
"advertised_discount_percentage",
"days_to_first_majority_sku_sellout",
"days_to_first_sellout",
"deepest_advertised_discount_percentage",
"deepest_discount_percentage",
"discount_percentage",
"first_advertised_discount_percentage",
"first_discount_percentage",
"first_price",
"full_price",
"inferred_full_price",
"normalised_average_rating",
"number_of_reviews",
"performance_score",
"price",
"price_increase_percentage",
"sellout_percentage"
],
"description": "Group-by field id. See the group-by field table (in the tool description, or `market_data_docs` `topic=\"group_by_fields\"`). / Histogram field id. See the histogram field table (in the tool description, or `market_data_docs` `topic=\"group_by_fields\"`).",
"examples": [
"retailer",
"brand_slug",
"price",
"discount_percentage"
],
"type": "string"
},
"include_missing": {
"default": false,
"description": "When True, products with no value for this field are surfaced as a single \"Unmapped\" bucket instead of being dropped from the results. ONLY supported on `brand_slug` (only ~half of products carry one), so a brand breakdown does not silently exclude unmapped brands; every other group-by field rejects it. Also requires a SINGLE group-by entry — it cannot be combined with a cross-tab. Off by default. Note the bucket is labelled \"Unmapped\" here, whereas `market_data_options_search` labels the same unmapped population \"Unknown\" — the two are not joinable on the label. The bucket is never rolled into the `\"others\"` row, so it cannot be truncated away. It rides ON TOP OF `limit` on a strict top-N (`include_others=false`); with `include_others=true` it can take one of the `limit` slots instead, leaving `limit - 1` real buckets. That label is synthetic, not a filter value: no filter can select products with no `brand_slug`, so an \"Unmapped\" row CANNOT be drilled into. If the user may want the products behind it, group by `brand` (raw) instead — it buckets every product and every label it returns is a valid `brand` filter value.",
"type": "boolean"
},
"interval": {
"description": "Bucket width, in the field's own units — for price fields the value x 100, a fixed scale the backend applies regardless of ISO 4217 exponent, matching price filters: GBP 1000 = a £10 band, JPY 1000 = a ¥10 band. Leave unset for the BE default (price 1000; discount_percentage 10%%); the default is a fixed count, not rescaled per currency. Returned bucket LABELS are in major units (a 1000 interval → 0-10, 10-20 bands), so the interval you send (x100) and the labels you read back (major) differ by 100x — see the price-units note (in the tool description, or `market_data_docs` `topic=\"metrics\"`, unfiltered).",
"exclusiveMinimum": 0,
"type": "number"
}
},
"required": [
"field"
],
"description": "Categorical group-by — buckets rows by exact term value. / Numeric histogram group-by — buckets rows by interval on a continuous field."
},
"type": "array"
},
"percentiles": {
"description": "Optional percentile metrics on numeric fields. Each entry adds one column (e.g. `price_p50`, `full_price_p90`) to the response. Use for median / quartile / p90-p99 stats alongside `metrics`.",
"examples": [
[
{
"field": "price",
"percentile": 50
}
],
[
{
"field": "price",
"percentile": 25
},
{
"field": "price",
"percentile": 50
},
{
"field": "price",
"percentile": 75
}
]
],
"items": {
"description": "Percentile of a numeric field — `p50` of price, `p90` of full_price, etc.\n\nUse percentiles when the user wants a pinpoint summary statistic of a\ndistribution (median, quartiles, p90/p95) alongside regular metrics.\nUse a histogram (via ``group_by``) when they want the full distribution\nshape as bucketed rows.",
"properties": {
"field": {
"description": "Numeric field id. See the percentile field table (in the tool description, or `market_data_docs` `topic=\"group_by_fields\"`).",
"enum": [
"first_price",
"full_price",
"price"
],
"examples": [
"price",
"full_price"
],
"type": "string"
},
"percentile": {
"description": "Integer 0-100. Common picks: 25 (lower quartile), 50 (median), 75 (upper quartile), 90.",
"examples": [
50,
75,
90
],
"maximum": 100,
"minimum": 0,
"type": "integer"
}
},
"required": [
"field",
"percentile"
],
"type": "object"
},
"type": "array"
},
"compare": {
"description": "Optional period-over-period comparison. When set, each metric gets a sibling `<metric>_compare` column carrying the formatted comparison value (percent_change by default). Use `mode='period'` with explicit dates, or `mode='relative'` with an offset (`previous_period`, `year_over_year`). Use for WoW / MoM / vs-prior-period asks.",
"examples": [
{
"compare_end_date": "2026-04-21",
"compare_start_date": "2026-04-15",
"format": "percent_change",
"mode": "period"
},
{
"mode": "relative",
"offset": "previous_period"
}
],
"type": "object",
"properties": {
"mode": {
"enum": [
"period",
"relative"
],
"description": "Comparison shape. `period` compares the primary range to the explicit comparison range — same filters across two date ranges. / Comparison shape. `relative` derives the comparison range from the primary range and the chosen offset.",
"type": "string"
},
"format": {
"default": "percent_change",
"description": "How to express the difference: `percent_change` (default — % change from the comparison period) or `absolute_change` (raw delta in metric units).",
"enum": [
"percent_change",
"absolute_change"
],
"type": "string"
},
"compare_start_date": {
"description": "Inclusive start date for the comparison range.",
"examples": [
"2026-04-15"
],
"format": "date",
"type": "string"
},
"compare_end_date": {
"description": "Inclusive end date for the comparison range.",
"examples": [
"2026-04-21"
],
"format": "date",
"type": "string"
},
"offset": {
"description": "`previous_period` — the whole-period window immediately before the primary (non-trend: the snapped primary window shifted back by its own span; trend: one interval). `year_over_year` — shift back by one calendar year.",
"enum": [
"previous_period",
"year_over_year"
],
"type": "string"
}
}
},
"trend": {
"default": false,
"description": "When True, splits the date range into sub-periods at the auto-selected granularity (W/M based on range length) and adds a `dates` column carrying each period as a `start:end` string. Output is long-format: one row per (period x group), re-ranked per period (top-N membership can vary period to period). Use for time-series questions. Compatible with `compare` using `mode='relative'`. Combining trend with multi-dimensional `group_by` is not available inline.",
"type": "boolean"
},
"sort": {
"description": "Optional sort order. Each entry sorts by a group-by/histogram field id, a metric id, or a percentile id (e.g. `price_p50`). Applied in order; later entries break ties from earlier ones.",
"items": {
"description": "Single sort directive applied to the table response.",
"properties": {
"field": {
"description": "Field id to sort by — either a `group_by` field id, a histogram field id, a `metric` id, or a percentile id (e.g. `price_p50`).",
"type": "string"
},
"order": {
"default": "desc",
"description": "Sort direction. Defaults to descending.",
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
"required": [
"field"
],
"type": "object"
},
"type": "array"
},
"limit": {
"default": 50,
"description": "Maximum number of rows in the response. Defaults to 50; combine with `sort` to get the top-N. Caps the group buckets, plus one trailing `\"others\"` row when `include_others=true` and one `\"Unmapped\"` row when a group-by sets `include_missing`. The `\"Unmapped\"` row rides outside `limit` on a strict top-N (`include_others=false`); with `include_others=true` it can occupy one of the `limit` slots, so a truncated result may show `limit - 1` real buckets.",
"maximum": 200,
"minimum": 1,
"type": "integer"
},
"include_others": {
"description": "Roll rows past `limit` into a single trailing row labelled `\"others\"` so the visible rows still sum to 100% of the population. When omitted, defaults to True for a categorical `group_by` (the roll-up both signals truncation and preserves 100% coverage) and False otherwise. Set True for share / mix / distribution / long-tail questions where coverage matters. Compatible with `trend=true` — the roll-up is computed within each period (per-period top-N + others). With multi-dim `group_by` only the first column is labelled `\"others\"`; the rest go blank.",
"type": "boolean"
},
"vertical": {
"description": "Optional. The market vertical (top-level data segment) to query: `apparel` | `beauty` | `homeware`. This is NOT a product category — product categories (e.g. dresses, mascara) are a within-vertical filter found via `search_product_searches`. All three verticals are supported and return the SAME row grain: one row per product option. Homeware options bundle more SKUs per option (e.g. bed sizes) than apparel or beauty, so `sku_count` runs higher there — but no vertical returns per-SKU rows, or exposes a size/variant dimension to group or sort on. When omitted it resolves to the account's default vertical (reported as `default_vertical` by `market_data_entitlements`). Available verticals vary by account — only pass this when the user explicitly asks about a different vertical; otherwise omit it and let the account default apply. Requesting a vertical you're not entitled to returns an error; call `market_data_entitlements` to see yours.",
"enum": [
"apparel",
"beauty",
"homeware"
],
"type": "string"
},
"currency": {
"description": "Optional ISO 4217 currency code (e.g. `USD`, `EUR`, `GBP`), case-insensitive. Converts every price-denominated value in the request to this currency. Price INPUTS — any price filter (`price` / `full_price`) threshold and, in `market_data_table`, the histogram `interval` — use a FIXED 'value x 100' scale, applied regardless of the currency's ISO 4217 exponent (GBP £49.99 = `4999`, £1,600 = `160000`, interval `1000` = a £10 band; JPY ¥5,000 = `500000`, interval `1000` = a ¥10 band). All returned prices — `market_data_table` price metrics / percentiles and `market_data_options_search` per-option prices — are in whole (major) currency units, so price inputs and outputs differ by 100x. See the price-units note — in this tool's description, or `market_data_docs` `topic=\"metrics\"` (unfiltered) when it is not — incl. the ISO deviation for zero-/3-decimal currencies. When omitted it is resolved from the caller's config (else `USD`). The backend validates the code; an unknown code is rejected upstream.",
"type": "string"
}
},
"required": [
"name",
"metrics",
"start_date",
"end_date",
"filters"
]
}