Metadata-Version: 2.5
Name: jumptech-sdk
Version: 0.6.1
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. |

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.

## 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, an account, a deposit, a position and a close, 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_...")

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.transactions` | create, get, list, approve, decline, chargeback |
| `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 now on that list.** Until 0.2 `transfers` and
`pending` were not, which mattered more than the missing convenience: a transfer
opened through the generated client gets a FRESH idempotency key per HTTP
attempt, and one transfer is two ledger rows on two accounts.

Everything else on `/v1` — customers, accounts, 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. Those
are reads. If you find yourself moving money through one, mint and reuse a key
yourself, or open an issue and we will wrap it.

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.

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

```python
from jumptech_sdk import (
    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")
except InsufficientMargin as e:
    print("shortfall:", e.details)                   # {required, available, ...}
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)
```

**`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))
```

## 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: 0=buy, 1=sell. 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

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"])

jt.webhooks.mount(app, "/jt")     # push: verifies, dedupes, dispatches
jt.events.reconcile()             # pull: same handler, same dedupe store
```

Flask and Django get `jt.webhooks.flask_view()` and `jt.webhooks.django_view()`.
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.

Reconcile 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

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

async def consume():
    async for event in jt.events.stream():
        ...
```

`stream()` yields **per event, not per page**, so persisting after each one costs
one duplicate on a crash instead of a page. It does not stop at the end of the
backlog — it keeps polling, so catch-up and live tail are the same loop.

## 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 `jt.events.reconcile()` — 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. **Three longer documents ship inside this
package** — no login, no repository access, nothing to request:

```bash
jumptech docs              # what is available, and where
jumptech docs identity     # which identifier goes where — read this one FIRST
jumptech docs reference    # every published operation
jumptech docs practices    # how to integrate without getting hurt
```

| | |
|---|---|
| **`identity`** | The shortest of the three 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 123 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. |

They are ordinary Markdown, so pipe them wherever you like —
`jumptech docs practices | less`, or open the directory `jumptech docs` prints.
Both 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 37 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.
