sifting/io
US Equities
5 min readSiftingIO Team

Stock portfolio tracker API: compute unrealized P&L from bars and live quotes

Build a stock portfolio tracker with a US stocks API: positions as data, daily-bar backfill, unrealized P&L from live quotes, and a WebSocket upgrade path.

Stock portfolio tracker API: compute unrealized P&L from bars and live quotes

A stock portfolio tracker is one of the most useful projects you can build against a market data API, and one of the least demanding: a handful of REST calls covers the whole thing. This post builds one end to end on SiftingIO's US stocks data. You define positions as plain data, backfill each one with daily bars to get a P&L history since purchase, value the whole portfolio on demand with live quotes, and, only when you actually need it, upgrade the valuation loop to WebSocket ticks. Everything below runs on the free tier, in Python, with no framework.

Define positions as data#

A position is four fields: the ticker, the share count, the cost basis per share, and the buy date. Resist the urge to start with a database. A list of dicts, or a CSV you load into one, is enough, and it keeps the interesting part of the project, the market data, at the center.

import os, requests

API = "https://api.sifting.io/v1"
S = requests.Session()
S.headers["X-API-Key"] = os.environ["SIFTING_KEY"]

POSITIONS = [
    {"ticker": "AAPL", "shares": 25, "cost_basis": 172.40, "buy_date": "2026-07-01"},
    {"ticker": "MSFT", "shares": 10, "cost_basis": 415.10, "buy_date": "2026-07-08"},
    {"ticker": "JPM",  "shares": 40, "cost_basis": 198.75, "buy_date": "2026-07-15"},
]

The tickers are neutral data examples; swap in whatever you hold. Auth is a single X-API-Key header, which the requests.Session attaches to every call from here on.

Backfill P&L history with daily bars#

The history view answers "how has this position done since purchase." One call per position to the historical bars endpoint gets every daily close from the buy date forward:

def pnl_history(pos):
    r = S.get(f"{API}/hist/stocks/{pos['ticker']}/bars",
              params={"interval": "1d", "start": pos["buy_date"]})
    r.raise_for_status()
    return [(bar["t"], round(pos["shares"] * (bar["c"] - pos["cost_basis"]), 2))
            for bar in r.json()["data"]]

Each bar carries t (epoch milliseconds), o, h, l, c, and v. Unrealized P&L on any given day is shares * (close - cost_basis), so the function returns a date-to-P&L table you can print, write to CSV, or diff day over day. No charting library required; a tracker's history view is tabular at heart.

One short aside that saves real pain: use adjusted prices for this math. If a stock splits 4-for-1 after your buy date, the unadjusted close drops to a quarter of your cost basis overnight and the table reports a 75% loss that never happened. Dividends distort the same math more slowly. SiftingIO serves US stock bars adjusted or unadjusted, with the bar parameters listed in the historical stocks docs; the full explanation of why the wrong series breaks return math is in Adjusted vs unadjusted stock prices.

Two practical notes on depth. Daily bars paginate at 1,000 per page by default, so even a position held for years comes back in one or two pages; follow meta.next_cursor when it isn't null. History depth depends on your plan, so check the retention window for your tier on the pricing page before you count on reaching an old buy date. The example positions above were opened this month and fit any plan; a portfolio bought years ago needs enough retention to reach back that far. The backfill code doesn't change either way.

Value the portfolio with live quotes#

Current value is a snapshot problem. The live quote endpoint returns the best bid and ask, and the mid between them is a fair mark for a position, less biased than the last trade on a thinly traded name:

def mid(ticker):
    q = S.get(f"{API}/last/quote/stocks/{ticker}").json()
    return (float(q["b"]) + float(q["a"])) / 2

total_cost = total_value = 0.0
for pos in POSITIONS:
    value = pos["shares"] * mid(pos["ticker"])
    cost = pos["shares"] * pos["cost_basis"]
    total_value += value
    total_cost += cost
    print(f"{pos['ticker']:<6} {value - cost:>+12.2f} {value / cost - 1:>+9.2%}")

print(f"TOTAL  {total_value - total_cost:>+12.2f} {total_value / total_cost - 1:>+9.2%}")

Why poll REST for this step: a valuation you run on demand, or on a timer, only needs a snapshot. Three positions cost three calls. Refreshing every five minutes across a 6.5 hour trading session is 234 calls a day, roughly 4,900 a month over 21 trading days, comfortably inside the free tier's 10,000 calls a month and 60 requests a minute. Polling also keeps the code stateless: no connection to hold open, nothing to reconnect. The general decision framework is in REST vs WebSocket for real-time market data; the short version for a tracker is that REST wins until a human is watching the number continuously.

When the portfolio tracker needs WebSocket ticks#

The moment the tracker becomes a screen someone leaves open, per-minute polling feels slow and wasteful at the same time. That's the point to move valuation, and only valuation, to the stream. Connect to wss://stream.sifting.io/ws/v1?key=$SIFTING_KEY and subscribe once:

{"op": "subscribe", "product": "us", "symbols": ["AAPL", "MSFT", "JPM"]}

The server first replays the last cached value for each symbol, so the screen paints immediately, then pushes tick frames like {"f":"tick","s":"AAPL","p":213.4,...} as prices move. Handling a tick is one line of math you already have: update that symbol's price, recompute its row and the total. Send {"op":"ping"} at least once a minute, because the server closes connections idle for 90 seconds. Everything else about the tracker, positions, backfill, and cost basis, stays on REST. The free tier's single connection with five symbol subscriptions covers this example portfolio; a larger book needs a paid tier or a REST fallback for the overflow symbols.

Common pitfalls#

Marking adjusted prices against an unadjusted cost basis. A cost basis copied from an old broker statement is unadjusted by definition. If the position has had a split or a large special dividend since purchase, either adjust the basis with the same factors the bars use or fetch unadjusted bars for that one position. Mixing the two silently misstates P&L in both directions.

Missing gzip on historical endpoints. Bar endpoints require gzip and return 406 gzip_required without it. Python's requests negotiates compression automatically, which is why the code above works unmodified, but the equivalent curl needs the flag:

curl --compressed -H "X-API-Key: $SIFTING_KEY" \
  "https://api.sifting.io/v1/hist/stocks/AAPL/bars?interval=1d&start=2026-07-01"

Unpaced quote loops. The valuation loop above fires requests back to back. At three positions that's harmless; at fifty positions refreshed aggressively it can brush the free tier's 60 requests a minute. Every response carries X-RateLimit-Remaining, and a 429 includes Retry-After in seconds. Check the first before a large refresh and honor the second instead of retrying immediately.

A portfolio tracker ends up exercising most of the US stocks product in one small script: historical bars, live quotes, and a stream, all under one key. The free tier needs no credit card. Start building free.

Keep reading

Related posts