Python Stock Tracker: Modules, Fundamentals, and CLI Flags with argparse
How splitting a 300-line file into five modules made it easier to extend, plus caching fundamentals data and adding command line flags with…
Python Stock Tracker: Modules, Fundamentals, and CLI Flags with argparse

Python Stock Tracker: Modules, Fundamentals, and CLI Flags with argparse
How splitting a 300-line file into five modules made it easier to extend, plus caching fundamentals data and adding command line flags with argparse.
This is Part 2 of a two-part series. Part 1 covers building the single-file tracker, live prices, P&L, watchlist, and news. Start there if you are new. This part covers splitting it into modules, adding fundamentals with caching, and moving news behind a command line flag.
The tracker was working well as a single file. But as I kept adding things, ETFs in a separate section, fundamentals, news, the file was growing in a way that made it harder to find things. The fetch logic, the display logic, the news logic, the fundamentals logic were all sitting together. Changing one thing meant scrolling past everything else.
On a free medium plan? Read here for free.
I decided to split it into separate files, each with a clear job.
main.py -> entry point
trackers.py -> loads portfolio, fetches prices, renders tables
fundamentals.py -> fetches and displays fundamentals
news.py -> news prompt
utils.py -> everything shared: fetch, format, build tables
This is a common pattern in Python projects. When a file starts doing too many things, you split responsibilities across modules. Each file imports what it needs from the others.
utils.py: the shared layer
The first thing I did was pull all the reusable functions into utils.py. Anything that more than one file would need, loading JSON, fetching stock data, building tables, went here.
python
def load_json(filename):
with open(filename, "r") as f:
data = json.load(f)
return data
def save_json(filename, data):
with open(filename, "w") as f:
json.dump(data, f)
Simple read and write wrappers. Every other file calls these instead of repeating the open() and json.load() pattern.
One small helper that turned out to be useful everywhere:
def clean_ticker(ticker):
return ticker.replace(".NS", "").replace(".BO", "")
The table displays HDFCBANK not HDFCBANK.NS. This strips the exchange suffix for display while keeping the full ticker for the actual fetch.
fetch_stock_data: handling the period=”2d” edge case
The core fetch function in utils.py:
def fetch_stock_data(ticker, buy_price=None, qty=None):
portfolioTicker = yf.Ticker(ticker)
hist = portfolioTicker.history(period="2d").dropna()
if len(hist) < 2:
hist = portfolioTicker.history(period="5d").dropna()
current_price = hist["Close"].iloc[-1]
previous_close = hist["Close"].iloc[-2]
day_change_pct = ((current_price - previous_close) / previous_close) * 100
...
Part 1 introduced fast_info for a quick current price. Here we use history(period=”2d”) instead because we need yesterday’s close to calculate the day change : fast_info’s previous_close can be unreliable on the first call of a session, so history() is the safer tool for this specific calculation.
The period="2d" call fetches today and yesterday so the day change can be calculated. But there is an edge case, on some days, particularly around market holidays or weekends, period="2d" returns only one row after .dropna() strips empty rows. If that happens, iloc[-2] would crash because there is no second row.
The fix is a fallback to period="5d". Five days of data almost always gives at least two trading days to work with. This was a small bug that only showed up on a Monday after a long weekend.
The function accepts buy_price and qty as optional parameters. When they are passed in, it calculates P&L. When they are not, for watchlist stocks, it just returns the price and day change.
result = {
"ticker": ticker,
"current_price": round(float(current_price), 2),
"day_change_pct": round(float(day_change_pct), 4),
}
if qty is not None and buy_price is not None:
result["daily_pnl_rs"] = round(float(daily_pnl_rs), 2)
result["invested"] = round(invested, 2)
result["pnl_rs"] = round(float(pnl_rs), 2)
result["pnl_pct"] = round(float(pnl_pct), 4)
return result
The float() calls are there to strip numpy's np.float64 type. yfinance returns numpy floats, not Python floats. They look the same when printed but behave differently in some contexts particularly when saving to JSON, which does not know how to serialize np.float64. Wrapping in float() converts them to plain Python floats before they cause a problem.
trackers.py: stocks, ETFs, and watchlist in separate sections
With utils.py handling the fetch and table building, trackers.py is mostly orchestration:
def run():
data = utils.load_json("portfolio.json")
# stocks
results = []
for stock in data["portfolio"]:
try:
results.append(utils.fetch_stock_data(stock["ticker"], stock["buy_price"], stock["qty"]))
except Exception as e:
print(f"Skipping {stock['ticker']}: {e}")
table = utils.build_pnl_table(results, f"Portfolio Tracker | {dateNow}")
# ETFs
etf_results = []
for r in data["etfs"]:
try:
etf_results.append(utils.fetch_stock_data(r["ticker"], r["buy_price"], r["qty"]))
except Exception as e:
print(f"Skipping {r['ticker']}: {e}")
etf_table = utils.build_pnl_table(etf_results, "ETFs")
# watchlist
watchlist_results = []
for r in data["watchlist"]:
try:
watchlist_results.append(utils.fetch_stock_data(r["ticker"]))
except Exception as e:
print(f"Skipping {r['ticker']}: {e}")
watchlist_table = utils.build_watchlist_table(watchlist_results, "Watchlist")
console.print(table)
console.print(etf_table)
console.print(watchlist_table)
Three sections, three loops, three tables printed one after the other. Stocks and ETFs both use build_pnl_table since they have the same columns, the title is the only difference. Watchlist uses build_watchlist_table which has fewer columns since there is no P&L to show.
Adding fundamentals
Once the portfolio table was working well, the next thing I wanted was a way to look at the underlying numbers for each stock, P/E ratio, EPS, return on equity, profit margins, and what analysts were recommending. The kind of data that does not change daily but is useful to review periodically.
yfinance has a .info property that returns a large dictionary of company data:
t = yf.Ticker("HDFCBANK.NS")
print(t.info)
Printing this shows dozens of fields. I picked the fields that answer the questions I actually ask before holding a stock, is it cheap relative to earnings (PE), is it growing (earningsGrowth), is it efficient (returnOnEquity), and what do analysts think the price should be (targetMeanPrice). Everything else in .info is either too granular or already visible in the price table.
Everything else in .info is either too granular or already visible in the price table.
def fetch_fundamentals(ticker, dateNow):
t = yf.Ticker(ticker)
return {
"ticker": ticker,
"trailingPE": t.info.get("trailingPE", None),
"forwardPE": t.info.get("forwardPE", None),
"trailingEps": t.info.get("trailingEps", None),
"earningsGrowth": t.info.get("earningsGrowth", None),
"returnOnEquity": t.info.get("returnOnEquity", None),
"profitMargins": t.info.get("profitMargins", None),
"targetMeanPrice": t.info.get("targetMeanPrice", None),
"recommendationKey": t.info.get("recommendationKey", None),
"lastUpdated": dateNow,
}
.info.get("trailingPE", None) is the safe way to read from a dictionary when a key might not exist. Some stocks, particularly smaller ones do not have all fields populated. Using .get() with None as the default means a missing field returns None instead of throwing a KeyError.
The caching problem
Fetching .info for 15 stocks takes noticeably longer than fetching prices. The data also does not change daily, P/E ratios and analyst targets update maybe once a week. Running this fetch on every tracker refresh would be slow and unnecessary.
The solution was to save the results to a file and only re-fetch when explicitly asked:
def run():
fundamentals = []
for stock in portfolio:
try:
fundamentals.append(utils.fetch_fundamentals(stock["ticker"], dateNow))
except Exception as e:
print(f"Skipping {stock['ticker']}: {e}")
utils.save_json("fundamentals.json", fundamentals)
fundamentals.json gets written to disk. The main tracker never touches it on a normal run. You only refresh it when you want to by running the fundamentals command specifically.
File-based caching has one advantage over keeping data in memory, it survives restarts. If you close the terminal and reopen it the next day, the cached fundamentals are still there without re-fetching.
fundamentals.json also goes into .gitignoresame reason as portfolio.json. It contains your holdings data.
Displaying the fundamentals table
The table has more columns than the P&L table. A few things were worth thinking through in the display:
def fmt(value):
return str(round(float(value), 2)) if value is not None else "-"
def fmt_pct(value):
return f"{value * 100:.2f}%" if value is not None else "-"
fmt() and fmt_pct() handle the None case. When a field is missing, the table shows - instead of crashing. These helpers are called inside the table-building loop in utils.py, the same build_pnl_table pattern from Part 1 but with more columns:
table.add_row(
clean_ticker(r["ticker"]),
fmt(r["trailingPE"]),
fmt(r["forwardPE"]),
fmt_pct(r["returnOnEquity"]),
fmt_pct(r["profitMargins"]),
fmt_pct(r["earningsGrowth"]),
fmt(r["targetMeanPrice"]),
f"[{recommend_color}]{str(r['recommendationKey']).replace('_', ' ').title()}[/{recommend_color}]",
r["lastUpdated"]
)
The first version of this table crashed immediately because I passed a None value directly into the formatting string. That is when fmt() and fmt_pct()
got written, the crash made the problem obvious. earningsGrowth and returnOnEquity come back as decimals from yfinance 0.14 means 14%. fmt_pct() multiplies by 100 and adds the % sign.
Analyst recommendations come as strings like strong_buy, buy, hold. The display maps these to colours:
if r["recommendationKey"] in ("strong_buy", "buy"):
recommend_color = "green"
elif r["recommendationKey"] == "hold":
recommend_color = "yellow"
else:
recommend_color = "white"
And cleans up the underscore for display:
str(r["recommendationKey"]).replace("_", " ").title()
# "strong_buy" becomes "Strong Buy"
.title() capitalises the first letter of each word. A small detail but it makes the table look cleaner.
Moving news to a flag
News was part of the default run originally, after the table rendered, a prompt would appear asking which stock you wanted headlines for. This was fine early on but it meant every run ended with an interactive prompt even when I just wanted to glance at prices.
The cleaner approach was to make news opt-in. Run the tracker normally and it just shows the table. Ask for news only when you want it.
This is where argparse came in.
argparse: adding command line flags
argparse is Python's built-in library for handling command line arguments. Instead of always running the same thing, you can pass flags to change what the program does.
python
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("-f", "--fundamentals", action="store_true")
parser.add_argument("-r", "--refresh", action="store_true")
parser.add_argument("-n", "--news", action="store_true")
args = parser.parse_args()
add_argument defines a flag. -f is the short form, --fundamentals is the long form. action="store_true" means the flag is a boolean — it is either present or not. When you run python3 main.py -f, args.fundamentals is True. When you run python3 main.py without it, args.fundamentals is False.
The full main.py:
if args.fundamentals:
fundamentals.run()
elif args.refresh:
while True:
os.system("clear")
trackers.run()
time.sleep(300)
elif args.news:
news.run()
else:
trackers.run()
The default with no flags just runs the tracker once. -r wraps it in the auto-refresh loop. -f runs the fundamentals fetch. -n runs the news prompt.
Moving the refresh loop into main.py also cleaned up the tracker itself. trackers.run() now just renders once and returns. Running the tool now:
# default: show portfolio once
python3 main.py
# auto-refresh every 5 minutes
python3 main.py -r
# fetch and display fundamentals
python3 main.py -f
# news headlines for any portfolio stock
python3 main.py -n
The news prompt shows available tickers, then waits for input:
Available tickers: HDFCBANK, INFY, BEL, MSFT
Enter ticker for news (or Enter to skip): INFY
Top news for INFY.NS:
Infosys Q4 results beat estimates on strong deal wins
Reuters
https://finance.yahoo.com/...
Enter ticker for news (or Enter to skip):
You type the clean ticker without the suffix. The code maps it back to the full ticker internally:
ticker_map = {clean_ticker(s["ticker"]): s["ticker"] for s in portfolio}
So typing HDFCBANK looks up HDFCBANK.NS in the map and fetches news for the full ticker. If a ticker is not in the portfolio, it defaults to adding .NS so you can look up any Indian stock, not just the ones you own.
What the project looks like now
main.py ← flags, routing
trackers.py ← portfolio, ETF, watchlist tables
fundamentals.py ← fundamentals fetch and display
news.py ← news prompt
utils.py ← fetch, format, build tables (shared)
portfolio.json ← your data (gitignored)
fundamentals.json ← cached fundamentals (gitignored)
Each file has one job. Adding a new feature means knowing exactly which file to touch. If the fundamentals table needs a new column, that is utils.py. If the news prompt needs a change, that is news.py and utils.py. Nothing bleeds across files unnecessarily.
What I took away from this part
Splitting into modules : the decision of when to split a file is not about line count. It is about whether the file is doing more than one thing. Once fetching, calculating, displaying, and news were all in one file, finding anything required scrolling past everything else. Splitting by responsibility made the project easier to navigate and easier to change.
Optional parameters : fetch_stock_data(ticker, buy_price=None, qty=None) taught me that functions can have optional arguments with defaults. The watchlist and portfolio use the same fetch function because of this. Less duplication, same logic.
**.get() for safe dictionary access** : using .info.get("trailingPE", None) instead of .info["trailingPE"] is the difference between a crash and a - in the table when a field is missing. For any data you do not fully control, .get() with a default is the safer choice.
**np.float64 and JSON serialization** : this was a quiet bug that only appeared when saving to JSON. yfinance returns numpy types, not Python types. json.dump() does not know what to do with np.float64. Wrapping in float() before storing in the result dictionary fixed it cleanly.
argparse : once you know it exists, it is hard to imagine building a CLI tool without it. One library, a few lines, and your tool has a proper interface instead of hardcoded behaviour.
The project now has a clear structure, each file with one job, and a proper CLI so you only run what you need. Part 1 was about getting something working. Part 2 was about making it something you can actually maintain.
The full code for both parts is on GitHub, clone it, add your tickers to portfolio.json, and you have a working modular tracker in under 10 minutes.
메타데이터
- post_id
- 3ee72d8ea39f
- slug
- python-stock-tracker-modules-fundamentals-and-cli-flags-with-argparse-3ee72d8ea39f
- url
- https://medium.com/@rk90229/python-stock-tracker-modules-fundamentals-and-cli-flags-with-argparse-3ee72d8ea39f
- canonical_url
- https://medium.com/@rk90229/python-stock-tracker-modules-fundamentals-and-cli-flags-with-argparse-3ee72d8ea39f
- author_url
- https://medium.com/@rk90229
- status
- ok
- fetched_at
- 2026-06-22 12:55:45