Metadata-Version: 2.5
Name: jumptech-sdk
Version: 0.8.0
Summary: Python client for the JumpTech engine /v1 API
License: Proprietary
Requires-Python: >=3.11
Requires-Dist: attrs>=22.2
Requires-Dist: httpx>=0.27
Requires-Dist: python-dateutil>=2.8.1
Description-Content-Type: text/markdown

# jumptech-sdk

Python client for the JumpTech engine `/v1` API.

Money mutations are the reason this exists rather than a bare generated client:
a retry here is a **replay**, not a second deposit, and it stays a replay across
a process crash.

```bash
pip install jumptech-sdk
```

Python 3.11+. Three dependencies (`httpx`, `attrs`, `python-dateutil`) — the
durable store and the webhook verifier use only the standard library.

---

## Before you write any code

Two credentials come from the broker you are integrating with. Neither is
self-service; a broker operator issues them.

| You need | Looks like | Notes |
|---|---|---|
| **API key** | `jt_demo_…` / `jt_live_…` | Ask for a **`jt_demo_`** key first. Same API, same code path, no real money. |
| **Webhook secret** | `whsec_…` | Only if you want events pushed to you. Polling works without it. Shown exactly once — *Events* below has the two ways it reaches you. |

Also ask **which base URL** — each broker runs its own engine, so a partner
serving three brokers holds three keys against three URLs.

Your key reaches only the grants your broker's operator scoped it to at
issuance — not a fixed role. A non-`GET` (or any route outside its scope)
returns `403 forbidden_page` or `403 forbidden_grant`, and `error.message`
names the grant it wanted. There is no self-service way to list what a key
already holds — ask your broker's operator up front rather than discovering
it on your first deposit.

## Check the connection first

Before writing anything, prove the credential and the network work:

```bash
python -m jumptech_sdk doctor --base-url https://api.yourbroker.com --api-key jt_demo_...
```

It names the actual problem — an unknown key, a key missing a grant, or an engine that
could not reach its own auth service (which is *not* a bad key, and rotating it
will not help). If you are receiving webhooks, point it at your own endpoint and
it will send one correctly-signed delivery:

```bash
python -m jumptech_sdk doctor --base-url https://api.yourbroker.com --api-key jt_demo_... \
    --probe https://crm.example/jt --secret whsec_...
```

Rejected means the fault is in your verifier. Accepted means it is upstream.
That distinction is the classic first-day afternoon, and this answers it in a
second.

The probe is a real delivery. Its `type` is `jumptech.doctor.ping` — not one of
the 28 published types — and it reaches your handler. A handler that raises on
an unknown type fails the probe; ignore the types you do not know.

## Then prove it end to end

`doctor` proves the credential. This proves the *integration* — that the numbers
on your screen are the engine's and not your own application answering itself:

```bash
jumptech verify --base-url https://api.yourbroker.com --api-key jt_demo_...
```

It opens a customer and an account, books an approved deposit of **10,000**
units of the account currency, opens a **0.01-lot** position and closes it, and
prints what the engine returned at each step — the customer UUID, the account login
*and* its separate UUID, the transaction id, the ticket, the fill and the
realised P&L. Look any one of them up in the dealer console. If it is there, the
integration is real.

This is worth running on day one and again whenever a screen looks wrong,
because the failure it catches is silent by construction: an application that
mints its own ticket numbers, invents its own fill prices and keeps withdrawals
in its own database looks completely correct until someone reconciles. Nothing
errors. One integration lost a working day to exactly that.

It **writes** — a real customer, a real deposit, a real trade — which is the only
way to tell a working integration from a convincing mock. It refuses a
`jt_live_` key unless you pass `--live`, and it cleans nothing up, so every run
is auditable.

## Your first call

