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.
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
| Field | Type | Meaning |
|---|---|---|
feed | string | live, connecting, authenticating, rejected (bad key, terminal) or stopped. |
indexes | number | Indexes currently being priced. |
instruments | number | Distinct upstream instruments subscribed. |
marks | number | Instruments with at least one mark held. |
uptimeSeconds | number | Since 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"] > 0const BASE = 'https://prices.indexes.fi'
const health = await fetch(`${BASE}/health`).then((r) => r.json())
console.log(health.feed) // live | connecting | rejected | stopped
console.log(health.dtfs) // indexes being priced
console.log(health.instruments) // distinct upstream instruments subscribed
console.log(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.
if (health.feed !== 'live' || health.marks === 0) {
throw new Error('Pricing service is up but not watching anything.')
}import { useEffect, useState } from 'react'
const BASE = 'https://prices.indexes.fi'
export function usePricingHealth(intervalMs = 30_000) {
const [health, setHealth] = useState(null)
useEffect(() => {
let cancelled = false
const check = async () => {
try {
const res = await fetch(`${BASE}/health`, { cache: 'no-store' })
const body = await res.json()
if (!cancelled) setHealth(body)
} catch {
if (!cancelled) setHealth({ feed: 'offline' })
}
}
void check()
const timer = setInterval(check, intervalMs)
return () => {
cancelled = true
clearInterval(timer)
}
}, [intervalMs])
return health
}
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
| Field | Type | Meaning |
|---|---|---|
[].id | uuid | The index id. |
[].slug | string | The public path segment, e.g. sctrio-sovereign-chain-trio-perp-composite. |
[].shareSymbol | string | The share token symbol. |
[].createdAt | iso8601 | Where the index is defined to be 1.0. |
[].rebalanceCadenceHours | number | null | Null means an index nobody rebalances, so the basket is held from inception. |
[].legs[] | array | One 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. |
[].unpriceableSymbols | string[] | Legs the upstream cannot mark, named rather than dropped. Non-empty means the index is missing part of itself. |
[].price | number | null | The 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. |
[].epochPrice | number | null | What the index settles on. Same fields as the price endpoint below. |
[].onchain | object | Where 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. |
[].stats | object | The 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.observedAtBlock | number | null | The 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. |
[].terms | object | What 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']}")const BASE = 'https://prices.indexes.fi'
// The collection is an array. There is no envelope to reach through.
const dtfs = await fetch(`${BASE}/v1/indexes`).then((r) => r.json())
for (const dtf of dtfs) {
// Each leg carries its weight and its current mark together.
const legs = dtf.legs.map((leg) => `${leg.symbol} ${leg.weightPct}% @ ${leg.price}`).join(', ')
console.log(dtf.shareSymbol, dtf.slug, legs, dtf.price?.price ?? 'awaiting marks')
// A leg the service cannot mark is named, not silently dropped.
if (dtf.unpriceableSymbols.length > 0) {
console.warn(`${dtf.shareSymbol} excludes ${dtf.unpriceableSymbols.join(', ')}`)
}
}import { useEffect, useState } from 'react'
const BASE = 'https://prices.indexes.fi'
export function DtfList() {
const [dtfs, setDtfs] = useState([])
useEffect(() => {
let cancelled = false
fetch(`${BASE}/v1/indexes`)
.then((r) => r.json())
.then((list) => {
if (!cancelled) setDtfs(list)
})
return () => {
cancelled = true
}
}, [])
return (
<ul>
{dtfs.map((dtf) => (
<li key={dtf.id}>
<a href={`/${dtf.slug}`}>{dtf.name}</a>{' '}
{dtf.price ? dtf.price.toFixed(4) : 'awaiting marks'} USDC
</li>
))}
</ul>
)
}
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}")const BASE = 'https://prices.indexes.fi'
const KEY = 'sctrio-sovereign-chain-trio-perp-composite' // slug, share symbol, or index id
const res = await fetch(`${BASE}/v1/indexes/${encodeURIComponent(KEY)}`)
if (res.status === 404) throw new Error(`No index matches ${KEY}`)
// The resource is the body, with its current price embedded.
const dtf = await res.json()
console.log(dtf.name, dtf.shareSymbol)
console.log('created', dtf.createdAt)
console.log('rebalances every', dtf.rebalanceCadenceHours, 'hours')
for (const leg of dtf.legs) {
console.log(' ', leg.symbol, leg.feedSymbol, leg.weightPct + '%', leg.price, leg.stale ? 'STALE' : '')
}
// The price sits flat on the index, so there is no wrapper to open.
console.log('live ', dtf.price ?? 'awaiting marks')
console.log('epoch', dtf.epochPrice ?? 'no epochs', '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.
console.log('TVL $' + dtf.stats.tvlUsd, 'over', dtf.stats.sharesOutstanding, 'shares')
console.log('NAV $' + dtf.stats.navPerShare, 'per share')
// A count whose walk has not caught up is a floor, not a total.
console.log('holders', dtf.stats.holders, dtf.stats.holdersComplete ? '' : '(still indexing)')
console.log('as of block', dtf.stats.observedAtBlock, 'on chain', dtf.onchain.chainId)
// Sizing a deposit: check it against the terms the contract will actually enforce.
console.log('min $' + dtf.terms.minimumDepositUsd, 'cap', dtf.terms.depositCapUsd ?? 'none')import { useEffect, useState } from 'react'
const BASE = 'https://prices.indexes.fi'
export function useDtf(key) {
const [state, setState] = useState({ dtf: null, status: 'loading' })
useEffect(() => {
let cancelled = false
fetch(`${BASE}/v1/indexes/${encodeURIComponent(key)}`)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((dtf) => {
if (!cancelled) setState({ dtf, status: 'ready' })
})
.catch(() => {
if (!cancelled) setState({ dtf: null, status: 'unavailable' })
})
return () => {
cancelled = true
}
}, [key])
return state
}
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
| Field | Type | Meaning |
|---|---|---|
price | number | null | Index 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. |
epochPrice | number | null | What 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. |
lastEpochRanAt | iso8601 | null | When the rebalance that struck epochPrice ran. |
nextEpochRunsAt | iso8601 | null | When 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. |
changeSinceLastEpochPct | number | null | How 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. |
observedAt | iso8601 | When the venue timestamped the trade, not when it arrived here. Parse it for a number. |
legs[].stale | boolean | Whether 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[].symbol | string | The constituent, matching the index’s legs. |
legs[].price | number | The mark it was priced from. Weights are not repeated here; they belong to the index. |
legs[].markAgeMs | number | null | How old that mark was at the tick. Null means never marked. |
reason | string | Present 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"])const BASE = 'https://prices.indexes.fi'
const KEY = 'sctrio-sovereign-chain-trio-perp-composite'
// The path already named the index, so the body is the price on its own.
const price = await fetch(
`${BASE}/v1/indexes/${encodeURIComponent(KEY)}/price`,
).then((r) => r.json())
// price=null 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 === null) throw new Error(price.reason ?? 'AWAITING_MARKS')
// Two prices, and they mean different things.
console.log(price.price) // indicative, moves on every trade
console.log(price.observedAt) // when the venue timestamped that trade
// What the index settles on: struck when this epoch opened, held until the next.
console.log(price.epochPrice, 'struck', price.lastEpochRanAt, 'next', price.nextEpochRunsAt)
console.log(`live is ${price.changeSinceLastEpochPct.toFixed(2)}% from the epoch`)
// 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.
const stale = price.legs.filter((leg) => leg.stale).map((leg) => leg.symbol)
if (stale.length > 0) console.warn('stale legs:', stale.join(', '))import { useEffect, useState } from 'react'
const BASE = 'https://prices.indexes.fi'
/** Polling fallback. Prefer the WebSocket below; this is for when one is not
* available, or for a page that only needs an occasional number. */
export function usePolledDtfPrice(key, intervalMs = 15_000) {
const [price, setPrice] = useState(null)
useEffect(() => {
let cancelled = false
const read = async () => {
const body = await fetch(
`${BASE}/v1/indexes/${encodeURIComponent(key)}/price`,
{ cache: 'no-store' },
).then((r) => r.json())
if (!cancelled) setPrice(body.price === null ? null : body)
}
void read()
const timer = setInterval(read, intervalMs)
return () => {
cancelled = true
clearInterval(timer)
}
}, [key, intervalMs])
const stale = (price?.legs ?? []).filter((leg) => leg.stale).map((leg) => leg.symbol)
return { price, stale }
}
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
| Field | Type | Meaning |
|---|---|---|
days | number | Window length. Default 90, capped at 400. |
resolutionMinutes | number | Force a granularity instead of letting the service choose. Floored at 5, the granularity bars are stored at. |
Response
| Field | Type | Meaning |
|---|---|---|
resolutionMinutes | number | The granularity this response came back at. |
candles[].t | number | Bucket open, Unix seconds. |
candles[].o / h / l / c | number | Open, high, low, close, as index levels. |
candles[].n | number | Ticks observed in the bar. Zero for a reconstructed one. |
candles[].source | string | live 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"]))const BASE = 'https://prices.indexes.fi'
const KEY = 'sctrio-sovereign-chain-trio-perp-composite'
const url = new URL(`${BASE}/v1/indexes/${encodeURIComponent(KEY)}/candles`)
url.searchParams.set('days', '90')
const body = await fetch(url).then((r) => r.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.
console.log('resolution', body.resolutionMinutes, 'minutes')
console.log('bars', body.candles.length)
for (const bar of body.candles.slice(-5)) {
console.log(new Date(bar.t * 1000).toISOString(), 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.
const observed = body.candles.filter((bar) => bar.source === 'live')
console.log(`${observed.length} of ${body.candles.length} bars observed here`)import { useEffect, useMemo, useState } from 'react'
const BASE = 'https://prices.indexes.fi'
export function useDtfCandles(key, days) {
const [series, setSeries] = useState(null)
useEffect(() => {
let cancelled = false
const url = new URL(`${BASE}/v1/indexes/${encodeURIComponent(key)}/candles`)
url.searchParams.set('days', String(days))
fetch(url, { cache: 'no-store' })
.then((r) => r.json())
.then((body) => {
if (!cancelled) setSeries(body)
})
// Refetch so newly closed bars land. The open bar is already included in
// every response, so the chart is never more than a tick behind.
const timer = setInterval(() => {
fetch(url, { cache: 'no-store' })
.then((r) => r.json())
.then((body) => {
if (!cancelled) setSeries(body)
})
}, 150_000)
return () => {
cancelled = true
clearInterval(timer)
}
}, [key, days])
// Re-base to the window's first point so each range starts at zero. The
// series is absolute levels, so plotting them raw makes every range switch
// look like a jump.
const points = useMemo(() => {
const candles = series?.candles ?? []
const base = candles[0]?.c
if (base === undefined || base === 0) return []
return candles.map((bar) => ({
t: bar.t * 1000,
returnPct: (bar.c / base - 1) * 100,
observed: bar.source === 'live',
}))
}, [series])
return { points, resolutionMinutes: series?.resolutionMinutes ?? null }
}
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
| Field | Type | Meaning |
|---|---|---|
[].feedSymbol | string | The upstream instrument, e.g. X:BTC-USD. |
[].price | number | The last trade price. |
[].observedAt | iso8601 | When 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"])const BASE = 'https://prices.indexes.fi'
const marks = await fetch(`${BASE}/v1/marks`).then((r) => r.json())
// The inputs every index price above was built from, so a quoted price can
// be checked rather than trusted.
for (const mark of marks) {
console.log(mark.feedSymbol, mark.price, mark.observedAt)
}import { useEffect, useState } from 'react'
const BASE = 'https://prices.indexes.fi'
export function ConstituentMarks() {
const [marks, setMarks] = useState([])
useEffect(() => {
const read = () =>
fetch(`${BASE}/v1/marks`, { cache: 'no-store' })
.then((r) => r.json())
.then(setMarks)
void read()
const timer = setInterval(read, 10_000)
return () => clearInterval(timer)
}, [])
return (
<table>
<tbody>
{marks.map((mark) => (
<tr key={mark.feedSymbol}>
<td>{mark.feedSymbol}</td>
<td>{mark.price}</td>
<td>{mark.observedAt}</td>
</tr>
))}
</tbody>
</table>
)
}
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
| Type | When | Carries |
|---|---|---|
hello | On connect | subscribed: the ids your keys resolved to. What is available is GET /v1/indexes, not repeated here. |
tick | On connect, then per tick | indexId, 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. |
subscribed | After a subscribe or unsubscribe | The full resolved set, so a client never has to track it. |
pong | After a ping | Nothing. |
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())// Node 22 has a global WebSocket, so there is no dependency to add.
const WS = 'https://prices.indexes.fi'.replace(/^http/, 'ws')
const KEY = 'sctrio-sovereign-chain-trio-perp-composite'
const BACKOFF_MS = [500, 1_000, 2_000, 5_000, 10_000, 30_000]
let attempt = 0
function connect() {
const socket = new WebSocket(`${WS}/v1/stream?index=${encodeURIComponent(KEY)}`)
socket.onopen = () => {
attempt = 0
}
socket.onmessage = (event) => {
const message = JSON.parse(event.data)
if (message.type === 'hello') {
console.log('subscribed to', message.subscribed)
return
}
if (message.type === 'tick') {
const stale = message.legs.filter((leg) => leg.stale).map((leg) => leg.symbol)
// The live price moves every tick; the epoch is what it settles on.
console.log(message.indexId, message.price.toFixed(6), 'epoch', message.epochPrice ?? '-',
'stale:', stale.join(', ') || 'none')
}
}
// The socket will drop. A client that does not reconnect goes quiet
// without reporting anything, which reads as a flat market.
socket.onclose = () => {
const delay = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)]
attempt += 1
setTimeout(connect, delay)
}
}
connect()
// Subscribe to more indexes on the same socket at any time:
// socket.send(JSON.stringify({ action: 'subscribe', index: ['IXDA', 'IXDB'] }))
// socket.send(JSON.stringify({ action: 'unsubscribe', index: 'IXDB' }))import { useEffect, useState } from 'react'
const BASE = 'https://prices.indexes.fi'
const BACKOFF_MS = [500, 1_000, 2_000, 5_000, 10_000, 30_000]
export function useDtfLivePrice(key) {
const [tick, setTick] = useState(null)
const [status, setStatus] = useState('connecting')
useEffect(() => {
let socket = null
let retry = null
let attempt = 0
let closed = false
const connect = () => {
if (closed) return
setStatus('connecting')
socket = new WebSocket(
`${BASE.replace(/^http/, 'ws')}/v1/stream?index=${encodeURIComponent(key)}`,
)
socket.onopen = () => {
attempt = 0
setStatus('live')
}
socket.onmessage = (event) => {
const message = JSON.parse(event.data)
if (message.type === 'tick') setTick(message)
}
socket.onclose = () => {
socket = null
if (closed) return
setStatus('offline')
const delay = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)]
attempt += 1
retry = setTimeout(connect, delay)
}
}
connect()
return () => {
closed = true
if (retry !== null) clearTimeout(retry)
if (socket !== null) {
// Cleared first, or the handler reads this deliberate close as an
// outage and reconnects a subscription nobody is listening to.
socket.onclose = null
socket.close()
}
}
}, [key])
const stale = (tick?.legs ?? []).some((leg) => leg.stale)
return { tick, status, stale }
}
export function LivePrice({ dtfKey }) {
const { tick, status, stale } = useDtfLivePrice(dtfKey)
if (tick === null) return <span>awaiting price</span>
return (
<span data-live={status === 'live' && !stale}>
{tick.price.toFixed(4)} USDC
{stale ? ' (stale mark)' : ''}
{/* What a holder settles against, which the live number is only an estimate of. */}
{tick.epochPrice === null ? null : (
<small> settles at {tick.epochPrice.toFixed(4)}</small>
)}
</span>
)
}
Errors
| Status | Body | Meaning |
|---|---|---|
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:
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.