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:
- Developers implementing or modifying
enrich.performanceMetrics.jsandinterestRates.ts— so calculation logic is never changed without understanding why it was designed that way. - The
/methodologyfrontend page — this document is the source of truth for what users read when they ask "how is this calculated?" - Future contributors — so design decisions made during early development are not accidentally undone.
Table of Contents
- Foundational Principles
- The Two-Timestamp Rule
- The Two-Context Rule
- Consensus Pricing Date
- Staleness: Reporting Lag vs Genuine Staleness
- Return Calculations
- Effective Annual Rate (EAR)
- Data Quality & Confidence Levels
- KPI Definitions — Tier 1 (Always Visible)
- KPI Definitions — Tier 2 (Comparative & Analysis)
- KPI Definitions — Tier 3 (Fund Characteristics)
- Anti-Patterns — What We Deliberately Do Not Do
- 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.
| Field | Meaning | Used for |
|---|---|---|
date | The NAV effective date — the day the fund's valuation is for | All financial calculations |
collected_at | The timestamp when our scraper retrieved the data | Pipeline 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
datevalue. - 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.
| Context | Field prefix | Example | Metadata field |
|---|---|---|---|
| Consensus (Comparative) | bare name | ret_1m_ear | metrics_meta |
| Actual (Fund-Centric) | _act suffix | ret_1m_act_ear | metrics_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 consensus | Indicator | Label |
|---|---|---|
| 0 − lag | ● Green | Fresh |
| 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
| Label | Target offset | Min history required |
|---|---|---|
| 1D | end_anchor − 1 calendar day | 2 data points |
| 1W | end_anchor − 7 calendar days | 5 data points |
| 1M | end_anchor − 30 calendar days | 20 data points |
| 3M | end_anchor − 91 calendar days | 60 data points |
| 6M | end_anchor − 182 calendar days | 120 data points |
| YTD | Dec 31 of previous calendar year | 20 data points |
| 1Y | end_anchor − 365 calendar days | 300 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.
| Level | Condition | Display treatment |
|---|---|---|
EXACT | Reference date found within ±1 day | Full value, no badge |
APPROXIMATE | Reference date found within ±2–3 days | Value shown with ~ prefix |
INSUFFICIENT | Not enough history, or no date found in tolerance window | Shown 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:
- Constant-NAV funds (e.g.
sanlam.pesammf,sanlam.usdFixedIncome) are excluded. These funds keepnav_per_unitfixed at 1 and credit returns as additional units, so a NAV-derivedret_1yis ≈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 insrc/utils/fundClassification.tsand mirrors theSANLAM_FUNDSdeclaration infunctions/src/config.ts. The KPI caption shows the exclusion count. - Funds missing
nav_total, withnav_total ≤ 0, or missingret_1yare 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):
| Metric | Nature | Public? |
|---|---|---|
AUM (nav_total) | Published | ✅ |
AUM Δ (aum_chg_*) | Calculated from published data | ✅ |
Units Δ (units_chg_*) | Calculated from published data | ✅ — lead with this |
| Estimated Net Flow | Estimate, clearly labeled | ✅ |
| Estimated Organic Growth Rate | Estimate, clearly labeled | ✅ |
| Flow Share of AUM Growth | Estimate ÷ 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.
| Field | Description | Notes |
|---|---|---|
minimum_investment | Minimum initial purchase in TZS | Static. Verify with fund manager periodically. |
annual_management_fee | Annual fee as % of AUM | Direct drag on net returns. Display as "X% p.a." |
redemption_period | How quickly you can withdraw | T+1, T+3, T+5 etc. Critical for liquidity-sensitive investors. |
inception_date | Date the fund launched | Used to calculate fund age. Longer track records warrant more confidence. |
reporting_lag | Fund manager's typical publishing delay | T+0, T+1, T+2. Used to distinguish structural lag from genuine staleness. |
cmsa_registration | CMSA registration number | Trust signal. Link to CMSA registry where possible. |
fund_type | Money Market / Bond / Balanced / Equity | Determines peer group for comparative rankings. |
currency | TZS (all current funds) | Retained for future multi-currency support. |
logo_url | Web-usable image URL for the fund/manager logo | Optional. 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_link | Link 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_link | Link to the fund's prospectus / offer document | Optional; 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 toret_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_ratepair with a full first-class period family:aum_chg_*andunits_chg_*(§10.6, all six periods, both Consensus and Fund-Centric contexts, full EXACT/APPROXIMATE/STALE/INSUFFICIENT confidence metadata — mirroringret_*exactly instead of one ad-hoc calculation), withestimated_net_flow_*and the correctedestimated_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_linkto 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.