Methodology

How we calculate

The authoritative reference for every financial metric NavViewTZ calculates and displays — the formulas we use, why we chose them, and the edge cases we handle.

NavViewTZ — Metrics Methodology

Version: 1.3 Date: August 2026 Status: Living Document

This document is the authoritative reference for every financial metric NavViewTZ calculates and displays. It records the formula used, the rationale for choosing it over alternatives, and the known edge cases. It serves three audiences:

  1. Developers implementing or modifying enrich.performanceMetrics.js and interestRates.ts — so calculation logic is never changed without understanding why it was designed that way.
  2. The /methodology frontend page — this document is the source of truth for what users read when they ask "how is this calculated?"
  3. Future contributors — so design decisions made during early development are not accidentally undone.

Table of Contents

  1. Foundational Principles
  2. The Two-Timestamp Rule
  3. The Two-Context Rule
  4. Consensus Pricing Date
  5. Staleness: Reporting Lag vs Genuine Staleness
  6. Return Calculations
  7. Effective Annual Rate (EAR)
  8. Data Quality & Confidence Levels
  9. KPI Definitions — Tier 1 (Always Visible)
  10. KPI Definitions — Tier 2 (Comparative & Analysis)
  11. KPI Definitions — Tier 3 (Fund Characteristics)
  12. Anti-Patterns — What We Deliberately Do Not Do
  13. Changelog

1. Foundational Principles

Every metric on NavViewTZ is designed around three non-negotiable principles:

1.1 Null is honest. Zero is not.

When there is insufficient history to calculate a metric, we display (null). We never display 0% when the real answer is "we don't know." A zero return and an unknown return are completely different things. Conflating them misleads users into thinking a fund has no performance history when in fact the data simply does not exist yet.

1.2 Comparisons require a shared anchor.

