# How estimates are computed

The full derivation of the install and revenue models, every constant, every calibration anchor, and the exact conditions under which each returns nothing.

PeekPanda publishes two estimates for an iOS app: monthly installs and monthly revenue. Neither is a measured number.

Apple does not publish download counts. Apple does not publish revenue. No public store surface exposes either. So both numbers are **models** built on top of things that *are* public, and this page shows you the whole model so you can decide how much of it you believe.

> An estimate here is a modelled number derived from public observations. It is never a measured download count and never reported revenue. Every estimate is labelled as an estimate wherever it appears, and it carries the name of the method that produced it.

Two methods do all the work:

| Method | Produces | Built on |
| --- | --- | --- |
| `ratings_delta_proxy` | Monthly installs, lifetime installs | Change in the app's public lifetime rating count over time |
| `grossing_rank_power_law` | Monthly revenue | The app's position on a public grossing chart |

Both live in `packages/libraries/src/domain/app-catalog/store-rating-tape.contract.ts`. Both names travel with the number in the API response, in the fields `installMethod` and `revenueMethod`, so a consumer can never receive an estimate without also receiving the name of how it was made.

## Monthly installs: `ratings_delta_proxy`

### The idea

Apple publishes one number that moves with usage: the lifetime rating count on the store listing. PeekPanda records that number once per app per day. The sequence of those daily records is called the app's **rating tape**.

If an app gained 300 ratings in 10 days, it gained 30 a day. Multiply by 30 for a month. Then multiply by the number of installs it takes to produce one rating.

### The formula

```text
days            = (latest.observedAt − previous.observedAt) / 86,400,000
delta           = max(0, latest.ratingCount − previous.ratingCount)
monthly_installs = round( (delta / days) × 30 × 50 )
```

| Symbol | Meaning | Value or source |
| --- | --- | --- |
| `latest.ratingCount` | Lifetime rating count at the most recent tape point | Observed from the store's public lookup |
| `previous.ratingCount` | Lifetime rating count at the tape point before it | Observed |
| `observedAt` | Timestamp of a tape point | Observed |
| `86,400,000` | Milliseconds in a day, converting the elapsed gap to days | n/a |
| `30` | Days in the reported month | n/a |
| `50` | Installs assumed per rating left | Constant `IOS_RATING_INSTALL_MULTIPLIER = 50` |
| `max(0, …)` | Rating counts can fall when the store prunes reviews. A fall is reported as zero growth, never as negative installs. | n/a |
| `round` | The result is an integer | n/a |

`50` is the single assumption carrying the most weight in this page. It is a fixed constant in code, not tuned per app, per category or per country. If you think the true ratio for the app you are looking at is 100 installs per rating rather than 50, the honest correction is to double the number PeekPanda shows you. The model is deliberately arranged so you can do exactly that.

### Preconditions

**Two tape points are required, in the same country.** With one tape point, `monthlyInstalls` is `null`. Not zero. One reading is a level, and you cannot get a rate of change out of a level.

**The elapsed gap must be positive.** If two points carry the same timestamp, or the later point is not later, the result is `null` rather than a division by zero.

The tape stores one point per app per UTC day for the **US** storefront. So in practice a brand-new app in the catalogue shows `null` installs on its first day and a number from its second.

### Lifetime installs

A separate, blunter number on the same multiplier:

```text
lifetime_installs = latest.ratingCount × 50
```

This is a proxy too, and a cruder one: it assumes the 50:1 ratio has held over the app's entire life, across every version and every country, which it certainly has not. Read it as an order of magnitude.

### Where this model breaks down

- **The 50:1 ratio is not universal.** Apps that prompt aggressively for ratings earn far more ratings per install than apps that never prompt. A rating-prompt A/B test will move this estimate without a single extra install.
- **Rating prompts changed over time.** Apple's in-app review API changed how often apps can ask. Historical tape and current tape are not on the same footing.
- **The tape is US-only, the ratings are not.** The lifetime rating count on the US listing includes ratings from other storefronts. The delta therefore reflects global rating activity, while the estimate is presented against a US tape. This inflates the number for apps with large non-US audiences.
- **Short windows are noisy.** Two consecutive days with a review-prompt spike between them will annualise that spike into a monthly figure.
- **Pruned reviews clip to zero.** A genuine decline is unrepresentable, so a month in which the store removed a batch of reviews reads as a quiet month rather than a negative one.

