Data to 5 October 2026

Pre-registered research

The portfolio library: architecture

Written 2026-10-02 by lane 2 (quant). This is the design of pipeline/tipsheet/lab/, the library behind the portfolio lab v2 (portfolio_lab_v2_spec.md) and the SIP studies (sip_studies_spec.md). The v1 script (compute/portfolio_lab.py) stays in place until v2 is merged and the site has moved to the v2 bundles.

Why a library

The v1 lab was one script with nine portfolios hard-wired into if branches. Adding a portfolio meant editing the simulator, the tax code lived in two copies, and the only cash-flow pattern was a lump sum. The owner’s priorities (a full library of allocation models, SIP outcome distributions, Indian tax by date, honest multiple-testing statistics) need building blocks that compose:

asset universe ──► allocation rule ──► rebalancing policy ──┐
                                                            ├──► engine ──► result ──► evaluators ──► bundles
cash-flow schedule ──► cost model ──► tax engine ───────────┘

Every portfolio is a declarative entry in lab/portfolios.yaml. The registry turns an entry into a rule plus a rebalancing policy, and the same entry is rendered on the site as the portfolio’s construction note. Adding a portfolio means adding an entry, not code, unless it needs a rule type that does not exist yet.

Modules

ModuleHoldsKey objects
lab/universe.pyThe asset registry and the daily panelAsset(key, label, source, tax_class, cost_class, live_date, backfilled), load_panel(keys)
lab/costs.pyRunning costs by date, trading costs, stamp duty, STTCostModel.drag(asset, d), CostModel.trade(asset, side, d)
lab/tax.pyIndian capital-gains rules by date, FIFO lots, the financial-year ledgerTaxRules, Ledger, Ledger.settle_fy()
lab/rules.pyAllocation rules: static, risk-based, tactical, ensembleRule.targets(view, d) -> {asset: weight}
lab/optimize.pyLong-only optimisers and estimators (Ledoit-Wolf, ERC, min variance, max diversification, HRP, mean-variance, Black-Litterman)pure numpy functions
lab/rebalance.pyWhen to trade towards targetsCalendar, Bands, CashFlowOnly, Never
lab/flows.pyCash-flow schedulesLumpSum, SIP(amount, day, step_up), STP, Pausing
lab/engine.pyThe simulatorsimulate(spec, panel, flows, start, end) -> Result
lab/evaluate.pyMetrics and statisticsCAGR, XIRR, risk, drawdown anatomy, rolling windows, calendar years, regimes, deflated Sharpe, PBO/CSCV, block bootstrap
lab/registry.py + lab/portfolios.yamlDeclarative portfolio specsload_registry() -> {code: PortfolioSpec}
lab/sip.pyThe SIP study runnerrolling-start SIPs at several horizons
lab/run.pyThe lab v2 runnerwrites .cache/derived/lab_v2_*.parquet

The data contract inside the library

The engine

An event loop over the sessions where something happens (a trade, a cash flow, a financial-year end), with the wealth path between events valued in one vectorised step (units are fixed between events). Each event, in order:

  1. Cash flow in: the instalment buys assets. Under the default cash-flow policy it goes to the sleeves furthest below target first (“cash-flow rebalancing”), so a SIP into a multi-asset portfolio sells less.
  2. Rebalance: if the policy says so, sell overweight sleeves (lots go through the tax engine; the tax is accrued, not paid yet) and buy underweight ones with the proceeds net of costs.
  3. Financial-year end (31 March, or the last session before it): the ledger settles the year’s realised gains with Indian set-off rules and the optional exemption, and the tax is paid by selling every sleeve pro rata. Gains realised by that sale fall into the next year’s ledger, which is how a real investor pays tax from the portfolio.
  4. Withdrawals (SWP) are sales like any other.
  5. Liquidation: at the end of a window everything is sold and that final year is settled at once, so buy-and-hold pays its deferred tax too.

The engine records the daily wealth, month-end weights, every trade, costs paid, tax paid by year, turnover, and the external cash flows (for XIRR).

The tax engine

lab/tax.py replaces the simplified compute/aftertax.py for v2 work (the trend models keep using the old one until they are moved). It encodes, by sale date and buy date:

Every rule carries its source in a comment and a worked example in tests/test_lab_tax.py.

Evaluators

Return measures (CAGR for lump sums, XIRR for flows; pre-tax, after-tax, and real after CPI), risk (volatility and downside deviation from monthly returns, Sharpe and Sortino over the T-bill, Ulcer index), drawdown anatomy (each drawdown’s peak, trough, recovery and length), rolling windows (3, 5, 10 years), calendar years, regime splits, tracking error and beta against 60/40, turnover, cost drag and tax drag, start-date sensitivity, and the multiple-testing set: the deflated Sharpe ratio (Bailey and López de Prado 2014), the probability of backtest overfitting by combinatorially symmetric cross-validation (Bailey, Borwein, López de Prado and Zhu 2017), and stationary block-bootstrap confidence intervals (Politis and Romano 1994).

Tests

pipeline/tests/test_lab_*.py: worked tax examples for each regime, XIRR against closed forms, rebalancing arithmetic (wealth conservation with zero costs, bands that trigger only outside the band), the look-ahead guard, optimiser sanity (ERC equalises risk contributions, min variance beats any random long-only mix), registry round-trips, and the SIP study’s window bookkeeping.

What it does not do (yet)

This is docs/research/portfolio_library.md. The specification was committed before any result was computed; changes after that are logged in it with dates and reasons.