```python
from jumptech_sdk import JumpTech

jt = JumpTech("https://api.yourbroker.com", api_key="jt_demo_...")
# This creates jumptech.db in the working directory. In production point
# store_path= at durable disk — see *State* below.

page = jt.transactions.list(page_size=5)
for tx in page.data:
    print(tx.id, tx.type, tx.amount, tx.account_currency)

jt.close()
```

Every list returns the same envelope: `.data`, `.total`, and `.next_cursor` on
the append-only logs. Money values always arrive with their currency beside
them — read `account_currency`, never assume.

Use it as a context manager and you can forget `close()`:

```python
with JumpTech("https://api.yourbroker.com", api_key="jt_demo_...") as jt:
    print(jt.transactions.list(page_size=1).total)
```

---

## What is typed

Hand-written, typed wrappers:

| Namespace | Operations |
|---|---|
| `jt.accounts` | get |
| `jt.customers` | create, open_account |
| `jt.transactions` | create, get, list, approve, decline, chargeback, exchange_cashier_token |
| `jt.positions` | place, place_async, request_status, list, get, close |
| `jt.transfers` | create, preview |
| `jt.pending` | place, modify, cancel, list |

Plus **`jt.events`** for the event log and **`jt.webhooks`** for inbound
deliveries. All of them exist on both `JumpTech` and `AsyncJumpTech`.

**Every route that moves money is on that list.** That matters more than the
convenience: a transfer sent through the bare generated client gets a fresh
idempotency key per HTTP attempt, and one transfer is two ledger rows on two
accounts.

Everything else on `/v1` — groups, instruments, the audit log — is reachable
but **unwrapped**: the generated client under `jumptech_sdk._generated` covers
the whole spec, and calls made through it get **no** retry, idempotency-key or
crash-recovery help from the layer above. One more trap there: a generated
operation **returns** a `V1ErrorEnvelope` on a 4xx instead of raising, so the
`except JumpTechError` pattern below never fires — check the type of what comes
back. Those are reads. If you find yourself moving money through one, mint and
reuse a key yourself.

Retries, the idempotency key, the event dedupe store, `recover()` and the webhook
verifier work identically for whichever resource you call.

## Money in

Mutations carry an `Idempotency-Key` automatically, and a retry reuses it — so a
retried deposit is a replay, not a second deposit.

```python
import uuid

from jumptech_sdk import JumpTech
from jumptech_sdk.models import TransactionCreateManual, TransactionCreateManualType

jt = JumpTech("https://api.yourbroker.com", api_key="jt_live_...")

tx = jt.transactions.create(TransactionCreateManual(
    type=TransactionCreateManualType.DEPOSIT,   # an UPPERCASE enum, not a string
    account_id=uuid.UUID("3f7c0d1e-2b4a-4c8e-9a71-2d1f6b3e5c40"),  # the account's
                                                                    # UUID, not the login
    amount="100.00",
    account_amount="100.00",   # deposits must be in the account's currency
    currency="USD",
))
jt.transactions.approve(tx.id)
```

Two rules the API enforces and the SDK cannot guess for you: a deposit or
withdrawal **must** be in the account's own currency, and `account_id` is the
account's UUID — not its numeric login. `jt.accounts.get(login).id` is that UUID.

When the money is already captured — a PSP has confirmed it — book with
`status=TransactionCreateManualStatus.APPROVED` instead, which books and
approves in one call. Two calls leave a window where a crash strands a captured
deposit as `PENDING`. It needs the approve grant for the type as well as
`transactions.create`. One call still leaves the window before `create()` runs:
a crash between the PSP's confirmation and your call leaves captured money with
no transaction and no key. Sweep PSP-confirmed payments against your intent
table on a schedule, regardless.

On the way back, `tx.amount` is a string (`"100.00"`) on a `create()` or
`approve()` response and a float (`100.0`) from `get()` and list rows. Compare
through `Decimal`.

## Handling failures

