Portfolio Reports


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_snapshots table (one row per index per day).
  • A scheduler job (daily_benchmark_snapshot, registered in PortfolioScheduler) fetches the last N daily closes once per evening after market close (PORTFOLIO_BENCHMARK_FETCH_TIME_IST, default 18:30 IST) via yfinance — the same source the Telegram /benchmark chart 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 usePortfolioReport hook and a new base-100 <ReturnsComparisonChart> at frontend/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 wraps portfolio_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 is has_targets=false and 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 via PUT /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_targets lists 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 reads Holding.target_allocation_pct and 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. null until at least 2 daily returns (3 daily snapshots) exist.
  • Sharpe ratio(CAGR − PORTFOLIO_RISK_FREE_RATE_PCT) / volatility, null whenever 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:

  1. Statictests/portfolio_app/test_reports_api_isolation.py AST-scans every module under reports_api/ for forbidden imports.
  2. Dynamic — the same test imports each module and asserts no live-trading module leaks into sys.modules. The portfolio-wide tests/portfolio_app/test_import_isolation.py includes the new modules in its PORTFOLIO_APP_MODULES tuple 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.