Reference
Definitions
What Sniper actually computes when it shows you a number — the exact formula, and when it refuses to show one.
Returns
How Sniper measures whether your money grew — and the two different questions XIRR and TWR each answer.
Solve for r in: Σ amount_i / (1 + r)^(days_i / 365) = 0 Investor sign convention: money you put in is negative; money you take out — or a current value, appended as a final "receipt" — is positive.
Solved numerically, not in closed form: Newton-Raphson is tried first from four seed rates (10%, 5%, 50%, −50%). If none of those converge — or the step stalls near a point that isn't actually a root — the solver falls back to a bisection scan across the whole −99.9% to +1000% range.
The solver never invents the terminal flow. Callers (the returns read model) append the closing receipts themselves; XIRR only ever sees the flows it's given.
At portfolio level there is not one terminal receipt but ONE PER VALUE SOURCE — the manual ledger and each broker connection — and each is dated at that source's own last observation, not at a single shared "today". Because the discount factor is a function of the date, stamping a connection that last synced 60 days ago as if it were current would assert a value that was never observed and bias the solved rate. Dating each receipt where its source actually last spoke says only what is known. This is algebraically identical to a single summed receipt exactly when every source shares one last date; sources routinely lag each other by a day, so the reported figure does move slightly. Sources worth zero or less are dropped — a terminal leg has to be a receipt.
This is a linear sum over the ledger, which makes it robust to per-transaction noise in a way TWR is not — replaying every buy/sell leg (rather than netting them) doesn't blow XIRR up the way it can a chain-linked return.
Hidden or hedged when
- Fewer than 2 non-zero cash flows exist.
- Every flow is the same sign — e.g. only deposits ever recorded, never a value observation — because there's no sign change for NPV(r) to cross zero at.
- All flows fall on the same calendar day (no time elapsed to annualize over).
- Neither Newton-Raphson nor the bisection fallback finds a root inside (−99.9%, +1000%) annualized.
Chain-linked daily growth factors: r_day = V_today / (V_yesterday + flow_today) flow_today is signed so a deposit inflates the denominator (money entering the portfolio is neutralized, never scored as a market gain). Cumulative return = ( Π r_day ) − 1.
Every consecutive pair of observations carries a "segment key" — which value sources (the manual ledger, each broker connection) contributed to it. When that key changes between yesterday and today — a new broker connection's first snapshot, a source appearing or disappearing — the chain may deliberately BREAK instead of linking through it. A value jump because a new account joined the total is a composition change, not performance, and chaining across it would read as a nonsense single-day return (this is a real bug that was found and fixed: one portfolio's value fell 89% across a closed account chapter while cost basis fell by almost the same amount — the positions left, prices didn't collapse).
A key mismatch alone is not enough to break the chain — it must also be MATERIAL. The sizes of the sources that appeared and disappeared are summed as absolute values (never netted, so an equal-size broker-to-broker handoff can't cancel itself into looking harmless) and divided by the larger of yesterday's and today's total value. Only a ratio above 1e-6 breaks the chain. The reason is a measured one: leftover fractional-share dust from a closed chapter finally zeroing measures ~1e-17 of portfolio value, while a genuine same-day account handoff measures ~1.96 — seventeen orders of magnitude apart. Without the threshold, dust had the same veto power as a real handoff and discarded a whole day of real performance, overstating cumulative return by 2.75 percentage points. The threshold is relative, never an absolute currency amount, so it stays correct in any base currency.
The chain also breaks whenever the flow-neutralized denominator (V_yesterday + flow) is ≤ 0 — a flow larger than the portfolio would otherwise produce a factor with no economic meaning.
A break shows up as index: null for that observation — the running growth index freezes rather than resetting, and resumes chaining from the last defined value once the gap ends. It is a real gap in comparable performance, never rendered as a fake 0%.
Annualized TWR = (1 + cumulative)^(365 / days) − 1, computed only when the series spans at least 1 day and the cumulative growth factor is positive.
One deliberate convention in that formula: `days` is the FULL calendar span from the first observation to the last, including any interval where the chain broke. A broken interval contributes an implicit growth factor of 1.0 to the cumulative product, but its calendar time still counts in the divisor — so a break dilutes the annualized figure slightly rather than being excised from the timeline. This is a modelling choice, not an oversight: the number answers "what did this portfolio earn per year since inception", and inception-to-today elapsed time is the honest denominator for that question. The alternative — dividing by only the comparable days — is arguably more precise but stops answering that question, and is harder to explain than the imprecision it removes. Current magnitude: the series spans 2,368 days with exactly 1 break, so the effect is about one part in 2,368 — below rounding. It mattered a great deal before the materiality threshold landed, when dust could discard hundreds of days, and it would matter again if breaks ever became common.
Hidden or hedged when
- Fewer than 2 usable value observations exist (nothing to chain).
- Annualized figure specifically: the series spans under 1 day, or the cumulative growth factor is ≤ 0.
drawdown_t = (index_t − peak_t) / peak_t × 100, where peak_t = running max(index) up to t.
Runs on the TWR growth index, not on raw currency value or cost basis — the same series the cumulative-return figure chains from, so drawdown can never disagree with it; it's a re-slicing of one series, not a second approximate calculation.
The running peak carries across a chain break (see TWR above) — a gap freezes the peak, it doesn't reset it, so resuming after a composition-change gap doesn't understate how far underwater the portfolio still is.
Points immediately after a chain break render as "no comparable data", never a fake 0% recovery.
Hidden or hedged when
- Fewer than 2 usable TWR index points exist.
- A specific point sits right after a chain break.
month return = index_end-of-month / index_start-of-month − 1 year total = ( Π (1 + month_i return) ) − 1, over whichever months in that year actually have data.
This replaced an earlier Modified-Dietz-style approximation that assumed flows land at month end. It's now an EXACT slice of the same chain-linked daily index used by the CAGR tile and the drawdown chart — deposits and withdrawals are neutralized day by day inside the index itself, so there is no separate approximation layer left to distrust here.
A year total marked with * is still in progress (the current calendar year, with fewer months present than the current month number) — it is a real number, just an incomplete one.
Hidden or hedged when
- A given month has no usable index baseline observation — rendered as an empty cell, not a 0% return.
- A year is skipped entirely from the year-total math if not a single month in it has data.
Risk
How concentrated the portfolio is, and how far below its own peak it has fallen.
HHI = Σ weight_i², weight_i = holding i's base-currency current value / total current value effective position count = 1 / HHI
A perfectly even 10-holding portfolio scores HHI = 0.10, i.e. an effective count of exactly 10. One dominant position pushes HHI toward 1 and the effective count toward 1 — "you technically own 30 things but you're really exposed to 2".
This is the same Herfindahl-Hirschman formula regulators use for market concentration, applied here to position weights instead of market shares — there is no invented variant.
Hidden or hedged when
- Never null: with zero portfolio value both HHI and effective count report as 0, not a gap.
sectorHhi = Σ weight_s², weight_s = sector s's base-currency current value / total current value effectiveSectorCount = 1 / sectorHhi
Deliberately includes both non-sector buckets — "Cash" and "Others" — in the sum. Excluding them would make a cash-heavy or unclassified-heavy portfolio look artificially diversified: cash and not-yet-classified positions genuinely dilute sector concentration risk even though neither can be attributed to a specific sector.
effectiveSectorCount answers "how many sectors am I really diversified across", the sector-level twin of the position-level effective count above.
Hidden or hedged when
- effectiveSectorCount is null specifically when sectorHhi is exactly 0 (no valued holdings at all).
Composition
What a holding's numbers mean, how sectors are assigned, and how cash is modeled.
invested = quantity × avgPrice currentValue = quantity × currentPrice (currentPrice falls back to avgPrice when no live price exists) gain = currentValue − invested; gainPercent = gain / invested × 100 weightPercent = holding's base-currency currentValue / total base-currency currentValue × 100
currentPrice prefers the latest priced observation Sniper has (instrument_daily / broker's own last trade); when neither exists it falls back to avgPrice itself, and that holding's priceSource is flagged "missing" rather than silently reported as flat/zero gain.
gainPercent is 0 (not null, not a divide-by-zero) whenever invested is ≤ 0 — a deliberate choice so the field always has a renderable number.
dayChange = quantity × (currentPrice − previousPrice) dayChangeCoveragePercent = value of holdings with a KNOWN day change / total current value × 100
Requires BOTH today's and yesterday's price to be real recorded observations. Cash holdings have no instrument row at all and are always null here; any equity Sniper hasn't priced today or yesterday (a cold or missing instrument_daily row) is also null — never assumed flat.
Every rollup that sums dayChange (portfolio total, sector buckets) reports dayChangeCoveragePercent alongside it — the share of that rollup's current value for which a real day-change figure actually exists — so a partial-coverage total is never mistaken for a complete one.
At the sector level specifically, dayChangePercent divides by the prior-day value of ONLY the covered positions, not the sector's full current value — dividing by the full value would silently understate the percentage whenever coverage is partial.
Hidden or hedged when
- Either today's or yesterday's price for that holding is unknown, cold, or belongs to a cash holding.
Resolution order, first non-null source wins: manual > eodhd > nasdaq > nse > isin_fanout > etf_heuristic
Vendors don't agree on wording — NSE calls it "Financial Services", Nasdaq calls it "Finance" — so nothing reaches the database without passing through an explicit per-vendor dictionary in taxonomy.ts. A label the dictionary doesn't recognize is never silently guessed into a bucket; it's reported so someone can add a mapping.
`manual` sits above every automated source and is never touched by the weekly classification cron — once a human corrects a sector, no job can revert it.
`isin_fanout` carries NSE's classification to other listings of the same security by ISIN (chiefly BSE, where tickers often differ from NSE's). `etf_heuristic` classifies funds by name pattern (e.g. "NIFTYBEES", "...BEES", "...ETF") and sits last, so a real vendor sector always wins over a name guess.
"Cash" is its own bucket, keyed off the holding's asset class rather than off a missing sector. Cash has no instrument row to classify, so grouping it with genuinely-unclassified equities made a fully-classified portfolio look like it had a coverage gap and left the actual cash weight unreadable.
"Others" is then only the real residue, and covers two cases, neither an error: instruments awaiting a source (some LSE/SHG-listed symbols aren't covered by any current loader), and vendor catch-all labels the dictionary knows to discard (e.g. Nasdaq's "Miscellaneous"). Both buckets are deliberately counted in the sector concentration math above, not excluded.
sector_raw preserves the vendor's original wording verbatim (before mapping) and sector_source records which source won — both exist so a wrong classification is auditable, not just correctable.
Hidden or hedged when
- An instrument has no sector row yet, or every source that reached it returned a known-junk / unrecognized label.
cash holding: symbol = currency code, exchange = NULL, avgPrice = 1, quantity = SUM(cash_delta) over every affects_cash = true transaction.
cash_delta rules by transaction type: cash buy / transfer_in / dividend / interest add (amount, or quantity × price when no amount is given, minus fees and taxes); cash sell / transfer_out / fee / tax subtract the same; a cash adjustment adds the raw amount with no fee/tax netting. A non-cash buy/transfer_in debits cash by the trade's full cost; a non-cash sell/transfer_out credits it; dividend/interest/fee/tax on a non-cash holding are pure cash income or expense.
The TypeScript function and the SQL expression used inside aggregate queries are written from the exact same rules on purpose, specifically so the two implementations can never quietly drift apart.
Data Quality
Why a number is sometimes hidden, greyed out, or hedged — and what to do about it.
raw_close = as-traded close, in the shares that existed that day (immutable) adj_close = raw_close ÷ Π quantity_factor, over every corporate action between that date and today (derived) position value = as-traded quantity × raw_close day change % = (adj_close − prev_adj_close) ÷ prev_adj_close
The rule behind both: on every date the quantity and the price must be in the same share denomination. The quantity Sniper replays from your ledger is the as-traded count — what you actually held that day — so the price paired with it has to be the as-traded price too.
Concretely, NVDA on 2021-07-19: 0.14658 shares at the adjusted price of $18.74 gives $2.75, but those shares were really worth about $109.87 at the traded price of $751.19. The gap is exactly 40× — NVDA's 4:1 split in 2021 times its 10:1 in 2024. Grayscale Ethereum Mini Trust (ETH) is the same error in reverse: a 1:10 reverse split made its history read 10× too high.
Day change deliberately uses the ADJUSTED pair instead, because a day change is a return and a return needs a series that a split does not disturb. Measured on raw prices, a 10:1 split would show up as a −90% day. The money figure shown next to the percentage is that percentage applied to today's actual value, so the two never disagree and no single calculation mixes the denominations.
adj_close is DERIVED, not authoritative, and it is expected to change. When a company you hold announces a split, every historical adjusted price for that instrument is restated — that is what makes the series continuous. The raw price never moves, which is why it is the one that gets stored and the one valuations are built on.
For US, LSE and Shanghai instruments the vendor supplies both prices. For Indian instruments before 2026-05-16 no raw price was ever captured — Zerodha only returned an already-adjusted series — so Sniper reconstructs it by inverting that series against its corporate-action records. Cross-checked two independent ways (measured price ratios versus factors derived from exchange announcements), agreeing to 4–5 decimal places.
One asymmetry is legitimate and worth knowing: the US vendor's adjusted price also strips out cash dividends, while Sniper's own adjustment only accounts for share-count events. So a US holding's raw and adjusted prices differ more often than an Indian one's.
Eight Indian instruments do not reconstruct exactly, because Zerodha adjusted for dividends too and dividends are not a share-count event: ITC (18%), ONGC (10.6%), RELIANCE (4.9%), WIPRO (4.6%), INDHOTEL (3.1%), GICRE (2.8%), BHARTIARTL (1.9%), TATACONSUM (1.2%). ITC's is a demerger rather than dividends and is a known open item. These residuals replace what was previously a ~40× error.
Hidden or hedged when
- Where no raw price exists for a market-priced holding, Sniper values it at your cost basis and says so, rather than quietly borrowing the adjusted price — a wrong denomination looks plausible and is undetectable afterwards.
- A price of exactly zero is treated as missing rather than as a holding worth nothing. About 2,050 such rows are inherited from the Indian data vendor, mostly pre-2015.
on the ex-date, for every account holding the instrument: quantity × = quantity_factor avg price ÷ = quantity_factor so quantity × avg price — your cost basis — is unchanged
A corporate action is a fact about the instrument, not about your ledger: when a company splits its stock, every holder's share count changes that morning. So Sniper treats its corporate-action records as the single source of truth and applies them to every position, rather than waiting for a broker to mention it.
The consequence is that a broker's own 'adjustment' entry for the same event is redundant, and is ignored for quantity purposes — otherwise the split would be counted twice. Sniper identifies those entries by provenance (a zero-consideration adjustment for that instrument inside the action's settlement window), not by checking whether the numbers happen to match, and it reports every entry it set aside.
Cost basis is preserved because no money changed hands. Scaling the quantity without also scaling the average price would double your recorded cost and turn a 1:1 bonus into a fabricated 100% loss.
This is what fixes LICI's apparent 50% overnight crash on 2026-05-29. The price genuinely halved — the share count had doubled — but nothing in the ledger recorded the doubling, so 200 shares were valued at the post-bonus price. With the action applied it is 400 shares at ₹411.35, a 0.9% move rather than a 50% collapse.
The factor is stored explicitly rather than re-derived from the ratio, because the ratio's meaning depends on the event type: a '4:1 split' multiplies your shares by 4, while a '4:1 bonus' multiplies them by 5 — a bonus grants extra shares on top of the ones you hold. A split and a bonus sharing one ex-date are two records whose factors multiply.
An action announced for a future date is not applied until its ex-date arrives. Exchanges publish ahead of time, and adjusting before the event is as wrong as not adjusting after it.
Only share-count events are modelled. Demergers, rights issues and capital reductions change value without being expressible as a share-count multiplier, and are not represented.
Hidden or hedged when
- An instrument with no record in Sniper's instrument list can have no corporate actions, so its share count changes only when your ledger says so.
display = false when observationDays < 30 confidence = "low" when ANY of: observationDays < 90; a flow's inference window spans > 3 days; a flow used a 1:1 FX fallback; staleDays > 45
This is deliberately the single gate every returns metric passes through, at every scope (per broker connection, per manual account, portfolio-level) — the same function decides display and confidence for XIRR and TWR alike, so the two numbers are never hedged inconsistently against each other.
"Inference window spans > 3 days" refers to a flow inferred from a day-over-day snapshot diff (see Flow Inference below) where the two snapshots were more than 3 days apart — a sync gap wide enough that the exact date of a deposit or withdrawal inside it is genuinely a guess, not just a rounding choice.
"1:1 FX fallback" means at least one cash flow feeding the metric had no real exchange rate available for its currency pair and date, and was converted at parity instead (see FX Conversion below) — the metric is still computed, but flagged as noisier than usual.
"staleDays > 45" catches a dead source. Without it, a broker connection whose token expired six months ago but which has years of prior history read as HIGH confidence, because history length says nothing about whether the data is current. staleDays is the gap between the scope's reference date and the last observation of any contributing source, so one silent connection downgrades the portfolio figure even while the others keep syncing. It does not apply to scopes with no synced source (a manual account is always priced at now).
The exact hedge text shown to the user is generated here, not invented per-component: "Only N day(s) of history — shown after 30.", "Short history — annualized figures are noisy.", "Contains flows inferred across multi-day sync gaps.", "Some flows used a 1:1 FX fallback (missing rates).", "No new data in N day(s) — figures may be stale."
Hidden or hedged when
- A metric is hidden (display: false) whenever its observation window is under 30 days, regardless of whether XIRR/TWR could technically be computed.
netInflow(prevDate, currDate) = Σ over every position: [+value today, if it appeared] + [−value at last sight, if it disappeared] + [Δquantity × today's per-unit price, if quantity changed] flow date = ceil(midpoint(prevDate, currDate))
Netted per date-pair across every position inside one "boundary" (a broker connection, or the whole manual ledger as a single boundary) — an internal rebalance that never left the account (sell A to fund buying B) cancels itself out in the sum, so only genuine external movement survives.
The flow's date is deliberately the midpoint of the gap, rounded UP — rounding down would place a 1-day gap's flow exactly on the start date, which TWR's (prev, curr] windowing would then silently drop from neutralization on every daily sync.
Deciding whether a quantity jump is a trade or a corporate action (split / bonus issue) is a two-tier rule, and the ledger wins outright. First: if a real buy / sell / transfer leg in your transaction history reconciles the observed quantity change, it is a TRADE — unconditionally, without ever consulting the heuristic below. That is what kills the heuristic's worst failure: averaging down (buying 25% more while the price fell 20%) is split-shaped on every measure, so the guess used to discard a genuine purchase and report a real −16.67% stretch as flat 0%. A trade can never appear in a corporate-action feed by definition, so only the ledger can settle it.
Only when no reconciling leg exists does a heuristic apply, and WHICH one depends on whether a ledger exists to have been consulted at all. For a manual account — a ledger exists, it just has nothing matching this quantity change — the test is strict: the quantity ratio must land on a genuine corporate-action ratio (2, 3, 1.5 or 1.25) within 1%, and total position value must have moved by less than half of what an ordinary trade at that ratio would have moved it. Corporate-action ratios are exact numbers, so the 1% only absorbs float noise; a ratio of 1.37 is a trade, not a split, no matter how the value moved.
For a broker connection there is no ledger at all — position snapshots only, no trade history — so the original, looser heuristic still runs there: quantity moved by at least 1.2×, the implied per-unit price moved by roughly the inverse ratio (within 15%), and total position value barely moved (under 5%), all three together. It is kept deliberately unchanged, because there is no data with which to do better. That means broker connections retain both of the heuristic's known failure modes — a split on a day the price also moved sharply reads as a trade, and averaging down into a falling position can read as a split. Those are surfaced as reduced confidence at the scope level, not per flow.
This inference method feeds broker-connection returns and the portfolio-level TWR chain specifically. Per-manual-account XIRR instead replays the actual transaction ledger, because a per-account value series doesn't exist to diff against.
Hidden or hedged when
- A date-pair contributes no flow when net inflow rounds to within 1e-9 of zero, or when the quantity change is classified as split-like.
Sniper looks up the latest available exchange rate for (holding currency → base currency) as of the relevant date. When no real rate is available for that pair — a rarely traded currency, or a date before rates were seeded — the conversion falls back to 1:1 instead of failing: the number shown is the FOREIGN-currency figure relabeled as base currency, not an actual conversion.
The holdings summary surfaces this explicitly with a warning ("FX rates unavailable for: X->Y. Values shown at 1:1 conversion.") whenever it happens, naming the exact pairs affected.
The returns engine treats this the same way independently: any cash flow that had to use a 1:1 fallback is one of the triggers that downgrades that metric's confidence to "low" (see Confidence & Display Gating).
15 definitions, all derived from the code that computes them — not a generic glossary.