Catch the specific class when you can act on it, `JumpTechError` when you cannot.
Every one carries `.code`, `.message`, `.status` and `.request_id` — quote
`request_id` when you ask the broker about a call. A response with a **named**
code maps to its own class or to the base `JumpTechError` — never to the
status-derived class — so `NotFound` catches a bare 404 but not
`404 token_invalid`; branch on `.code` for those.

```python
from jumptech_sdk import (
    Conflict, InsufficientMargin, JumpTechError, NotFound, RateLimited,
    UnknownOutcome, ValidationError,
)

try:
    jt.transactions.approve(tx_id)
except ValidationError as e:
    print("the request was malformed:", e.fields)     # per-field, from the API
except NotFound:
    print("no such transaction")                      # a bare 404; a named 404 such as token_invalid lands below
except InsufficientMargin as e:
    print("shortfall:", e.shortfall, e.currency)      # also .required, .available
except Conflict as e:
    print("not now — wait, then retry:", e.code)      # liquidation_in_progress, account_closed
except RateLimited as e:
    print("slow down for", e.retry_after, "seconds")  # already honoured on retry
except UnknownOutcome as e:
    print("MAY have committed — reconcile, do not retry:", e)
except JumpTechError as e:
    print(e.status, e.code, e.message, e.request_id)
```

Which codes are typed: `insufficient_free_margin` → `InsufficientMargin`;
`liquidation_in_progress` and `account_closed` → `Conflict` (a 409 that means
"not now": wait, then retry); `idempotency_key_required` →
`MissingIdempotencyKey`. `currency_mismatch`, `customer_frozen` and
`withdrawal_limit_reached` are the base `JumpTechError` — branch on `.code`;
there is no subclass for them.

**`UnknownOutcome` is the one to read twice.** It means the request left your
process and no answer came back, so the mutation may or may not have committed.
The SDK deliberately does **not** retry it — see *After a crash*.

Retries are automatic and already done by the time an exception reaches you:
5 attempts, exponential backoff from 0.5s capped at 8s, on `429`, `502` and
`503` only. Tune it if you must:

```python
from jumptech_sdk import JumpTech, RetryPolicy

jt = JumpTech("https://api.yourbroker.com", api_key="jt_live_...",
              policy=RetryPolicy(max_attempts=3, base_delay=1.0))
```

The HTTP timeout is 30 seconds and is not a constructor knob today. A read
timeout on a mutation surfaces as `UnknownOutcome`.

## Orders

Placement is asynchronous — a 202 and a request id. `place()` polls it to a
terminal state; `place_async()` hands you the id.

```python
from jumptech_sdk.models import PlaceOrderRequest

fill = jt.positions.place(PlaceOrderRequest(
    symbol="EURUSD", cmd=0, volume=0.10, login=5001
))
print(fill.order, fill.open_price)      # cmd at placement: 0 = buy, 1 = sell, nothing else (cmd=2 is a 422).
                                        # Rows you read back use 0–7 and encode the variant. Ticket is `order`.
```

A rejection raises rather than returning a status: `MarginRejected` for
insufficient margin, `OrderRejected` otherwise, and `OrderRequestExpired` if the
request id aged out — which says nothing about whether the order executed, so
check the book before retrying.

## Events

One handler, two delivery routes, deduped against each other because both share
the same store:

```python
from fastapi import FastAPI

from jumptech_sdk import JumpTech
from jumptech_sdk.events import deliver

app = FastAPI()
jt = JumpTech("https://api.yourbroker.com", api_key="jt_live_...",
              webhook_secret="whsec_...")   # jt.webhooks is None without this

@jt.on_event
def handle(event):
    print(event["type"], event["event_id"])   # the work — never the cursor

jt.webhooks.mount(app, "/jt")                 # push: verifies, dedupes, dispatches, answers 200

def poll():                                   # schedule this with whatever you already use
    cursor = load_cursor()
    for event in jt.events.stream(after=cursor):
        deliver(jt.store, event, handle)      # pull: same handler, same dedupe store
        cursor = str(event["id"])             # after handling, never before
        save_cursor(cursor)
```

