Skip to content

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:

  1. Reads a CSV of (symbol, quantity, cost_basis).
  2. Calls /v1/quote?symbols=... in batches of 8 to get current prices.
  3. Calls /v1/fx/time_series?pair=... for any non-target currencies.
  4. Calls /v1/dividends?symbols=... for projected income.
  5. Prints a table.

Steps

  1. 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.

  2. Define the holdings CSV.

    symbol,quantity,cost_basis
    AAPL,100,142.50
    MSFT,50,310.00
    BMW.DE,75,98.20
    SHOP.TO,40,55.10

    Note the suffixed tickers for non-US listings. See exchanges.

  3. Fetch quotes in batches of 8.

    import csv
    import os
    import httpx
    API_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 buf
    buf = []
    if buf:
    yield buf
    def 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
  4. 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 amount
    pair = f"{src_ccy}/{tgt_ccy}"
    return amount * rates[pair]
  5. 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
  6. 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.0
    print(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"]) * qty
    q = quotes.get(sym)
    if not q:
    print(f"{sym:<10} ?? not found")
    continue
    price = q["price"]
    src_ccy = q["currency"]
    value = to_target(price * qty, src_ccy, target_ccy, rates)
    ann_div = divs.get(sym) or 0.0
    income = to_target(ann_div * qty, src_ccy, target_ccy, rates)
    total_value += value
    total_cost += to_target(cost, src_ccy, target_ccy, rates)
    total_income += income
    print(f"{sym:<10} {qty:>6.0f} {price:>10.2f} {value:>18,.2f} {income:>14,.2f}")
    pnl = total_value - total_cost
    print("-" * 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), currency is GBX (pence), not GBP. Read currency handling before adding LSE names.
  • Dividend yield is a forecast. annualDividend is 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) or URTH (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