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.4 Date: September 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. Market Share & Industry Denominators
  13. Anti-Patterns — What We Deliberately Do Not Do
  14. 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.

2.1 date is not one freshness state — AUM and performance diverge

date answers "when was this fund valued?" It does not answer "when was this fund's return calculated to?" Those are different dates whenever the fund publishes ahead of the consensus (Section 4), which for a prompt reporter is most days.

FieldMeaningApplies to
aum_as_ofThe fund's own NAV effective date (= date)nav_total, units_outstanding, balance-sheet levels
performance_as_ofThe consensus anchor (= ret_as_of)ret_*, headline_metric, aum_chg_*, every ranked value

A fund can hold a 24 August AUM observation while its returns remain anchored to a 21 August consensus. Both numbers are correct. Reporting one freshness state for the pair is what is wrong — it invites a reader to date a return with an observation that postdates the window the return was calculated over.

Rule: public surfaces publish both fields. A return is dated by performance_as_of; an AUM level is dated by aum_as_of. Neither substitutes for the other, and data_as_of alone is not sufficient to date anything.


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 13 — "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 13) 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).
trustee_or_custodian (governance facts)Who holds and safeguards the fund's assets, plus board appointment, auditor, related-party disclosureManager-level, not fund-level: every fund from the same manager shares one custodian/trustee/board/auditor record, sourced from docs/fund-intelligence/managers/<manager_id>.json (schema v2) rather than repeated per fund. Each field there cites the source document and page (field_sources), which is a stronger provenance guarantee than the catalogue-wide field-provenance.json sidecar gives most other Tier 3 fields above. This replaced a flat per-fund Trustee/Custodian catalogue cell that had carried copy-paste errors (e.g. one manager's real custodian being recorded incorrectly across several of its funds because the fact was never checked against a primary source per fund).

12. Market Share & Industry Denominators

Fund-level AUM growth is already published (§10.6). Converting it into market share requires something NavViewTZ does not yet expose: total industry AUM at each historical comparison date.

Δ market share = AUM_fund,t1 / AUM_industry,t1
               − AUM_fund,t0 / AUM_industry,t0

The numerator is solved. The denominator is the entire difficulty, and every way of getting it wrong produces a number that looks plausible and is false. This section records the decisions taken before implementation.

12.1 The denominator is consensus-anchored. Always.

Two industry totals are meaningful, and they are not interchangeable.

TotalDefinitionPermitted use
ConsensusΣ AUM of the cohort, each valued at performance_as_ofMarket share, Δ market share, every time series
LatestΣ each fund's most recent aum_as_ofA single "industry size today" headline

Rule: Δ market share is computed from consensus totals only. A latest-based total is never differenced against anything.

A latest-based total is a ragged panel — every fund valued on its own date — so the sum is as-of nothing. Differencing two ragged panels manufactures share movement out of publication timing alone. A fund sitting at T−0 inside a denominator anchored at T−3 is credited with three days of its own growth that no other constituent received. This is not a rounding concern: prompt-reporting funds are disproportionately the fast-growing ones, so the artifact correlates with precisely the signal the metric exists to detect. It would confirm the hypothesis whether or not the hypothesis is true.

Latest totals are also irreproducible. One laggard backfilling silently changes a historical t0 denominator after publication. Consensus totals are idempotent by construction — /funds/{id}/enriched/{ret_as_of} is keyed on the anchor date, so a given date's denominator is fixed once written.

12.2 One denominator per currency. Never blended.

Rule: market share is computed within a currency bucket. TZS funds are never summed with USD funds, and no FX conversion is applied to build a combined denominator.

An FX-converted denominator makes market share move when the shilling moves. A TZS fund could post a share "gain" during a depreciation without a single new subscription, because the USD-denominated constituents shrank in TZS terms. That is an FX artifact wearing the costume of a flow signal.

The shape is a keyed map, not fixed per-currency fields:

industry_totals: Record<string, IndustryTotal>   // "TZS", "USD", "UNKNOWN"

Flat total_aum_tzs / total_aum_usd fields were rejected. They hardcode today's currency set into the wire contract, so onboarding a fund in a third currency becomes a breaking schema change for every consumer; they cannot express the UNKNOWN bucket that largest_funds_by_currency already carries; and each total needs its own cohort metadata (§12.3), which flat scalars can only express by multiplying into total_aum_tzs_fund_count, total_aum_tzs_imputed_count and so on. The keyed map also matches the shape briefing.summary.largest_funds_by_currency already uses, and it makes the rule structural: there is no field an integrator could add to another.

12.3 Coverage drift is disclosed, never absorbed

NavViewTZ's "industry" is the set of funds NavViewTZ tracks. That set grows — Ushindi and Thamani were onboarded in August 2026. A naive total treats onboarding as industry growth, so every incumbent's share drops on that date by an amount indistinguishable from a real share loss.

Rule: Δ market share is computed over a fixed cohort — the funds present at both t0 and t1. Funds absent at either endpoint are excluded from both the numerator and the denominator for that comparison.

Each published total therefore carries its cohort identity:

FieldPurpose
cohort_idStable hash of the sorted constituent fund_id set
cohort_fund_countConstituents in this total
performance_as_ofThe anchor every constituent is valued at
basis"consensus" or "latest" — see §12.1

A Δ computed across two differing cohort_id values is not a market-share change and must be labelled as a coverage change instead. Historical denominators are immutable: onboarding a fund does not back-stamp prior dates. The series shows a documented discontinuity rather than a silently rewritten past.

