Back to catalog

portfolio-tracking

Track Indian portfolio holdings, create snapshots, and generate weekly news digests.

Category πŸ’° Wealth

Portfolio Tracking

When to use

When Joseph provides holding updates (quantity changes, new purchases, avg price changes) and wants to maintain the tracking file and generate a dated portfolio snapshot. Also when generating the weekly portfolio news digest (every Sunday via cron). This is the recurring maintenance workflow β€” distinct from quarterly analysis (see portfolio-analysis).

Key Files

  • wiki/wealth/invest/portfolio/holdings.csv β€” master holdings (source, code, ticker, name, qty, cost_price, asset_class)
  • wiki/wealth/invest/portfolio/tracking/YYYY-MM-DD-portfolio.md β€” dated snapshots
  • wiki/wealth/invest/portfolio/news/YYYY-Wnn-weekly-news.md β€” weekly news digests (ISO week number)

Workflow

Automated Weekly Snapshot (cron)

The portfolio snapshot runs weekly via cron (Sundays 13:30 UTC).

  • Job ID: de96312284a9 ("Portfolio Weekly Snapshot")
  • Script: python3 /opt/data/scripts/portfolio-report.py
  • Deliver: slack:C0BNN2Z950V
  • Schedule: 30 13 0 (weekly, Sunday)
  • Script behavior: Reads holdings.csv, fetches Yahoo Finance (stocks/ETFs) + AMFI (MFs), writes YYYY-MM-DD-portfolio.md, outputs Slack-formatted summary to stdout.
  • The agent prompt runs the script and delivers stdout verbatim β€” the script's output IS the Slack message.

To test without writing wiki files: python3 /opt/data/scripts/portfolio-report.py --dry-run

To debug delivery failures: check cronjob_manage(action='list') β†’ look at last_status, last_error, last_delivery_error. Then read the job JSON at /opt/data/cron/jobs.json for full error context.

Manual Update (when Joseph provides holding changes)

Option A: Sync from hledger (preferred)

When hledger has been updated with new transactions:

1. Run sync script: python3 /opt/data/scripts/update-holdings.py

2. Script queries hledger for quantities (commodity balances) and computes per-person cost prices dynamically from transaction data

3. Updates tracking/holdings.csv with new schema (source, holder, code, name, qty, cost_price, asset_class, yahoo_symbol, exchange, amfi_code)

4. Verify: spot-check 3 holdings, confirm Stock costs: N person:stock combinations is non-zero in script output

