Metadata-Version: 2.5
Name: inflection-exchange
Version: 0.2.1
Summary: Python client for the Inflection exchange.
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Requires-Python: >=3.11
Requires-Dist: cryptography>=44
Requires-Dist: httpx>=0.28.1
Requires-Dist: websockets>=17.1
Description-Content-Type: text/markdown

# Inflection Python SDK

A Python client for the Inflection exchange. It authenticates with your API
key, keeps the connections open, streams market data, and tracks your orders so
you can place them and await the outcome.

## Install

```sh
pip install inflection-exchange
```

## Quick start

```python
import asyncio
from inflection_exchange import (
    BookLevel,
    ExchangeClient,
    MarketDataFeed,
    Outcome,
    Side,
)


async def main():
    async with (
        MarketDataFeed(series=["btc-updown-5m"], book_level=BookLevel.L1) as feed,
        ExchangeClient("your-key-id", "inflection-bot.pem") as client,
    ):
        market = feed.tradable_markets()[0]
        book = feed.books.get((market.market_id, Outcome.UP))
        print(market.slug, book)

        order = await client.place_limit(
            market.market_id, Outcome.UP, Side.BID, 4500, 100
        )
        await order.wait_accepted()
        print(order.status, order.filled_quantity)

        await client.cancel_all()


asyncio.run(main())
```

