Skip to content

Commit e46e4b7

Browse files
iamabhi9claude
andcommitted
Refresh fund NAVs from AMFI, and say how old the prices are
Prices were never updated except by a broker sync, and nothing said so. `app/navs.py` fetches AMFI's daily NAV file — public, no key, nothing personal sent — and marks every mutual fund position. This matters most for Paytm, which reports no NAV at all: those funds were marked at the NAV of their last transaction, and one was 24 days stale. Refreshing corrected India from 97,31,673 to 97,20,028. Matching refuses to guess. A NAV on the wrong fund is worse than a stale one, because a stale price is visibly stale and a wrong one is not. Funds with an ISIN match on it. Paytm funds have none, so the scheme name must reduce to exactly one Direct/Growth scheme after filler words are dropped — which correctly matched "HDFC Index Fund - BSE Sensex Plan" to AMFI's "HDFC BSE Sensex Index Fund" despite the word order, and refuses anything ambiguous. Regular plans and IDCW options are excluded, since an IDCW NAV would permanently understate a growth holding. Resolved ISINs are stored so a name is matched once and can be audited. "Data through" measured the wrong thing. It reported the last transaction date, so a trade this morning made the header look healthy while fund NAVs were a month old. Price age is now reported per asset class as the OLDEST mark in each group, not the newest — one fund priced today does not make the fund holdings current — shown beside the transaction date and bannered past a week. The quotes table was dead: written by upsert_quotes, read by nothing, 0 rows. It now backs pricing. A quote is used only when dated later than the broker snapshot, so a stale quote can never override a fresh sync. Writing the tests found a real bug. results() had a second pricing path for holdings with no transactions — which is every mutual fund — that read positions.price directly and ignored quotes entirely. So the funds this feature exists to reprice were the one group it would not have reached. Both paths now go through _positions(). 22 new tests: feed parsing, the matcher's refusals, and that a stale quote loses to a fresh broker price. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 56663dc commit e46e4b7

7 files changed

Lines changed: 479 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ GET /api/analysis/status rather than blocking. Report the decision line and wher
4444
report lives. Always present the result as **third-party generated research, not a
4545
recommendation**, and never restate it as your own investment advice.
4646

47+
**"refresh navs" / "update nav"**`python3 app/navs.py`. Fetches AMFI's daily NAV file
48+
(public, no key, nothing personal sent) and marks every mutual fund position. Zerodha
49+
funds match on ISIN; Paytm funds carry none, so the scheme name must reduce to exactly one
50+
Direct/Growth scheme or it is skipped and reported. Report which funds moved and by how
51+
much. `--dry-run` writes nothing.
52+
4753
**"backup"**`python3 app/backup.py snapshot manual` (or POST /api/backup). Only the
4854
database file is copied; code lives in git, not Drive. Snapshots go to
4955
`<drive root>/trans`, auto-detected on macOS and Linux. Report the filename and

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ In Claude Code, in this folder:
311311
| `accounts` | shows your broker account numbers |
312312
| `classify tickers` | maps unclassified holdings to sectors |
313313
| `bootstrap` | rebuilds the database from scratch |
314+
| `refresh navs` | marks mutual funds at today's NAV from AMFI |
314315

315316
To import files instead, drop them in `sync/inbox/` and run `python3 app/ingest.py inbox`.
316317
Re-importing is always safe — every importer deduplicates, so overlapping exports and

app/analytics.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,63 @@ def _txns(c, market=None):
3636
return out
3737

