Build a portfolio tracker
A portfolio tracker is the canonical first project for oneapi.finance: it
exercises quote batching, currency conversion, dividend lookup, and the cache
behavior that keeps your quota intact. This guide builds one from scratch in
Python.
The end state is a CLI that takes a CSV of holdings and prints current value in a target currency, plus next-12-month projected dividend income.
What we are building
A 200-line Python script that:
- Reads a CSV of
(symbol, quantity, cost_basis). - Calls
/v1/quote?symbols=...in batches of 8 to get current prices. - Calls
/v1/fx/time_series?pair=...for any non-target currencies. - Calls
/v1/dividends?symbols=...for projected income. - Prints a table.
Steps
-
Get an API key and store it.
Terminal window export ONEAPI_KEY=oa_live_...In production, store this in a secret manager (see authentication). For this demo, an environment variable is fine.
-
Define the holdings CSV.
symbol,quantity,cost_basisAAPL,100,142.50MSFT,50,310.00BMW.DE,75,98.20SHOP.TO,40,55.10Note the suffixed tickers for non-US listings. See exchanges.
-
Fetch quotes in batches of 8.
import csvimport osimport httpxAPI_KEY = os.environ["ONEAPI_KEY"]HEADERS = {"Authorization": f"Bearer {API_KEY}"}BASE = "https://api.oneapi.finance/v1"def chunked(iterable, n):buf = []for item in iterable:buf.append(item)if len(buf) == n:yield bufbuf = []if buf:yield bufdef fetch_quotes(symbols: list[str]) -> dict[str, dict]:out = {}for chunk in chunked(symbols, 8):r = httpx.get(f"{BASE}/quote",params={"symbols": ",".join(chunk)},headers=HEADERS,timeout=15.0,)r.raise_for_status()out.update(r.json()["quotes"])return out -
Convert non-target currencies.
def fetch_rates(pairs: set[str]) -> dict[str, float]:if not pairs:return {}r = httpx.get(f"{BASE}/fx/time_series",params={"pairs": ",".join(pairs), "outputsize": 1},headers=HEADERS,timeout=15.0,)r.raise_for_status()rates_obj = r.json()["rates"]return {p: rates_obj[p]["current"] for p in pairs}def to_target(amount: float, src_ccy: str, tgt_ccy: str, rates: dict[str, float]) -> float:if src_ccy == tgt_ccy:return amountpair = f"{src_ccy}/{tgt_ccy}"return amount * rates[pair] -
Project annual dividend income.
def fetch_dividends(symbols: list[str]) -> dict[str, float | None]:out: dict[str, float | None] = {}for chunk in chunked(symbols, 8):r = httpx.get(f"{BASE}/dividends",params={"symbols": ",".join(chunk)},headers=HEADERS,timeout=15.0,)r.raise_for_status()for sym, info in r.json()["dividends"].items():out[sym] = info.get("annualDividend")return out -
Stitch it together.
def run(csv_path: str, target_ccy: str = "USD"):with open(csv_path) as f:rows = list(csv.DictReader(f))symbols = [r["symbol"] for r in rows]quotes = fetch_quotes(symbols)divs = fetch_dividends(symbols)ccys = {q.get("currency") for q in quotes.values() if q.get("currency")}needed_pairs = {f"{c}/{target_ccy}" for c in ccys if c != target_ccy}rates = fetch_rates(needed_pairs)total_value = total_cost = total_income = 0.0print(f"{'Symbol':<10} {'Qty':>6} {'Price':>10} {'Value ('+target_ccy+')':>18} {'Income (1y)':>14}")for row in rows:sym = row["symbol"]qty = float(row["quantity"])cost = float(row["cost_basis"]) * qtyq = quotes.get(sym)if not q:print(f"{sym:<10} ?? not found")continueprice = q["price"]src_ccy = q["currency"]value = to_target(price * qty, src_ccy, target_ccy, rates)ann_div = divs.get(sym) or 0.0income = to_target(ann_div * qty, src_ccy, target_ccy, rates)total_value += valuetotal_cost += to_target(cost, src_ccy, target_ccy, rates)total_income += incomeprint(f"{sym:<10} {qty:>6.0f} {price:>10.2f} {value:>18,.2f} {income:>14,.2f}")pnl = total_value - total_costprint("-" * 72)print(f"{'Total':<10} {'':>6} {'':>10} {total_value:>18,.2f} {total_income:>14,.2f}")print(f"P&L: {pnl:+,.2f} {target_ccy} ({pnl / total_cost * 100:+.1f}%)")print(f"Projected yield: {total_income / total_value * 100:.2f}%")if __name__ == "__main__":run("holdings.csv", target_ccy="EUR")
What this teaches
- Batching matters. With 4 holdings, the script makes 3 API calls (1 quote, 1 dividend, 1 FX). Without batching, it would be 12.
- Currency normalization is your problem. The API returns prices in source currency. Conversion is one line of code, but you have to think about it.
- Sub-units bite. If your CSV includes a UK small-cap (e.g.
BARC.L),currencyisGBX(pence), notGBP. Read currency handling before adding LSE names. - Dividend yield is a forecast.
annualDividendis trailing 12 months, not future 12 months. For Aristocrats it is a reasonable estimator. For cyclicals or growth stocks, treat it as a wide range.
Going further
- Cache quotes in a local SQLite for one minute to avoid hitting the rate limit during interactive use. See caching recipe.
- Add a “performance vs benchmark” column by fetching
^GSPC(S&P 500) orURTH(MSCI World) time series. - Persist holdings in Postgres and add a web UI. The dashboard at app.oneapi.finance is open source if you want a reference.
See also
/v1/quote— batched quote endpoint./v1/dividends— dividend history and TTM aggregates./v1/fx/time_series— currency conversion.- Caching recipe — keep popular symbols out of the bucket.