A return figure displayed in isolation (e.g., on a fund's own analysis page) can be anchored to that fund's own data. But any return figure used in a comparison across funds must be anchored to the same calendar reference point. Otherwise you are comparing different windows of time and the comparison is mathematically dishonest, even if each individual number is internally correct.

1.3 Show your working.

Every displayed metric must be traceable to its inputs. The exact start date, end date, number of days, and NAV values used should be accessible via tooltip. A user who wants to verify a number should always be able to do so. This is what distinguishes a trustworthy data platform from a black box.


2. The Two-Timestamp Rule

Every row in the NavViewTZ pipeline carries two date fields that serve completely different purposes. Conflating them is the single most common source of calculation errors.

FieldMeaningUsed for
dateThe NAV effective date — the day the fund's valuation is forAll financial calculations
collected_atThe timestamp when our scraper retrieved the dataPipeline health monitoring only

Why this matters in practice

A fund manager may publish April 12's NAV results on April 14. Our scraper picks it up on April 14 and writes collected_at = April 14, date = April 12. If you look at collected_at to judge data freshness, this fund appears current. If you look at date, it is 2 days behind. The date field is the truth. The financial valuation is for April 12 regardless of when we collected it.

Rule: collected_at is never used in any financial calculation. All return calculations, comparisons, and freshness indicators use the date field only.


3. The Two-Context Rule

The same return metric requires different calculation anchoring depending on where it is displayed. We distinguish two contexts explicitly.

Context A — Fund-Centric (individual fund analysis page)

Question being answered: "What has this specific fund done?"

  • End anchor: the fund's own most recent date value.
  • Period: exactly N calendar days before that end anchor.
  • Rationale: maximises accuracy for that fund. There is no cross-fund comparison happening, so a shared reference point is unnecessary and would only introduce approximation error.
end_date   = fund's latest NAV date
start_date = end_date − N calendar days
return     = (NAV_end / NAV_start) − 1

Display: "1-month return as of April 12" — the as-of date is always shown.

Context B — Comparative (home table, comparator, momentum pulse, rankings)

Question being answered: "How does this fund compare to others right now?"

  • End anchor: the consensus pricing date (see Section 4) — shared across all funds.
  • A fund that does not have data on the consensus date uses its most recent NAV on or before that date, and is flagged with a staleness badge.
  • Rationale: if Fund A's "1-month return" covers April 14 → March 15, and Fund B's covers April 10 → March 11, sorting them side by side is comparing different 30-day windows. Markets can move in 4 days. The comparison is misleading.
end_date   = fund's latest NAV date on or before consensus_date
start_date = consensus_date − N calendar days
            (then find fund's nearest NAV date to that start)
return     = (NAV_end / NAV_start) − 1

Display: Table header shows "Returns as of [consensus_date]". Each stale fund cell shows a badge indicating how many days behind it is.

3.3 Field naming convention

Both contexts are computed on every enrichment run and stored in the same document. The UI selects which set to display based on where the number appears.

ContextField prefixExampleMetadata field
Consensus (Comparative)bare nameret_1m_earmetrics_meta
Actual (Fund-Centric)_act suffixret_1m_act_earmetrics_meta_act

Simple (non-annualized) variants follow the same pattern: ret_1m vs ret_1m_act.


4. Consensus Pricing Date

The consensus pricing date is the shared end anchor used for all comparative calculations. It is recomputed every time the enrichment pipeline runs.

Algorithm

1. Read the `date` field (NAV effective date, not collected_at) for every
   fund from _latestFundData.

2. For each candidate date D (starting from the most recent, working backwards):
     count = number of funds whose latest `date` falls within [D − 2, D]
     if count / total_funds >= 0.70:
       consensus_date = D
       break

3. Store consensus_date in _enrichedFundData as a single shared metadata value.

Why 70% and ±2 days?

  • 70%: A simple majority is not enough when one outlier is a routinely slow publisher. 70% means the consensus reflects the bulk of the market. If fewer than 70% of funds agree on any date, the market itself is fragmented and the consensus date moves back until it finds agreement.
  • ±2 days: Tanzania's CIS fund managers publish on varying schedules. Some publish same-day, others on T+1 or T+2. A ±2 day window acknowledges this without being so wide that a 5-day-stale fund is treated as current.

What the consensus date is NOT

The consensus date is not "today." It is not "the date most funds were collected." It is the most recent NAV effective date that a meaningful majority of the market has actually priced to. On a normal business day this will typically be T−1 or T−2 because fund managers do not always post same-day NAVs.


5. Staleness: Reporting Lag vs Genuine Staleness

A fund whose date is behind the consensus date can be in one of two states. These are communicated differently to users.

5.1 Structural Reporting Lag (expected, normal)

Some fund managers publish on a consistent T+1 or T+2 schedule. This is their policy, not a data quality problem. The fundsOverview metadata sheet contains a reporting_lag field (e.g., "T+1", "T+2") for each fund.

A fund is in structural lag if:

days_behind_consensus <= reporting_lag_days

UI treatment: Small informational badge. "Data as of [date] — this fund reports on a T+2 schedule." No alarm. Just context.

5.2 Genuine Staleness (unexpected, investigate)

A fund is genuinely stale if its date is behind the consensus date by more than its expected reporting lag:

days_behind_consensus > reporting_lag_days

UI treatment: Yellow or red indicator (depending on severity). "Last update [N] days ago." This is the signal surfaced in the admin QA report today — it should also be visible to end users on the fund card.

Staleness thresholds

Days behind consensusIndicatorLabel
0 − lag● GreenFresh
lag+1 to 5● Yellow"X days ago"
> 5● Red"Data may be outdated — last: [date]"

6. Return Calculations

6.1 Reference date lookup — date-based, not index-based

Do not look up historical NAVs by row offset (e.g., "5 rows back = 1 week"). This breaks silently whenever a fund skips a day due to weekends, holidays, or a data outage.

Do look up by calendar date with a tolerance window:

findNAV(targetDate, rows, toleranceDays = 3):
  search rows for the most recent date d where:
    d <= targetDate AND (targetDate − d) <= toleranceDays
  if found: return NAV at d, record actual_date = d
  if not found: return null (INSUFFICIENT)

The tolerance window prefers dates on or before the target (not after) because we never want to use a future NAV to calculate a past-anchored return.

6.2 Period definitions

LabelTarget offsetMin history required
1Dend_anchor − 1 calendar day2 data points
1Wend_anchor − 7 calendar days5 data points
1Mend_anchor − 30 calendar days20 data points
3Mend_anchor − 91 calendar days60 data points
6Mend_anchor − 182 calendar days120 data points
YTDDec 31 of previous calendar year20 data points
1Yend_anchor − 365 calendar days300 data points

end_anchor is consensus_date for the Comparative context and the fund's own latest NAV date for the Fund-Centric context. The offsets and tolerance rules are identical in both cases.

The minimum history requirements prevent displaying a "1Y return" calculated from only 2 months of data. In such cases the metric is shown as (INSUFFICIENT).

6.3 Simple return formula

simple_return = (NAV_end / NAV_start) − 1

Simple returns are used as intermediate values only. They are not displayed to users directly (see Section 7 on EAR).


7. Effective Annual Rate (EAR)

Why EAR, not simple returns

Tanzania's CIS funds are primarily money market and bond funds. Their NAV grows slowly and steadily — a daily increment of roughly 0.03%. A raw 1-month return of 0.95% is technically correct but provides little intuition to an investor deciding where to put their savings. They think in annual terms: "what will my money earn this year?"

EAR converts any period's return into its annualized equivalent, enabling apples-to-apples comparison regardless of the period length.

Formula

EAR = (1 + simple_return) ^ (365 / actual_days) − 1

Where actual_days is the real number of calendar days between the start and end NAV dates actually used (not the target period length). This is important: if the nearest available date to "30 days ago" is 28 days ago, we use 28 as the denominator, not 30.

Worked example

Fund A: NAV on April 14 = 116.45
        NAV on March 15 = 115.35  (30 days prior, exact match)

simple_return = (116.45 / 115.35) − 1 = 0.00954 = 0.954%
EAR           = (1.00954) ^ (365/30) − 1 = 0.1206 = 12.06%

Display: "1M: 12.1% p.a."
Tooltip: "NAV 116.45 (Apr 14) vs 115.35 (Mar 15) · 30 days · annualized"

EAR for short periods (1D, 1W)

EAR amplifies short-period noise significantly. A single day's NAV movement annualized can produce eye-catching numbers (positive or negative) that are not meaningful. Display rules:

  • 1D: Show EAR but label clearly as "daily annualized — high variance." Do not use in rankings.
  • 1W: Show EAR. Use with caution in rankings.
  • 1M and longer: Primary metric. Use in all rankings and comparisons.

8. Data Quality & Confidence Levels

Every calculated metric carries a confidence level that determines how it is displayed.

LevelConditionDisplay treatment
EXACTReference date found within ±1 dayFull value, no badge
APPROXIMATEReference date found within ±2–3 daysValue shown with ~ prefix
INSUFFICIENTNot enough history, or no date found in tolerance windowShown as

The confidence level is stored in the enriched data schema alongside the value so the frontend can render accordingly without needing to re-derive it.

Schema for each metric (backend)

{
  "value": 0.1206,
  "simple": 0.00954,
  "end_date": "2026-04-14",
  "start_date": "2026-03-15",
  "actual_days": 30,
  "confidence": "EXACT",
  "days_behind_consensus": 0
}

The frontend uses value for display and the metadata fields for tooltips.


9. KPI Definitions — Tier 1 (Always Visible)

These appear on every fund card and the main dashboard table. They must be correct before the platform is promoted to a wider audience.

9.1 Effective Annual Return — 1M (primary rate metric)

What it is: The annualized return over the most recent 30-day period.

Why 1M is primary: For money market and bond funds, daily fluctuation is noise. Monthly performance is the shortest meaningful signal for these asset classes.

Formula: EAR with 30-day lookback. See Section 7.

Display label: "1M p.a." with tooltip showing exact dates and NAVs used.


9.2 YTD Return

What it is: EAR from December 31 of the previous calendar year to the consensus date.

Why YTD: Investors naturally think in calendar years. YTD is the most common framing in financial journalism and personal finance conversations.

Start anchor: Dec 31 of the previous year. If date is on or before Jan 5, use the fund's first available date of the current year to avoid a zero-day period.

Formula: EAR = (1 + simple_ytd) ^ (365 / days_since_jan1) − 1


9.3 1-Year Return

What it is: EAR over the trailing 365-day period.

Why 1Y: Primary track record metric. The only period long enough to capture a full market cycle for these fund types.

Minimum history: 300 data points. Below this, show .

Formula: EAR with 365-day lookback. See Section 7.


9.3a Market-level Average — NAV-weighted

What it is: The home dashboard's Avg NAV Return (1Y) KPI — the single-figure return for "the market" as a whole.

Why NAV-weighted: A simple arithmetic mean across funds treats a small TZS 5 bn fund the same as a TZS 500 bn one. The headline figure is meant to describe the return experienced by the average shilling invested in the market, not the average fund, so each fund's return is weighted by its NAV.

Formula:

avg_nav_return_1y = Σ (nav_total_i × ret_1y_i) / Σ nav_total_i

over funds where both nav_total > 0 and ret_1y is present.

Exclusions:

  1. Constant-NAV funds (e.g. sanlam.pesammf, sanlam.usdFixedIncome) are excluded. These funds keep nav_per_unit fixed at 1 and credit returns as additional units, so a NAV-derived ret_1y is ≈0% even though the investor is earning yield. Including them would drag the aggregate toward 0 and misrepresent the market. The list of constant-NAV funds lives in src/utils/fundClassification.ts and mirrors the SANLAM_FUNDS declaration in functions/src/config.ts. The KPI caption shows the exclusion count.
  2. Funds missing nav_total, with nav_total ≤ 0, or missing ret_1y are dropped from both numerator and denominator.

If no fund qualifies, render rather than 0% or NaN% (see Section 12 — "Showing 0% when data is missing").

Note: Per-fund tables (home list, comparator, weekly report) still show each fund's own ret_1y. Only market-level aggregates are NAV-weighted; this distinction should be reflected in any caption that says "average".

Follow-up: A proper total return field that captures yield/distributions for constant-NAV funds (so they can be re-included on equal footing) is tracked as a follow-up to issue #34.


9.4 Data Freshness Indicator

What it is: A visual signal showing how current the fund's NAV data is, relative to the consensus pricing date.

Why it's Tier 1: A return figure is only as trustworthy as its underlying data. An investor comparing funds deserves to know that one fund's "today's return" is actually based on data from 5 days ago. Without this signal, the platform can mislead users without technically lying.

Calculation:

days_behind = consensus_date − fund's latest NAV date

Visual mapping: See Section 5 staleness thresholds.

Critically: This uses the date field (NAV effective date), never collected_at (scrape timestamp). A fund scraped today but with a date of 4 days ago is a 4-day-stale fund — not a current one.


10. KPI Definitions — Tier 2 (Comparative & Analysis)

These appear in the comparison matrix, analysis view, and momentum pulse. They require more history and are suited for the more engaged investor personas.

10.0 Sparkline (7-day)

What it is: A small array of NAV-per-unit values used to render the mini chart on each fund card.

Period: From the start of the fund's Actual 1-week period (w1.start_date from the Fund-Centric metrics) through the fund's latest NAV date, oldest-first. This ensures the sparkline always covers the same calendar window as the displayed 1W return.

Fallback: If temporal filtering yields fewer than 2 points (e.g. a data gap in that window), the last 2 available NAV rows are used so the chart is never empty or single-point.

Note: This is anchored by date, not by row count. An index-based "last 7 rows" approach is an anti-pattern (see Section 12) because gaps in publishing would silently shift the window.


10.1 3-Month Return

What it is: EAR over the trailing 91-day period.

Why add 3M: The quarter is a natural reporting unit for institutional investors and financial journalists. It also captures seasonal patterns that 1M misses and is less noisy than 1W.

Formula: EAR with 91-day lookback.

Currently missing from the enrichment schema. To be added.


10.2 Volatility (30-day)

What it is: The standard deviation of daily NAV-per-unit percentage changes over the trailing 30 days.

Why it matters: Two funds with identical 1Y returns are not identical products if one grows smoothly and the other lurches up and down. Volatility is the simplest measure of this difference. For money market funds, investors expect near-zero volatility — any significant reading is a warning signal.

Formula:

daily_returns = [(NAV[i] / NAV[i-1]) − 1 for each consecutive pair in last 30 days]
volatility    = standard_deviation(daily_returns) × sqrt(252)  [annualized]

Note: We annualize using 252 (approximate trading days per year) rather than 365 because volatility is a function of trading activity, not calendar time.

Display: As a percentage. "Low / Medium / High" label for non-technical users, with the raw % in the tooltip.


10.3 Maximum Drawdown (all-time)

What it is: The largest peak-to-trough decline in NAV per unit across the fund's entire history.

Why it matters: It answers the question: "What is the worst this fund has ever done to an investor's money?" For money market funds this should be approximately zero. Any material drawdown is a significant finding.

Formula:

peak = running maximum of NAV_per_unit up to each date
drawdown[i] = (NAV[i] − peak[i]) / peak[i]
max_drawdown = minimum(drawdown)   [most negative value]

Display: As a negative percentage. A value of −0.3% means the fund once fell 0.3% from its high-water mark.


10.4 Consistency Score

What it is: The percentage of calendar months in which the fund posted a positive return, calculated over its full available history.

Why it matters: A fund with an 11% 1Y return achieved via 11 good months and 1 flat month is different from one that achieved 11% via 6 months of 3% gains and 6 months of −2.5% losses. Consistency captures reliability in a single number that non-technical users understand intuitively.

Formula:

monthly_returns = return for each complete calendar month in fund history
consistency     = count(monthly_returns > 0) / count(monthly_returns)

Minimum history: 12 complete calendar months.


10.5 AUM Trend (3-Month)

What it is: The percentage change in nav_total (total assets under management) over the trailing 91 days.

Why it matters: AUM growth signals investor confidence. AUM decline signals redemptions outpacing new investment. For a relatively small, developing market, consistent AUM growth is a meaningful indicator of a fund's health and reputation. This is not a return metric — it is a fund health metric.

Formula: Simple percentage change on nav_total, not annualized.

aum_trend_3m = (nav_total_today / nav_total_91_days_ago) − 1

Caveat: AUM can grow due to new investor inflows OR due to NAV appreciation. The tooltip should note this. A fund with flat performance but strong AUM growth is attracting new money. A fund with strong performance but flat AUM may not be well known.


10.6 AUM Change & Units Change

What they are: Simple (non-annualized) % change in nav_total and units_outstanding respectively, computed for the full set of periods (1D/1W/1M/3M/YTD/1Y) using the exact same date-lookup-with-tolerance engine and EXACT/APPROXIMATE/STALE/INSUFFICIENT confidence ladder as ret_* (§6, §8) — just applied to nav_total/units_outstanding instead of nav_per_unit. Both contexts exist, same convention as returns: bare field = Consensus (aum_chg_1m), _act suffix = Fund-Centric (aum_chg_1m_act).

Why these are the foundation, not the estimate. Both are computed directly from published data — no assumptions, no modelling. If units go from 1.00bn to 1.08bn, we can say with full confidence "units outstanding +8.0%." That is a materially stronger claim than "investors put TZS 8bn into this fund," which requires inferring investor behaviour from a raw count. Prefer leading with units_chg_* in any UI that surfaces this layer — it requires the least modelling of anything in this hierarchy.

Formula:

aum_chg   = (nav_total_end / nav_total_start) − 1
units_chg = (units_outstanding_end / units_outstanding_start) − 1

No EAR variant — annualizing an AUM or units swing amplifies noise far more than it clarifies (the same reasoning that makes 1D/1W EAR unreliable for returns, §7, applies more strongly here since AUM/units moves are lumpier than NAV moves).

Relationship to aum_trend_3m (§10.5): identical formula and window — aum_chg_3m supersedes aum_trend_3m. The older field is kept for backward compatibility; new consumers should read aum_chg_3m.


10.7 Estimated Net Flow & Estimated Organic Growth Rate

What they are — the first estimated layer. Everything above this line is either published or a direct calculation on published numbers. Estimated Net Flow is the first figure that requires an assumption: it approximates net investor subscriptions minus redemptions by asking "what would this fund's AUM be today if performance were the only thing that moved it — and how far off is the actual number from that?" This is the same conceptual approach Morningstar and ICI use to estimate mutual-fund flows (change in assets, backing out performance).

Formula:

expected_aum = units_outstanding_start × nav_per_unit_end
net_flow     = nav_total_end − expected_aum

expected_aum answers "what would AUM be today if the only thing that happened was the NAV moving, holding unit count fixed at its starting value?" The gap between that and the fund's actual nav_total is attributed to flow.

Estimated Organic Growth Rate expresses that same flow as a % of starting assets — the Morningstar-style definition:

organic_growth_rate = net_flow / nav_total_start

This is not the NAV-per-unit return — that would just be ret_3m again under a different name (an earlier version of this doc made exactly that mistake; see the v1.3 changelog entry). Organic growth here specifically means growth from flows, expressed relative to the fund's size, which is why a large fund's small absolute inflow and a small fund's large relative inflow become directly comparable:

Fund A: TZS 1 trillion AUM, +10bn estimated inflow → organic growth +1% Fund B: TZS 50bn AUM, +5bn estimated inflow → organic growth +10%

Fund B is attracting capital dramatically faster relative to its size, even though its absolute inflow is smaller.

Both fields are computed for all six periods, both contexts (bare = Consensus, _act = Fund-Centric), mirroring §10.6.

Display treatment — label unmistakably as an estimate. Never present these as manager-reported figures:

Estimated Net Flow: + TZS 4.7bn NavViewTZ estimate based on changes in fund NAV, NAV/unit and units outstanding.

Tooltip: "Estimated net subscriptions less redemptions. This is not a manager-reported flow figure and may differ from actual fund cash flows due to distributions, valuation timing and other fund events."

Caveat — this is an estimate, not a ledger figure. We do not have access to fund managers' actual subscription/redemption records. The decomposition assumes any change in units_outstanding not explained by the NAV-per-unit formula above is net flow; it cannot distinguish, for example, a large single redemption from many small ones, or account for distributions/ dividends paid in units. Treat it as directional, not exact.

Null conditions (both fields): non-nav_return performance basis; either AUM-change or units-change boundary for that period is INSUFFICIENT; or the two boundaries resolve to different start/end dates (guards against silently mixing mismatched snapshots).


10.8 Flow Share of AUM Growth — INTERNAL ONLY, not a public metric

What it is: net_flow / ΔAUM — what fraction of the period's actual AUM change is attributable to flow rather than performance.

Why this is not published, even though it looks like the natural next ratio. It's an estimate (net_flow) divided by a second, potentially noisy and near-zero quantity (ΔAUM). Small changes in either number can produce dramatic, easily-misread percentages:

AUM starts at 100bn. Performance adds +5bn. Investors withdraw −4bn. Ending AUM = 101bn.

ΔAUM = +1bn. Estimated net flow = −4bn. Flow share = −4 / 1 = −400%

Mathematically correct, but an ordinary reader will not parse "−400% flow share" as "outflows of 4bn against a 1bn net AUM increase." And when ΔAUM is close to zero, the ratio explodes toward ±∞ — see the worked example above, where a fund with zero AUM change can still have a large, perfectly well-defined net flow.

Where it's stored: computed and written to /funds/{id} and /funds/{id}/enriched/{date} on every enrichment run, for a future internal analytics view. Deliberately excluded from publicExports.ts and briefing.ts — never appears in the public API, the AI briefing payload, or any MCP tool response. If a future internal dashboard needs it, read it directly from Firestore.

Public hierarchy, for reference (most trusted → most inferred):

MetricNaturePublic?
AUM (nav_total)Published
AUM Δ (aum_chg_*)Calculated from published data
Units Δ (units_chg_*)Calculated from published data✅ — lead with this
Estimated Net FlowEstimate, clearly labeled
Estimated Organic Growth RateEstimate, clearly labeled
Flow Share of AUM GrowthEstimate ÷ noisy estimate❌ internal only

11. KPI Definitions — Tier 3 (Fund Characteristics)

These are primarily static metadata sourced from fundsOverview. They describe what a fund is, not how it has performed. Critical for the first-time investor persona.

FieldDescriptionNotes
minimum_investmentMinimum initial purchase in TZSStatic. Verify with fund manager periodically.
annual_management_feeAnnual fee as % of AUMDirect drag on net returns. Display as "X% p.a."
redemption_periodHow quickly you can withdrawT+1, T+3, T+5 etc. Critical for liquidity-sensitive investors.
inception_dateDate the fund launchedUsed to calculate fund age. Longer track records warrant more confidence.
reporting_lagFund manager's typical publishing delayT+0, T+1, T+2. Used to distinguish structural lag from genuine staleness.
cmsa_registrationCMSA registration numberTrust signal. Link to CMSA registry where possible.
fund_typeMoney Market / Bond / Balanced / EquityDetermines peer group for comparative rankings.
currencyTZS (all current funds)Retained for future multi-currency support.
logo_urlWeb-usable image URL for the fund/manager logoOptional. Not to be confused with logo_key, the Android-only drawable resource name — the web app currently resolves logos by filename convention instead.
fund_page_linkLink to the fund's own page (distinct from the manager's homepage)Optional; only set where a genuinely distinct fund page exists — falls back to manager_link otherwise.
offer_doc_linkLink to the fund's prospectus / offer documentOptional; trust and verification signal, sourced from official manager URLs — omitted where no working public document link is known (see docs/fund-intelligence/manifest.json).

12. Anti-Patterns — What We Deliberately Do Not Do

These are approaches that were considered and rejected. Documenting them prevents future contributors from re-introducing them.

❌ Index-based historical lookups

// WRONG — breaks on any gap day
const nav1w = rows[rows.length - 6][3];

// RIGHT — date-based with tolerance window
const nav1w = findNAV(endDate - 7 days, rows, tolerance = 3);

Index-based lookups assume every consecutive row represents exactly one trading day. In practice, funds skip weekends, public holidays, and occasionally miss days due to outages. A 5-row offset for "1 week" can silently end up being 7 or 9 calendar days, producing incorrect returns with no error.


❌ Using collected_at for freshness or calculations

// WRONG — this is when WE scraped it, not when the NAV is from
const isStale = (today - row.collected_at) > 3 days;

// RIGHT — use the actual NAV effective date
const isStale = (consensusDate - row.date) > reportingLagDays;

A fund collected today with a date of 4 days ago is a stale fund. Using collected_at would incorrectly mark it as fresh.


❌ Displaying simple returns without annualizing in a comparative context

// WRONG — 1M simple return is not comparable to 1Y simple return
ret_1m = 0.95%    // looks tiny
ret_1y = 11.8%    // looks large

// RIGHT — both expressed as EAR (annualized)
ret_1m_ear = 12.1% p.a.   // now directly comparable
ret_1y_ear = 11.8% p.a.   // now directly comparable

Simple returns over different periods are not comparable. An investor cannot meaningfully judge whether 0.95% (1M) is better or worse than 11.8% (1Y) without annualizing both. EAR puts them on the same footing.


❌ Showing 0% when data is missing

// WRONG — implies the fund had zero return, which may be false
if (!navStart) return 0;

// RIGHT — null propagates to UI as "—" (insufficient data)
if (!navStart) return null;

A zero return and an unknown return look identical as numbers but mean completely different things to an investor.


❌ Per-fund collected_at-based consensus date

// WRONG — conflates collection time with NAV effective date
const consensusDate = mostCommon(funds.map(f => f.collected_at));

// RIGHT — use NAV effective dates
const consensusDate = computeConsensus(funds.map(f => f.date));

See Section 4. The consensus date must reflect when the market was actually valued, not when our scraper ran.


This document should be updated whenever a calculation is changed. The date and version at the top must reflect the current state. If a formula is changed, the old formula and the reason for the change should be noted inline.


13. Changelog

v1.3 — August 2026

  • Correction: v1.2's §10.6 "Estimated Organic Growth Rate" was defined as (nav_per_unit_end / nav_per_unit_start) − 1 — the NAV-per-unit return, numerically identical to ret_3m. That is not what "organic growth rate" means in the industry (Morningstar/ICI): it specifically refers to the flow-driven component, estimated_net_flow / starting_AUM. The old formula measured performance, not flow — the opposite of the field's name. Corrected in §10.7 below.
  • Replaced the single fixed 91-day estimated_flow_contribution/ estimated_organic_growth_rate pair with a full first-class period family: aum_chg_* and units_chg_* (§10.6, all six periods, both Consensus and Fund-Centric contexts, full EXACT/APPROXIMATE/STALE/INSUFFICIENT confidence metadata — mirroring ret_* exactly instead of one ad-hoc calculation), with estimated_net_flow_* and the corrected estimated_organic_growth_ rate_* (§10.7) derived on top.
  • Added §10.8 Flow Share of AUM Growth — computed and stored, but deliberately not part of any public export (too sensitive to near-zero ΔAUM to present as a headline figure; see the worked example in §10.8).
  • estimated_flow_contribution/estimated_organic_growth_rate (the old field names) are removed from Firestore on the next full-enrichment run.

v1.2 — August 2026

  • Added §10.6 Estimated Organic Growth Rate and §10.7 Estimated Flow Contribution — a decomposition of trailing-91-day AUM change into performance-driven vs flow-driven growth, fund-wide.
  • Added logo_url, fund_page_link, offer_doc_link to the Tier 3 table (§11). Additive only — no existing formula changed.

v1.1 — May 2026

  • Prior state of this document; no changelog entries recorded before v1.2.