Metadata-Version: 2.4
Name: jumptech-sdk
Version: 0.2.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. |

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 may be **read-only**, in which case every non-`GET` returns
`403 readonly_key`. Worth confirming 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 read-only key, 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.

## 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 in v0.1

Hand-written, typed wrappers: **`jt.transactions`** (create, get, list, approve,
decline, chargeback) and **`jt.positions`** (place, place_async, request_status,
list, get, close). Plus **`jt.events`** for the event log and **`jt.webhooks`**
for inbound deliveries.

Everything else on `/v1` — customers, accounts, transfers, pending orders,
groups, instruments, the audit log — is reachable but **untyped**: 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. If you are moving money through one of those, mint and reuse a key
yourself, or wait for a typed wrapper.

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