title: Portfolio Reports description: The /api/portfolio/reports surface — time-series snapshots, performance, diversification, attribution, tax-lots, income, net-worth, and exports for the portfolio dashboard.
Portfolio Reports
The portfolio_app/reports_api sub-package is the dashboard's single HTTP
boundary for portfolio reporting. It mounts under /api/portfolio/reports/*
alongside the existing resource-shaped routes (/v2/portfolios, /holdings,
…) and exposes thin pass-throughs over the existing analytics layer plus
purpose-built pure-function modules added feature-slice by feature-slice.
Workspace dashboard read model
GET /api/portfolio/v2/dashboard is the versioned read model for the Portfolio
Overview. It is intentionally outside /reports/*: it composes current
valuation, portfolio summaries, allocations, recent persisted snapshots, goal
highlights, broker sync health, freshness, and warnings for one user decision
surface.
The service reads holdings once and calculates every section at one UTC
calculated_at timestamp. FX is acquired at most once for the response. Values
remain grouped by original currency and the response also includes a converted
base-currency total. A converted total spanning currencies has
is_estimated=true, because current USD/INR is not a historical acquisition FX
rate.
Dashboard totals use explicit states:
AVAILABLE: all required holding prices are usable.PARTIAL: at least one holding cannot be valued; the returned current value covers only valued holdings and gains are unavailable when cost/value scope differs.UNAVAILABLE: no usable valuation exists.ESTIMATED: valuation is complete but currency conversion is estimated.
Today's change is null when any valued holding lacks previous-close data. The
API never treats a missing previous close as zero movement. freshness reports
the valuation timestamp, a 30-minute threshold, stale holding count, and
unavailable holding count. warnings provides stable machine-readable codes
such as VALUATION_PARTIAL, VALUATION_STALE, FX_ESTIMATED,
PREVIOUS_CLOSE_MISSING, and SYNC_FAILED.
Persisted quote observations are read newest-first, but a non-finite or
non-positive quote is never selected. The dashboard instead uses the newest
valid prior observation for that holding; if none exists, it marks the holding
unavailable and reports a PARTIAL valuation rather than silently treating the
bad quote as a real price.
Reports foundation (Slice 1)
Slice 1 wires the already-populated portfolio.portfolio_snapshots table to
the dashboard. The scheduler in arb_bot/portfolio_app/scheduler.py writes
one row per hourly tick — arb_bot/telegram_handlers.py:1384 calls
capture_portfolio_snapshot(manager, repo, "Networth") so a "Networth"
aggregate series is always present when the bot is running.
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/portfolio/reports/snapshots?portfolio=&from=&to=&limit= | Time-series of snapshots for a portfolio (default Networth) |
| GET | /api/portfolio/reports/snapshots/latest?portfolio= | Most recent snapshot, or 404 if none |
portfolio is optional — omit it for the Networth aggregate. from/to
are ISO-8601 datetimes. limit caps the most recent N rows after the window
filter (oldest-first ordering within the window is preserved).
Snapshot shape
Each snapshot carries current_value, total_invested, total_profit,
total_profit_pct, total_dividends, xirr, holdings_count, plus the
recorded timestamp. The xirr column is computed by the existing
arb_bot/portfolio_app/analytics.py::compute_xirr helper at capture time —
the reports API reads it verbatim, never recomputes.
Frontend wiring
frontend/src/portfolio/pages/Reports.tsx was previously a dead stub
(useState<Snapshot[]>([]) populated by nothing). Slice 1 replaces the local
state with usePortfolioReport('/reports/snapshots', { portfolio, limit: 500 })
from frontend/src/portfolio/hooks/usePortfolioReport.ts, then renders the
series through the reusable <PerformanceLine> chart in
frontend/src/portfolio/components/charts/PerformanceLine.tsx. Every later
report slice (Performance, Diversification, Attribution, …) reuses the same
hook and chart primitives.
Returns & Benchmark (Slice 2)
Slice 2 adds the Returns page (/portfolio/returns) and the
GET /api/portfolio/reports/performance endpoint: money-weighted and
annualized returns compared against a market benchmark, with alpha.
Methodology
| Metric | Definition |
|---|---|
| XIRR (money-weighted return) | Solves the IRR of dated cashflows: BUY/CASH_IN are outflows (−), SELL/CASH_OUT/DIVIDEND are inflows (+), and the current portfolio value is a terminal inflow. Reuses analytics.compute_xirr. Reported since inception. |
| CAGR | (current_value / total_invested) ^ (1 / years) − 1, where years spans the first snapshot to now. |
| Total return | (gain + dividends) / invested, split into capital return (gain / invested) and dividend return (dividends / invested). |
| Period returns (1M/3M/6M/1Y/3Y/Max) | (end_value − start_value + dividends_in_window) / start_value, using the snapshot at/before the window start as start_value and the latest snapshot as end_value. dividends_in_window is the delta of cumulative dividends across the window. |
| Alpha | total_return − benchmark_return over the same window. |
All figures are in percentage points (e.g. 12.5 = 12.5%). The comparison
chart normalizes both the portfolio and the benchmark to base 100 at the start
of the window.
Endpoint
GET /api/portfolio/reports/performance?portfolio=&from=&to=&benchmark=NIFTY_50
returns { xirr_pct, cagr_pct, total_return_pct, capital_return_pct, dividend_return_pct, alpha_pct, periods, series, benchmark }. series and
benchmark.series are RAW values (portfolio current_value, index close); the
frontend <ReturnsComparisonChart> normalizes each to base 100.
Benchmark data
- Default benchmark: NIFTY 50 (
^NSEI). SENSEX (^BSESN) is also available. - Closes live in the new
portfolio.benchmark_snapshotstable (one row per index per day). - A scheduler job (
daily_benchmark_snapshot, registered inPortfolioScheduler) fetches the last N daily closes once per evening after market close (PORTFOLIO_BENCHMARK_FETCH_TIME_IST, default 18:30 IST) viayfinance— the same source the Telegram/benchmarkchart uses. The fetch is resilient: on any failure it logs and retries the next evening; it never blocks the scheduler. - The Returns page reuses the Slice 1
usePortfolioReporthook and a new base-100<ReturnsComparisonChart>atfrontend/src/portfolio/components/charts/ReturnsComparisonChart.tsx.
Diversification & Rebalancing
The Diversification page (/portfolio/diversification) breaks a portfolio
down by asset class and sector, ranks holdings by weight, and surfaces a
Herfindahl concentration index (0 = fully diversified, 1 = a single position)
plus human-readable concentration warnings.
GET /api/portfolio/reports/diversification?portfolio=<name>returns{ asset_class, sector, holdings, concentration, herfindahl, warnings }. The breakdown wrapsportfolio_app.analytics.asset_class_breakdown/sector_breakdown/concentration_warnings; Herfindahl and the holdings ranking are the only new math.portfolio=Networth(or omitted) aggregates across all holdings; USD holdings are normalized to INR.
The Rebalance tab compares the current asset-class allocation against a per-portfolio target allocation and suggests the INR buy/sell per bucket to move current → target (proportional drift-correction, no optimization).
GET /api/portfolio/reports/rebalance?portfolio=<name>returns{ has_targets, targets, current, drift, suggested_trades, max_drift_pct }. With no target set the response ishas_targets=falseand the UI shows a "set a target allocation" prompt.- Target weights are stored as JSON on
portfolio.portfolios.target_allocation_json({bucket_name: target_pct}) and edited viaPUT /api/portfolio/v2/portfolios/{id}with a{ target_allocation: {...} }body (weights must sum to ~100). There is no Networth aggregate target, so rebalance targets apply to individual portfolios only. unmatched_targetslists target buckets that don't match any holding's current asset class (usually a bucket-name typo, e.g. target "Indian Equity" vs holding asset class "Equity"); the UI warns so you can correct the names.
Note: this portfolio-level, per-bucket rebalance is distinct from the Telegram bot's per-holding
rebalancer.compute_rebalance_actions, which readsHolding.target_allocation_pctand is unchanged.
Fund and ETF overlap
The Diversification allocation tab also highlights duplicated look-through
stock exposure across direct equity, mutual funds, and ETFs. GET /api/portfolio/reports/overlap?portfolio=<name> returns { exposed_stocks, warnings, covered_funds, missing_funds }. Each source weight is its effective
percentage of the portfolio: a fund worth 40% of the portfolio with a 10%
allocation to a stock contributes 4%.
Constituents are intentionally best-effort manual data. The app does not
scrape AMC fact sheets or make broker calls while rendering a report. Upload a
verified snapshot with PUT /api/portfolio/reports/fund-holdings/<fund_symbol>:
{
"as_of": "2026-07-09",
"holdings": [{ "stock_symbol": "RELIANCE", "weight_pct": 10.5 }],
"source": "MANUAL"
}
The request replaces that fund's snapshot for the supplied date and rejects
weights above 100%. Reports use only the newest snapshot per held wrapper.
missing_funds makes incomplete constituent coverage explicit; it never means
that a fund has zero overlap. fund_snapshots returns each covered wrapper's
as_of date and is_stale status, and a stale-data warning appears after
PORTFOLIO_FUND_HOLDINGS_STALE_DAYS (default 7). A separate warning appears
when a duplicated stock's total effective exposure meets
PORTFOLIO_OVERLAP_WARNING_THRESHOLD_PCT (default 5%).
Sector auto-enrichment
The sector breakdown depends on Holding.sector, which broker syncs do not
populate. A daily PortfolioScheduler job (daily_sector_enrichment, ~18:45 IST)
backfills it from yfinance .info for listed-equity holdings missing one — the
same symbol resolution and resilient-fetch pattern as beta enrichment
(arb_bot/portfolio_app/sector.py). The backfill now retries transient misses
for a few passes before giving up, so a temporary yfinance/NSE lookup failure
does not leave an entire Dhan portfolio stuck in Unknown. Funds, bonds,
commodities, and unlisted holdings are skipped by the enrichment job itself,
but the report UI no longer collapses them into Unknown: non-equity sleeves
are shown under their asset class names in the sector chart, while genuine
equity rows with no sector still appear as Unknown. The job only enriches
empty sectors, so it auto-targets newly-synced assets. A second one-shot job
(boot_sector_enrichment) fires ~PORTFOLIO_SECTOR_BOOT_DELAY_MIN minutes
after each restart so freshly synced holdings are classified without waiting for
the evening cron (set the delay to 0 to disable). Dashboard-only deployments
also start the same boot-time worker from dashboard_server.py, so the
classified sector values stay fresh even when the trading bot process is not
running. Sector ETFs also fall back to the official NSE ETF list when yfinance
does not expose a sector; for example, HEALTHIETF is classified from its NSE
underlying index metadata instead of being left blank. Precious-metal symbols
such as XAUUSD and XAGUSD are reclassified as Commodity at import time so
they no longer flow into the unknown bucket. US ETFs with no sector data are
shown as US Equity rather than Unknown so the chart reflects the sleeve type
instead of a missing-data bucket. Configure via
PORTFOLIO_SECTOR_FETCH_TIME_IST, PORTFOLIO_SECTOR_FETCH_DELAY_S, and
PORTFOLIO_SECTOR_BOOT_DELAY_MIN.
Attribution, Risk & FX (Slice 4)
Attribution (F3)
GET /reports/attribution?portfolio= ranks each holding's contribution to
total portfolio return: contributed_pct = gain / total_invested * 100 where
gain = current_value - cost_basis and both are FX-normalized INR values
computed the same way as /reports/diversification. top_contributors /
top_detractors are the top 5 by contributed_pct in each direction.
Holdings with no cost basis (avg_buy_price unset) are excluded — there is
nothing to attribute against. Unlike the spec's original route shape, this
endpoint does not take from/to: attribution is computed from each
holding's current avg_buy_price (a running average with no historical
point-in-time cost basis), so a date-windowed variant isn't meaningful yet.
Risk metrics (F4)
GET /reports/risk?portfolio=&from=&to= computes three figures from the
hourly portfolio_snapshots series, downsampled to one point per calendar
day (the last snapshot of each day — hourly-to-hourly noise is not a
meaningful volatility input):
- Max drawdown — largest peak-to-trough decline in the daily series, as a negative percentage.
- Annualized volatility — population stdev of daily returns ×
sqrt(PORTFOLIO_RISK_TRADING_DAYS_PER_YEAR)(default 252), as a percentage.nulluntil at least 2 daily returns (3 daily snapshots) exist. - Sharpe ratio —
(CAGR − PORTFOLIO_RISK_FREE_RATE_PCT) / volatility,nullwhenever volatility or CAGR is unavailable. The risk-free rate defaults to 6% (an India 10Y G-Sec proxy) and is configurable.
FX gains breakdown (F5)
Folded into /reports/performance's response as fx_return_pct /
fx_estimated. For USD-currency holdings, splits INR gain into a capital
component (price appreciation in USD, converted at the current USD/INR
rate) and an FX-translation component
(cost_basis_usd × (current_fx − PORTFOLIO_USD_TO_INR_REFERENCE_FX)).
Known limitation: the schema has no historical per-transaction FX rate,
so the FX split uses a single configured reference rate
(PORTFOLIO_USD_TO_INR_REFERENCE_FX, default 82.0) as a stand-in for the
true buy-time rate rather than the actual historical rate on each purchase
date. fx_estimated=true on the response signals this approximation.
fx_return_pct is null when the portfolio holds no USD-currency
positions.
Income projection, yield & calendar (Slice 5)
Slice 5 extends the existing Income page with read-only planning and yield
analytics. It reuses portfolio_income_events, holdings, SIP plans, and goals;
there are no new tables, migrations, broker calls, or trading actions.
| Endpoint | Contents |
|---|---|
| GET /api/portfolio/reports/income-projection?portfolio=&months=12 | Expected dividend/coupon cashflows grouped by calendar month, type, and symbol. |
| GET /api/portfolio/reports/dividend-yield?portfolio= | Trailing-paid-dividend yield table for current holdings. |
| GET /api/portfolio/reports/calendar?from=&to= | Income dates, SIP due dates, and active goal deadlines in one dated feed. |
income-projection includes only non-paid expected states (FORECAST,
DECLARED, and ENTITLED) whose payment date lands in the requested next
calendar months. It returns every requested calendar month, including explicit
zero-income months, so the dashboard has a continuous forward timeline. A
response keeps amounts in by_currency; it never adds USD and INR into a
misleading single total. expected_amount is populated only when a month has
one currency, and the dashboard draws a separate bar for each currency.
The dividend-yield endpoint uses PAID dividend events with payment dates in
the trailing 12 months. For each current holding:
- Yield on cost = trailing-12-month paid dividends ÷ current holding cost
basis (
quantity × avg_buy_price). - Current yield = the same paid dividends ÷ current market value
(
quantity × current_price). - Trend = paid dividend amounts per unit for the two preceding calendar years plus the current year marked YTD, so a partial current year is not mistaken for a completed-year decline.
Either yield is null when its denominator is unavailable or zero. These are
cash-income measures, not total return, and they do not estimate future
dividend changes.
The unified calendar contributes a separate row for payment, ex-, and record
dates of each non-cancelled income event; expands active monthly or quarterly
SIP plans on the day-of-month in start_date; and includes active goals with a
target date. Quarterly SIP installments are three times the configured monthly
amount, and configured step-ups compound at their configured frequency.
Bond holdings with a maturity date, face value, coupon rate, and coupon frequency also contribute derived coupon dates (walking backwards from maturity) and a principal maturity row. A materialized coupon income event takes precedence over the derived coupon for the same holding/date, avoiding duplicates. Rows are de-duplicated by source/date/type and sorted ascending. Without a window it defaults to today through the next 90 days. All reporting routes remain authenticated through the portfolio router mount and are read-only.
Tax lots & capital gains (Slice 6)
GET /api/portfolio/reports/tax-lots?portfolio=&fy=2025-26 reconstructs FIFO
buy lots from the transaction ledger, consumes them for sales, and reports
realized LTCG/STCG plus remaining lots as of the selected FY end. Buy-side
fee_tax increases cost basis and sell-side fee_tax reduces proceeds. A sale
without enough preceding known buys is surfaced in unmatched_sells rather
than assigned a fabricated cost basis.
The India-specific classification uses current holding asset classes and the
named capital-gains thresholds/rates in Config. Equity lots become LTCG only
when held more than 12 months; debt/bond lots use 36 months. The endpoint is
read-only and does not persist reconstructed lots. See
Portfolio Tax Lots for exemptions, grandfathering, and
the important STT/FMV ledger limitations.
Config
PORTFOLIO_RISK_FREE_RATE_PCT = 6.0 # India G-Sec proxy for Sharpe
PORTFOLIO_RISK_TRADING_DAYS_PER_YEAR = 252
PORTFOLIO_USD_TO_INR_REFERENCE_FX = 82.0 # approximate buy-time reference; see limitation above
Isolation invariant
reports_api is portfolio-only and MUST NOT import arb_bot.bot,
arb_bot.execution.engine, arb_bot.command_handler, arb_bot.client, or
dashboard_server. This is enforced two ways:
- Static —
tests/portfolio_app/test_reports_api_isolation.pyAST-scans every module underreports_api/for forbidden imports. - Dynamic — the same test imports each module and asserts no live-trading
module leaks into
sys.modules. The portfolio-widetests/portfolio_app/test_import_isolation.pyincludes the new modules in itsPORTFOLIO_APP_MODULEStuple so a regression fails the broader suite too.
This mirrors the research subsystem's
tests/research/test_import_isolation.py rule: reporting is read-only and
must never reach into the order-placing orchestrator.
Roadmap (per-feature slice)
| Slice | Routes | New tables |
|---|---|---|
| Slice 1 (this doc) | /reports/snapshots, /reports/snapshots/latest | none |
| Slice 2 | /reports/performance, /reports/benchmark | portfolio.benchmark_snapshots |
| Slice 3 | /reports/diversification, /reports/rebalance | none (target_allocation_json column) |
| Slice 4 | /reports/attribution, /reports/risk, FX in /reports/performance | none |
| Slice 5 | /reports/income-projection, /reports/dividend-yield, /reports/calendar | none |
| Slice 6 | /reports/tax-lots | none |
| Slice 7 | /reports/networth, liability CRUD, /reports/goals/{id}/history, /reports/goal-scenario | portfolio.liabilities, portfolio.goal_snapshots |
| Slice 8 | /reports/export, /reports/activity | none |
| Slice 9 | /reports/overlap | portfolio.fund_holdings |
Each row maps a future docs subsection to its slice. This page will be extended as those slices ship.
Net worth, liabilities, and goal history
The Net Worth view aggregates the current value of all portfolio holdings and subtracts user-maintained liabilities. Liabilities are never inferred from broker data: add loans, mortgages, credit cards, or other debts explicitly, including their outstanding balance and currency. USD holdings and liabilities are normalised using the portfolio reporting FX rate.
GET /api/portfolio/reports/networth returns the assets, liabilities, net worth identity, account breakdown, and existing Networth snapshot history. Until liability history has accumulated, historical asset snapshots are reduced by the current recorded liabilities; the API does not invent past debt values. Liability records use the authenticated CRUD API at /api/portfolio/liabilities.
Active goals receive one computed progress snapshot per day when portfolio snapshots run. GET /api/portfolio/reports/goals/{id}/history exposes this series. For corpus goals, POST /api/portfolio/reports/goal-scenario estimates a non-persistent what-if outcome from goal_id, monthly_sip, optional annual step_up_pct, and optional years; it does not alter goal or SIP-plan settings.
Exports & activity (Slice 8)
GET /api/portfolio/reports/export?report=performance|tax|income|diversification&portfolio=&format=csv|pdf&fy=&from=&to= downloads the same data exposed by the corresponding report endpoint. CSV is UTF-8 (with a spreadsheet-friendly BOM) and contains the complete row set. PDF is intentionally a compact one-page summary with key figures and the first 18 detailed rows; use CSV for the complete ledger. The route is read-only and never calls a broker.
GET /api/portfolio/reports/activity?portfolio=&from=&to=&type=sync|trade|income|edit is a reverse-chronological provenance trail. It combines broker-sync state, imported transactions, income-event updates, and holding-update timestamps. A sync-state read failure is contained: it is logged and does not prevent the rest of the activity feed from loading. Date bounds are ISO-8601 datetimes and type filtering happens on the server.