**The poll loop owns the cursor. The webhook handler never touches it.** A
webhook subscribed to a subset of types can deliver event 100 while the poller
stands at 90; a handler that saved `"100"` would make the next poll skip 91–99,
silently and for good — the dedupe store cannot help, because it never saw
them. `doctor --probe` delivers `"id": 0` through the same handler, which would
send the cursor back to the start of the log. `reconcile(after=…)` runs this loop
through the registered handler but returns a count, not a cursor — a one-off
full replay, not the scheduled poller.

Your handler has **ten seconds**. `mount()` answers 200 only after the handler
returns, and the engine retries a delivery that took longer — so a slow handler
runs twice. Return fast; hand real work to a queue.

Getting the secret: ask the broker for your key's `integration_id` and the
`webhooks.manage` grant. Then either register the endpoint yourself —
`POST /v1/webhooks`; `secret` is in that response and nowhere else — or have
the broker register it and hand you the `whsec_`. Either way it is shown once.
There is no wrapper for that one call; it is a single POST, so make it with
`httpx`, which the SDK already installs:

```python
import httpx

r = httpx.post(
    "https://api.yourbroker.com/v1/webhooks",
    headers={"Authorization": "Bearer jt_demo_..."},
    json={
        "integration_id": integration_id,          # from the broker
        "url": "https://crm.example/jt",           # https only
        "event_types": ["transaction.approved", "order.closed"],   # omit for everything
    },
)
r.raise_for_status()
secret = r.json()["secret"]                        # shown here and nowhere else — store it now
```

Flask and Django:

```python
app.add_url_rule("/jt", view_func=jt.webhooks.flask_view(), methods=["POST"])  # Flask — without methods= it is a 405
```

```python
urlpatterns = [path("jt/", jt.webhooks.django_view())]   # Django — keep the trailing slash and register the
                                                          # URL with it, or APPEND_SLASH turns the POST into a 301
```

Use an adapter rather than verifying by hand: the signature is computed over the
**raw bytes**, and a handler that verifies against a re-serialised object rejects
every delivery.

Your handler runs **at most once per event** across both routes, and only counts
as handled once it returns — if it raises, nothing is recorded and the event is
redelivered.

`poll()` is one pass — schedule it with whatever you already use. Treat
`GET /v1/events` as the system of record and webhooks as an optimisation, not the
other way round.

## After a crash

`recover()` finishes every pending intent with its ORIGINAL key, so each returns
the original answer instead of booking a second one. It hands back one
`(key, outcome)` per intent — the response, or the exception if that one did not
complete. An intent whose last outcome was unknown (a 5xx, or a read timeout: the
handler may have committed and the API does not cache either) is refused, and its
outcome says so. Reconcile those, then resolve them by hand.

```python
for key, intent in jt.unresolved():
    ...                            # reconcile against GET /v1/events or the
                                    # resource itself, then:
    jt.store.resolve_intent(key, "done")

for key, outcome in jt.recover():
    if isinstance(outcome, Exception):
        ...                        # this one did not finish; the others still ran
```

Call `recover()` on startup. It is the payoff for the store being on disk.

## Async

`AsyncJumpTech` in `jumptech_sdk.aio` is the same API with `await`, and
`jt.events.stream()` becomes an async generator:

```python
from jumptech_sdk.aio import AsyncJumpTech
from jumptech_sdk.events import adeliver

jt = AsyncJumpTech("https://api.yourbroker.com", api_key="jt_live_...")

import asyncio

async def consume():
    cursor = load_cursor()
    while True:
        async for event in jt.events.stream(after=cursor, limit=500):
            await adeliver(jt.store, event, handle)   # same dedupe as the sync route
            cursor = str(event["id"])
            save_cursor(cursor)
        await asyncio.sleep(2)
```

`stream()` yields **per event, not per page**, so persisting after each one costs
one duplicate on a crash instead of a page. It **returns when the log is caught
up** — it does not keep polling. The `while True` around it is what turns
catch-up into a live tail; without it a consumer drains the backlog and exits.