3838
def _positions(c, market=None):
39+
"""Positions marked at the best price available.
40+
41+
The broker's price is whatever it reported at the last sync. `quotes` can hold
42+
something newer — AMFI publishes fund NAVs daily, and a fund held at Paytm has no
43+
broker price at all, only the NAV of its last transaction. A quote is used only when
44+
it is dated later than the position snapshot, so a stale quote can never override a
45+
fresh broker price.
46+
"""
3947
br = _brokers(market)
40-
q = "SELECT ticker,quantity,price FROM positions" + _bw(br)
41-
return {r["ticker"]: Position(r["ticker"], r["quantity"], r["price"])
42-
for r in c.execute(q, br)}
48+
latest = {}
49+
for r in c.execute(
50+
"SELECT q.ticker, q.date, q.price FROM quotes q"
51+
" JOIN (SELECT ticker, MAX(date) d FROM quotes GROUP BY ticker) m"
52+
" ON m.ticker = q.ticker AND m.d = q.date"):
53+
if r["price"]:
54+
latest[r["ticker"]] = (r["date"], r["price"])
55+
56+
out = {}
57+
for r in c.execute("SELECT ticker,quantity,price,asof FROM positions" + _bw(br), br):
58+
price = r["price"]
59+
q = latest.get(r["ticker"])
60+
if q and r["asof"] and q[0] > r["asof"]:
61+
price = q[1]
62+
out[r["ticker"]] = Position(r["ticker"], r["quantity"], price)
63+
return out
64+
65+
66+
def price_asof(c, market=None):
67+
"""How old the marks are, per asset class, worst first.
68+
69+
Reported as the OLDEST effective price date in each group, not the newest: one fund
70+
priced today does not make the fund holdings current, and averaging would hide the
71+
month-old NAV that is the only figure worth warning about.
72+
73+
A position's effective date is its broker snapshot, or a later quote if one exists —
74+
matching how _positions() actually marks it.
75+
"""
76+
br = _brokers(market)
77+
latest = {r["ticker"]: r["d"] for r in
78+
c.execute("SELECT ticker, MAX(date) d FROM quotes GROUP BY ticker")}
79+
groups = collections.defaultdict(list)
80+
for r in c.execute("SELECT ticker,asset,asof FROM positions" + _bw(br), br):
81+
eff = r["asof"]
82+
q = latest.get(r["ticker"])
83+
if q and eff and q > eff:
84+
eff = q
85+
if eff:
86+
groups[r["asset"] or "equity"].append(eff)
87+
88+
today = dt.date.today()
89+
out = []
90+
for asset, dates in groups.items():
91+
oldest, newest = min(dates), max(dates)
92+
out.append({"asset": asset, "n": len(dates), "oldest": oldest, "newest": newest,
93+
"days": (today - dt.date.fromisoformat(oldest)).days})
94+
out.sort(key=lambda x: -x["days"])
95+
return out
4396

