docs
checking

DTF pricing API

Prices every DTF on the platform from live constituent marks, on every trade rather than on every epoch, and keeps a five minute candle history of the result.

Base URL https://prices.indexes.fi

No authentication. Every endpoint is a GET returning JSON, except the WebSocket. Responses are no-store: the whole point is that they change.

Currently pricing ixda-dtf-btc-eth-50-50. Every example on this page uses ixda-dtf-btc-eth-50-50 and the base URL above, so they run as written.

This is indicative. It is not attested, and it is not the price a redemption settles against. That number is the DTF's own pricePerShareWad(), which only moves when a lifecycle run checkpoints it. This service answers the different question of what the basket is worth right now.

Concepts

Index level

Every price is an index level that is 1.0 at the DTF's creation, so the number reads directly as a price per share rather than as an abstract index. History reaches back before creation, where the same basket is carried backwards; those points are indicative in a second sense and are what a chart should label as pre-launch.

Fresh legs against total legs

Every price carries pricedLegs and totalLegs. A price built from one of two legs is not wrong, but it is not the index either. Marks are never forgotten, so a leg that goes quiet keeps its last price and the index keeps moving on the other legs; pricedLegs is how you tell that apart from a market that stopped moving.

Observed against reconstructed

Bars are tagged live or backfill. Reconstructed bars come from upstream aggregates and their high and low are their own endpoints, because the legs' extremes did not occur at the same instant and combining them would quote a level the index never reached. A flat run of each means a different thing, so the two are never merged.

Resolution

Bars are stored every five minutes. A long window is rolled up rather than truncated, and the response states the resolution it used. Ask for resolutionMinutes=5 to force the stored granularity on a short window.

Endpoints

Every sample below runs against the base URL above. Run it calls this service for real, so the response shown is the live one.

GET /health

Whether the service is running, and what it is watching.

Read feed and marks, not just status. A service whose upstream authenticated and then subscribed to nothing answers ok on any check that only looks at the process.

Response

FieldTypeMeaning
feedstringlive, connecting, authenticating, rejected (bad key, terminal) or stopped.
dtfsnumberDTFs currently being priced.
instrumentsnumberDistinct upstream instruments subscribed.
marksnumberInstruments with at least one mark held.
uptimeSecondsnumberSince the process started.
GET /health
import requests

BASE = "https://prices.indexes.fi"

health = requests.get(f"{BASE}/health", timeout=10).json()

print(health["feed"])          # live | connecting | rejected | stopped
print(health["dtfs"])          # DTFs being priced
print(health["instruments"])   # distinct upstream instruments subscribed
print(health["marks"])         # instruments with at least one mark

# "ok" alone is not enough. A feed that authenticated and subscribed to
# nothing also answers ok, so check that it is actually watching something.
assert health["feed"] == "live" and health["marks"] > 0

GET /v1/dtfs

Every DTF being priced, with its legs and its current price.

An array of DTFs. The set matches the platform's public directory, so a DTF appears here exactly when it appears on the site, and it refreshes once a minute.

Response

FieldTypeMeaning
[].iduuidThe DTF id.
[].slugstringThe public path segment, e.g. ixda-dtf-btc-eth-50-50.
[].shareSymbolstringThe share token symbol.
[].createdAtiso8601Where the index is defined to be 1.0.
[].rebalanceCadenceHoursnumber | nullNull means a DTF nobody rebalances, so the basket is held from inception.
[].legs[]arrayOne entry per constituent, carrying both what it is and what it is worth: symbol, feedSymbol (the upstream instrument), a signed weightPct (negative is short, above 100 is levered), the current price, its markAgeMs, and stale.
[].unpriceableSymbolsstring[]Legs the upstream cannot mark, named rather than dropped. Non-empty means the index is missing part of itself.
[].priceobject | nullThe index price: price, returnBps and observedAt. Null until every leg has printed. The constituent marks are not repeated here; they are on legs above.
GET /v1/dtfs
import requests

BASE = "https://prices.indexes.fi"

# The collection is an array. There is no envelope to reach through.
dtfs = requests.get(f"{BASE}/v1/dtfs", timeout=10).json()

for dtf in dtfs:
    # Each leg carries its weight and its current mark together.
    legs = ", ".join(f"{leg['symbol']} {leg['weightPct']}% @ {leg['price']}" for leg in dtf["legs"])
    price = dtf["price"]["price"] if dtf["price"] else None
    print(f"{dtf['shareSymbol']:8} {dtf['slug']:32} {legs:24} {price}")

    # A leg the service cannot mark is named, not silently dropped.
    if dtf["unpriceableSymbols"]:
        print(f"  not priced: {dtf['unpriceableSymbols']}")

GET /v1/dtfs/:key

One DTF. The key is its slug, its share symbol, or its DTF id.

All three resolve to the same record, so a caller holding any of them does not need a lookup first. An unknown key answers 404 with {"code":"DTF_NOT_FOUND"}.

The DTF is the response body, with its current price under price. Fields are the same as one element of the collection above.

GET /v1/dtfs/ixda-dtf-btc-eth-50-50
import requests

