Design

Data flow

tradebook CSVs --(profile TOML)--> importer --> SQLite store (trades, deduped)
                                                        |
asset master + corporate actions + taxlots.toml -------+--> Engine --> Ledger
                                                                        |  sales (with per-lot matches)
                                                                        |  intraday trades
                                                                        |  open lots
             gains / schedule112a / explain  <-- reports  <-------------+
             summary                         <-- summary (set-off, exemption, tax)
             holdings / harvest              <-- reports / harvest (+ prices CSV)

Only trades go into the database. The asset master, corporate actions and prices are read from files on every run, so if you fix an FMV or add a split, every report picks it up straight away without a re-import. The whole ledger is rebuilt from scratch on each command. That takes milliseconds for tens of thousands of trades, and it avoids keeping stale derived state in the database.

Module map

Module Responsibility
rules.py Pure tax law: FY arithmetic, CII table, holding periods, treatment() (section, rate, indexation), 112A exemption limit. No I/O.
models.py Dataclasses: Trade, Asset, CorporateAction, Lot, LotMatch, Sale, IntradayTrade, Ledger.
importer.py CSV → Trade using a column-mapping profile; built-in zerodha, generic; content fingerprint IDs.
store.py SQLite trade store, INSERT OR IGNORE dedupe on trade ID.
master.py Loaders for assets (TOML/CSV), corporate actions, prices, taxlots.toml.
engine.py Intraday split, event replay, FIFO/specific-ID matching, evaluate() for one lot slice.
summary.py FY bucketing, set-off, exemption allocation, carry-forward, tax estimate.
harvest.py FIFO-prefix greedy tax-gain harvesting.
reports.py Row builders (gains, holdings, Schedule 112A), Indian number formatting, explain narrative.
cli.py Click commands; thin — only parsing and rendering.

Engine: event replay

Trades are first passed through split_intraday, which takes the same-day matched quantity of listed equity out as speculative trades. The rest becomes a sorted event stream:

Order on a date Event Why
0 corporate actions ex/allotment date: holdings at the start of the day are entitled; shares bought that day are not
1 buys a same-day delivery buy (MF, or equity left over after intraday netting) must exist before a sell can draw on it
2 sells
3 FMV stamp (31-Jan-2018 only) runs after that day's trades, so a lot bought on 31-Jan-2018 qualifies

Why stamp FMV instead of looking it up at sale time? The asset master stores the FMV as quoted on 31-Jan-2018. A lot's per-unit FMV has to follow whatever happened after that date: splits divide it, and mergers move it to a new ISIN at the swap ratio. Stamping the quoted value onto the lots open at that moment, and then letting the same corporate-action code that rescales quantity also rescale FMV, gets both directions right with no special cases. A split before 2018 does not touch the FMV (it has not been stamped yet), which is correct because the 2018 quote is already post-split.

Every lot carries a history list (FMV stamped, split, merger), which explain prints. This lets a user check why a lot of 100 HDFCBANK has an FMV of 995 when the master says 1,990.

Lot identity

The fingerprint is sha1(account|date|isin|side|qty|price|charges|n), where n counts identical rows within the file. Re-importing a file is idempotent, while two genuinely identical fills on the same day are both kept.

Decimal arithmetic

All money and quantities are Decimal end to end. Values are only rounded (half-up, paise) when shown, so per-lot pro-rating never loses paise. test_fifo_across_partial_lots... checks that 606 × 20/50 is exactly 242.40, with 363.60 left on the lot.

Set-off algorithm

The problem: assign losses to gain buckets so that tax is minimal, subject to

The 112A exemption is modelled as a zero-rate slice at the bottom of each 112A bucket. Each loss pool (current LTCL, current STCL, b/f LTCL, b/f STCL) walks its eligible buckets by rate, highest first: first the taxable slices, then the exempt slices. Moving a loss into an exempt slice turns that exemption into set-off, so the total stays consistent. Because LT-eligible ⊂ all-eligible, settling the more constrained LTCL pool first and then STCL greedy is optimal. A pairwise exchange argument shows that no swap of assignments can lower the tax.

Gains and losses inside one bucket are netted first, like the ITR's Schedule CG. Sec 10(38) buckets (exempt income) are left out of set-off entirely.

Harvest optimiser

The objective is to get as close as possible to target − realised without going over, while trading as few units as possible. FIFO means each holding gives an ordered list of (units, gain per unit) steps that can only be consumed from the front. The greedy:

  1. For each holding, keep the prefix of lots that are 112A-long-term on the planned date and have positive gain per unit (computed with evaluate(), so grandfathering applies).
  2. Sort holdings by the gain per unit of their first lot, descending.
  3. For each holding, consume lots in order, taking ⌊budget / gain-per-unit⌋ units (0.001 for MF units). Stop at the first partly taken lot, since FIFO makes later lots unreachable.

The leftover is always less than the smallest per-unit gain among the remaining reachable lots, which in practice is a few rupees. An exact knapsack would buy nothing material and would ignore the "fewest units" preference.

Profiles

date_format = "%d/%m/%Y"         # optional; otherwise several common formats are tried
[columns]                        # canonical -> CSV header
date = "Date"
symbol = "Stock"
isin = "ISIN"
side = "Type"
qty = "Qty"
price = "Rate"
charges = "Charges"              # optional
account = "Account"              # optional; else --account
trade_id = "Trade ID"            # optional; else fingerprint
[side_values]
buy = ["BUY", "B"]
sell = ["SELL", "S"]

Pass a path (--profile groww.toml), or register a name in taxlots.toml under [profiles] (groww = "profiles/groww.toml").