### Where to find it

`estimateMonthlyInstallsFromRatingTape` and `lifetimeInstallsFromRatingCount` in `packages/libraries/src/domain/app-catalog/store-rating-tape.contract.ts`. Persisted and served by `apps/backend/src/modules/app-catalog/enrichment/rating-tape.repository.ts`.

## Monthly revenue: `grossing_rank_power_law`

### The idea

Apple publishes an ordered grossing chart per country and per category. Position 1 out-earns position 2, which out-earns position 3, and the drop-off follows something close to a power law: steep at the top, flattening out down the tail.

If you know what the number-one app actually earns, and you assume a shape for the curve, you can read a dollar figure off any position on that chart. That is the entire model.

### The formula

```text
monthly_revenue = country_scale × top1_usd × rank^(−0.85)
```

| Symbol | Meaning | Value or source |
| --- | --- | --- |
| `rank` | The app's observed position on a public grossing chart | Observed. Must be 1 or greater, otherwise the result is `null`. |
| `0.85` | The power-law exponent. Larger means a steeper drop from #1. | Constant `GROSSING_REVENUE_ALPHA = 0.85` |
| `top1_usd` | Assumed monthly USD of the #1 app on that chart | Calibration anchor, see below |
| `country_scale` | Multiplier for a non-US storefront relative to US | See table below |

The result is rounded to whole dollars when it is stored.

### The calibration anchors

`top1_usd` is chosen by the category of the chart the rank came from.

| Chart | `top1_usd` | Anchor app | Constant |
| --- | --- | --- | --- |
| Overall (`all`) | $235,000,000 | ChatGPT, while it held US overall grossing #1 | `US_OVERALL_GROSSING_TOP1_USD` |
| Games (`6014`) | $61,000,000 | MONOPOLY GO!, US Games grossing #1 | `US_GAMES_GROSSING_TOP1_USD` |
| Business (`6000`) | $48,000,000 | LinkedIn, US Business grossing #1 | `US_BUSINESS_GROSSING_TOP1_USD` |
| Food & Drink (`6023`) | $2,000,000 | ReciMe, US Food & Drink grossing #1 | `US_FOOD_DRINK_GROSSING_TOP1_USD` |
| Every other category | $3,000,000 | No anchor. This is the fallback. | `DEFAULT_US_CATEGORY_GROSSING_TOP1_USD` |

All four anchors were read on **6 September 2026** from public AppRank iOS pages.

Two things about that, stated plainly:

**AppRank is a calibration reference, not a data source.** PeekPanda does not call AppRank, does not have an account there, and does not import anything from it. Four monthly-revenue figures were read off four public pages once, hard-coded as constants, and that is the whole of the relationship. If AppRank's own numbers are wrong, PeekPanda's are wrong in the same direction.

**Most categories have no anchor at all.** Only four charts are calibrated. Everything else falls back to a flat $3,000,000 for the #1 app. A Health & Fitness estimate and a Photo & Video estimate are both built on that same unvalidated placeholder. Treat them as far weaker than the four anchored charts.

### Country scaling

| Country | Scale | Meaning |
| --- | --- | --- |
| `us` | 1 | The reference market |
| `gb` | 0.12 | A UK #1 is assumed to earn 12% of a US #1 |
| `de` | 0.1 | 10% |
| `fr` | 0.08 | 8% |
| `br` | 0.05 | 5% |
| `be` | 0.02 | 2% |

Any country outside this list returns `null` rather than being guessed at. The scales are flat per country and are not varied by category. They are estimates of relative market size, not measurements of it.

### Which rank gets used

An app can sit on more than one grossing chart on the same day: the overall chart and one or more category charts. The model picks one.

1. If the app is on the **overall** grossing chart, that rank is used, with the $235M overall anchor.
2. Otherwise the **best** available rank across the category charts is used, meaning the numerically lowest one, with that category's anchor.

That is `preferRank` in `apps/backend/src/modules/app-catalog/enrichment/rating-tape.repository.ts`.

### Preconditions

**The app must be on a grossing chart.** No chart position means no revenue estimate, and the field is `null`. It is not zero.

