docs
checking

Index pricing API

Prices every index 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 sctrio-sovereign-chain-trio-perp-composite, pspace7-perp-spot-and-space, rhspx-robinhood-spot-index-spcx-spy, hcore-halved-core-beta-index, l1eqw-smart-contract-l1-equal-weight-fund, runtime-distinct-runtime-l1-perp-fund, trichn-three-chain-even-split-fund, mcthrd-multichain-thirds-perp-index. Every example on this page uses sctrio-sovereign-chain-trio-perp-composite 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 index'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 index'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.

Because the base is exactly 1.0, return is (price − 1) × 10000 basis points. The service does not send what that arithmetic already gives you.

Epochs

An index does not settle continuously. It settles on epochs, opened on its own rebalanceCadenceHours anchored at its creation. An index created at 23:19 on a twelve-hour cadence opens epochs at 11:19 and 23:19, not at midday and midnight. An epoch opens when the basket is rebalanced; the two grids are the same grid.

So every price carries two numbers. price is responsive and indicative, and moves on every trade. epochPrice is the level struck when the current epoch opened, and does not move again until the next one. The epoch is what a holder settles against; the live number is what the basket is worth right now, and changeSinceLastEpochPct is the gap between them. Epoch zero opens at creation, where the index is 1.0 by construction.

The fields sit flat rather than nested, and use the same names at the top of an index and at the top of the price endpoint, so the two prices can be read side by side. The epoch is given as the two runs that bound it, lastEpochRanAt and nextEpochRunsAt, rather than as a span with an end, because the end and the next run are the same instant.

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.
indexesnumberIndexes 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"])          # indexes 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/indexes

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

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

Response

FieldTypeMeaning
[].iduuidThe index id.
[].slugstringThe public path segment, e.g. sctrio-sovereign-chain-trio-perp-composite.
[].shareSymbolstringThe share token symbol.
[].createdAtiso8601Where the index is defined to be 1.0.
[].rebalanceCadenceHoursnumber | nullNull means an index 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. returnPct is that constituent’s OWN price change over the trailing returnWindowDays: its price then against its price now, not what it contributed to the index, so a 50% leg reports the whole of its own move. Multiply by the weight if the contribution is what you want. It is measured from daily bars on a slow timer rather than per request, so it moves when a daily bar closes, and it is null while nothing has measured it. A leg that has not been measured and a leg that ended the window flat are different claims, and only the second is a zero. Legs the upstream cannot price at all are not here; they are named in unpriceableSymbols.
[].unpriceableSymbolsstring[]Legs the upstream cannot mark, named rather than dropped. Non-empty means the index is missing part of itself.
[].pricenumber | nullThe index price, flat on the index rather than wrapped. Null until every leg has printed, along with the epoch fields below. The constituent marks are not repeated here; they are on legs above.
[].epochPricenumber | nullWhat the index settles on. Same fields as the price endpoint below.
[].onchainobjectWhere the index actually is: vaultAddress, chainId (999, HyperEVM), and the deploymentBlock its contract was created at. Everything under stats and terms can be checked against a node with these.
[].statsobjectThe vault’s own accounting, read from the contract rather than from the feed. tvlUsd is total value locked: navPerShare times sharesOutstanding, so the sum of what every holder’s shares are worth is the total by construction. That is deliberately not the vault’s accountedTotalAssets6(): the contract’s quote is that total floored across the supply, so the two differ by up to a micro-dollar of dust that belongs to no share, and it is the quote a redemption pays at. Reconcile tvlAssets6 against navPerShareWad × sharesOutstandingAtomic / 1030, not against the vault’s own total. sharesOutstanding is the share supply and navPerShare is the contract’s own quote, which is 1.0 for an empty vault rather than a division. holders counts addresses holding a positive balance, excluding the vault’s own share escrow. Each figure is also sent as its exact integer (tvlAssets6, sharesOutstandingAtomic, navPerShareWad) for a caller reconciling against a node, since above 253 atomic units a JSON number no longer holds every one of them.
[].stats.observedAtBlocknumber | nullThe block every figure in stats was read at, and observedAt is when. Null with reason: "AWAITING_CHAIN_READ" before the first read lands. The fields are all present either way. holdersComplete: false means the walk behind the count has not caught up yet, so the count is a floor rather than a total.
[].termsobjectWhat the deployed contract will enforce, not what the draft proposed: minimumDepositUsd, depositCapUsd (null means uncapped), redemptionDelaySeconds, driftThresholdBps, maxSlippageBps, managementFeeBps and performanceFeeBps. Null throughout until the first chain read.
GET /v1/indexes
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/indexes", 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/indexes/:key

One index. The key is its slug, its share symbol, or its index 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":"index_NOT_FOUND"}.

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

