sifting/io
Forex & Crypto
7 min readSiftingIO Team

Multi-currency portfolio valuation: convert every position to one base currency

Convert a multi-currency portfolio to one base currency: mid vs bid, timestamp alignment, triangulated crosses, and weekend FX gaps, with worked numbers.

Multi-currency portfolio valuation: convert every position to one base currency

Multi-currency portfolio valuation looks like a single loop: multiply each position by its price, convert everything to the user's home currency, add it up. The loop is correct. The trouble hides in the inputs. Which FX rate do you convert at, was that rate observed at the same moment as the asset price, and what do you show on a Saturday when crypto trades but the currency market doesn't? A user who holds US stocks in USD, some ETH, a few ounces of gold, and a EUR cash balance wants one number in EUR, and they'd like it to mean something. That makes valuation a data problem before it's a math problem. This post works through the decisions with concrete numbers, then ends with a short Python sketch and a checklist.

Mid or bid: a worked conversion to one base currency#

An FX quote has two sides. The bid is where the market buys the base currency from you, the ask is where it sells it to you, and the mid sits halfway between. Say EURUSD quotes 1.0999 bid and 1.1001 ask, mid 1.1000:

curl -H "X-API-Key: $SIFTING_KEY" "https://api.sifting.io/v1/last/quote/forex/EURUSD"

Which number you use depends on what the valuation is for. For reporting, the portfolio total on a dashboard or a statement, use the mid. It doesn't bake in a trading cost the user hasn't paid, and it's the convention a statement reader expects. For a liquidation estimate, what would actually land in the account if everything were sold and converted right now, use the side of the market you'd trade at. Converting USD proceeds into EUR means buying EUR, which happens at the ask.

A worked example. The portfolio: 50 shares of AAPL at a 230.00 USD mid (11,500 USD), 2 ETH at a 4,600 ETHUSD mid (9,200 USD), 5 troy ounces of gold at a 3,300 XAUUSD mid (16,500 USD), plus 10,000 EUR in cash. The USD sleeve totals 37,200 USD.

Reported value at the mid: 37,200 / 1.1000 = 33,818.18 EUR, plus cash, 43,818.18 EUR total. Liquidation flavor at the ask: 37,200 / 1.1001 = 33,815.11 EUR, about 3 EUR less. On a major pair the distinction is a rounding error. On an exotic it isn't. If USDTRY quotes 41.20 bid and 41.60 ask, converting the same 37,200 USD into lira at the mid (41.40) gives 1,540,080 TRY, while actually selling USD at the bid gives 1,532,640 TRY. That gap is 7,440 TRY, roughly half a percent of the sleeve, from spread alone. Pick one convention per purpose, apply it to every leg, and label the on-screen number as reported or liquidation. Don't mix them in one total.

Timestamp alignment: a mismatched rate manufactures gains#

Every converted value is the product of two observations, and they must come from the same moment. Suppose AAPL last printed 230.00 on Friday at 20:00 UTC, and the valuation job runs Monday at 07:00 UTC after EURUSD slid from 1.1000 to 1.0850 over the weekend. Friday's stock price at Monday's rate is 11,500 / 1.0850 = 10,599.08 EUR, versus 10,454.55 EUR on Friday. The dashboard shows a 1.4 percent gain on a stock that hasn't traded since Friday. The gain is pure rate drift, and it can reverse just as arbitrarily.

The fix is mechanical. Every price you use carries its own timestamp (live ticks carry an epoch-millisecond t field on every frame), so compare them. Pair each asset price with an FX observation taken within some tolerance, a minute is reasonable for a live dashboard, and stamp the whole valuation with the oldest input used. If one input is hours older than the rest, show that instead of quietly averaging over it.

Crosses, weekends, and where a defensible rate comes from#

Not every conversion has a direct pair. A GBP-denominated line inside a TRY-based portfolio has no traded GBPTRY, so you build the rate from two legs that do trade: GBPUSD times USDTRY, for example 1.2700 x 41.40 = 52.578 TRY per GBP. The mechanics, including how the two spreads compound, are covered in the cross-rate triangulation post. The valuation consequence is that a triangulated cross inherits the staleness of both legs, so the timestamp check from the previous section applies to each leg separately.

