Skip to content
investiDocs

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.

On this page

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.
Claude (MCP) Tools at /api/mcp call the same functions. get_research_verdict returns the same company report object the app renders. See MCP.
Chrome extension Fetches GET /api/v1/securities/{ticker}/intel, the same payload as the in-app hover card. See 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.

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