## State

A local sqlite file (`jumptech.db` by default) holds pending idempotency intents
and handled event ids. Point it somewhere durable in production:

```python
jt = JumpTech(
    "https://api.yourbroker.com", api_key="jt_live_...",
    store_path="/var/lib/yourapp/jumptech.db",
)
```

An in-memory store does not survive the crash it exists to protect against, so
`InMemoryStore` is for tests only. Implement the `Store` protocol to put this in
your own database instead.

Both clients close their connection pool and their store — `jt.close()`, or
`with JumpTech(...) as jt:` (`await jt.aclose()` / `async with` on the async
one).

## Going to production

- [ ] Swap the `jt_demo_` key for `jt_live_`.
- [ ] Point `store_path` at durable, writable disk that survives a redeploy.
- [ ] Call `jt.recover()` on startup.
- [ ] Schedule the poll loop — `stream()` + `deliver()` — and save the cursor only
      there; webhooks alone will miss events.
- [ ] Decide what `UnknownOutcome` does in your system. It is the only outcome
      that needs a human or a reconciliation, and it will happen.
- [ ] Log `request_id` from every `JumpTechError`.

## Further reading

This README covers the client. **Six longer documents ship inside this
package** — no login, no repository access, nothing to request:

```bash
jumptech docs              # what is available, and where
jumptech docs python       # START HERE — a key to live in ten steps, for a Python backend
jumptech docs identity     # which identifier goes where
jumptech docs practices    # how to integrate without getting hurt
jumptech docs reference    # every published operation
jumptech docs api          # the wire contract: events, webhooks, retries, idempotency
jumptech docs java         # the same ten steps for a Java backend
```

| | |
|---|---|
| **`python`** | The starting point. Ten numbered steps from "I have a key" to "I am live" — the credential, identity, money, the cashier handoff, events, trading, going live. Read it end to end once; the guides below go deeper on each step. |
| **`identity`** | The shortest of them and the one that saves the most time. An account has **two** identifiers — `login` for every path and every order, `id` for money — and sending either where the other belongs is a 404 or a wrong-account booking. Also: why `GET /v1/accounts` has no `login` filter, and why an instrument's economics must be read from the catalogue and never parsed out of its symbol. |
| **`reference`** | All 118 published operations — parameters, request and response shapes, error codes — plus the identity model your database joins on, the page envelope, the error taxonomy and the 28 event types. Generated from the same OpenAPI document this client is built from, so it cannot drift from the wire. |
| **`practices`** | Idempotency, the events cursor, what to reconcile against, what never to cache, and where the engine ends and your CRM begins. Every rule is justified by a measured behaviour of the running system, and it closes with a pre-go-live checklist. |
| **`api`** | The wire contract: the event object and its catalogue, cursors, webhook signing with the published test vector, delivery semantics, and the retry and idempotency rules for every mutation. |
| **`java`** | The same ten steps for a Java backend, on a client generated from the contract. Here so a mixed team reads one set of guides. |

They are ordinary Markdown, so pipe them wherever you like —
`jumptech docs practices | less`, or open the directory `jumptech docs` prints.
All of them are versioned with the client, so the reference always describes the
surface this exact version was generated against.

### What this client does not cover, on purpose

`/v1/me/*` — the trader's own surface — takes a **customer token**, so an API key
gets `403 customer_token_required` on every one of its operations. Those
methods are not generated. Act on a customer's behalf through the staff
endpoints: `/v1/positions`, `/v1/transactions`, `/v1/accounts`.

`/v1/reports/*` is the broker's dealer-console dashboard and is likewise not
generated. You already hold the data those figures summarise; the `total` on any
collection gives you the counts.

`/v1/users/me/*` — a staff member's own profile, password, sessions and
authenticator — is no longer published, so the client has no methods for it
either. Nothing a partner integration does lives there.