This is the model's largest coverage limitation and it is worth being blunt about it: charts are a top slice of the store. The overwhelming majority of apps in the catalogue never appear on any grossing chart, so the overwhelming majority of apps have `null` revenue. That is not a bug in the collection. There is simply no public signal to model from.

**The country must have a scale.** Ranks from storefronts outside the six covered countries produce `null`.

**Rank must be 1 or greater.** A missing or nonsensical rank produces `null`.

### Where this model breaks down

- **The exponent is a single assumed shape.** `0.85` is one number applied to every chart in every country. Real revenue curves differ sharply between Games, where whales concentrate spend at the top, and utility categories.
- **Four anchors, read on one day.** They were true of four apps on 6 September 2026. The #1 app on any chart changes, and so does what it earns. The constants do not move on their own.
- **The uncalibrated fallback dominates by count.** Most categories use $3,000,000 with no anchor behind it.
- **A chart position is not revenue.** Two apps at the same grossing rank in different weeks are not earning the same amount; the chart is relative, and the whole chart moves.
- **Country scaling is coarse.** One multiplier per country, applied identically to a game and a B2B tool.

### Where to find it

`estimateMonthlyRevenueFromGrossingRank` in `packages/libraries/src/domain/app-catalog/store-rating-tape.contract.ts`.

## What an estimate record actually contains

Every estimate is served as an observation with its own date and its own method labels, not as a bare number.

| Field | Meaning |
| --- | --- |
| `observedAt` | The timestamp of the tape point this estimate was computed at |
| `country` | Two-letter storefront |
| `ratingCount` | The observed lifetime rating count at that point |
| `lifetimeInstalls` | `ratingCount × 50`, or `null` |
| `monthlyInstalls` | The install model, or `null` |
| `previousMonthlyInstalls` | The same model one tape point earlier, so you can see the direction |
| `estimatedMonthlyRevenue` | The revenue model, or `null` |
| `previousMonthlyRevenue` | The same model at the previous chart day |
| `currency` | Always `USD` |
| `installMethod` | Always `ratings_delta_proxy` |
| `revenueMethod` | Always `grossing_rank_power_law` |
| `source` | Always `peekpanda`, marking the row as PeekPanda's own derived output |

The `source: peekpanda` marker matters. It separates a number PeekPanda derived from an observation PeekPanda merely recorded. Nothing in an estimate row is a store fact.

**Comparisons are made against the previous observation, not against a general baseline.** `previousMonthlyInstalls` runs the same install model over the two tape points before the latest one. `previousMonthlyRevenue` runs the same revenue model over the preceding chart observation. When the history is too short for either, the previous value is `null` and no movement is drawn.

Contract: `peekPandaEstimateObservationSchema` in `packages/libraries/src/domain/app-catalog/store-rating-tape.contract.ts`.

## Null is a result

Both models return `null` rather than a fallback whenever their preconditions are unmet:

| Situation | Result |
| --- | --- |
| One tape day only | `monthlyInstalls` is `null` |
| Two tape points with no elapsed time | `monthlyInstalls` is `null` |
| App not on any grossing chart | `estimatedMonthlyRevenue` is `null` |
| Storefront outside the six covered countries | `estimatedMonthlyRevenue` is `null` |
| No rating count recorded | `lifetimeInstalls` is `null` |

A `null` means PeekPanda does not know. A zero would mean PeekPanda knows the answer is zero. They are never interchanged, and no fallback value is ever substituted for a missing one.

## Reading these numbers well

Use them for **shape and relative order**, not for absolute dollars. The models are consistent, which makes app-to-app comparison inside the same category and country meaningfully informative. They are not accurate in the sense of matching a company's real books, and a category using the uncalibrated $3M fallback should not be compared against an anchored one at all.

If you disagree with a number, you can now say which step you disagree with: the 50:1 install ratio, the 0.85 exponent, one of the four anchors, the $3M fallback, or a country scale. Each is one constant in one file.

## Related

- [Where the data comes from](/docs/where-the-data-comes-from)
- [Freshness and source dates](/docs/freshness-and-source-dates)
- [Honest unknowns](/docs/honest-unknowns)
- [Reading the evidence](/docs/reading-the-evidence)