4497
def cost_basis(c, market=None):
4598
"""{ticker: cost of the shares still held}, plus the tickers where it is a guess.
@@ -167,6 +220,7 @@ def results(c, as_of=None, market=None):
167220
# Fall back to the broker's own average cost so value and P/L are still real; XIRR
168221
# genuinely cannot be computed without dated flows, and says so rather than showing 0.
169222
seen = {r["ticker"] for r in rows}
223+
marks = _positions(c, market) # one source of truth for what a share is worth
170224
br = _brokers(market)
171225
# Once a tradebook is loaded, "import a tradebook" stops being the right explanation.
172226
# What is left are holdings the equity tradebook structurally cannot contain: bonds
@@ -180,7 +234,12 @@ def results(c, as_of=None, market=None):
180234
continue
181235
inv = 0.0 if M.demerged_from(p["ticker"], CFG.demergers()) \
182236
else (p["avg_cost"] or 0) * p["quantity"]
183-
val = (p["price"] or 0) * p["quantity"]
237+
# Mark through the same path as everything else. This branch used to read
238+
# positions.price directly, so a holding with no transactions — which is every
239+
# mutual fund — silently ignored a fresher quote while the rest of the portfolio
240+
# used it. Two pricing paths is one too many.
241+
marked = marks.get(p["ticker"])
242+
val = (marked.price if marked else (p["price"] or 0)) * p["quantity"]
184243
rows.append({
185244
"ticker": p["ticker"], "xirr": None,
186245
"note": _no_history_note(p["asset"], has_txns),
@@ -623,7 +682,10 @@ def health(c, market=None):
623682
maxd = c.execute("SELECT MAX(date) d FROM transactions"
624683
+ _bw(br), br).fetchone()["d"]
625684
stale = (dt.date.today() - dt.date.fromisoformat(maxd)).days if maxd else None
685+
pa = price_asof(c, market)
686+
worst = max((x["days"] for x in pa if x["days"] is not None), default=None)
626687
return {"last_transaction_date": maxd, "days_stale": stale, "runs": runs,
688+
"prices": pa, "price_days_stale": worst,
627689
"n_transactions": c.execute("SELECT COUNT(*) n FROM transactions"
628690
+ _bw(br), br).fetchone()["n"],
629691
"n_positions": c.execute("SELECT COUNT(*) n FROM positions"

app/navs.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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())

app/static/index.html

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
.kpi b .pos{color:var(--pos)} .kpi b .neg{color:var(--neg)}
1717
.kpi b .na{color:var(--mut)}
1818
.kpi.date b{font-size:13px;font-weight:500;color:var(--mut);padding-top:5px}
19+
.pxage{display:block;font-size:11px;color:var(--warn);font-weight:500}
1920
.kpi span{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.06em}
2021
nav{display:flex;gap:2px;padding:0 14px;border-bottom:1px solid var(--line);overflow-x:auto}
2122
nav button{background:none;border:0;padding:11px 14px;color:var(--mut);cursor:pointer;
@@ -304,9 +305,20 @@
304305
+`still held. Counted that way rather than by every flow landing in the window, `
305306
+`because a sale whose purchase predates it would bring proceeds with none of the `
306307
+`cost and read as a spectacular year.`],
307-
['Data through',h.last_transaction_date
308-
? h.last_transaction_date+(h.days_stale>1?` (${h.days_stale}d old)`:'')
309-
: 'holdings only', null, 'date']
308+
// Two different ages, and conflating them hid a month-old fund NAV behind a
309+
// one-day-old trade. Transactions lead; prices are named underneath when they lag.
310+
['Data through',
311+
(h.last_transaction_date
312+
? h.last_transaction_date+(h.days_stale>1?` (${h.days_stale}d old)`:'')
313+
: 'holdings only')
314+
+ (h.price_days_stale>1 ? `<span class="pxage">prices ${h.price_days_stale}d old</span>` : ''),
315+
(h.prices||[]).length
316+
? 'Marks were last refreshed: '
317+
+ h.prices.map(p=>`${p.asset} ${p.days}d ago`).join(', ')
318+
+ '. Transactions and prices age separately — a recent trade does not mean '
319+
+ 'current prices.'
320+
: null,
321+
'date']
310322
].map(([s,v,t,cls])=>`<div class="kpi${cls?' '+cls:''}"`
311323
+`${t?` title="${String(t).replace(/&/g,'&amp;').replace(/"/g,'&quot;')}"`:''}>`
312324
+`<span>${s}</span><b>${v}</b></div>`).join('');
@@ -339,6 +351,13 @@
339351
$('#main').innerHTML=`
340352
${h.days_stale>2?`<div class="banner">Data is ${h.days_stale} days old. Run a sync from the
341353
<b>Sync &amp; cost</b> tab.</div>`:''}
354+
${h.price_days_stale>7?`<div class="banner">
355+
<b>Prices are ${h.price_days_stale} days old.</b>
356+
${(h.prices||[]).filter(p=>p.days>7).map(p=>`${p.n} ${p.asset} position${p.n>1?'s':''}
357+
last marked ${p.oldest}`).join('; ')}. Values and unrealized gain are computed from
358+
those marks. Run a sync, and for mutual funds
359+
<code>python3 app/navs.py</code> refreshes NAVs from AMFI.
360+
</div>`:''}
342361
${(o.unreconciled||[]).length?`<div class="banner">
343362
<b>${o.unreconciled.length} fund${o.unreconciled.length>1?'s hold':' holds'} more units than the
344363
imported order history accounts for</b> —

docs/IMPORTING.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,34 @@ free, so their entire value counts as gain. They carry a **no cost** chip saying
187187
Do **not** extend this to SGBs, bonds or merger remnants. Those were bought with real
188188
money that simply is not in the equity tradebook.
189189

190-
### 5. Dividends are invisible on the India side
190+
### 5. Fund NAVs go stale, and nothing refreshes them by itself
191+
192+
Prices are not live. `positions.price` is whatever the broker reported at the last sync,
193+
and there is no scheduled job. Zerodha reports a NAV for funds you still hold there;
194+
**Paytm reports none at all**, so those are marked at the NAV of their most recent
195+
transaction and drift a little further out of date every week.
196+
197+
```bash
198+
python3 app/navs.py # fetch AMFI's daily NAV file and mark every fund
199+
python3 app/navs.py --dry-run # show what would change, write nothing
200+
```
201+
202+
AMFI publishes every Indian scheme's NAV as one public text file — no key, no account,
203+
nothing personal sent. Funds carrying an ISIN match on it. Paytm funds have none, so the
204+
scheme name must reduce to exactly one Direct/Growth scheme after filler words are
205+
dropped; anything ambiguous is skipped and reported rather than guessed at, because a NAV
206+
attached to the wrong fund is worse than a stale one — a stale price is visibly stale and
207+
a wrong one is not. Resolved ISINs are stored so the match is made once and can be
208+
audited.
209+
210+
The header shows how old the marks are alongside the transaction date, and warns past a
211+
week. The two ages are unrelated: a trade this morning tells you nothing about whether
212+
prices were refreshed.
213+
214+
**Equities are not covered.** AMFI is funds only; stock prices still come from the broker
215+
at sync time.
216+
217+
### 6. Dividends are invisible on the India side
191218

192219
Indian equities pay cash into your bank account. There is no reinvestment order to infer
193220
from, the way DRIP works on the US side, and Kite exposes no dividend endpoint. India

0 commit comments

Comments
 (0)