Weekends invert the staleness problem. Crypto and DEX markets trade straight through Saturday while FX and gold pause, so ETH can move 3 percent after EURUSD's last tick on Friday around 22:00 UTC. There's no honest way to conjure a fresher rate. Value the crypto legs at their live USD prices, convert at the last available rate, and display that rate's timestamp next to the total. Don't interpolate a weekend FX rate and don't hide the gap. The API makes this state explicit: a live FX snapshot older than the freshness threshold returns 503 stale_snapshot, which is precisely the signal to fall back to the last close and label it.

That leaves the question of the rate itself. Any single venue's EURUSD print can be stale, thin, or briefly out of line, and a portfolio total inherits whatever is wrong with it. A defensible rate is a consensus: SiftingIO publishes one fair price per pair, formed as a weighted median across multiple independent venues, so a majority of venues would have to err in the same direction before the published rate is wrong. Why a median rather than an average is its own topic, covered in the weighted-median aggregation post. For valuation the practical point is that the same aggregation stands behind the crypto and commodities quotes on the other side of the conversion, so every leg is priced on one consistent basis.

A code sketch and a valuation checklist#

The sketch below values the example portfolio in EUR from live quotes, using mids and enforcing the timestamp tolerance:

import os
import requests

API = "https://api.sifting.io/v1"
HEADERS = {"X-API-Key": os.environ["SIFTING_KEY"]}

positions = [
    {"venue": "stocks",      "symbol": "AAPL",   "qty": 50.0},
    {"venue": "crypto",      "symbol": "ETHUSD", "qty": 2.0},
    {"venue": "commodities", "symbol": "XAUUSD", "qty": 5.0},
]  # all USD-quoted; positions in other quote currencies need their own FX leg
cash_eur = 10_000.0
MAX_SKEW_MS = 60_000  # widen, and label the total, when FX is closed

def mid_quote(venue, symbol):
    r = requests.get(f"{API}/last/quote/{venue}/{symbol}", headers=HEADERS)
    r.raise_for_status()
    q = r.json()  # snapshot bid/ask come back as strings; only t is a number
    return (float(q["b"]) + float(q["a"])) / 2, q["t"]

fx_mid, fx_t = mid_quote("forex", "EURUSD")  # USD per 1 EUR

total_eur = cash_eur
oldest_t = fx_t
for p in positions:
    px, t = mid_quote(p["venue"], p["symbol"])
    if abs(t - fx_t) > MAX_SKEW_MS:
        raise ValueError(f"{p['symbol']}: price is {abs(t - fx_t) / 1000:.0f}s from the FX rate")
    total_eur += p["qty"] * px / fx_mid  # USD -> EUR: divide by USD-per-EUR
    oldest_t = min(oldest_t, t)

print(f"{total_eur:,.2f} EUR as of {oldest_t}")

The checklist version:

  • Use the mid for reported value and the traded side of the spread for liquidation estimates, and label which one is on screen.
  • Reject or flag any asset price and FX rate observed more than a tolerance apart, and stamp the total with the oldest input.
  • Triangulate through USD when no direct pair trades, and run the staleness check on both legs.
  • When FX is closed but the asset trades, convert at the last close, show the rate's timestamp, and never interpolate.
  • Store the rate, its timestamp, and its source with every saved valuation so the number can be reproduced later.

Common pitfalls#

Multiplying when you should divide. EURUSD is USD per EUR, so USD amounts divide by the rate; USDTRY is TRY per USD, so USD amounts multiply. Get it backwards and the 37,200 USD sleeve above becomes 40,920 instead of 33,818, a 21 percent error that a unit test using a rate of 1.0 will never catch. Test the conversion with an asymmetric rate like 2.0.

Backfilling history without gzip. Reconstructing past valuations means pulling bars, and /v1/hist/forex/EURUSD/bars requires Accept-Encoding: gzip; without the header it returns 406 gzip_required and the nightly job dies on its first request.

Treating weekend 503 stale_snapshot as an outage. When FX is closed, a stale snapshot is the expected state, not a failure. The correct handling is the last-close fallback described above; retrying in a tight loop just spends rate limit against a market that's closed.

All of the quotes and bars above come from one API key across stocks, forex, crypto, and commodities, which is what makes a single valuation pass practical. Start building free.

Keep reading

Related posts