The client trades for the account its API key belongs to. Create a key in
the app under Settings > API keys > New key: you get a key id, and the
private key downloads as a `.pem` file. The exchange never sees the private
key and can't send it again, so keep the file safe. See the
[API keys guide](https://inflection.exchange/docs/guides/api-keys/).

`ExchangeClient` signs the order socket's handshake with the key. To call the
account API yourself, sign each request the same way:

```python
from inflection_exchange import ApiKey

key = ApiKey("your-key-id", "inflection-bot.pem")
path = "/api/trading/accounts/7/balance"
httpx.get("https://inflection.exchange" + path, headers=key.sign("GET", path))
```

A signature is good for 30 seconds, so keep your clock in sync. If the
exchange refuses the key (a wrong id, a revoked key, or a clock that is off),
the client raises instead of retrying.

The SDK connects to `https://inflection.exchange`.

## Units

| Thing    | Unit          | Notes                                                                                      |
| -------- | ------------- | ------------------------------------------------------------------------------------------ |
| Price    | basis points  | `10_000` = $1 = 100%. Limit prices go from 100 to 9900 in steps of 100.                    |
| Quantity | lots          | 100 lots = 1 share. A winning share pays $1.                                               |
| Outcome  | `up` / `down` | Every market is binary. Buying `down` at p is the same order as selling `up` at 10000 − p. |

A market takes orders from the moment it is listed. While its window hasn't
started it is `open` and has no strike yet. Once the window starts it is
`in_event`, and the strike is known. The market closes when the window ends,
and at that point the exchange cancels every order still resting in it.

## Orders

Orders default to **good-till-cancelled**. Market orders are the exception:
they default to **immediate-or-cancel**, because a market order can't rest on
the book.

```python
order = await client.place_limit(market_id, Outcome.UP, Side.BID, 4500, 200)
order = await client.place_limit(
    market_id, "up", "bid", 4500, 200, expiry_ms=now_ms + 5 * 60_000
)  # good-till-date
order = await client.place_limit(market_id, "up", "ask", 5500, 200, post_only=True)
order = await client.place_market(market_id, "down", "bid", 100)  # IOC
order = await client.place_market(market_id, "down", "bid", 100, TimeInForce.FOK)

await client.modify(order, price_bps=4600)  # new price: goes to the back of the queue
await client.modify(order, quantity=100)  # smaller at the same price: keeps its place
await client.cancel(order)
await client.cancel_all()
```

Each call returns an `Order` that the SDK keeps up to date. You can await it,
or just read it later:

```python
try:
    await order.wait_accepted()  # raises OrderRejectedError if refused
except OrderRejectedError as e:
    if e.reason == RejectReason.INSUFFICIENT_COLLATERAL:
        ...

await order.wait_done()
order.status  # OrderStatus.FILLED, CANCELLED, UNFILLED, REJECTED, ...
order.filled_quantity  # lots filled so far
order.remaining_quantity  # lots still working on the book
order.reason  # why it was rejected, cancelled or unfilled
```

| Status      | Meaning                                                                                     |
| ----------- | ------------------------------------------------------------------------------------------- |
| `PENDING`   | Sent; the exchange hasn't replied yet.                                                      |
| `OPEN`      | Accepted and working. It may be partly filled.                                              |
| `FILLED`    | Completely filled.                                                                          |
| `CANCELLED` | Cancelled. `reason` says why: `client_request`, `expired`, `market_closed` or `self_trade`. |
| `UNFILLED`  | An IOC or market order's remainder was dropped. It may have partly filled first.            |
| `REJECTED`  | Refused. `reason` is a `RejectReason`.                                                      |
| `UNKNOWN`   | Finished while you were disconnected. Your fills say what happened.                         |

`client.orders` holds your live orders. On reconnect it is rebuilt from the
exchange's list of resting orders, so it includes orders from earlier runs.

**Orders are checked before they're sent.** An order the exchange would
certainly refuse raises `InvalidOrder` without being sent: a price that's off
the tick or outside 100–9900, a quantity that isn't a positive whole number
of lots, a resting market order, or a GTD expiry less than a minute ahead.

**The SDK doesn't track positions or P&L.** It passes every fill to you
(`on_fill`), so you can keep whatever view you need. The exchange's account
API holds the authoritative positions and balances.

## Market data

`MarketDataFeed` holds one connection to the public feed and keeps:

- `feed.markets`: the unfinished windows of what you follow, by id, oldest
  first. `feed.tradable_markets()` gives the ones taking orders.
- `feed.books[(market_id, outcome)]`: the best bid and ask (`BookTop`).
- `feed.depths[(market_id, outcome)]`: every price level, best first
  (`BookDepth`).

You must say what to follow. The exchange has no feed of everything. Pass
`series`, `markets`, or both, with at least one name or id. Also pass
`book_level`. If you leave one out, the constructor raises `ValueError` and
says what to pass.

A series is a line of windows, such as `btc-updown-5m`. Each new window of a
followed series arrives without a new request. `markets` follows markets by
id.

`book_level` is `BookLevel.L2` for every price level, or `BookLevel.L1` for the
best bid and ask only. With level 1, `feed.depths` stays empty.

Trades and status are opt-in. Pass `trades=True` to get `on_trade`. Pass
`status=True` to get `on_market_closed` and `on_market_settled`.

```python
from inflection_exchange import BookLevel, MarketDataFeed

feed = MarketDataFeed(
    series=["btc-updown-5m"], book_level=BookLevel.L2, trades=True, status=True
)
```

To follow every series, list them first. `list_markets` reads the filtered,
paged market list. Always name a status: a series with no status holds every
window ever listed.

```python
from inflection_exchange import BookLevel, MarketDataFeed, list_markets

series = sorted({m.series for m in await list_markets(status="open")})
feed = MarketDataFeed(series=series, book_level=BookLevel.L1)
```

The feed fills `feed.markets` from the markets service when it starts. For
each series, it asks for the `open`, `in_event` and `blocked` windows, one
request per status, and follows every page. A blocked window is one whose
venue is shut. For six series, that is 18 requests. If the markets service
refuses a name, `async with` raises with its reason. An unknown series is one
example.

After the start, the feed does not poll. It lists a series again only when the
socket names a market that `feed.markets` does not hold, such as a new
window. It lists only that market's series. It makes at most one list at a
time, and none within 5 seconds of the last. A market's `status` and `strike`
in `feed.markets` change only at those lists. A market that has left the list
loses its book. A feed that names only market ids lists once and does not list
again.

The server closes a connection that falls too far behind. So open one feed
per process, and share it between your bots.

### Books

The exchange sends Up prices only. The feed mirrors them. A Down bid at `p` is
an Up ask at `10_000 - p`. So `books` and `depths` hold both outcomes.

Each market counts its book changes: 1, 2, 3. The feed applies an update when
its number is one more than the last. It ignores an update at or below the
last. If a number is skipped, the feed drops that market's book and asks for a
new snapshot. It tells no listener about the dropped book.

`OrderBook` holds the Up book of one market with its sequence number.
`MarketDataApi.book` returns one.

### Listeners

To react to changes as they happen, add a listener with any of these methods:

```python
class Watcher:
    def on_book(self, book): ...
    def on_depth(self, depth): ...
    def on_trade(self, trade): ...  # public trades, anyone's
    def on_market_closed(self, market_id): ...  # trading has stopped
    def on_market_settled(self, market_id, outcome): ...  # outcome is the winner


feed.subscribe(Watcher())
```

A blocked, suspended or cancelled market also counts as closed.

Your own order events work the same way. Set `client.listener` to an object
with `on_fill(fill)` and/or `on_order_event(event)`. Listeners are called as
messages arrive, so they should be quick; do slow work in your own tasks.

A `Trade` is one match. It has these fields:

| Field                         | Meaning                                                              |
| ----------------------------- | -------------------------------------------------------------------- |
| `trade_id`                    | Counts 1, 2, 3 within the market.                                    |
| `up_price_bps`                | The price of Up in the match.                                        |
| `quantity`                    | Lots.                                                                |
| `taker_outcome`, `taker_side` | What the order that took liquidity did: `up` or `down`, `buy` or `sell`. |
| `time_ms`                     | When the match happened, in epoch milliseconds.                      |
| `price_bps`                   | The taker's price in the outcome it traded.                          |

Down bought at an Up price of 6000 is Down bought at 4000.

### If the connection drops

The feed clears every book, waits a random time, and connects again. The wait
is random between half a limit and the limit. The limit starts at 1 second,
doubles after each failed try, and stops at 30 seconds. After it connects, the
feed subscribes again and gets new snapshots. The server also repeats the
status of a market that closed and has not settled. So `on_market_closed` can
fire twice for one market.

The feed does not replay trades that it missed. A jump in `trade_id` shows a
missed trade. Read the missed trades with `MarketDataApi.trades`.

### REST

`MarketDataApi` reads the same data without a socket:

```python
from inflection_exchange import MarketDataApi, Outcome

async with MarketDataApi() as api:
    summaries = await api.summary(series=["btc-updown-5m"])
    print(summaries[0].top(Outcome.DOWN))
    book = await api.book(summaries[0].market_id, depth=10)
    trades = await api.trades(summaries[0].market_id, limit=50)
    older = await api.trades(summaries[0].market_id, before=trades[-1].trade_id)
```

| Call      | Returns                                                                        |
| --------- | ------------------------------------------------------------------------------ |
| `summary` | The best bid and ask, last trade and volume of named markets or series.        |
| `book`    | The top `depth` levels of an open market, at most 50.                          |
| `trades`  | A market's trades, newest first, at most 1,000. It works for past markets.     |

If the server answers with an error, the call raises `MarketDataApiError`. It
holds `status_code` and `detail`. After a 503, `retry_after_s` says when to try
again.

## Development

```sh
uv sync
uv run pytest
```

`tests/fixtures/*.json` holds a sample of every message the exchange sends.
[test_fixtures.py](tests/test_fixtures.py) checks that the SDK can read all
of them.

To test a strategy without a network, build a feed with any series and book
level, set `feed.markets` yourself, and pass messages to `feed.apply()`. Use
the samples in `tests/fixtures/marketdata.json` as a guide to their shape.

## License

Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