Frequency: once or twice a month (Joseph invests, doesn't trade). Run before weekly portfolio cron.

How it works:
hledger (source of truth) β†’ update-holdings.py β†’ tracking/holdings.csv β†’ portfolio-report.py β†’ portfolio values
What the script does:

1. Queries hledger for quantities per person per fund/stock (commodity balances)

2. Computes per-person cost prices from hledger -B flag (MFs) and journal lot parsing (stocks)

3. Enriches with metadata (name, yahoo_symbol, amfi_code, exchange) from hardcoded lookup tables

4. Adds FD/Bond flat entries (hardcoded until hledger import)

5. Writes updated tracking/holdings.csv

Limitations:
  • ICICI MFs are hardcoded (not tracked as commodities in hledger)
  • FDs and bonds are hardcoded until hledger import is set up
  • New funds must be added to lookup tables in the script before sync

Option B: Manual update

When Joseph provides holding changes directly:

1. Parse Joseph's update: fund/stock name, new units, new avg price, total invested value

2. Locate the matching row in holdings.csv (match by name or scheme code)

3. Patch the row: update qty and cost_price columns

4. Verify the patch landed correctly (read back the affected lines)

holdings.csv format: source,code,nse_ticker,yahoo_symbol,name,qty,cost_price,asset_class

After running update-holdings.py

1. Verify holdings.csv has correct FD/Bond entries with current values: grep 'fd\|bond' tracking/holdings.csv

2. Verify ITCHOTELS (or any new demerged stock) appears: grep ITCHOTELS tracking/holdings.csv

3. Spot-check 3 holdings: qty and cost_price match hledger

4. Then run python3 /opt/data/scripts/portfolio-report.py to regenerate the portfolio file

  • Stocks/ETFs: Yahoo Finance v8 chart API (requires crumb auth β€” see Pitfalls)
  • MFs: AMFI portal.amfiindia.com/spages/NAVAll.txt (semicolon-delimited)

Batch-fetch all tickers in one script. Do not make serial curl calls (times out).

3. Generate portfolio snapshot

1. Read holdings.csv for all quantities and cost prices

2. Compute weighted-average buy price for stocks held in both ICICI and Zerodha (e.g., Coal India, GAIL, NMDC)

3. Value = qty Γ— CMP (stocks/ETFs) or units Γ— NAV (MFs)

4. P&L = value - (qty Γ— cost_price)

5. Asset allocation: stocks %, ETFs % (with gold/silver/index sub-breakdown), MFs %

6. Write YYYY-MM-DD-portfolio.md following the template (see references/snapshot-template.md)

4. Confirm to Joseph

  • Summary table: total value, P&L, asset allocation
  • Day-over-day change if previous snapshot exists
  • Note any NAV lag (PPFAS funds publish NAV with2-day delay)

Pitfalls

Cron delivery format

  • The deliver value in cron jobs MUST be platform:chat_id (e.g., slack:C0BNN2Z950V), not a bare channel ID.
  • A bare ID causes blocked_config error: "delivery platform 'C0BNN2Z950V' is not a known cron delivery target".
  • The script may run fine and write wiki files, but the Slack summary never arrives β€” the delivery failure is silent from the user's perspective.
  • On thread/topic platforms, delivery without a :thread_id segment lands in the main chat, not a topic.

Yahoo Finance API auth

  • v7/finance/quote returns 401 Unauthorized β€” do not use this endpoint
  • Use v8/chart with crumb auth:

1. GET https://fc.yahoo.com (ignore error, just need cookie)

2. GET https://query2.finance.yahoo.com/v1/test/getcrumb (returns crumb string)

3. Use http.cookiejar + urllib.request.build_opener to maintain session

4. Fetch: https://query2.finance.yahoo.com/v8/finance/chart/{ticker}?range=1d&interval=1d&crumb={crumb}

  • For specific date prices: use period1/period2 timestamps instead of range=1d
  • Write the fetch script to a file and run it β€” f-string escaping in inline scripts is fragile

AMFI NAV format

  • URL: https://portal.amfiindia.com/spages/NAVAll.txt
  • Semicolon delimited β€” Fields: Scheme Code;ISIN Payout;ISIN Reinvest;Scheme Name;Plan;Option;NAV;Date
  • Filter by scheme code using awk for speed (not Python line-by-line)
  • PPFAS funds have2-day NAV lag β€” snapshot may show older NAV for Parag Parikh funds. Note this.

Number formatting

  • Indian lakhs/crores: β‚Ή1,36,96,321 (not β‚Ή13,696,321)
  • Format: last 3 digits comma-separated, then groups of 2 going left
  • Percentages: one decimal place, signed (+/-)

holdings.csv gotchas

  • Same stock in ICICI + Zerodha β†’ separate rows, merged in portfolio snapshot
  • MF scheme codes are the reliable identifier (names vary across platforms)
  • cost_price is average buy price, not current price
  • Asset classes: stock, etf, mf, fd, bond
  • hledger is source of truth for qty and cost_price β€” use update-holdings.py to sync
  • holdings.csv is the sole input to portfolio-report.py β€” the script does not compute or override cost_price; whatever is in the CSV appears in the snapshot. If a value changed between weeks, the CSV was edited (manually or via update-holdings.py), not recomputed by the report script.
  • Debugging unexpected cost_price changes between snapshots: (1) diff the two wiki snapshot files for the changed row, (2) check holdings.csv mtime (stat) to see if it was modified between the two snapshot dates, (3) check what update-holdings.py would produce β€” ICICI stocks use hardcoded ICICI_COST_PRICES (line ~243), Zerodha stocks use journal-parsed costs via get_all_stock_costs(). If the CSV value differs from what the script would produce, the CSV was manually edited. (4) Verify by running python3 /opt/data/scripts/update-holdings.py β€” it will overwrite with the script's values.
  • FDs and bonds use a current_value column in tracking/holdings.csv for accrued/maturity values. portfolio-report.py reads this column for FDs/bonds instead of falling back to cost. Current values must be updated manually in the hardcoded entries in update-holdings.py (FD accrued values change over time). The weekly cron uses portfolio-report.py which pulls live prices for stocks/MFs and uses current_value for FDs/bonds β€” no OCR screenshots needed.
  • Two holdings.csv files exist: portfolio/holdings.csv (old schema, no holder column, used by sync-holdings.py) and portfolio/tracking/holdings.csv (new schema with holder column, used by portfolio-report.py and the cron). The weekly cron reads from tracking/holdings.csv.

Diagnosing "invested" discrepancies with broker dashboard

When the portfolio file's "Price" (invested) column doesn't match the broker dashboard's invested amount:

1. Stocks match closely β€” differences under β‚Ή5K per stock are normal (brokerage/taxes in broker's calculation vs hledger's clean cost).

2. MFs diverge significantly β€” the cost_price in holdings.csv is a weighted average from hledger transactions. Zerodha calculates "invested" from actual transaction-level costs (including charges). For funds with many SIPs, the weighted average can diverge by β‚Ή4–5L per person.

3. Total invested may still match β€” the portfolio file total and Zerodha total can agree even when per-person breakdowns differ. This means the issue is cost allocation across accounts, not missing transactions.

4. Diagnosis steps:

a. Compute qty Γ— cost_price per holder from tracking/holdings.csv (use Python, not mental math β€” fractional units matter)

b. Compare against broker dashboard per-account invested amounts

c. Stocks will match closely; large gaps point to MF cost_price divergence

d. Re-sync with python3 /opt/data/scripts/update-holdings.py to pull fresh hledger quantities and per-person cost prices

e. If gap persists after re-sync, the hledger MF transactions may be missing or have incorrect cost entries β€” check the investment journals directly

5. The Individual Holdings table may show rounded quantities while calculations use exact fractional units from holdings.csv β€” this is cosmetic, not a data error.

6. Duplicate flat INR entries from old imports β€” when convert-zerodha-stocks.py generates commodity entries (with per-unit cost) from broker CSVs, the OLD flat INR entries for the same transactions may still exist in investments.journal for ANY year (2024, 2025, etc.). Both are included via main.journal, so hledger double-counts: the INR balance is inflated by the flat amounts, while commodity quantities are correct. Symptom: portfolio file's invested amount per person is higher than Zerodha dashboard. Fix: remove ALL flat INR entries (both BUYs and Sells) from the relevant investments.journal files β€” check every year's file, not just the most recent. After removal, update ALL subsequent balance assertions in balances.journal for the affected bank account: each assertion must be adjusted by the net amount of removed entries (sum of BUY debits minus SELL credits). Verify: hledger -f journals/main.journal bal Assets:Invest:Stocks:Zerodha:<Person> --no-total should show only commodity balances, no flat INR amounts. Also run hledger -f journals/main.journal bal to confirm no balance assertion errors.

7. MF redemptions recorded as flat INR β€” when MF sell/redemption transactions are imported as flat INR amounts to the parent account (Assets:Invest:MFs:Zerodha:Rani) instead of as commodity-level sells to the specific fund (Rani:AX-LF-DG), the fund's unit balance stays at the pre-sale quantity. The portfolio file will count redeemed units as still held. Symptom: large per-person invested gap between portfolio file and Zerodha dashboard, especially for funds with partial redemptions. Fix: convert flat INR redemption entries to proper commodity sells with correct unit quantities and lot costs from the source CSV (the CSV has quantity and price fields for sells).

8. Hardcoded cost prices shared across holders are wrong β€” the update-holdings.py script now computes per-person cost prices dynamically from hledger transaction data (-B flag for MFs, journal parsing for stocks). If the script still uses hardcoded COST_PRICES for Zerodha holdings, costs will be wrong because different people bought at different times/prices. Verify: the script output should show Stock costs: N person:stock combinations (non-zero). If it shows 0, the journal parsing regex needs fixing.

9. hledger -B shows phantom buy cost for fully-sold positions β€” when a commodity position is completely sold (qty=0), hledger bal -B on the sub-account may still display the original buy cost as INR while the commodity balance is 0 or negative. Do NOT sum sub-account -B balances to get total cost for an account with fully-sold positions β€” use only the active sub-accounts, or use the parent account's INR balance. Symptom: portfolio invested total is higher than broker dashboard because phantom costs inflate the sum.

10. Zerodha "invested" includes transaction charges; hledger does not β€” hledger records the clean share price per unit (the commodity line {PRICE INR}). Zerodha's dashboard "invested" includes STT (0.1% buy-side for equity), stamp duty (0.015%), exchange transaction charges, IPFT, SEBI charges, and GST on brokerage. This creates a persistent gap: Zerodha_invested - hledger_cost β‰ˆ clean_cost Γ— 0.12%. The gap is proportional to total stock value β€” small for small portfolios (β‚Ή1–2K on β‚Ή10L, invisible after OCR rounding), larger for bigger ones (β‚Ή7–8K on β‚Ή25L). Both JJU and Rani are affected; it's just invisible for Rani because her stock portfolio is smaller.

11. Tax P&L reports (CSV and HTML) only have sell-side charges β€” the Zerodha Tax P&L reports (taxpnl-.csv and taxpnl-.html) contain charge breakdowns (brokerage, STT, stamp duty, exchange charges, GST) only for closed/sold positions. The HTML version additionally has "Other Credits & Debits" (AMC, DP charges, delayed payment) but these are account-level, not transaction-level. Neither format has buy-side charges for open holdings. To reconcile the gap from pitfall #10, you need Contract Notes from Zerodha Console β†’ Reports β†’ Contract Notes. Contract Notes have per-trade charge breakdowns on both buys and sells.

12. Portfolio file "invested" comes from Zerodha OCR, not hledger β€” compile-portfolio.py reads Zerodha dashboard screenshots via OCR and uses the "Price" (invested) column directly. It does NOT compute invested from hledger. This means the portfolio file's "invested" will always include Zerodha's transaction charges, while hledger-based calculations (holdings.csv cost_price Γ— qty) will not. When comparing, expect a gap equal to total transaction charges across all holdings.

13. Corporate actions (demergers, splits, bonuses) may be missing from hledger β€” when a demergers allocates new shares (e.g., ITC Hotels from ITC demerger), Zerodha shows the new holding with a cost basis but hledger may not have the transaction. Symptom: total "invested" gap equals exactly the missing holding's invested amount. Diagnosis: screenshot the Zerodha dashboard by asset class (stocks/ETFs separately), compare each holding's invested against hledger's qty Γ— cost_price. A holding present in Zerodha but absent from hledger is a missing import. Fix: add the corporate action as a commodity entry in zerodha-stocks.journal with a balancing Income:CorporateActions:Demerger line (no bank debit β€” shares are allocated free). Add the account to accounts.journal and the ticker to update-holdings.py STOCK_NAMES.

14. Best way to verify "invested" match: per-asset-class screenshots β€” don't rely on the total alone. Screenshot Zerodha dashboard for MFs and stocks/ETFs separately. Compare each holding's invested against qty Γ— cost_price from holdings.csv. MFs typically match within rounding (Zerodha charges nil for direct plans). Stock gaps represent transaction charges. A gap that exactly equals a specific holding's value means that holding is missing from hledger.

15. Contract Notes (XML) have buy-side equity charges; Tax P&L does not β€” Zerodha Contract Notes (Console β†’ Reports β†’ Contract Notes) contain per-trade charge breakdowns on both buys and sells for equity trades. Tax P&L reports only have sell-side charges for closed positions. Contract Notes do NOT cover MFs (not exchange-traded). To reconcile the gap between hledger clean cost and Zerodha "invested", Contract Notes are the authoritative source for stock charges.

Weekly News Digest

A weekly digest covering news for ALL holdings (stocks, ETFs, MFs). Complements the price snapshot β€” snapshot tracks values, digest tracks events.

When to run

Every Sunday morning via cron. Covers the Mon–Sat trading week.

Workflow

1. Read holdings.csv for the full portfolio (stocks, ETFs, MFs).

2. Fetch price data via yfinance for all stock/ETF tickers (5-day period for WoW change).

3. Fetch news β€” yfinance .news attribute returns empty for Indian (.NS) tickers. Use web_search as fallback. Focus searches on significant movers (>2% WoW change) to avoid noise. Search queries: "{stock name} India stock news September 2026".

4. Get market context β€” Nifty 50, crude oil, gold, silver, Nasdaq 100 levels and WoW change via yfinance.

5. Write digest to wiki/wealth/invest/portfolio/news/YYYY-Wnn-weekly-news.md (date = ISO week number of today).

Format rules

  • Every holding gets a section β€” even if "No material news this week." Nothing should feel missed.
  • Stocks: individual sections with CMP, WoW %, and news bullets. Big movers (>5%) get ⚑ flag.
  • ETFs: single table with INR price, WoW change, underlying price, underlying WoW change. Flag currency impact if INR commodity ETFs diverge from USD underlying.
  • MFs: single table with notes column. Rarely have weekly news; quarterly holdings disclosures are the signal.
  • Market Context: Nifty, crude, gold, silver, Nasdaq + key events for the week.
  • Action Items: dividend record dates, ex-dates, AGMs, things to monitor.
  • Number formatting: Indian lakhs/crores, signed percentages (+/-), one decimal place.

Pitfalls

  • yfinance .news is empty for Indian tickers β€” always use web_search fallback. Don't assume the API will work.
  • Don't search news for ALL tickers β€” that's 25+ web searches. Filter to movers (>2% WoW change) + any stock with known upcoming events (dividends, earnings).
  • ETF currency impact β€” Gold/Silver ETFs in INR can diverge from USD underlying due to INR/USD movement. Always compare INR change vs underlying change and flag significant divergence.
  • Dividend dates β€” check BSE/NSE corporate action calendars. Record dates matter (must hold before), not ex-dates.
  • MF NAV lag β€” PPFAS funds publish NAV with 2-day delay. Note this in the digest if NAVs appear stale.

Storage

  • wiki/wealth/invest/portfolio/news/ β€” one file per week
  • File naming: YYYY-Wnn-weekly-news.md (ISO week number of run date)
  • Joseph may clean up files older than 1 year

Template

See references/news-digest-template.md for the markdown structure.

Data Sources Reference

  • references/yahoo-finance-api.md β€” crumb auth, endpoints, rate limits
  • references/amfi-nav.md β€” NAV file format, scheme code lookup
  • references/snapshot-template.md β€” portfolio snapshot markdown template
  • references/news-digest-template.md β€” weekly news digest markdown template