GET /v1/indexes/sctrio-sovereign-chain-trio-perp-composite
import requests

BASE = "https://prices.indexes.fi"
KEY = "sctrio-sovereign-chain-trio-perp-composite"   # slug, share symbol, or index id

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

if res.status_code == 404:
    raise SystemExit(f"No index 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 price sits flat on the index, so one request is enough and there is no wrapper to open.
print("live ", dtf["price"])
print("epoch", dtf["epochPrice"], "next runs", dtf["nextEpochRunsAt"])
print("drift", dtf["changeSinceLastEpochPct"], "%")

# The price above is indicative and marked off a trade feed. Everything below is the vault's own
# accounting at a stated block -- what a deposit mints against and a redemption settles at.
stats = dtf["stats"]
print("TVL  $", stats["tvlUsd"], "over", stats["sharesOutstanding"], "shares")
print("NAV  $", stats["navPerShare"], "per share")

# A count whose walk has not caught up is a floor, not a total.
print("holders", stats["holders"], "" if stats["holdersComplete"] else "(still indexing)")
print("as of block", stats["observedAtBlock"], "on chain", dtf["onchain"]["chainId"])

# Sizing a deposit: check it against the terms the contract will actually enforce.
cap = dtf["terms"]["depositCapUsd"]
print("min $", dtf["terms"]["minimumDepositUsd"], "cap", "none" if cap is None else f"${cap}")

GET /v1/indexes/:key/price

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

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

Two prices, and they mean different things. price is indicative and moves on every trade. epoch.price is what the index settles on: the level struck when the current epoch opened, held until the next one. A holder is redeemed against the epoch; the live number is what the basket is worth right now.

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

Response

FieldTypeMeaning
pricenumber | nullIndex level, and the responsive one: it moves on every trade and is indicative. 1.0 at the index’s creation, so it reads as a price per share. Null when there is no price yet.
epochPricenumber | nullWhat the index actually settles on: the level struck when the open epoch began. It does not move until the next epoch opens. Null for an index with no cadence, which has no epochs.
lastEpochRanAtiso8601 | nullWhen the rebalance that struck epochPrice ran.
nextEpochRunsAtiso8601 | nullWhen the next one runs. An epoch ends exactly when the next begins, so there is no separate end: it would be this instant under a second name.
changeSinceLastEpochPctnumber | nullHow far the responsive price has drifted from the one it settles at, in percent, signed. Derivable, and sent anyway: it is the comparison the two prices exist to support, and the epoch is the denominator rather than the other way round.
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 index’s legs.
legs[].pricenumberThe mark it was priced from. Weights are not repeated here; they belong to the index.
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/indexes/sctrio-sovereign-chain-trio-perp-composite/price
import requests

BASE = "https://prices.indexes.fi"
KEY = "sctrio-sovereign-chain-trio-perp-composite"

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

# price=None means the service is up and this index 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"))

# Two prices, and they mean different things.
print(price["price"])       # indicative, moves on every trade
print(price["observedAt"])  # when the venue timestamped that trade

# What the index settles on: struck when this epoch opened, held until the next.
print(price["epochPrice"], "struck", price["lastEpochRanAt"], "next", price["nextEpochRunsAt"])
print(f"live is {price['changeSinceLastEpochPct']:+.2f}% from the epoch it settles on")

# 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/indexes/: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/indexes/sctrio-sovereign-chain-trio-perp-composite/candles?days=1&resolutionMinutes=5
import requests
from datetime import datetime, timezone

BASE = "https://prices.indexes.fi"
KEY = "sctrio-sovereign-chain-trio-perp-composite"

body = requests.get(
    f"{BASE}/v1/indexes/{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/indexes/{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?index=:key

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

Name indexes in the query string as ?index=IXDA&index=IXDB or ?index=IXDA,IXDB, and change the set at any time by sending {"action":"subscribe","index":["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/indexes, not repeated here.
tickOn connect, then per tickindexId, then the price endpoint’s fields, flat. The id travels here because one socket carries several indexes; 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 index, 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 = "sctrio-sovereign-chain-trio-perp-composite"

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?index={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["indexId"], round(message["price"], 6),
                          "epoch", message["epochPrice"], "stale:", stale)
        except websockets.ConnectionClosed:
            continue   # the generator reconnects with backoff

asyncio.run(main())

Errors

StatusBodyMeaning
200{"latest": null, "reason": "AWAITING_MARKS"}The index exists and has no price yet. Not an error, and retrying will not change it.
404{"code": "index_NOT_FOUND"}No index 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 an index is priced

An index 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 index'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 index 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 index's own cadence, anchored to its creation, so a reconstruction and the live engine agree on where the boundaries fall. An index 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.