BASE = "https://prices.indexes.fi"
KEY = "ixda-dtf-btc-eth-50-50"   # slug, share symbol, or DTF id

res = requests.get(f"{BASE}/v1/dtfs/{KEY}", timeout=10)

if res.status_code == 404:
    raise SystemExit(f"No DTF matches {KEY}")

# The resource is the body.
dtf = res.json()

print(dtf["name"], dtf["shareSymbol"])
print("created", dtf["createdAt"])
print("rebalances every", dtf["rebalanceCadenceHours"], "hours")
for leg in dtf["legs"]:
    flag = " STALE" if leg["stale"] else ""
    print(" ", leg["symbol"], leg["feedSymbol"], leg["weightPct"], "%", leg["price"], flag)

# The current price comes embedded, so one request is enough.
print("price", dtf["price"]["price"] if dtf["price"] else "awaiting marks")

GET /v1/dtfs/:key/price

The index price as of the last trade, and the marks behind it.

The path already names the DTF, so the body is the price and the marks it was built from, with nothing that restates which DTF that is.

A DTF with no price yet answers 200 with price: null and a reason, not an error. The service is up and the DTF exists; it has simply not seen every leg print. Retrying will not change that, only a trade will.

Response

FieldTypeMeaning
pricenumber | nullIndex level. 1.0 at the DTF’s creation, so it reads as a price per share. Null when there is no price yet.
returnBpsnumberReturn since creation, in basis points.
observedAtiso8601When the venue timestamped the trade, not when it arrived here. Parse it for a number.
legs[].stalebooleanWhether the mark is too old to count as live. Read it: a stale leg and a flat market draw the same line. Naming the leg beats a count, and the service knows the threshold a caller would otherwise have to invent.
legs[].symbolstringThe constituent, matching the DTF’s legs.
legs[].pricenumberThe mark it was priced from. Weights are not repeated here; they belong to the DTF.
legs[].markAgeMsnumber | nullHow old that mark was at the tick. Null means never marked.
reasonstringPresent only when price is null. Currently AWAITING_MARKS.
GET /v1/dtfs/ixda-dtf-btc-eth-50-50/price
import requests

BASE = "https://prices.indexes.fi"
KEY = "ixda-dtf-btc-eth-50-50"

# The path already named the DTF, so the body is the price on its own.
price = requests.get(f"{BASE}/v1/dtfs/{KEY}/price", timeout=10).json()

# price=None means the service is up and this DTF has not had every leg
# print yet. It is not an error and retrying will not change it; only a
# trade will.
if price["price"] is None:
    raise SystemExit(price.get("reason", "AWAITING_MARKS"))

print(price["price"])       # index level, 1.0 at the DTF's creation
print(price["returnBps"])   # return since creation, basis points
print(price["observedAt"])  # when the venue timestamped the trade

# Always read this. A price built on a stale leg is not wrong, but it is not
# the index either, and a stale leg looks identical to a flat market.
stale = [leg["symbol"] for leg in price["legs"] if leg["stale"]]
if stale:
    print("stale legs:", stale)

for leg in price["legs"]:
    print(" ", leg["symbol"], leg["price"], "age(ms)", leg["markAgeMs"])

GET /v1/dtfs/:key/candles

The index history over a trailing window, plus the bar still taking ticks.

Bars are stored every five minutes. Long windows are rolled up, not truncated: ninety days of five-minute bars is 25,920 rows, so the response comes back at a coarser resolution and says which one in resolutionMinutes. The series always spans the days you asked for.

The open bar is merged into the series, so a caller opening mid-bucket ends at the current price rather than up to a bucket behind it. That is also why no separate current price is sent: the last bar’s close already is it.

Query parameters

FieldTypeMeaning
daysnumberWindow length. Default 90, capped at 400.
resolutionMinutesnumberForce a granularity instead of letting the service choose. Floored at 5, the granularity bars are stored at.

Response

FieldTypeMeaning
resolutionMinutesnumberThe granularity this response came back at.
candles[].tnumberBucket open, Unix seconds.
candles[].o / h / l / cnumberOpen, high, low, close, as index levels.
candles[].nnumberTicks observed in the bar. Zero for a reconstructed one.
candles[].sourcestringlive was observed tick by tick here. backfill was reconstructed from upstream aggregates, and its high and low are its own endpoints, since the legs’ extremes did not occur at the same instant.
GET /v1/dtfs/ixda-dtf-btc-eth-50-50/candles?days=1&resolutionMinutes=5
import requests
from datetime import datetime, timezone

BASE = "https://prices.indexes.fi"
KEY = "ixda-dtf-btc-eth-50-50"

body = requests.get(
    f"{BASE}/v1/dtfs/{KEY}/candles",
    params={"days": 90},
    timeout=30,
).json()

# Read this rather than assuming five minutes. The service coarsens long
# windows so the series always spans the days you asked for; what varies
# is how finely.
print("resolution", body["resolutionMinutes"], "minutes")
print("bars", len(body["candles"]))

