|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | +"""Refresh mutual fund NAVs from AMFI. |
| 4 | +
|
| 5 | +Fund prices are the weakest thing in the database. Zerodha reports a live NAV only for |
| 6 | +funds you still hold there; Paytm reports none at all, so those positions are marked at |
| 7 | +the NAV of their last transaction and drift further out of date every week. |
| 8 | +
|
| 9 | +AMFI publishes every Indian scheme's NAV daily as one public text file — no key, no |
| 10 | +account, nothing personal sent. This fetches it and marks the funds we hold. |
| 11 | +
|
| 12 | + python3 app/navs.py fetch and update |
| 13 | + python3 app/navs.py --dry-run show what would change, write nothing |
| 14 | +
|
| 15 | +Matching is deliberately strict. A NAV attached to the wrong fund is worse than a stale |
| 16 | +one, because a stale price is visibly stale and a wrong one is not. Where a fund carries |
| 17 | +an ISIN it is matched on that alone. Where it does not — Paytm statements have no ISIN — |
| 18 | +the scheme name must reduce to exactly one Direct/Growth scheme after dropping filler |
| 19 | +words, and anything ambiguous is skipped and reported rather than guessed at. A resolved |
| 20 | +ISIN is stored so the guess is made once and can be audited afterwards. |
| 21 | +
|
| 22 | +Equities are not covered: AMFI is funds only. Stock prices still come from the broker at |
| 23 | +sync time. |
| 24 | +""" |
| 25 | +import os, re, sys, urllib.request, datetime as dt |
| 26 | + |
| 27 | +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| 28 | +import db as D |
| 29 | + |
| 30 | +URL = "https://www.amfiindia.com/spages/NAVAll.txt" |
| 31 | +# Words that appear in almost every scheme name and so carry no identifying signal. |
| 32 | +_FILLER = {"FUND", "PLAN", "OPTION", "SCHEME", "DIRECT", "GROWTH", "THE", "OF", "AND"} |
| 33 | + |
| 34 | + |
| 35 | +def _tokens(s): |
| 36 | + return {t for t in re.findall(r"[A-Z0-9&]+", (s or "").upper()) if t not in _FILLER} |
| 37 | + |
| 38 | + |
| 39 | +def fetch(url=URL, timeout=30): |
| 40 | + req = urllib.request.Request(url, headers={"User-Agent": "trans (github.com/winnertechserv/trans)"}) |
| 41 | + with urllib.request.urlopen(req, timeout=timeout) as r: |
| 42 | + return r.read().decode("utf-8", "replace") |
| 43 | + |
| 44 | + |
| 45 | +def parse(raw): |
| 46 | + """-> list of {isin, name, plan, opt, nav, date, tok}. Rows without an ISIN are |
| 47 | + dropped: they cannot be matched safely and are not worth guessing about.""" |
| 48 | + out = [] |
| 49 | + for line in raw.split("\n"): |
| 50 | + p = line.split(";") |
| 51 | + if len(p) < 8 or p[0].strip() == "Scheme Code": |
| 52 | + continue |
| 53 | + _, isin1, isin2, name, plan, opt, nav, date = [x.strip() for x in p[:8]] |
| 54 | + isin = isin1 if isin1 and isin1 != "-" else (isin2 if isin2 and isin2 != "-" else None) |
| 55 | + if not isin: |
| 56 | + continue |
| 57 | + try: |
| 58 | + nav_f = float(nav) |
| 59 | + except ValueError: |
| 60 | + continue |
| 61 | + out.append({"isin": isin, "name": name, "plan": plan, "opt": opt, |
| 62 | + "nav": nav_f, "date": _date(date), "tok": _tokens(name)}) |
| 63 | + return out |
| 64 | + |
| 65 | + |
| 66 | +def _date(s): |
| 67 | + """'03-Sep-2026' -> '2026-09-03'. Returns None rather than a wrong date.""" |
| 68 | + try: |
| 69 | + return dt.datetime.strptime(s, "%d-%b-%Y").date().isoformat() |
| 70 | + except (ValueError, TypeError): |
| 71 | + return None |
| 72 | + |
| 73 | + |
| 74 | +def by_isin(schemes): |
| 75 | + return {s["isin"]: s for s in schemes} |
| 76 | + |
| 77 | + |
| 78 | +def resolve_name(schemes, name): |
| 79 | + """-> (scheme, reason). Only a single unambiguous Direct/Growth match is accepted.""" |
| 80 | + want = _tokens(name) |
| 81 | + if not want: |
| 82 | + return None, "no usable words in the name" |
| 83 | + cands = [s for s in schemes |
| 84 | + if s["tok"] == want |
| 85 | + and "DIRECT" in s["plan"].upper() |
| 86 | + and "GROWTH" in s["opt"].upper() |
| 87 | + and "IDCW" not in s["opt"].upper()] |
| 88 | + if len(cands) == 1: |
| 89 | + return cands[0], "matched on scheme name" |
| 90 | + if not cands: |
| 91 | + return None, "no Direct/Growth scheme matches that name" |
| 92 | + return None, f"ambiguous — {len(cands)} schemes match" |
| 93 | + |
| 94 | + |
| 95 | +def update(c, schemes, dry_run=False): |
| 96 | + """Mark every mutual fund position we can identify. -> list of report rows.""" |
| 97 | + isins = by_isin(schemes) |
| 98 | + names = {r["ticker"]: r["text_value"] for r in c.execute( |
| 99 | + "SELECT ticker,text_value FROM fundamentals WHERE metric='name'" |
| 100 | + " AND text_value IS NOT NULL")} |
| 101 | + # An ISIN resolved on a previous run, so a name is only ever matched once. |
| 102 | + known = {r["ticker"]: r["text_value"] for r in c.execute( |
| 103 | + "SELECT ticker,text_value FROM fundamentals WHERE metric='isin'" |
| 104 | + " AND text_value IS NOT NULL")} |
| 105 | + |
| 106 | + report = [] |
| 107 | + for r in c.execute("SELECT ticker,quantity,price,asof,broker FROM positions" |
| 108 | + " WHERE asset='mf' ORDER BY ticker"): |
| 109 | + tick = r["ticker"] |
| 110 | + label = names.get(tick, tick) |
| 111 | + isin = tick if re.fullmatch(r"INF[0-9A-Z]{9}", tick) else known.get(tick) |
| 112 | + why = "isin on the holding" if isin == tick else ( |
| 113 | + "isin resolved earlier" if isin else None) |
| 114 | + |
| 115 | + s = isins.get(isin) if isin else None |
| 116 | + if s is None: |
| 117 | + s, why = resolve_name(schemes, label) |
| 118 | + if s is not None: |
| 119 | + isin = s["isin"] |
| 120 | + |
| 121 | + if s is None: |
| 122 | + report.append({"ticker": tick, "name": label, "status": "skipped", |
| 123 | + "detail": why, "old": r["price"], "new": None}) |
| 124 | + continue |
| 125 | + |
| 126 | + drift = (s["nav"] / r["price"] - 1) * 100 if r["price"] else None |
| 127 | + report.append({"ticker": tick, "name": label, "status": "updated", |
| 128 | + "detail": why, "old": r["price"], "new": s["nav"], |
| 129 | + "date": s["date"], "drift": drift, "isin": isin, |
| 130 | + "matched": f"{s['name']} {s['plan']} {s['opt']}".strip()}) |
| 131 | + if dry_run: |
| 132 | + continue |
| 133 | + c.execute("INSERT INTO quotes(ticker,date,price,prev_close) VALUES(?,?,?,NULL)" |
| 134 | + " ON CONFLICT(ticker,date) DO UPDATE SET price=excluded.price", |
| 135 | + (tick, s["date"], s["nav"])) |
| 136 | + if isin and tick != isin: |
| 137 | + c.execute("INSERT INTO fundamentals(ticker,asof,metric,value,text_value)" |
| 138 | + " VALUES(?,?,?,NULL,?) ON CONFLICT(ticker,asof,metric)" |
| 139 | + " DO UPDATE SET text_value=excluded.text_value", |
| 140 | + (tick, dt.date.today().isoformat(), "isin", isin)) |
| 141 | + if not dry_run: |
| 142 | + c.commit() |
| 143 | + return report |
| 144 | + |
| 145 | + |
| 146 | +def main(): |
| 147 | + dry = "--dry-run" in sys.argv |
| 148 | + print(f"fetching {URL}") |
| 149 | + try: |
| 150 | + raw = fetch() |
| 151 | + except Exception as e: |
| 152 | + print(f" could not reach AMFI: {type(e).__name__}: {e}") |
| 153 | + print(" fund prices are unchanged; nothing was written.") |
| 154 | + return 1 |
| 155 | + schemes = parse(raw) |
| 156 | + print(f" {len(schemes)} schemes\n") |
| 157 | + |
| 158 | + c = D.connect() |
| 159 | + rows = update(c, schemes, dry_run=dry) |
| 160 | + if not rows: |
| 161 | + print("no mutual fund positions to price.") |
| 162 | + return 0 |
| 163 | + for r in rows: |
| 164 | + if r["status"] == "updated": |
| 165 | + d = f"{r['drift']:+.1f}%" if r["drift"] is not None else " n/a" |
| 166 | + print(f" {r['name'][:38]:<40} {r['old']:>9,.2f} -> {r['new']:>9,.2f}" |
| 167 | + f" {d:>7} as of {r['date']}") |
| 168 | + if r["detail"] == "matched on scheme name": |
| 169 | + print(f" matched by name to: {r['matched'][:66]}") |
| 170 | + else: |
| 171 | + print(f" {r['name'][:38]:<40} skipped — {r['detail']}") |
| 172 | + n = sum(1 for r in rows if r["status"] == "updated") |
| 173 | + print(f"\n{'would update' if dry else 'updated'} {n} of {len(rows)} fund(s)") |
| 174 | + if dry: |
| 175 | + print("dry run — nothing written") |
| 176 | + return 0 |
| 177 | + |
| 178 | + |
| 179 | +if __name__ == "__main__": |
| 180 | + sys.exit(main()) |
0 commit comments