indiquant-sdk
The contributor's client for the IndiQuant v3 tournament API. It ships two
things: indiquant.core, the rule book — the submission JSON Schema and the one
validator that reads it — and indiquant.sdk, the HTTP client for /v3.
It ships nothing else. indiquant.platform (which holds the obfuscated↔real
identity mapping) and indiquant.etz (the execution trust zone) are not in this
wheel and never will be. Instrument ids are STK_NNNNN on the wire, in the data
files, and in your code; nothing here can resolve one to a real security.
Install
pip install indiquant-sdk
The package is on PyPI as indiquant-sdk — with the hyphen, and imported as
indiquant.sdk. Check the name before you install: anything else claiming to be
the IndiQuant client is not ours. Installing it gives you nothing to trade on
and nothing to see: the data is served only to a signed-in account, through the
signed data sessions described below.
Python 3.11 or newer. That is indiquant.core's floor, not a preference.
Dependencies are pulled in for you: numpy, pandas, scipy, jsonschema
(core needs all four, and without jsonschema the validator raises instead of
validating), plus requests and pyarrow for the client and for reading the
round files, which are parquet and nothing else, and cryptography, which signs
every dataset request with this install's key (see Data access below).
Where things are. The API is https://api.indiquantresearch.in
(indiquant.sdk.DEFAULT_BASE_URL). The website is
https://platform.indiquantresearch.in — register there, and read the SDK page
at https://platform.indiquantresearch.in/sdk. Do not point the client at the
website: it answers every API path with 200 text/html, and the first call
fails on a JSON parse that reads like an outage.
The whole loop in one file. examples/quickstart.py in the source
distribution signs in, registers your install key, picks or creates a model,
trains a transparent baseline on the round's training data, predicts the live
round, validates locally, writes predictions.csv and submits. Run it with
--dry-run first: that validates and writes the CSV and spends nothing.
Credentials: both are yours to mint
You need two things before anything below works, and they come from different places.
1. A token pair — you sign in for this. Register on the website at
https://platform.indiquantresearch.in, then sign in against the API:
import getpass, os
from indiquant.sdk import DEFAULT_BASE_URL, IndiQuantClient
client = IndiQuantClient.from_login(
DEFAULT_BASE_URL, # https://api.indiquantresearch.in
email=os.environ["INDIQUANT_EMAIL"],
password=getpass.getpass(), # never a literal in a script
)
from_login signs in and returns a client already holding both halves of the
pair, so a session longer than the 15-minute access token rotates itself. Use
the module-level login(...) instead if you want the TokenPair without a
client — to persist it, or to hand it to something that is not this client.
Either way the password is used once, for that request, and is not stored on
anything returned.
Every login refusal is one indistinguishable 401: unknown address, no
credential, wrong password, missing field. Do not write a caller that branches
on the reason — there is not one, deliberately, so that the endpoint cannot be
asked whether a given person has an account. login also does not retry, which
the rest of this client would: a retry loop on a failed login is a
password-guessing loop, and the server rate-limits per address.
2. A model_id — you mint it. Since 2026-09-21, POST /v3/models creates a
model under your own contributor and GET /v3/models lists the ones you hold:
client = IndiQuantClient.from_login(BASE_URL, email=..., password=...)
mine = client.models() # [] on a fresh account
model = mine[0] if mine else client.create_model("quiet-fox")
receipt = client.submit(round_id, model.model_id, predictions)
display_name is the public handle on the leaderboard (Spec §7.2), unique
platform-wide, 2–40 characters; a taken name is a Conflict, so choose another
rather than retry. An account holds at most three models, and a fourth is a
Conflict too. The account page on the platform does the same thing with a
form. The operator CLI (indiquant model-create) still exists for support.
If you are reading an older copy of this file, it said there was no
POST /v3/auth/loginand that tokens arrived out of band. That stopped being true on 2026-09-03, when the credential store landed, and this section was corrected on 2026-09-18. Themodel_idhalf was true until 2026-09-21, whenPOST /v3/modelslanded.
The access token is read from the constructor or from $INDIQUANT_API_TOKEN.
Nothing in this package writes a token to disk — no dotfile, no keyring — and
repr(client) redacts both secrets.
The refresh token is single-use
Rotation is one-for-one: presenting a refresh secret returns its successor and consumes it. Presenting a consumed one revokes the whole token family and pages an operator. It is treated as an incident, not as an accident, because a secret being presented twice is what a stolen secret looks like.
Three rules follow, and the client already obeys them:
- The presented secret is dropped before the request goes out. If a refresh times out, this client will not send it a second time. A timeout is not evidence the server failed to consume it.
on_token_refreshis called with the successor before the retried request is issued. That is your one chance to persist it; if your callback raises, the client stops rather than carrying on with the only copy in memory.- Persistence is yours. This package has no idea whether the successor belongs in a keyring, a secret manager, or an encrypted CI variable, and an SDK that promises never to persist a secret cannot ship a token store.
import json, pathlib
from indiquant.sdk import IndiQuantClient, TokenPair
STORE = pathlib.Path("~/.config/indiquant/tokens.json").expanduser()
def persist(pair: TokenPair) -> None:
STORE.parent.mkdir(parents=True, exist_ok=True)
STORE.write_text(json.dumps({
"access_token": pair.access_token,
"refresh_token": pair.refresh_token,
}))
STORE.chmod(0o600)
saved = json.loads(STORE.read_text())
client = IndiQuantClient(
"https://api.indiquantresearch.in",
token=saved["access_token"],
refresh_token=saved["refresh_token"],
on_token_refresh=persist,
)
Access tokens last 15 minutes, refresh tokens 30 days. A 401 with a refresh token present triggers exactly one refresh and one retry.
The submit loop
from indiquant.sdk import (
DEFAULT_BASE_URL, IndiQuantClient, IndiQuantError, InstallKey, SubmissionRejected,
)
client = IndiQuantClient(
DEFAULT_BASE_URL, # token from $INDIQUANT_API_TOKEN
install_key=InstallKey.load_or_create("~/.indiquant/install.key"),
)
MODEL_ID = client.models()[0].model_id # or client.create_model("…")
# 1. Which round am I being asked to predict?
round_ = client.current_round("core")
print(round_.round_id, round_.state, "closes", round_.submission_close)
current_round returns the round with the latest open_date, not the
latest round that is still open. Once the submission window shuts you get the
same round back with state == "LOCKED", not a 404. Branch on round_.state;
never on this call merely succeeding.
# 2. The descriptor first — it carries universe_order, which you need twice,
# and dataset_version, the training data whose features match this round.
descriptor = client.round_dataset(round_.round_id)
training = client.load_training_frame(descriptor.dataset_version) # era, features…, target
# 3. The data, through a signed data session (see "Data access" below). Every
# page is hashed against the session manifest before you see it; a mismatch
# raises FingerprintMismatch rather than returning bytes.
features = client.load_round_frame(round_.round_id) # indexed by id
Rows arrive in your session's own order, not universe_order. That is
deliberate — the order is one of your session's attribution marks — and it does
not matter to scoring: predictions are matched by id.
# 4. Rank the universe. Your model goes here; this is a placeholder.
scores = my_model.predict(features) # one number per row
predictions = dict(zip(features.index, scores))
# 5. Submit.
try:
receipt = client.submit(
round_.round_id,
MODEL_ID,
predictions,
universe_order=descriptor.universe_order, # saves a request
)
except SubmissionRejected as exc:
print(exc.summary()) # every problem, listed
raise
except IndiQuantError as exc: # one except covers all
raise
print(receipt.created, receipt.sha256, receipt.attempts_remaining, "left")
predictions may be a {id: score} mapping or a list of
{"id": ..., "score": ...} rows.
Submitting through the website instead. The upload form at
https://platform.indiquantresearch.in/submit takes a CSV whose header is
exactly id,score, one row per universe id. Build it with the SDK and check it
with the same validator the server runs, which spends nothing:
from indiquant.sdk import prepare_submission, predictions_frame, to_submission_csv
frame = predictions_frame(predictions, descriptor.universe_order) # id, score
prepare_submission(round_.round_id, MODEL_ID, frame.to_dict("records"),
descriptor.universe_order) # raises SubmissionRejected
to_submission_csv(frame, "predictions.csv") # UTF-8, no index
``` Every exception this package raises subclasses
`IndiQuantError`, so a submission script needs exactly one `except` to be safe
and can branch further when it wants to.
---
## Data access: SDK-only, signed, and attributed
Round files and training artefacts are served **only inside an SDK data
session**. There are no download links any more: a deployment still inside a
published deprecation window answers old links with a `Deprecation` header, and
otherwise with `410 legacy_download_closed`.
**One key per install.** Each machine you work on holds its own Ed25519 key.
The private half never leaves the machine; the platform stores only the public
half. Register it once:
```python
from indiquant.sdk import IndiQuantClient, InstallKey
key = InstallKey.load_or_create("~/.indiquant/install.key") # written 0600
client = IndiQuantClient.from_login(
"https://api.indiquantresearch.in", email=..., password=..., install_key=key
)
client.register_install_key("research laptop", password=...) # once per install
Registering a key asks for your account password again, in that one request —
a stolen sign-in token on its own cannot add a key — and the platform emails
your account's address naming the key's label and fingerprint. If you get that
email for a key you did not add, revoke it, change your password and tell the
operator. A wrong password is reauthentication_failed (401), which a fresh
token does not cure.
At most five live keys per account. client.install_keys() lists them and
client.revoke_install_key(key_id) withdraws one — do that the day a machine
leaves your hands; it also closes every session that key opened.
Sessions. load_round_frame and load_training_frame open a session, fetch
every page and verify each against its digest. To stream instead:
version = client.round_dataset(round_id).dataset_version # e.g. "v3.1-pv14-2026-09-23"
session = client.open_data_session("training", version=version)
for frame in session.iter_frames(): # one verified page at a time
...
A session lasts fifteen minutes and serves at most three passes of its pages;
an account may open 48 sessions in any rolling 24 hours. Every request carries
your token and a signature by the install key, with a timestamp that must be
within five minutes of the platform's clock — a machine whose clock has drifted
is refused with sdk_signature_invalid, which a fresh token does not cure.
Attribution marks. Every session is served with marks unique to it: the
order of its rows and, in a training artefact, changes to the training target
far below its published noise (±2⁻¹⁸). They change no feature, no universe
member, nothing that is scored and nothing about how it is scored. They exist so
a copy found outside the platform can be attributed to the session that fetched
it. The full terms, versioned, are at GET /v3/docs/data-access; in short, the
data is for your own research and your own submissions, and redistributing it,
sharing it with another account, pooling copies across accounts or removing the
marks is not permitted.
Hosted models. Where hosted execution is offered, your model runs on the
platform and never receives the data at all:
client.upload_model(model_id, build_package("my_model/", entry_point="model:predict"))
makes it the ACTIVE version and client.hosted_runs(model_id) reports each run;
indiquant.sdk.hosted documents the package it expects. A deployment that runs
models inside the platform may also withhold the round file itself: a round
data session then answers round_file_withheld (403).
Ten attempts, and which failures spend one
There are ten submission attempts per (round_id, model_id). Not ten stored
submissions — there is only ever one stored row per round per model, and each
accepted submission replaces it. The ten is how many times you may correct it.
Receipt.attempts_used and Receipt.attempts_remaining are your running
balance.
| What happened | Costs an attempt? |
|---|---|
| Stored — a first submission, or a correction replacing the last one | yes |
| A repeat whose scores are byte-identical to what is already stored | no — nothing is written, so nothing is charged (created is False) |
Rejected by the server (422 submission_rejected) |
yes |
| Submission window closed (409) | no |
| Cap already exhausted (409) | no — there is nothing left to spend |
| Round or model not found (404) | no |
| Body over 258048 bytes (413) | no |
| Refused locally, by this SDK, before the POST | no |
A server-side rejection charges you deliberately: the attempt is committed before the refusal is raised, so the rollback cannot hand it back. Ten rejected payloads is a round you sat out.
That asymmetry is the entire reason submit validates before it sends. The
check it runs is not an approximation of the server's check — it is
indiquant.core.integrity.validate_submission, the single validator, reading
the same schema file the platform reads. A payload this SDK refuses raises the
same SubmissionRejected with the same report shape a server refusal carries,
so one handler prints both; report["local"] tells them apart.
A payload that passes locally can still be refused server-side in exactly two
ways the local check cannot see: your model_id is not a UUID the platform
knows (the schema admits a looser pattern than the handler accepts), and the two
copies of the schema have drifted apart. For the second:
check = client.submission_schema()
if not check.matches:
raise SystemExit(
f"upgrade indiquant-sdk: packaged {check.packaged_sha256[:12]} "
f"vs served {check.served_sha256[:12]}"
)
That comparison is between digests of bytes. The endpoint serves the schema
file verbatim with its sha256 as the ETag, and this wheel ships that same file
unmodified — which is what makes one comparison conclusive. Never re-serialise
the schema and hash the result: the same schema written out again is a different
byte string, and the comparison then fails for everyone who is correct.
Scores are ranks
The v3 schema imposes no bound on score. Predictions are read as ranks, so
any strictly monotone transform of your vector scores identically: x, 2x + 7,
logit(x) and rank(x) all produce the same round score.
Practical consequences:
- Do not clamp to
[-1, 1]. The priorindiquant-dataSDK did, and it silently flattened the tails of otherwise good signals — every value it truncated became a tie. If you are porting from that package, delete the clamp. - Do not spend effort calibrating magnitudes. Only the ordering is read.
- Ties are real information loss, because tied names cannot be ordered against each other. Rounding to two decimals over a large universe is a real cost.
- Values must be finite. Non-finite scores are a fatal validation problem, and a score outside float32's range cannot be stored at all.
The receipt digest is over float32
Receipt.sha256 is the digest of the stored scores: IEEE-754 binary32,
little-endian, four bytes per value, concatenated in universe order. The
column is a PostgreSQL real[], so that is what exists to be hashed.
Reproduce it locally with the helper, which packs it the same way:
from indiquant.sdk import scores_sha256
local = scores_sha256([predictions[i] for i in descriptor.universe_order])
assert local == receipt.sha256
Two ways to get a digest that will never match, both of them easy:
hashlib.sha256(np.asarray(scores).tobytes())— that is float64, eight bytes per value, so the digest is wrong however correct the scores are.- Hashing in your own iteration order. The digest is over a sequence; a
permutation of the same scores is a different sequence. Order by
descriptor.universe_order, which is the order the round file is in.
Reading back what a prediction earned
history = client.scores(MODEL_ID) # resolved rounds, one row each
snapshot = client.trust(MODEL_ID) # latest visible trust posterior
board = client.leaderboard("core") # the latest visible published board
costs = client.cost_reference() # the six reference cost books
Some numbers are shown late, on purpose. A round's MMC and FNC appear 20
sessions after the round resolves; until then the row carries them as None
and withheld_until says the session they appear. CORR and its standard error
are shown as soon as the round is scored. The trust posterior and the
leaderboard are lagged the same way, because both carry mean MMC. The rules
themselves — the points formula, the grant split, the t-statistic ranking and
the vesting terms — are published in full and are not lagged.
trust returning 404 means no snapshot old enough to show exists yet — a model
that has not been scored, or was scored recently — which is a different fact
from a model that does not exist, and the message says which. The leaderboard
is a published board rather than one rebuilt per request, because a board
rebuilt on demand answers a slightly different question every time it is
asked. Each row states its grant eligibility and, when it is not eligible, why;
it does not carry anyone's contributor id or grant balance.
Things to check before you file a bug
- Every page is hashed against its digest in the session manifest before the
bytes are returned.
FingerprintMismatchmeans a truncated transfer or a rewriting intermediary — open a new session and fetch again. It is not a condition to retry blindly. sdk_signature_invalid(401): this install is not registered, its key was revoked, or this machine's clock is more than five minutes off.data_access_suspended(403): the operator has withdrawn this account's data access. A new key or session will not restore it; contact the operator.- Errors come back as RFC 9457
application/problem+json. Each publishedcodehas its own exception class;exc.retryablesays whether the platform considers it worth another attempt, and 429s and 503s carryRetry-After, which the client honours formax_retriesattempts. 501 training_artefact_not_publishedis not retryable. No artefact exists for that dataset version yet and asking again will not create one.session=accepts anything withrequests.Session.request's signature. Use it for a corporate proxy, a mutual-TLS jump host, a retry adapter — or to test your own submission script with no sockets at all.
Release files for indiquant-sdk 3.3.5
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| indiquant_sdk-3.3.5-py3-none-any.whl | Python 3 | none | any | Details |
Release files / indiquant_sdk-3.3.5-py3-none-any.whl
| Download URL | indiquant_sdk-3.3.5-py3-none-any.whl |
|---|---|
| Size | 226.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
873e55273b25cf0b4797bfb7bcd9731bb5e9b2535f6c6e4d3dbef914cc1b87b0
|
|
BLAKE2b-256 checksum How to use checksums |
139ebf635a63bcf1ec85da59f53918503a29a2e8e1d576eb3092283e093cbba5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.6
|