# Canonical numbers

> Every surface reads the same server calculations, so a P/L figure or verdict is identical in the web app, API, MCP, extension and desktop app.

Source: https://investi.live/docs/concepts/canonical-numbers

Investi computes every number once, on the server, with one engine. The web app, the REST API, the Claude MCP connector, the Chrome extension and the desktop app are all views of that result. If two surfaces ever disagreed, one of them would be wrong, so none of them is allowed to calculate on its own.

## One engine, many surfaces

All calculation lives in `packages/core`, a pure TypeScript package with no network or database access: the ledger, indicators, the policy evaluator, risk, performance, importers and the report schema. The web server in `apps/web` gathers inputs (your records from the database, market data, SEC filings), calls the engine, and serves the result.

| Surface | How it gets numbers |
| --- | --- |
| Web app | Server components call the same server functions the API uses. |
| REST API | `/api/v1` routes call those functions and return JSON. See [REST API](/docs/integrations/rest-api). |
| Claude (MCP) | Tools at `/api/mcp` call the same functions. `get_research_verdict` returns the same company report object the app renders. See [MCP](/docs/integrations/mcp). |
| Chrome extension | Fetches `GET /api/v1/securities/{ticker}/intel`, the same payload as the in-app hover card. See [Chrome extension](/docs/integrations/chrome-extension). |
| Desktop app | A native window around your Investi deployment. It ships no calculation code and no UI of its own beyond an offline page. See [Desktop app](/docs/integrations/desktop-app). |

The language model that can explain a report is not on this list. It receives a finished report and writes prose about it. It never produces a number that a surface displays as a result.

## The company report

The research page, the report endpoint (`GET /api/v1/securities/{ticker}/report`) and the MCP verdict tool all return one structure, the canonical company report, defined with a strict schema in `packages/core/src/report.ts`. The report is validated against that schema every time it is built, so a malformed report fails loudly instead of rendering partially.

Every report carries a `schemaVersion` string. Changing the shape requires a new version, so a client can tell exactly which fields to expect. The schema started as `report.v1`; the engine now emits `report.v2`, which added the framework fields (FLAGGED verdicts, manual rows, sizing, exit target, the 7-day estimate and the market regime).

| Section | Contents |
| --- | --- |
| `identity` | Instrument id, ticker, issuer, exchange, sector, industry, SEC CIK. |
| `quote` | Last price, change, market time, observed time, session, provider and freshness label. |
| `tradingHistory` | Your realized P/L, unknown-basis proceeds, round trips, buys and sells, open quantity, average open price, last buy and sell, open orders, lot method, record sources, and the coverage window. |
| `businessQuality` | Annual and trailing figures from SEC XBRL, with the source and filing date. |
| `priceSetup` | 52-week low and high with dates, distance above the low, 14-day ATR, recent new lows, 50-day average and direction, 30-session dollar volume, and the indicator definitions used. |
| `catalysts` | Next earnings date and recent material filings with links. |
| `analystViews` | Consensus target, low and high, dispersion, buy, hold and sell counts, as-of date and a `stale` flag. |
| `strategyVerdict` | Verdict, policy version tag, summary, every rule outcome, input fingerprint and position signal. |
| `buyZone`, `framework` | The entry zone, sizing, trigger price, exit target, 7-day estimate, regime, earnings context and manual checks. |
| `bearBull` | Passing rules (bull), failing or warning rules (bear), conditions that would change the verdict, and unknowns. |
| `changeLog` | Previous verdict and time, a one-sentence diff and the rule changes. |
| `provenance` | One entry per dataset with source, observed time and version. See [Coverage and provenance](/docs/concepts/coverage). |
| `disclaimer` | The fixed research-not-advice statement. |

Each report has a `reportId` built from the stored evaluation id and its input fingerprint. When a new evaluation revision is saved, its report is stored alongside it, so you can see the exact object any surface was shown at the time.

## The P/L breakdown

Total P/L is never one bare number. Every surface shows the same breakdown, built by one function (`pnlBreakdown` in `packages/core/src/pnl.ts`) and served as `pnl` on the portfolio: Home (under the cumulative curve), Analytics, Portfolio, `GET /api/v1/portfolio`, and the MCP tools `get_positions` and `evaluate_strategy_health`.

