{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"build-your-own-mutual-fund-nav-tracker-in-python-cli-web-ui-no-api-key-ts1d8","url":"https://zyvop.com/build-your-own-mutual-fund-nav-tracker-in-python-cli-web-ui-no-api-key-ts1d8","title":"Build Your Own Mutual Fund NAV Tracker in Python: CLI, Web UI, No API Key","subtitle":"A tiny Python CLI and local web dashboard that search any Indian mutual fund and flag the moment a new NAV is published.","tldr":"Mutual fund NAVs aren't live. They land once a day, after markets close. Here's how to build a zero-dependency Python CLI and a local web UI that search any Indian fund, show returns and charts, track your holdings, and flag a fresh NAV, using free data from mfapi.in and AMFI.","keywords":["personal finance","Python","mutual funds","api","cli","Tutorial"],"entities":["Anshu Pathak","personal finance","Python","mutual funds","api","cli","Tutorial","ZyVOP"],"keyTakeaways":["Open any investing app and you'll see a mutual fund's NAV, but the app is slow, buried under ads, or asks you to log in first.","We'll build it as two small Python files: a command-line tool and a local web UI with search, charts and a watchlist.","There are no installs, no API key and no account."],"headings":["First, a reality check: there is no \"live\" NAV","Where the data comes from","Set up a project folder and a virtual environment","How the tool works","Three commands in the terminal","A web UI on top","Gotchas worth knowing","The complete code","navtracker.py","navtracker_web.py","Run it","Where to take it next"],"outboundLinks":[],"contentText":"Open any investing app and you'll see a mutual fund's NAV, but the app is slow, buried under ads, or asks you to log in first. What if one terminal command could show your funds' NAV, the day's change, and your holding's value? We'll build it as two small Python files: a command-line tool and a local web UI with search, charts and a watchlist. There are no installs, no API key and no account. The complete code for both is included at the end of this post. First, a reality check: there is no \"live\" NAV A stock price changes every second. A mutual fund's NAV (Net Asset Value) doesn't. It's the price of one unit, calculated once a day after markets close and published by AMFI, usually in the evening. So \"live NAV tracking\" really means two things: Fetch the latest published NAV quickly. Notice the moment a new one appears. That's what the tool does. Polling every few seconds gains you nothing, and a 5-minute interval is plenty. Where the data comes from Two free sources, no signup: mfapi.in, a free JSON API with search, latest NAV, and full history per fund. AMFI's NAVAll.txt, the official file with every scheme's latest NAV. It's large, so we use it only as a fallback. The three endpoints we need: GET https://api.mfapi.in/mf/search?q=parag parikh # find a scheme code GET https://api.mfapi.in/mf/{code} # full NAV history GET https://api.mfapi.in/mf/{code}/latest # latest NAV onlyEvery fund is identified by a scheme code. Direct and Regular plans, and Growth and IDCW options, each have their own code, so check you've picked the right one. Set up a project folder and a virtual environment Start with a clean folder and a virtual environment (venv). A venv is a private copy of Python for this project, so anything you install later stays out of your system Python. mkdir nav-tracker &amp;&amp; cd nav-tracker python3 -m venv .venvOn Windows, use py -m venv .venv instead of python3 -m venv .venv. Activate it: # macOS / Linux source .venv/bin/activate # Windows (PowerShell) .venv\\Scripts\\Activate.ps1Your prompt now starts with (.venv). Check that you're on Python 3.8 or newer, and leave the environment any time with deactivate: python --versionTwo honest notes. First, this project uses only Python's standard library, so there is nothing you must pip install (the optional certifi package is covered under Gotchas). The venv still earns its place: if you later add a package such as requests or rich, it lands in .venv and not on your system. On newer macOS and Linux setups it also avoids the \"externally managed environment\" error you get from installing into the system Python. Second, remember to activate the venv in every new terminal window before you run the scripts. If you use git, keep the environment out of your repository: echo \".venv/\" &gt;&gt; .gitignoreWhen you've saved both scripts from the end of this post, the folder looks like this: nav-tracker/ .venv/ navtracker.py navtracker_web.pyHow the tool works flowchart LR A[\"navtracker CLI\"] --&gt; B{\"mfapi.in latest\"} B -- \"success\" --&gt; D[\"NAV + date\"] B -- \"error\" --&gt; C[\"AMFI NAVAll.txt\"] C --&gt; D D --&gt; E{\"Newer date than last poll?\"} E -- \"yes\" --&gt; F[\"Flag NEW NAV and beep\"] E -- \"no\" --&gt; G[\"Wait, then poll again\"] G --&gt; BThe fallback is a small try/except. If mfapi.in fails, we scan AMFI's semicolon-separated file instead (simplified here; the full version is below): def fetch_latest(code): try: data = get_json(f\"{MFAPI}/mf/{code}/latest\") row = data[\"data\"][0] return data[\"meta\"][\"scheme_name\"], parse_date(row[\"date\"]), float(row[\"nav\"]) except Exception as err: result = amfi_latest(code) # fallback: official AMFI file if result: return result raise LookupError(f\"could not fetch NAV for {code}: {err}\")Three commands in the terminal 1. Find the scheme code python navtracker.py search \"parag parikh flexi cap direct growth\"2. See a fund's NAV and returns python navtracker.py nav 122639 --units 250.5You get the latest NAV, the change versus the previous day, trailing returns for 1 week to 5 years, and the value of your holding. Returns are simple arithmetic on the NAV history. For anything over a year, we annualise (CAGR) so a 5-year number is comparable to a 1-year one: span = (latest_date - base_date).days cagr = (latest_nav / base_nav) ** (365.0 / span) - 13. Watch for new NAVs python navtracker.py watch 122639 120503:250.5 --interval 300CODE:UNITS adds your holding's value, and with several funds you get a portfolio total. The table refreshes every 5 minutes. When a fund publishes a fresh NAV, its row is flagged: Code Scheme NAV Date Day % Value 122639 Example Flexi Cap Fund 78.4120 03-Nov-2026 +0.62 19,640.72 &lt;- NEW NAV(Numbers above are illustrative.) The detection is one comparison. If the NAV's date is newer than the one we saw last, it's new: if d &gt; h[\"last\"][0]: h[\"prev\"] = h[\"last\"] # keep yesterday's NAV to compute the day change h[\"last\"] = (d, n) h[\"new\"] = TrueAdd --once and the same command prints once and exits, which is ideal for a cron job. A web UI on top The terminal is great for a quick check, but searching by name and looking at a chart is nicer in a browser. navtracker_web.py is a small local server. It imports the data functions from navtracker.py and serves a single-page UI. flowchart LR B[\"Browser UI\"] -- \"search and fund requests\" --&gt; S[\"navtracker_web.py\"] S -- \"reuses\" --&gt; N[\"navtracker.py\"] N --&gt; M[\"mfapi.in\"] N -.-&gt; A[\"AMFI file, fallback\"] B --- L[(\"Watchlist in localStorage\")]Why a local server instead of calling mfapi.in straight from the page? It sidesteps browser cross-origin limits, and it lets us cache responses for five minutes, which keeps us polite to a free API: CACHE, TTL = {}, 300 def cached(key, fn): hit = CACHE.get(key) if hit and time.time() - hit[0] &lt; TTL: return hit[1] val = fn() CACHE[key] = (time.time(), val) return valThe UI does three things: Search. Type a fund name and pick from the matches. Requests are debounced, so the API isn't hit on every keystroke. Fund page. The latest NAV with the day's change, returns from 1 week to 5 years, and a NAV history chart with 1M to All ranges. Hover the chart to read any day's NAV. The chart is plain SVG, so there's no chart library to install. Watchlist. Add a fund, optionally with the units you hold, and see each holding's value and a portfolio total. The page re-checks every 5 minutes and marks a fund \"New NAV\" when a newer date appears: if (d.date &gt; w.date) fresh[w.code] = 1; // a newer NAV date means a fresh NAVRun it from the same folder, with the venv active: python navtracker_web.py # opens http://127.0.0.1:8000 python navtracker_web.py 9000 # or choose another portThe server listens on 127.0.0.1 only, so only your own machine can reach it. The watchlist is saved in your browser's localStorage, so it stays on your device. Gotchas worth knowing Pick the right scheme code. Direct vs Regular and Growth vs IDCW are different schemes with different NAVs. Stale NAVs. If the date is more than a few days old, the scheme may be closed, merged, or delayed. The nav command warns you. Be polite to free APIs. mfapi.in asks for fair usage, so don't hammer it. A 5-minute interval is enough, and AMFI's big file is fetched only on fallback. CERTIFICATE_VERIFY_FAILED errors. Python installed from python.org on macOS has no trusted root certificates until you run the Install Certificates.command file in /Applications/Python 3.x/. Alternatively run pip install certifi inside your venv; the script uses it automatically when it's present. A company proxy or some antivirus tools cause the same error because they re-sign HTTPS traffic, and the right fix there is to trust their root certificate. For a quick local test only, NAV_INSECURE=1 skips verification, so don't leave it on. The watchlist lives in your browser. Clearing site data, or opening the UI in a different browser, starts you with an empty list. Past returns aren't a promise. The numbers are history, not a forecast. The complete code Save both files in your project folder. Each uses only standard-library Python (3.8 or newer). navtracker.py The command-line tool and the data layer, about 310 lines: #!/usr/bin/env python3 \"\"\" navtracker - track the latest NAV of any Indian mutual fund. Data sources (free, no API key): * mfapi.in - primary (search, latest NAV, full NAV history) * AMFI NAVAll.txt - automatic fallback for the latest NAV Usage: python navtracker.py search \"parag parikh flexi cap direct growth\" python navtracker.py nav 122639 python navtracker.py watch 122639 120503:250.5 --interval 300 (CODE:UNITS also shows the current value of your holding) Only the Python standard library is required (certifi is used automatically if installed). \"\"\" import argparse import json import os import ssl import sys import time import urllib.error import urllib.parse import urllib.request from datetime import datetime, timedelta MFAPI = os.environ.get(\"NAV_MFAPI_URL\", \"https://api.mfapi.in\").rstrip(\"/\") AMFI = os.environ.get(\"NAV_AMFI_URL\", \"https://www.amfiindia.com/spages/NAVAll.txt\") HEADERS = {\"User-Agent\": \"navtracker/1.0\"} PERIODS = [(\"1W\", 7), (\"1M\", 30), (\"6M\", 182), (\"1Y\", 365), (\"3Y\", 1095), (\"5Y\", 1825)] # ---------------------------------------------------------------- data layer def _ssl_context(): if os.environ.get(\"NAV_INSECURE\") == \"1\": # last resort, see SSL_HINT print(\"WARNING: NAV_INSECURE=1 - HTTPS certificates are NOT being verified.\", file=sys.stderr) ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE return ctx try: import certifi # optional: pip install certifi return ssl.create_default_context(cafile=certifi.where()) except ImportError: return ssl.create_default_context() SSL_CONTEXT = _ssl_context() SSL_HINT = (\"Fix: on macOS run 'Install Certificates.command' from /Applications/Python 3.x/, \" \"or run 'pip install certifi' and try again. Behind a company proxy, ask IT for its root \" \"certificate. Last resort for a quick local test: set NAV_INSECURE=1 (skips verification).\") def http_get(url, timeout=20): req = urllib.request.Request(url, headers=HEADERS) try: with urllib.request.urlopen(req, timeout=timeout, context=SSL_CONTEXT) as resp: return resp.read().decode(\"utf-8\", \"replace\") except urllib.error.URLError as err: if \"CERTIFICATE_VERIFY_FAILED\" in str(err): raise OSError(f\"HTTPS certificate check failed ({err.reason}). {SSL_HINT}\") from err raise def get_json(url): return json.loads(http_get(url)) def parse_date(text): for fmt in (\"%d-%m-%Y\", \"%d-%b-%Y\"): try: return datetime.strptime(text.strip(), fmt).date() except ValueError: continue raise ValueError(f\"unrecognised date: {text!r}\") def search(query): return get_json(f\"{MFAPI}/mf/search?q={urllib.parse.quote(query)}\") def fetch_history(code): \"\"\"Return (meta, [(date, nav), ...] newest first).\"\"\" data = get_json(f\"{MFAPI}/mf/{code}\") rows = data.get(\"data\") or [] hist = [] for row in rows: try: hist.append((parse_date(row[\"date\"]), float(row[\"nav\"]))) except (KeyError, ValueError): continue if not hist: raise LookupError(f\"no NAV data found for scheme code {code}\") hist.sort(reverse=True) return data.get(\"meta\", {}), hist def amfi_latest(code): \"\"\"Fallback: scan AMFI's NAVAll.txt (semicolon separated).\"\"\" for line in http_get(AMFI, timeout=40).splitlines(): parts = [p.strip() for p in line.split(\";\")] if len(parts) &gt;= 6 and parts[0] == str(code): try: return parts[3], parse_date(parts[5]), float(parts[4]) except ValueError: return None return None def fetch_latest(code): \"\"\"Return (name, date, nav) from mfapi, falling back to AMFI.\"\"\" try: data = get_json(f\"{MFAPI}/mf/{code}/latest\") row = data[\"data\"][0] name = data.get(\"meta\", {}).get(\"scheme_name\", str(code)) return name, parse_date(row[\"date\"]), float(row[\"nav\"]) except Exception as err: # network error, bad JSON, unknown code ... try: result = amfi_latest(code) except Exception: result = None if result: return result raise LookupError(f\"could not fetch NAV for {code}: {err}\") # ---------------------------------------------------------------- analytics def nav_on_or_before(hist, target): for d, n in hist: # newest first if d &lt;= target: return d, n return None def trailing_returns(hist): latest_d, latest_n = hist[0] out = [] for label, days in PERIODS: base = nav_on_or_before(hist, latest_d - timedelta(days=days)) if not base or base[1] &lt;= 0: out.append((label, None, False)) continue span = (latest_d - base[0]).days or 1 if days &gt; 365: # annualise multi-year periods (CAGR) value = (latest_n / base[1]) ** (365.0 / span) - 1 out.append((label, value * 100, True)) else: out.append((label, (latest_n / base[1] - 1) * 100, False)) return out def pct(new, old): return (new / old - 1) * 100 if old else 0.0 # ---------------------------------------------------------------- commands def cmd_search(args): results = search(args.query) if not results: print(\"No schemes found. Try fewer / different words.\") return 1 for r in results[: args.limit]: print(f\"{r['schemeCode']:&gt;7} {r['schemeName']}\") if len(results) &gt; args.limit: print(f\"... {len(results) - args.limit} more (use --limit)\") return 0 def cmd_nav(args): meta, hist = fetch_history(args.code) d, n = hist[0] print(meta.get(\"scheme_name\", args.code)) bits = [meta.get(\"fund_house\"), meta.get(\"scheme_category\")] print(\" \" + \" | \".join(b for b in bits if b)) line = f\"\\nNAV {n:,.4f} as of {d:%d %b %Y}\" if len(hist) &gt; 1: pd, pn = hist[1] line += f\" ({n - pn:+.4f}, {pct(n, pn):+.2f}% vs {pd:%d %b})\" print(line) if (datetime.now().date() - d).days &gt; 5: print(\" ! NAV is more than 5 days old - scheme may be closed, merged or delayed.\") print(\"\\nReturns (3Y/5Y are annualised):\") for label, value, annual in trailing_returns(hist): shown = \" n/a\" if value is None else f\"{value:+7.2f}%\" print(f\" {label:&gt;3} {shown}{' p.a.' if annual and value is not None else ''}\") if args.units: print(f\"\\nHolding value: {args.units:,.3f} units = {args.units * n:,.2f}\") return 0 def render(holdings, interval, once): if sys.stdout.isatty(): sys.stdout.write(\"\\033[2J\\033[H\") print(f\"NAV tracker - {datetime.now():%d %b %Y %H:%M:%S}\" + (\"\" if once else f\" (refresh every {interval}s, Ctrl+C to quit)\")) print(f\"{'Code':&lt;7} {'Scheme':&lt;42} {'NAV':&gt;11} {'Date':&lt;11} {'Day %':&gt;8} {'Value':&gt;14}\") print(\"-\" * 98) for h in holdings: if h.get(\"last\") is None: print(f\"{h['code']:&lt;7} ERROR: {h.get('error', 'no data')}\") continue d, n = h[\"last\"] chg = f\"{pct(n, h['prev'][1]):+.2f}\" if h.get(\"prev\") else \"n/a\" value = f\"{h['units'] * n:,.2f}\" if h[\"units\"] else \"\" flag = \" &lt;- NEW NAV\" if h.get(\"new\") else \"\" name = h[\"name\"][:41] print(f\"{h['code']:&lt;7} {name:&lt;42} {n:&gt;11,.4f} {d:%d-%b-%Y} {chg:&gt;8} {value:&gt;14}{flag}\") if h.get(\"warn\"): print(f\" ! {h['warn']}\") if holdings and any(h[\"units\"] and h.get(\"last\") for h in holdings): total = sum(h[\"units\"] * h[\"last\"][1] for h in holdings if h[\"units\"] and h.get(\"last\")) print(\"-\" * 98) print(f\"{'Total value':&gt;77} {total:&gt;14,.2f}\") sys.stdout.flush() def cmd_watch(args): holdings = [] for item in args.funds: code, _, units = item.partition(\":\") try: u = float(units) if units else None except ValueError: print(f\"Bad units in {item!r}; use CODE or CODE:UNITS\", file=sys.stderr) return 2 holdings.append({\"code\": code, \"units\": u, \"name\": code, \"last\": None, \"prev\": None, \"new\": False}) for h in holdings: # seed with history so we can show the day change try: meta, hist = fetch_history(h[\"code\"]) h[\"name\"] = meta.get(\"scheme_name\", h[\"code\"]) h[\"last\"] = hist[0] h[\"prev\"] = hist[1] if len(hist) &gt; 1 else None except Exception as err: try: # no history (e.g. AMFI-only scheme): start from latest NAV name, d, n = fetch_latest(h[\"code\"]) h[\"name\"], h[\"last\"] = name, (d, n) except Exception: h[\"error\"] = str(err) try: while True: render(holdings, args.interval, args.once) if args.once: return 0 time.sleep(args.interval) for h in holdings: h[\"new\"] = False h.pop(\"warn\", None) try: name, d, n = fetch_latest(h[\"code\"]) except Exception as err: if h.get(\"last\"): h[\"warn\"] = f\"refresh failed, showing last known NAV ({err})\" else: h[\"error\"] = str(err) continue h[\"name\"] = h[\"name\"] if h[\"name\"] != h[\"code\"] else name if h[\"last\"] is None: h[\"last\"] = (d, n) elif (d, n) != h[\"last\"]: if d &gt; h[\"last\"][0]: h[\"prev\"] = h[\"last\"] h[\"last\"] = (d, n) h[\"new\"] = True if not args.no_bell: sys.stdout.write(\"\\a\") except KeyboardInterrupt: print(\"\\nStopped.\") return 0 def main(argv=None): p = argparse.ArgumentParser(prog=\"navtracker\", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = p.add_subparsers(dest=\"cmd\", required=True) s = sub.add_parser(\"search\", help=\"find a scheme code by name\") s.add_argument(\"query\") s.add_argument(\"--limit\", type=int, default=20) s.set_defaults(fn=cmd_search) n = sub.add_parser(\"nav\", help=\"latest NAV, day change and returns for one scheme\") n.add_argument(\"code\") n.add_argument(\"--units\", type=float, help=\"units held, to show holding value\") n.set_defaults(fn=cmd_nav) w = sub.add_parser(\"watch\", help=\"poll one or more schemes and flag new NAVs\") w.add_argument(\"funds\", nargs=\"+\", metavar=\"CODE[:UNITS]\") w.add_argument(\"--interval\", type=int, default=300, help=\"seconds between polls (default 300)\") w.add_argument(\"--once\", action=\"store_true\", help=\"print once and exit (good for cron)\") w.add_argument(\"--no-bell\", action=\"store_true\", help=\"no terminal beep on a new NAV\") w.set_defaults(fn=cmd_watch) args = p.parse_args(argv) try: return args.fn(args) except (urllib.error.URLError, LookupError, OSError) as err: print(f\"Error: {err}\", file=sys.stderr) return 1 if __name__ == \"__main__\": sys.exit(main())navtracker_web.py The local web server and UI, about 240 lines. It imports navtracker.py, so keep the two files together: #!/usr/bin/env python3 \"\"\" navtracker_web - local web UI for navtracker.py (keep both files in one folder). python navtracker_web.py [port] then open http://127.0.0.1:8000 Search any Indian mutual fund, see NAV, returns and a NAV history chart, and keep a watchlist with your units. Standard library only. Your watchlist is stored in your browser (localStorage); the server only proxies mfapi.in / AMFI and caches for 5 minutes. \"\"\" import json import os import sys import time import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import navtracker as nt # noqa: E402 CACHE, TTL = {}, 300 def cached(key, fn): hit = CACHE.get(key) if hit and time.time() - hit[0] &lt; TTL: return hit[1] val = fn() CACHE[key] = (time.time(), val) return val def fund_payload(code): meta, hist = nt.fetch_history(code) (d, n), prev = hist[0], (hist[1] if len(hist) &gt; 1 else None) return { \"code\": code, \"name\": meta.get(\"scheme_name\", code), \"house\": meta.get(\"fund_house\", \"\"), \"category\": meta.get(\"scheme_category\", \"\"), \"date\": d.isoformat(), \"nav\": n, \"prev_date\": prev[0].isoformat() if prev else None, \"prev_nav\": prev[1] if prev else None, \"returns\": nt.trailing_returns(hist), \"history\": [[x.isoformat(), v] for x, v in reversed(hist)], } PAGE = r\"\"\"&lt;!doctype html&gt; &lt;html lang=\"en\"&gt;&lt;head&gt;&lt;meta charset=\"utf-8\"&gt; &lt;meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"&gt; &lt;title&gt;NAV Tracker&lt;/title&gt; &lt;style&gt; :root{--bg:#f2f4f7;--panel:#fff;--ink:#0f1b2d;--mute:#5d6b7e;--line:#d9dee5;--acc:#0b6e99;--up:#0a7d4d;--down:#c0392b} @media(prefers-color-scheme:dark){:root{--bg:#0c1219;--panel:#121b26;--ink:#e6ebf2;--mute:#8d9bb0;--line:#223044;--acc:#4cb8e6;--up:#3ddc97;--down:#ff7b72}} *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,\"Segoe UI\",Roboto,sans-serif} .serif{font-family:\"Iowan Old Style\",\"Palatino Linotype\",Palatino,Georgia,serif} :focus-visible{outline:2px solid var(--acc);outline-offset:2px} header{display:flex;gap:20px;align-items:center;flex-wrap:wrap;padding:16px 24px;border-bottom:1px solid var(--line);background:var(--panel)} header b{font-size:20px;letter-spacing:-.01em} .sw{position:relative;flex:1;min-width:240px;max-width:600px} input{width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--ink);font:inherit} #res{display:none;position:absolute;left:0;right:0;top:46px;z-index:5;max-height:360px;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:6px} #res&gt;div{padding:9px 12px;cursor:pointer;border-bottom:1px solid var(--line)} #res&gt;div:hover,#res&gt;div:focus{background:var(--bg)} #res small{display:block;color:var(--mute)} main{display:grid;grid-template-columns:minmax(0,1fr) 340px;max-width:1220px;margin:0 auto} @media(max-width:860px){main{grid-template-columns:1fr}aside{border-left:0!important;border-top:1px solid var(--line)}} #detail{padding:28px 24px} aside{padding:24px;border-left:1px solid var(--line)} h2{margin:0 0 4px;font-size:24px;line-height:1.25} h3{margin:0 0 12px;font-size:17px} .mute{color:var(--mute)}.up{color:var(--up)}.down{color:var(--down)} .big{font-size:44px;line-height:1.1;margin:18px 0 4px;font-variant-numeric:tabular-nums} .big span{font-size:17px;font-family:system-ui,sans-serif;margin-left:10px} .rets{display:flex;flex-wrap:wrap;gap:8px 28px;margin:20px 0;padding:14px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)} .rets div{display:flex;flex-direction:column}.rets b{font-size:17px;font-variant-numeric:tabular-nums}.rets small{color:var(--mute)} .rb{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-bottom:6px} .rb button{border:1px solid var(--line);background:none;color:var(--ink);border-radius:6px;padding:4px 10px;font:inherit;cursor:pointer} .rb button.on{background:var(--ink);color:var(--bg);border-color:var(--ink)} .rb #hv{margin-left:auto;font-variant-numeric:tabular-nums} svg{width:100%;height:auto;display:block;touch-action:pan-y}.ax{font-size:10px;fill:var(--mute)} .add{display:flex;gap:10px;margin-top:22px;align-items:center;flex-wrap:wrap}.add input{max-width:200px} .btn{background:var(--acc);color:#fff;border:0;border-radius:6px;padding:10px 16px;font:inherit;cursor:pointer} .lk{background:none;border:0;padding:0;color:var(--acc);font:inherit;cursor:pointer;text-decoration:underline} .wr{padding:12px 0;border-bottom:1px solid var(--line);cursor:pointer} .wr:hover .wn{color:var(--acc)} .wn{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .wv{display:flex;gap:10px;align-items:baseline;font-variant-numeric:tabular-nums}.wv span:last-child{margin-left:auto} .badge{font-size:12px;font-weight:600;background:var(--acc);color:#fff;border-radius:4px;padding:1px 6px} .tot{display:flex;justify-content:space-between;padding:14px 0;font-size:17px} .note{margin-top:20px;font-size:13px;color:var(--mute)} &lt;/style&gt;&lt;/head&gt;&lt;body&gt; &lt;header&gt;&lt;b class=\"serif\"&gt;NAV Tracker&lt;/b&gt; &lt;div class=\"sw\"&gt;&lt;input id=\"q\" type=\"search\" placeholder=\"Search a fund, e.g. parag parikh flexi cap direct growth\" autocomplete=\"off\" aria-label=\"Search mutual funds\"&gt;&lt;div id=\"res\"&gt;&lt;/div&gt;&lt;/div&gt;&lt;/header&gt; &lt;main&gt; &lt;section id=\"detail\"&gt;&lt;h2 class=\"serif\"&gt;Look up any mutual fund&lt;/h2&gt; &lt;p class=\"mute\"&gt;Type a fund name in the search box to see its latest NAV, returns and full NAV history.&lt;/p&gt; &lt;p class=\"mute\"&gt;Try &lt;button class=\"lk\" data-q=\"parag parikh flexi cap\"&gt;Parag Parikh Flexi Cap&lt;/button&gt;, &lt;button class=\"lk\" data-q=\"nifty 50 index direct\"&gt;Nifty 50 index&lt;/button&gt; or &lt;button class=\"lk\" data-q=\"hdfc mid cap\"&gt;HDFC Mid Cap&lt;/button&gt;.&lt;/p&gt;&lt;/section&gt; &lt;aside&gt;&lt;h3 class=\"serif\"&gt;Your watchlist&lt;/h3&gt;&lt;div id=\"wl\"&gt;&lt;/div&gt; &lt;p class=\"note\"&gt;&lt;span id=\"upd\"&gt;&lt;/span&gt;&lt;br&gt;Funds publish one NAV a day, usually in the evening. This page checks every 5 minutes and flags new NAVs. Data from mfapi.in and AMFI. Not investment advice.&lt;/p&gt;&lt;/aside&gt; &lt;/main&gt; &lt;script&gt; const $=s=&gt;document.querySelector(s),esc=s=&gt;String(s).replace(/[&amp;&lt;&gt;\"]/g,c=&gt;({'&amp;':'&amp;amp;','&lt;':'&amp;lt;','&gt;':'&amp;gt;','\"':'&amp;quot;'}[c])); const fmt=(n,d=2)=&gt;Number(n).toLocaleString('en-IN',{minimumFractionDigits:d,maximumFractionDigits:d}); const sg=v=&gt;(v&gt;=0?'+':'\\u2212')+fmt(Math.abs(v))+'%'; async function api(u){const r=await fetch(u),j=await r.json();if(j.error)throw new Error(j.error);return j} const R={'1M':30,'6M':182,'1Y':365,'3Y':1095,'5Y':1825,'All':1e5}; let cur=null,range='1Y',fresh={},W=[],base=''; try{W=JSON.parse(localStorage.getItem('navwl')||'[]')}catch(e){} const save=()=&gt;{try{localStorage.setItem('navwl',JSON.stringify(W))}catch(e){}}; let timer; $('#q').addEventListener('input',e=&gt;{clearTimeout(timer);const v=e.target.value.trim(),box=$('#res'); if(v.length&lt;2){box.style.display='none';return} timer=setTimeout(async()=&gt;{ try{const r=await api('/api/search?q='+encodeURIComponent(v)); box.innerHTML=r.length?r.map(x=&gt;`&lt;div tabindex=\"0\" data-c=\"${x.schemeCode}\"&gt;${esc(x.schemeName)}&lt;small&gt;Scheme code ${x.schemeCode}&lt;/small&gt;&lt;/div&gt;`).join(''):'&lt;div class=\"mute\"&gt;No fund matches. Try fewer words, like \"parag parikh flexi\".&lt;/div&gt;'} catch(err){box.innerHTML=`&lt;div class=\"down\"&gt;Search failed: ${esc(err.message)}&lt;/div&gt;`} box.style.display='block'},300)}); document.addEventListener('click',e=&gt;{const t=e.target,rm=t.closest('[data-rm]'),rg=t.closest('[data-r]'),ch=t.closest('[data-q]'),c=t.closest('[data-c]'); if(rm){W=W.filter(w=&gt;w.code!=rm.dataset.rm);save();wl();if(cur)show();return} if(rg){range=rg.dataset.r;show();return} if(ch){const q=$('#q');q.value=ch.dataset.q;q.dispatchEvent(new Event('input'));q.focus();return} if(t.id==='addb'){add();return} if(c){$('#res').style.display='none';$('#q').value='';load(c.dataset.c);return} if(!t.closest('.sw'))$('#res').style.display='none'}); document.addEventListener('keydown',e=&gt;{if(e.key==='Enter'&amp;&amp;e.target.dataset&amp;&amp;e.target.dataset.c)e.target.click()}); async function load(code){const d=$('#detail');d.innerHTML='&lt;p class=\"mute\"&gt;Loading fund\\u2026&lt;/p&gt;'; try{cur=await api('/api/fund/'+code);show()} catch(err){cur=null;d.innerHTML=`&lt;h2 class=\"serif\"&gt;Couldn't load this fund&lt;/h2&gt;&lt;p class=\"down\"&gt;${esc(err.message)}&lt;/p&gt;&lt;p class=\"mute\"&gt;Check the scheme code and your internet connection, then search again.&lt;/p&gt;`}} function show(){const d=cur;if(!d)return; const chg=d.prev_nav?d.nav-d.prev_nav:0,p=d.prev_nav?(d.nav/d.prev_nav-1)*100:0,cls=chg&gt;=0?'up':'down',inW=W.some(w=&gt;w.code==d.code); const age=(Date.now()-new Date(d.date))/864e5; $('#detail').innerHTML=`&lt;h2 class=\"serif\"&gt;${esc(d.name)}&lt;/h2&gt; &lt;div class=\"mute\"&gt;${esc(d.house)}&lt;/div&gt;&lt;div class=\"mute\"&gt;${esc(d.category)}&lt;/div&gt; &lt;div class=\"big serif\"&gt;\\u20B9${fmt(d.nav,4)}&lt;span class=\"${cls}\"&gt;${chg&gt;=0?'\\u25B2':'\\u25BC'} ${fmt(Math.abs(chg),4)} (${sg(p)})&lt;/span&gt;&lt;/div&gt; &lt;div class=\"mute\"&gt;NAV as of ${d.date}${age&gt;5?'. This NAV is over 5 days old: the fund may be closed, merged or delayed.':''}&lt;/div&gt; &lt;div class=\"rets\"&gt;${d.returns.map(([l,v,a])=&gt;`&lt;div&gt;&lt;b class=\"${v==null?'':v&gt;=0?'up':'down'}\"&gt;${v==null?'n/a':sg(v)}&lt;/b&gt;&lt;small&gt;${l}${a&amp;&amp;v!=null?' a year':''}&lt;/small&gt;&lt;/div&gt;`).join('')}&lt;/div&gt; &lt;div class=\"rb\"&gt;${Object.keys(R).map(k=&gt;`&lt;button data-r=\"${k}\" class=\"${k==range?'on':''}\"&gt;${k}&lt;/button&gt;`).join('')}&lt;span id=\"hv\" class=\"mute\"&gt;&lt;/span&gt;&lt;/div&gt; &lt;div id=\"chart\"&gt;&lt;/div&gt; &lt;div class=\"add\"&gt;${inW?'&lt;span class=\"mute\"&gt;This fund is in your watchlist.&lt;/span&gt;':'&lt;input id=\"u\" type=\"number\" min=\"0\" step=\"any\" placeholder=\"Units you hold (optional)\" aria-label=\"Units held\"&gt;&lt;button id=\"addb\" class=\"btn\"&gt;Add to watchlist&lt;/button&gt;'}&lt;/div&gt;`; draw()} function draw(){const h=cur.history,last=new Date(h[h.length-1][0]),cut=new Date(last-R[range]*864e5),pts=h.filter(p=&gt;new Date(p[0])&gt;=cut),box=$('#chart'); if(pts.length&lt;2){box.innerHTML='&lt;p class=\"mute\"&gt;Not enough history for this range.&lt;/p&gt;';return} const vs=pts.map(p=&gt;p[1]);let lo=vs[0],hi=vs[0];for(const v of vs){if(v&lt;lo)lo=v;if(v&gt;hi)hi=v} const pad=(hi-lo)*.1||1,X=i=&gt;8+i/(pts.length-1)*584,Y=v=&gt;196-(v-lo+pad)/(hi-lo+2*pad)*180; const line=pts.map((p,i)=&gt;(i?'L':'M')+X(i).toFixed(1)+' '+Y(p[1]).toFixed(1)).join(''); box.innerHTML=`&lt;svg viewBox=\"0 0 600 210\" role=\"img\" aria-label=\"NAV history chart\"&gt;&lt;path d=\"${line}L592 196L8 196Z\" fill=\"var(--acc)\" opacity=\".1\"/&gt;&lt;path d=\"${line}\" fill=\"none\" stroke=\"var(--acc)\" stroke-width=\"2\" stroke-linejoin=\"round\"/&gt;&lt;line id=\"vl\" y1=\"8\" y2=\"196\" stroke=\"var(--mute)\" stroke-dasharray=\"3 3\" display=\"none\"/&gt;&lt;circle id=\"dot\" r=\"4.5\" fill=\"var(--acc)\" display=\"none\"/&gt;&lt;text x=\"8\" y=\"12\" class=\"ax\"&gt;${fmt(hi)}&lt;/text&gt;&lt;text x=\"8\" y=\"208\" class=\"ax\"&gt;${fmt(lo)}&lt;/text&gt;&lt;/svg&gt;`; base=`${pts[0][0]} to ${pts[pts.length-1][0]}: ${sg((vs[vs.length-1]/vs[0]-1)*100)}`;$('#hv').textContent=base; const svg=box.firstChild,vl=$('#vl'),dot=$('#dot'); svg.onpointermove=e=&gt;{const r=svg.getBoundingClientRect(),f=Math.min(1,Math.max(0,((e.clientX-r.left)/r.width*600-8)/584)),i=Math.round(f*(pts.length-1)),x=X(i); vl.setAttribute('x1',x);vl.setAttribute('x2',x);dot.setAttribute('cx',x);dot.setAttribute('cy',Y(pts[i][1])); vl.removeAttribute('display');dot.removeAttribute('display');$('#hv').textContent=`${pts[i][0]}: \\u20B9${fmt(pts[i][1],4)}`}; svg.onpointerleave=()=&gt;{vl.setAttribute('display','none');dot.setAttribute('display','none');$('#hv').textContent=base}} function add(){const u=parseFloat($('#u').value)||0;if(!cur||W.some(w=&gt;w.code==cur.code))return; W.push({code:cur.code,name:cur.name,units:u,nav:cur.nav,date:cur.date,chg:cur.prev_nav?(cur.nav/cur.prev_nav-1)*100:null});save();wl();show()} function wl(){const el=$('#wl'); if(!W.length){el.innerHTML='&lt;p class=\"mute\"&gt;Your watchlist is empty. Search for a fund and choose Add to watchlist. Enter the units you hold to see what they are worth.&lt;/p&gt;';return} let tot=0; el.innerHTML=W.map(w=&gt;{const v=w.units?w.units*w.nav:0;tot+=v; return `&lt;div class=\"wr\" tabindex=\"0\" data-c=\"${w.code}\"&gt;&lt;div class=\"wn\"&gt;${esc(w.name)}&lt;/div&gt; &lt;div class=\"wv\"&gt;&lt;b&gt;\\u20B9${fmt(w.nav,4)}&lt;/b&gt;&lt;span class=\"${w.chg==null?'':w.chg&gt;=0?'up':'down'}\"&gt;${w.chg==null?'':sg(w.chg)}&lt;/span&gt;&lt;span class=\"mute\"&gt;${w.units?'\\u20B9'+fmt(v,0):''}&lt;/span&gt;&lt;/div&gt; &lt;div class=\"mute\"&gt;As of ${w.date}${fresh[w.code]?' &lt;span class=\"badge\"&gt;New NAV&lt;/span&gt;':''} &lt;button class=\"lk\" data-rm=\"${w.code}\"&gt;Remove&lt;/button&gt;&lt;/div&gt;&lt;/div&gt;`}).join('') +(tot?`&lt;div class=\"tot\"&gt;&lt;span&gt;Total value&lt;/span&gt;&lt;b&gt;\\u20B9${fmt(tot,0)}&lt;/b&gt;&lt;/div&gt;`:'')} async function refresh(){ for(const w of W){try{const d=await api('/api/fund/'+w.code+'?brief=1'); if(d.date&gt;w.date)fresh[w.code]=1; Object.assign(w,{name:d.name,nav:d.nav,date:d.date,chg:d.prev_nav?(d.nav/d.prev_nav-1)*100:null})}catch(e){}} save();wl();if(Object.keys(fresh).length)document.title='New NAV - NAV Tracker'; $('#upd').textContent='Updated '+new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})+'.'} wl();refresh();setInterval(refresh,300000); &lt;/script&gt;&lt;/body&gt;&lt;/html&gt; \"\"\" class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass def reply(self, body, ctype=\"application/json\", status=200): data = body if isinstance(body, bytes) else body.encode(\"utf-8\") self.send_response(status) self.send_header(\"Content-Type\", ctype) self.send_header(\"Content-Length\", str(len(data))) self.end_headers() self.wfile.write(data) def do_GET(self): url = urlparse(self.path) query = parse_qs(url.query) try: if url.path == \"/\": return self.reply(PAGE, \"text/html; charset=utf-8\") if url.path == \"/api/search\": term = query.get(\"q\", [\"\"])[0].strip() found = cached(\"s:\" + term.lower(), lambda: nt.search(term)) if len(term) &gt; 1 else [] return self.reply(json.dumps(found[:15])) if url.path.startswith(\"/api/fund/\"): code = url.path.rsplit(\"/\", 1)[1] if not code.isdigit(): return self.reply(json.dumps({\"error\": \"invalid scheme code\"}), status=400) data = cached(\"f:\" + code, lambda: fund_payload(code)) if query.get(\"brief\"): data = {k: v for k, v in data.items() if k != \"history\"} return self.reply(json.dumps(data)) self.reply(json.dumps({\"error\": \"not found\"}), status=404) except Exception as err: # upstream/network problems become a readable message self.reply(json.dumps({\"error\": str(err)}), status=502) def main(): port = int(sys.argv[1]) if len(sys.argv) &gt; 1 else 8000 server = ThreadingHTTPServer((\"127.0.0.1\", port), Handler) url = f\"http://127.0.0.1:{port}\" print(f\"NAV tracker UI running at {url} (Ctrl+C to stop)\") try: webbrowser.open(url) except Exception: pass try: server.serve_forever() except KeyboardInterrupt: print(\"\\nStopped.\") if __name__ == \"__main__\": main()Run it source .venv/bin/activate # Windows: .venv\\Scripts\\Activate.ps1 python navtracker.py search \"your fund name\" # find a scheme code python navtracker.py nav &lt;scheme code&gt; # NAV and returns in the terminal python navtracker.py watch &lt;scheme code&gt;[:units] --interval 300 python navtracker_web.py # or open the web UI Where to take it next Run watch --once from cron and send yourself a Telegram or email message when a NAV drops. Log every NAV to SQLite and chart your portfolio over time. Add a compare-two-funds view or a SIP return calculator to the web UI. Swap the data source to support US mutual funds. No dependencies means it runs anywhere Python does, and nothing breaks when a package updates. Disclaimer: this is an educational project, not investment advice.","contentHash":"sha256:9c9e1c3486a0e34bd2bd7a4008af67759f453532845930ea8dd167887a211e05","authorName":"Anshu Pathak","authorUrl":"https://zyvop.com/author/anshu","authorSameAs":["https://www.linkedin.com/in/anshu-pathak-zyvop/"],"category":"Tutorial","tags":["personal finance","Python","mutual funds","api","cli"],"audience":"Software engineers and developers building applications with Tutorial","tone":"Instructional, practical, code-first","readingTimeMinutes":22,"wordCount":5626,"faqs":null,"primaryTopic":"Tutorial","publishedAt":"2026-11-02T06:38:42.501Z","updatedAt":"2026-11-02T06:38:42.501Z","canonicalUrl":"https://zyvop.com/build-your-own-mutual-fund-nav-tracker-in-python-cli-web-ui-no-api-key-ts1d8"}