Metadata-Version: 2.4
Name: indiquant-sdk
Version: 3.3.5
Summary: Python client for the IndiQuant tournament: sign in, read a round, load its data, validate and submit predictions.
Project-URL: Homepage, https://platform.indiquantresearch.in
Project-URL: Documentation, https://platform.indiquantresearch.in/sdk
Author: IndiQuant Research
License-Expression: MIT
License-File: LICENSE
Keywords: equities,india,indiquant,machine-learning,quant,tournament
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Office/Business :: Financial :: Investment
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Requires-Python: >=3.11
Requires-Dist: cryptography>=42.0
Requires-Dist: jsonschema>=4.21
Requires-Dist: numpy>=1.26
Requires-Dist: pandas>=2.1
Requires-Dist: pyarrow>=12
Requires-Dist: requests>=2.28
Requires-Dist: scipy>=1.11
Description-Content-Type: text/markdown

# 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:

```python
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:

```python
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.

```python
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

```python
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.

```python
# 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`.

```python
# 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:

```python
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:

```python
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:

```python
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:

```python
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

```python
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.