| Field | What it is |
| --- | --- |
| `realized` | Closed lots with a known basis, matched with your lot method. |
| `unrealized` | Open positions at the last quote against cost basis, broker-reported where a Schwab sync or positions file exists. |
| `dividends` | Dividends dated from your first imported fill on. |
| `marginInterest` | Margin interest charged (negative). |
| `interestEarned` | Interest credited on cash, such as sweep or bank interest. |
| `accountFees` | Fees and taxes booked as cash, such as ADR fees (negative). |
| `tradingFees` | Commissions and fees on fills. Already inside realized and cost basis, so not subtracted again. |
| `net` | realized + unrealized + dividends + margin interest + interest earned + account fees. |

What the net leaves out is listed beside it, never hidden:

- **Legacy sales, basis unknown** (`legacy`): shares sold that were bought before your history starts. Their proceeds are shown, for example "proceeds $X excluded", and they are never treated as zero cost or as a loss. See [The ledger](/docs/concepts/ledger#legacy-sales-basis-unknown).
- **Before your history** (`beforeCoverage`): dividends, interest and fees dated before your first fill. They belong to holdings the imported fills do not cover.
- **Unclassified cash** (`otherCash`): cash Investi could not classify, such as cash-merger proceeds.
- **No quote** (`unpriced`): positions whose unrealized P/L is missing because no quote arrived.

The cumulative P/L curve on Home and Analytics follows the same rules, so its last point equals `net` apart from one difference: the curve marks positions to daily closes on the basis rebuilt from your fills, while `unrealized` uses live quotes on the broker's basis.

## Performance

The performance line on Analytics and in `evaluate_strategy_health` answers a different question: how much did the account grow, net of the money you put in or took out?

- **Daily equity** is rebuilt backwards from the latest broker balance (cash minus margin debit) and positions: each later fill and cash transaction is undone, and shares are marked to that day's close. No cost basis is needed, so holdings bought before your history are valued correctly until the day they are sold. Shares that left the account without a recorded sale (a cash merger, a transfer out) are valued at zero and listed in `gaps`.
- **Gain net of flows** = ending equity minus starting equity minus deposits and withdrawals. Starting equity is the close before your first fill.
- **Time-weighted return** is a daily TWR: each day's return is equity divided by the previous day's equity plus that day's deposits and withdrawals, chain-linked. Modified Dietz over the whole period is given as a cross-check.
- **Benchmarks:** RSP (the equal-weight S&P 500) and your chosen benchmark over the same dates, as price returns (dividends not included), plus each one's dollar gain had your starting equity and every deposit and withdrawal gone into it at that day's close.

It needs a broker balance for every synced or imported account (a Schwab connection or a positions export).

## Money rounding

Money arithmetic is defined in `packages/core/src/money.ts` and shared by every calculation.

- **Rounding mode:** half away from zero, to the cent. A small relative nudge corrects binary floating point artifacts, so 1.005 rounds to 1.01 and -2.345 rounds to -2.35, as a person would expect.
- **Realized P/L:** for each lot match, cost and proceeds are rounded to cents separately, and realized is proceeds minus cost, the way a broker statement settles. See [The ledger](/docs/concepts/ledger).
- **Totals:** sums of money (realized P/L, fees, notional, open cost) are added in integer cents, so adding many values never drifts by a fraction of a cent.
- **Quantities:** shares keep up to 6 decimal places, enough for fractional shares. A quantity smaller than 0.0000001 in absolute value counts as zero, so a position closed in fractional pieces reads as flat rather than as a leftover sliver.
- **Display:** negative amounts always carry a minus sign in the text, for example `-$361.00`, so sign never depends on color alone. Missing values show as a dash, never as zero.

Percentages are stored as fractions (0.0525 means 5.25%) and formatted only at display time.

## Why it matters

- A realized P/L figure you read in Claude is the same figure the History page shows, to the cent.
- A verdict in the extension's hover card is the latest stored evaluation for that security (or your override, marked as overridden with the computed verdict kept alongside), not a lighter recalculation.
- When you change the lot method in Settings, every surface switches at once, because the method is a server setting the engine reads.
- When the policy changes, the policy version tag changes on every surface at the same time. See [Verdicts and policy](/docs/concepts/verdicts-and-policy).