In practice the tracked universe grows several times a year, so most useful windows contain an onboarding and a strict refusal would answer nothing. fixedCohort restricts both endpoints to the funds valued at each before the totals are built, which makes the cohort_id values match by construction. The resulting Δ answers a narrower question — how these funds' shares of their own combined AUM moved — and the excluded ids are published alongside it. The denominator is not the whole tracked market whenever any are dropped, and a surface that hides that fact is back to reporting a new fund's arrival as an incumbent's loss.

12.4 Stale constituents are carried forward, and the imputation is published

A fund behind the consensus date has no NAV at the anchor. Neither remedy is free: dropping it shrinks the denominator and inflates every other fund's share; carrying its last value forward understates industry growth and inflates share differently.

Rule: carry forward the constituent's most recent NAV on or before the anchor, bounded by STALE_HARD_CAP_DAYS (30, matching §6.1's NAV lookup). Beyond that bound the fund leaves the cohort. Every total discloses how much of itself is imputed.

FieldPurpose
imputed_fund_countConstituents valued by carry-forward of any distance
imputed_aumTheir combined contribution to the total
stale_imputed_fund_countConstituents carried forward from beyond the ±2-day consensus window
stale_imputed_aumTheir combined contribution — the figure that carries signal
max_imputed_daysLargest gap between a constituent's valuation and the anchor

The split between the two pairs matters more than it looks. The anchor is the most recent date meeting quorum within the ±2-day window (§4), so it is routinely a date most funds have not reached — on a normal day the majority sit at anchor−1. Measured against live data on 2 September 2026, a carry-forward flag defined as "valuation date ≠ anchor" marked 96% of the TZS denominator as imputed, of which all but two funds were a single day behind. imputed_aum is literally true and nearly useless on its own; stale_imputed_aum is the one to act on.

Carry-forward is chosen over exclusion because the cohort rule (§12.3) already protects the comparison, and a stable constituent set matters more to a denominator than marginal precision on one lagging fund. The disclosure fields exist so a consumer can decide whether the distortion matters for their question rather than having that judgement made for them.

12.5 Category denominators are pinned, not derived at read time

Share of the MMF category is a more useful figure than share of all CIS. It is also more fragile: if category membership is resolved when the query runs, then reclassifying one fund rewrites every historical share in that category.

Rule: category membership is stamped onto the snapshot at enrichment time and read back from the snapshot, never recomputed from current metadata.

Note that performance_basis currently doubles as the constant-NAV proxy (fundClassification.ts). It must not also become the category key — a field serving three masters cannot be changed for one of them.

12.6 Share is reported in basis points

Rule: Δ market share is expressed in basis points, Δ share and share are never expressed as a percentage change of each other, and neither is ever rendered adjacent to an AUM growth percentage without its unit.

"+42.4% AUM growth" and "+80 bp share" answer different questions and share no unit. An agent reading the briefing will conflate them if the payload permits it. A fund can grow AUM strongly and still lose share.


13. 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.


❌ Summing each fund's latest AUM into a market-share denominator

// WRONG — a ragged panel; the sum is as-of nothing
const industry = funds.reduce((t, f) => t + f.nav_total, 0);
const share = fund.nav_total / industry;

// RIGHT — every constituent valued at the same anchor
const industry = cohort.reduce((t, f) => t + navAt(f, performanceAsOf), 0);

Differencing two such sums manufactures share movement out of publication timing, and the artifact favours prompt reporters — who are disproportionately the fast-growing funds. See §12.1.


❌ Letting a newly onboarded fund dilute historical share

// WRONG — every incumbent "loses share" on the onboarding date
const share = fund.nav_total / totalOfEveryFundWeNowTrack;

// RIGHT — fixed cohort, present at both endpoints
const cohort = fundsPresentAt(t0).filter((f) => presentAt(f, t1));

Coverage growth is not industry growth. A Δ spanning two different cohorts is a coverage change, not a market-share change, and must be labelled as one. See §12.3.


❌ Blending currencies into one industry total

// WRONG — share now moves when the shilling moves
const industry = tzsFunds.total + usdFunds.total * fxRate;

// RIGHT — one denominator per currency, no conversion
const industry = industryTotals[fund.currency];

A TZS fund would post a share gain during a depreciation without a single new subscription. See §12.2.


❌ 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.


14. Changelog

v1.4 — September 2026

  • Added §2.1 — the aum_as_of / performance_as_of split. date was serving as a single freshness state for two different questions, so a fund could show a 24 August AUM observation against returns anchored to a 21 August consensus with nothing in the payload distinguishing them. Both fields are now published on /api/v1/briefing, the MCP get_market_briefing tool, and the in-page WebMCP tool.
  • Corrected the briefing and MCP caveat text. It claimed stale metrics were excluded from rankings while stale_fund_ids was built from an unrelated publication-lag test, so funds appearing in both read as a contradiction. The two tests (headline_metric_status vs freshness_status / days_behind_consensus) are now named separately wherever either is reported, and the expected overlap is stated rather than implied.
  • Added §12 Market Share & Industry Denominators — the decisions governing the industry-AUM denominator ahead of implementation: consensus anchoring (§12.1), per-currency totals with no FX blending (§12.2), fixed cohorts and disclosed coverage drift (§12.3), bounded carry-forward with published imputation (§12.4), pinned category membership (§12.5), and basis-point reporting (§12.6). Three corresponding anti-patterns added to §13.
  • No formula in §6–§11 changed.

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.