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
- A lot's ID is the buy trade's ID (the broker trade ID, or
H+ a 10-hex fingerprint). - Bonus lots get
BONUS-<ISIN>-<date>. - Splits and mergers keep the ID, so specific-ID config and
explainoutput stay stable.
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
- LTCL → LT buckets only; STCL → any bucket;
- set-off is mandatory (no voluntary carry forward while gains exist);
- the 112A exemption applies to what remains after set-off.
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:
- 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). - Sort holdings by the gain per unit of their first lot, descending.
- 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").