for bar in body["candles"][-5:]:
    when = datetime.fromtimestamp(bar["t"], tz=timezone.utc)
    print(when.isoformat(), bar["o"], bar["h"], bar["l"], bar["c"], bar["source"])

# source distinguishes bars observed tick by tick from bars reconstructed
# out of upstream aggregates. A flat run of each means different things.
observed = [b for b in body["candles"] if b["source"] == "live"]
print(f"{len(observed)} of {len(body['candles'])} bars observed here")

# Force the stored granularity for a short window.
fine = requests.get(
    f"{BASE}/v1/dtfs/{KEY}/candles",
    params={"days": 1, "resolutionMinutes": 5},
    timeout=30,
).json()
print("fine bars", len(fine["candles"]))

GET /v1/marks

The latest upstream mark per instrument.

An array of marks: the inputs every index price is built from, so a quoted price can be checked against them rather than trusted.

Response

FieldTypeMeaning
[].feedSymbolstringThe upstream instrument, e.g. X:BTC-USD.
[].pricenumberThe last trade price.
[].observedAtiso8601When the venue timestamped it.
GET /v1/marks
import requests

BASE = "https://prices.indexes.fi"

marks = requests.get(f"{BASE}/v1/marks", timeout=10).json()

# The inputs every index price above was built from, so a quoted price can
# be checked rather than trusted.
for mark in marks:
    print(mark["feedSymbol"], mark["price"], mark["observedAt"])

Realtime

WS /v1/stream?dtf=:key

Live index prices, one message per tick, for the DTFs you name.

Name DTFs in the query string as ?dtf=IXDA&dtf=IXDB or ?dtf=IXDA,IXDB, and change the set at any time by sending {"action":"subscribe","dtf":["IXDB"]} or the matching unsubscribe. {"action":"ping"} answers {"type":"pong"}.

Messages received

TypeWhenCarries
helloOn connectsubscribed: the ids your keys resolved to. What is available is GET /v1/dtfs, not repeated here.
tickOn connect, then per tickdtfId, then the price endpoint’s fields. The id travels here because one socket carries several DTFs; the slug does not, because one routing key is enough and a slug can change.
subscribedAfter a subscribe or unsubscribeThe full resolved set, so a client never has to track it.
pongAfter a pingNothing.

The first tick arrives immediately. A client that connects mid-bucket gets the current price without waiting for the next trade, so a page never opens on an empty number.

Prices are computed on every trade. Broadcasts are coalesced to at most one per 250ms per DTF, because a browser redrawing twelve times a second gains nothing. The five-minute bar is the durable record either way.

import json
import asyncio
import websockets   # pip install websockets

WS = "https://prices.indexes.fi".replace("https://", "wss://").replace("http://", "ws://")
KEY = "ixda-dtf-btc-eth-50-50"

async def main():
    # Reconnecting generator: the socket will drop, and a client that does
    # not reconnect goes quiet without reporting anything.
    async for socket in websockets.connect(f"{WS}/v1/stream?dtf={KEY}"):
        try:
            async for raw in socket:
                message = json.loads(raw)

                if message["type"] == "hello":
                    print("subscribed to", message["subscribed"])
                    continue

                if message["type"] == "tick":
                    stale = [l["symbol"] for l in message["legs"] if l["stale"]]
                    print(message["dtfId"], round(message["price"], 6), "stale:", stale)
        except websockets.ConnectionClosed:
            continue   # the generator reconnects with backoff

asyncio.run(main())

Errors

StatusBodyMeaning
200{"latest": null, "reason": "AWAITING_MARKS"}The DTF exists and has no price yet. Not an error, and retrying will not change it.
404{"code": "DTF_NOT_FOUND"}No DTF matches that slug, symbol or id.
404{"error": "No route for ..."}Unknown path.
405{"error": "Only GET is served here."}The read surface is GET only.
403{"error": "Origin not allowed."}The browser origin is not in PRICING_ALLOWED_ORIGINS.
500{"error": "The pricing service failed to answer."}Unhandled fault. The detail is in the service log, never in the response.

How a DTF is priced

A DTF holds a basket, so the index holds units and cash, not weights:

open units_i = V × w_i / P_i cash = V × (1 − Σ w_i) value V(P') = cash + Σ units_i × P'_i

Units are fixed between rebalances, so weights drift with the market exactly as the DTF's real holdings do. Pricing the index as a fixed-weight blend of leg returns assumes the opposite, continuous and free rebalancing on every tick, which over ninety days is not a rounding difference: it harvests volatility the DTF never traded.

Cash is what lets shorts and leverage share one code path. It is zero for a long only book, negative when levered because the notional is borrowed, and positive collateral for a market neutral one.

Rebalances are applied on the DTF's own cadence, anchored to its creation, so a reconstruction and the live engine agree on where the boundaries fall. A DTF with no cadence holds the basket it opened with.

Where the numbers come from

  • Constituent marks are trades from the upstream crypto feed, per instrument.
  • History before the service was watching is reconstructed from upstream five minute aggregates, then divided through so the index reads exactly 1.0 at creation.
  • The live basket opens at the level and the marks the reconstruction ended on, so there is no step where history meets live.