Skip to main content

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/login and 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. The model_id half was true until 2026-09-21, when POST /v3/models landed.

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_refresh is 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 prior indiquant-data SDK 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. FingerprintMismatch means 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 published code has its own exception class; exc.retryable says whether the platform considers it worth another attempt, and 429s and 503s carry Retry-After, which the client honours for max_retries attempts.
  • 501 training_artefact_not_published is not retryable. No artefact exists for that dataset version yet and asking again will not create one.
  • session= accepts anything with requests.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)

Table of built distributions (wheels) for indiquant-sdk 3.3.5
File Interpreter ABI Platform
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

Release history Release notifications | RSS feed

This release

3.3.5 This release

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page