PeekPanda docs
Keyword metrics explained
The full derivation of Demand, Difficulty, Ranking apps and the derived Opportunity score, with every constant and every failure mode.
Download Markdown ↓Four numbers describe a keyword: Demand, Difficulty, Ranking apps and Opportunity. Three are observed. One is derived. This page gives the arithmetic for all four.
The keyword explorer covers the screen those numbers appear on. This page covers what is behind them.
Two things to hold onto before the formulas:
Demand is not search volume. Difficulty is not a competition index bought from anyone. Both are computed from what the public store returns for free, and both are named after the observation they come from, not after the thing you wish you had.
Every number here is for the US storefront in English. That is the only storefront the keyword collection covers.
Where the raw material comes from
Two public surfaces feed all four numbers.
| Surface | What it returns | Which numbers use it |
|---|---|---|
| The store's public search endpoint | An ordered list of apps for a query, each with a name, store id, rating and lifetime rating count | Difficulty, Ranking apps |
| The store's search autocomplete | The phrases the store suggests as you type | Demand |
One collection run for one query stores one snapshot: the ordered results, and the time it was observed. Everything below is computed from stored snapshots. Reading a keyword page never calls the store.
The search endpoint returns at most 200 results for one query, so a position is a position within those 200 and nothing is known past them. See IOS_ASO_RESULT_LIMIT in packages/libraries/src/domain/app-catalog/aso-observation.contract.ts.
Difficulty
Difficulty answers one question: how established are the apps already sitting on page one?
It uses the lifetime rating counts the store hands back with the search results. An app with two million ratings has been there a long time and has a lot of gravity. An app with forty has not.
textpage_one = results where position <= 10 and ratingCount is a number
median_ratings = round(median(page_one.ratingCount))
difficulty = round( min(1, log10(median_ratings + 1) / 6) × 100 )
| Symbol | Meaning | Value or source |
|---|---|---|
position <= 10 | Page one. Ten results, matching how the store paginates. | Constant PAGE_ONE = 10 |
ratingCount | Lifetime rating count the search endpoint returns for that app | Observed, per result |
median | Ordinary median. Even sample sizes average the two middle values. | n/a |
+ 1 | Guards log10(0) when the median rated app has zero ratings | n/a |
6 | The log ceiling. log10(1,000,000) = 6, so a million ratings on the median page-one app saturates the scale at 100. | Constant DIFFICULTY_LOG_CEILING = 6 |
× 100 | Puts the score on 0–100 | n/a |
min(1, …) | Clamps. Anything past a million ratings still scores 100. | n/a |
Because the scale is logarithmic, each step of ten in the median rating count costs about 17 points:
| Median page-one rating count | Difficulty |
|---|---|
| 10 | 17 |
| 100 | 33 |
| 1,000 | 50 |
| 10,000 | 67 |
| 100,000 | 83 |
| 1,000,000 and up | 100 |
What is reported alongside it. The score never travels alone. medianRatingCount is the raw median, so you can check the arithmetic. sampleSize is how many of the ten page-one results actually carried a rating count, capped at 10. Below three, the interface flags the score as weak evidence.
When it is null. If no page-one result carried a rating count, there is no median and difficulty is null. It is not zero. Zero would mean "page one is empty of established apps", which is a completely different fact.
Where it breaks down. Rating count is a lifetime total, not a current-activity measure. A long-abandoned app that accumulated 200,000 ratings five years ago pushes difficulty up exactly as hard as a thriving one. The median is taken over at most ten values, so one atypical result moves it. And the score measures incumbency, not relevance: a page one of giants that only loosely match the phrase still reads as Hard.
Source: computeAsoDifficulty in packages/libraries/src/domain/app-catalog/aso-metrics.ts.
Demand
Demand answers a different question: how much does the store itself push this phrase?
There is no public search-volume number for the App Store. What is public is the autocomplete: type a word and the store offers an ordered list of phrases. That ordering is the store's own judgement about what people search for, and it is free to observe. Demand turns it into a 0–100 score.
Two readings go in.
Reading one, suggestion rank. Type only the first word of the keyword. If the exact keyword appears in that suggestion list, note its 1-based position.
textrank_score = max(40, 100 − (hint_rank − 1) × 10)
| Symbol | Meaning | Value or source |
|---|---|---|
hint_rank | 1-based position of the exact keyword in the first-word suggestion list. null when it is not suggested. | Observed |
100 | Score for the top suggestion | Constant HINT_RANK_TOP_SCORE = 100 |
10 | Cost of each place lower down | Constant HINT_RANK_STEP = 10 |
40 | Floor. Being suggested at all is worth 40, however far down. | Constant HINT_RANK_FLOOR = 40 |
The floor bites at rank 7 and beyond: rank 1 scores 100, rank 2 scores 90, rank 6 scores 50, and rank 7 onwards all score 40.
Reading two, variants. Type the whole keyword. Count the suggestions that are longer phrases starting with it, excluding the keyword itself.
textvariant_score = min(30, variant_count × 6)
| Symbol | Meaning | Value or source |
|---|---|---|
variant_count | Suggestions that start with the keyword and are not equal to it | Observed |
6 | Points per variant | Constant VARIANT_SCORE_EACH = 6 |
30 | Cap. Five variants max out this reading. | Constant VARIANT_SCORE_CAP = 30 |
Combining them. The two readings are not added. One of them is chosen:
textdemand = hint_rank is null ? variant_score : rank_score
If the store suggests the exact phrase for its first word, the rank score wins outright and the variant count is reported but does not change the number. If it does not, the variant score is the whole score. So a phrase the store never suggests in any form scores 0, and a phrase it suggests at the top scores 100.
Comparison is case-insensitive and whitespace-trimmed on both sides.
What is reported alongside it. hintRank (or null), variantCount, and observedAt, the date of the suggestion snapshot. Suggestion snapshots are reused for 7 days before they are refetched, so demand is usually the older half of an opportunity score. See hintFreshnessHours in packages/libraries/src/domain/app-catalog/aso-workstation.contract.ts.
When it is null. Demand is null when no suggestion snapshot has been stored for the phrase. That is different from a stored snapshot that produced a score of 0, which means the store was asked and offered nothing.
Where it breaks down. Autocomplete ordering is a proxy, not a measurement. The store may rank a suggestion highly for reasons that have nothing to do with volume, and it personalises and regionalises its suggestions. The score is also discrete and coarse: it can only take the values 0, and the multiples the two formulas produce. Do not read a 10-point gap between two keywords as a 10 percent difference in anything real.
Source: computeAsoDemand in packages/libraries/src/domain/app-catalog/aso-metrics.ts.
Ranking apps
The plainest of the four. It is the number of apps in the stored ranking for that keyword.
It is a count of what was collected, not an estimate of how many apps compete for the phrase. The store caps one response at 200 results, so 200 means "at least 200", not "exactly 200". A count well below 200 means the store returned fewer results, not that collection was truncated.
Under the count, a distribution buckets the top 20 results by order of magnitude of rating count: 0, 1–9, 10–99, 100–999, 1K–10K, 10K–100K, 100K–1M, 1M+. That shape tells you something the median cannot: whether page one is one giant among newcomers, or twenty giants.
Sources: RATING_BUCKET_APP_LIMIT and BUCKET_LABELS in apps/peekpanda/components/aso/rating-buckets.ts.
On an app's own profile the ranking list is capped at 25 apps rather than 200, and when the app sits below position 25 the list is stitched: the head of the ranking, then the app with two neighbours either side, so you always see both the leaders and its actual neighbourhood. That is capIosAsoAppsInRanking in packages/libraries/src/domain/app-catalog/aso-profile.ts.
Opportunity
Opportunity is the only derived number of the four. It is never collected, never stored as an observation, and never computed in the interface.
textopportunity = round( demand × (100 − difficulty) / 100 )
| Symbol | Meaning | Range |
|---|---|---|
demand | The demand score above | 0–100 |
difficulty | The difficulty score above | 0–100 |
100 | The scale both inputs share | Constant SCALE = 100 |
Read it as how much the store pushes this phrase, discounted by how locked down page one already is. A phrase with demand 90 behind a page one of million-rating apps scores 0. The same phrase with an unrated page one scores 90.
It is null when either input is null. Not zero. A keyword with no demand reading and a keyword with no demand are different facts, and the score refuses to collapse them. The tile reads Not collected · Needs a demand and a difficulty reading instead.
It is only as fresh as the older of its two inputs. Demand snapshots last a week; search snapshots last a day. In practice the demand reading is usually the limiting one.
Where it breaks down. It is a product of two proxies, so it inherits both sets of limits and compounds them. It says nothing about whether your app is a good match for the phrase, whether the phrase converts, or whether the traffic is worth having. Treat it as a shortlisting device, not a verdict.
Source: computeAsoOpportunity in packages/libraries/src/domain/app-catalog/aso-opportunity.ts. One function, called everywhere the score appears, so the tile, the workspace list and the keyword table can never disagree.
Worked example
A keyword where the store suggests the exact phrase second for its first word, offers three longer variants, and page one has median rating count 4,300 across nine rated results.
textrank_score = max(40, 100 − (2 − 1) × 10) = 90
variant_score = min(30, 3 × 6) = 18
demand = 90 (hint_rank is not null, so rank_score wins)
difficulty = round(min(1, log10(4301) / 6) × 100)
= round(min(1, 3.634 / 6) × 100) = 61
opportunity = round(90 × (100 − 61) / 100) = 35
That reads as Demand High, Difficulty Medium, Opportunity Worth a try.
The words next to the scores
Every 0–100 score gets a word so the number is never the only thing to read. Demand and difficulty share one ladder; opportunity, being a product of two scores, sits lower and has its own.
| Score | Demand | Difficulty | Opportunity |
|---|---|---|---|
| 67 and up | High | Hard | Wide open (50+) |
| 34 to 66 | Some | Medium | Worth a try (25–49) |
| below 34 | Low | Easy | Crowded (below 25) |
Cut points live in apps/peekpanda/components/aso/opportunity-words.ts.
What these numbers are not
- Not native App Store rank. Every position is the position observed in the public search response. It carries the label
observed_search_api_positionthrough the whole contract so it can never be quietly relabelled. - Not search volume. No public store surface exposes it, and PeekPanda does not model it.
- Not a 5–100 popularity score. The store's advertising platform publishes one. PeekPanda is not connected to it, so that number does not exist here.
- Not multi-country. One storefront, US, in English.