Metadata-Version: 2.4
Name: olive-solana-sdk
Version: 1.3.0
Summary: Async Python SDK for market makers quoting Olive Solana options
Project-URL: Documentation, https://olive.ag/docs
Project-URL: Repository, https://github.com/macols77/olive
Project-URL: Issues, https://github.com/macols77/olive/issues
Author: Olive
License-Expression: MIT
License-File: LICENSE
Keywords: market-maker,olive,options,rfq,solana
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: jsonschema<5,>=4.23
Requires-Dist: solders<1,>=0.26
Requires-Dist: websockets<18,>=15
Provides-Extra: dev
Requires-Dist: build<2,>=1.2; extra == 'dev'
Requires-Dist: mypy<2,>=1.15; extra == 'dev'
Requires-Dist: psycopg[binary,pool]<4,>=3.2; extra == 'dev'
Requires-Dist: pytest-asyncio<2,>=0.25; extra == 'dev'
Requires-Dist: pytest<10,>=8.3; extra == 'dev'
Requires-Dist: ruff<1,>=0.11; extra == 'dev'
Requires-Dist: twine<7,>=6; extra == 'dev'
Requires-Dist: types-jsonschema<5,>=4.23; extra == 'dev'
Provides-Extra: postgres
Requires-Dist: psycopg[binary,pool]<4,>=3.2; extra == 'postgres'
Description-Content-Type: text/markdown

# Olive Solana Python SDK

Olive's Long Calls and Long Puts are cash-settled: both pay intrinsic value in
USDC, and holders never supply an exercise payment.

This SDK implements the V12 program and the native `/maker/v3/ws` protocol
(`protocolVersion` `3.0`) and nothing else. There is no translation or
compatibility layer: 0.x clients are refused by the server, and quotes signed
for earlier programs cannot be carried forward.

## How V12 margining works

- **One entry route.** Every entry quote is signed under the single domain
  `OLIVE_ENTRY_QUOTE_V12`. There is no JIT route, no `collateralMode`, no
  `entry_collateral_modes` subscription preference and no `signing.routes`.
- **Margin requirement, not maximum payout.** Each bucket holds a
  price-driven requirement (`risk.bucket_requirement`): every strike slot's
  time-value add-on (`addon`) plus the worse of the bucket's intrinsic payout
  at a shocked-up and a shocked-down price. That is **netting**: calls and puts
  in the same bucket offset, because only one side of a strangle can pay at
  expiry. Credit from your facility covers the unfunded part
  (`required - locked`); a maker with a zero credit limit simply prefunds
  buckets and every fill is covered by that prefunding.
- **Up to 8 buckets per expiry.** A maker can hold up to
  `MAX_BUCKETS_PER_EXPIRY` (8) buckets for one market and maturity, each with
  32 strike slots. Buckets are numbered by `index` (0 to 7): it is part of the
  bucket address (`bucket_address(program, maker, market, maturity, index)`)
  and appears on every bucket object in `maker.capacity` and on bucket events.
  Olive routes each fill to the bucket that already holds its strike, else to
  the open bucket where it adds the least unfunded exposure (so a bucket holding
  the opposite side is preferred), and opens the next bucket before the open
  ones fill up. Netting, deadlines and funding are per bucket; credit is shared
  across all of them through your facility.
- **24h funding deadline.** Credit stops backing a bucket 24 hours before
  maturity (`funding_deadline(maturity)`); by then `locked` must reach
  `required`. Fills inside the lead are prefund-only.
- **Warnings at the deadline, default only at the cutoff (1.2.0).** A bucket
  found short after the deadline starts a shortfall clock (`bucket.shortfall`)
  and warnings, not a default. On chain it becomes default-eligible (reportable;
  the maker is deactivated on every market) at
  `max(maturity - 3600, shortfallSince + 14400)`: the hard cutoff is one hour
  before maturity (`DEFAULT_CUTOFF_LEAD_SECONDS`, `default_cutoff(maturity)`)
  and the shortfall must be at least 4 hours old (`default_eligible_at`,
  `is_default_eligible`, `seconds_until_default_cutoff`, `bucket_timings`). A
  shortfall that first opens in the last 4 hours before maturity is never
  eligible before maturity (settlement handles it); one that opens between 5 h
  and 4 h before maturity becomes eligible between the cutoff and maturity. At the deadline, credit is denied, locked collateral only grows and the
  prefunding withdrawal rule applies. The cutoff is a
  backstop: keep funding before the 24h deadline.
- **Repeated warnings and the routing gate.** Olive sends `bucket.prefunding_required` from six hours before the deadline,
  then `bucket.funding_required` at the deadline and 12 h, 6 h, 3 h, 2 h and
  90 min before expiry while the bucket is short, plus one extra notice when the
  deficit has grown since the last one by at least the larger of 5% of the
  requirement and 100 USDC (the baseline only moves down), one per deposit that
  leaves the bucket short (with what is left), and one when the routing pause
  begins; each carries the current deficit,
  `secondsUntilCutoff` (negative after the cutoff) and `defaultEligibleAt`. A maker receives no
  new entry requests while any of its buckets is short for 4 hours past its
  deadline (`funding_shortfall`), Pending with a gap (`settlement_shortfall`) or
  in an open incident or unfinalized Haircut (`incident`); a position that gets
  worse does not end the pause, and it resumes only when none qualifies.
  `maker.capacity` reports `routing: {paused, reason?, since?}` on both scopes
  (`reason` and `since` absent, not null, when not paused) and
  `maker.routing_paused` / `maker.routing_resumed` (`on_routing_paused` /
  `on_routing_resumed`) announce it. Exits, funding, true-up and settlement continue. The notifications are
  sent to every session (the SDK always declares `maker.routing_notifications`;
  there is no opt-out and no fallback). These events are durable
  but are not money-at-risk and need no handler. `ManagedMaker` reports
  `funding_warning`, `routing_paused` and `routing_resumed` through `on_status`
  without any handler, and does not fund buckets itself (neither SDK's managed
  maker does): funding is your own code. React to `on_prefunding_required`,
  `on_funding_required` and `on_routing_paused` by building a
  `fund_bucket_instruction` for the notice's `deficit`, signing it with the
  funding authority and sending it (`bucket_timings` gives the deadline and cutoff). The fields added in 1.2.0 are optional when
  decoding, so the SDK reads a gateway that predates them.
- **4h true-up.** A bucket that settles short enters `pending` with a 4-hour
  true-up window (`bucket.true_up_pending`, `true_up_deadline`). Fund
  `remaining` before it lapses; afterwards insurance is drawn
  (`insurance.drawn`) and holders are paid pro rata (`haircut`).
- **Headroom.** `maker.capacity` (scope `all`) returns your `facility`
  (`creditLimit`, `unfundedExposure`, `availableCredit`, `breached`), each
  market's risk parameters and price, and every bucket's `required`, `locked`,
  `prefunded`, `index`, slots (`product`, `strikeE8`, `qty`, `addon`) and
  deadlines. `capacity_entry_route(capacity, ...)` tells you which bucket Olive
  would route a fill to, or why none can take it (`bucket_slots_full`,
  `insufficient_credit`, `funding_bucket_unavailable`).
  `capacity_max_size(capacity, ...)` runs the program's entry credit check
  through the risk mirror over every open bucket to give the largest size you
  can still fill for one strike. `credit.breached` reports a facility whose
  unfunded exposure exceeds its limit after a price move; only fills that add
  unfunded exposure are refused.

`olive_solana.lifecycle` builds the bucket instructions (`initialize_bucket_instruction`,
`fund_bucket_instruction`, `withdraw_bucket_funds_instruction`, `refresh_bucket_instruction`,
`settle_bucket_instruction`, `record_pyth_settlement_instruction`, `redeem_position_instruction`,
`report_bucket_incident_instruction`, `fund_insurance_instruction`,
`finalize_defaulted_bucket_instruction`, and the close instructions);
`olive_solana.pdas` derives the canonical facility, bucket, bucket token
account and insurance addresses. Sign and send them with your own custody.

Which Pyth feeds Olive supports is registered on chain (1.1.0): `olive_solana.feeds`
decodes the `FeedConfig` account and the `FeedRegistered` / `FeedClosed` events
(`decode_feed_config`, `decode_feed_registered_event`, `decode_feed_closed_event`),
encodes and validates the 12-byte symbol (`encode_feed_symbol`, `decode_feed_symbol`)
and builds the admin `register_feed_instruction` and `close_feed_instruction`;
`feed_config_address` derives the account. Makers that only trade are unaffected.

`ManagedMaker` is the recommended API. Maker code supplies entry premium or exit
amount and funding source; the SDK validates requests, binds canonical quote
fields, allocates durable quote IDs and entry nonces, signs through a local/HSM callback,
persists before submission, reconnects, and durably routes lifecycle events.

## Install

```bash
# From this repository:
pip install -e "packages/python-sdk[postgres]"

# After the distribution is published:
pip install "olive-solana-sdk[postgres]"
```

Python 3.11 or newer is required. Create a new store once by constructing
`PostgresMakerStore(database_url, schema_mode="migrate")` with a DDL-capable
role and awaiting `store.initialize()`, then `store.close()`, or apply the
packaged `schemas/postgres-maker-store-v2.sql`. Use `schema_mode="verify"` for
production starts. The SDK reads schema version 2 stores only.
`InMemoryMakerStore` is test-only.

The new-store schema is also available programmatically with
`postgres_maker_store_schema()` so deployment tooling does not need to locate
the installed wheel's data directory.

## Managed maker quickstart

```python
import asyncio
import os
import time

from olive_solana import (
    DeploymentIdentity,
    LocalKeypairMakerSigner,
    ManagedMaker,
    ManagedMakerEventHandlers,
    ManagedMakerOptions,
    PostgresMakerStore,
    capacity_entry_route,
    entry_strike_grid_warning,
)


async def main() -> None:
    signer = LocalKeypairMakerSigner.from_json_file(
        os.environ["OLIVE_QUOTE_SIGNER_KEYPAIR"]
    )
    store = PostgresMakerStore(
        os.environ["DATABASE_URL"], schema_mode="verify"
    )

    async def entry(rfq):
        request = rfq.request
        # Olive only sends on-grid strikes; this is a cheap defensive check.
        if entry_strike_grid_warning(request) is not None:
            return rfq.decline("strike_off_grid")
        capacity = await maker.client.capacity("all")
        route = capacity_entry_route(
            capacity,
            market_config=request["marketConfig"],
            maturity=int(request["maturity"]),
            product=request["product"],
            strike_e8=int(request["strikeE8"]),
            size=int(request["size"]),
            now_seconds=int(time.time()),
        )
        if route.bucket is None:
            # RFQs are all-or-nothing: no bucket can back the full size.
            return rfq.decline(route.reason or "insufficient_credit")
        premium = await price_entry(request)
        return (
            rfq.decline("outside_risk_limits")
            if premium is None
            else rfq.quote(premium_usdc=premium)
        )

    async def exit_(rfq):
        amount = await price_exit(rfq.request)
        return (
            rfq.decline("outside_risk_limits")
            if amount is None
            else rfq.quote(
                exit_amount_usdc=amount,
                usdc_source=os.environ["OLIVE_EXIT_USDC_SOURCE"],
            )
        )

    maker = ManagedMaker(
        ManagedMakerOptions(
            url=os.environ["OLIVE_MAKER_WS_URL"],
            maker_config=os.environ["OLIVE_MAKER_CONFIG"],
            expected_deployment=DeploymentIdentity(
                program_id=os.environ["OLIVE_PROGRAM_ID"],
                genesis_hash=os.environ["OLIVE_GENESIS_HASH"],
                chain_tag=os.environ["OLIVE_CHAIN_TAG"],
            ),
            signer=signer,
            store=store,
            assets=("btc", "sol"),  # the topics this session owns
            topics=("btc", "sol"),
            products=(0, 1),
            on_entry_rfq=entry,
            on_exit_rfq=exit_,
            events=ManagedMakerEventHandlers(
                # Selection is feedback, not execution.
                on_entry_selected=record_selection,
                # Only finalized fills trigger hedging/reconciliation.
                on_entry_filled=hedge_finalized_entry,
                on_exit_filled=reconcile_finalized_exit,
                # Money-at-risk margin events need a handler (or on_event).
                on_prefunding_required=fund_before_deadline,
                on_funding_required=fund_before_deadline,
                on_shortfall=top_up_within_grace,
                on_true_up_pending=true_up_before_deadline,
                on_default=page_risk,
                on_incident=page_risk,
                on_credit_breached=alert_credit_breach,
                on_settlement=record_settlement,
            ),
        )
    )
    try:
        await maker.run()
    finally:
        await store.close()


asyncio.run(main())
```

`capacity_entry_route` answers for the whole size: before the 24h deadline it
counts facility credit plus the bucket's prefunding; inside it, prefunding only.
The quickstart leaves `price_entry`, `price_exit` and the event handlers to you.

Lifecycle delivery is at least once. Make every external side effect idempotent
by `eventId`; a process can stop after performing the side effect but before the
SDK records local handler completion. Prefunding, funding, shortfall,
true-up, default and incident events (`on_prefunding_required`,
`on_funding_required`, `on_shortfall`, `on_true_up_pending`, `on_default`,
`on_incident`) must have explicit handlers unless the generic `on_event`
handler covers them; otherwise the event is never acknowledged, and Olive
refuses the maker new entries while one is unacknowledged. The other margin
events (`bucket.refreshed`, `bucket.funded`, `bucket.settled`,
`insurance.drawn`, `credit.breached`) have optional handlers.

Two off-chain events report the routing gate:

| Event | Handler | Gates entries | Meaning |
| --- | --- | --- | --- |
| `maker.routing_paused` | `on_routing_paused` | no (the pause itself stops new entry requests) | A bucket is short for 4h past its funding deadline (`reason` `"funding_shortfall"`), Pending with a gap (`"settlement_shortfall"`) in an incident (`"incident"`) or held after a cure while the maker is still deactivated (`"maker_inactive"`); an open set, treat an unknown reason as paused, with `bucket`, `marketConfig`, `maturity`, `shortfallSince`, `deficit` and `pausedAt`. Sent again if the reason changes. Fund it now (your code does that). |
| `maker.routing_resumed` | `on_routing_resumed` | no | None of the buckets qualifies any more: covered, settled Solvent, or the incident cured or finalized (`resumedAt`). Never sent for a pause whose paused notification you did not receive. |

Their typed payloads are `RoutingPaused` and `RoutingResumed`.

Six finalized chain events report custody incidents and parameter changes:

| Event | Handler | Gates entries | Meaning |
| --- | --- | --- | --- |
| `bucket.incident` | `on_incident` (required) | yes, until acknowledged | The bucket's token account is frozen, or its live balance (`available`) is below its accounted obligation (`obligation`, equal to `required`). `defaulted` is always `False`; a default arrives as `bucket.defaulted`. The program deactivates the maker until the bucket is whole and a report cures it (`bucket.cured`). |
| `bucket.default_finalized` | `on_default_finalized` | no | A Haircut bucket was fully redeemed and finalized (`dustReturned`, `insuranceDrawn`); any open incident on it is cleared. |
| `facility.changed` | `on_facility_changed` | no | The facility's `active` flag or `creditLimit` changed; refresh cached credit headroom. |
| `maker.status_changed` | `on_maker_status_changed` | no | The maker's on-chain `active` flag changed (`previousActive` to `active`). |
| `market.risk_changed` | `on_market_risk_changed` | no | A market's margin parameters (`risk`) changed; refresh cached `maker.capacity`. |
| `market.controls_changed` | `on_market_controls_changed` | no | A market's `active` flag, term bounds (`minTermSeconds`, `maxTermSeconds`) or notional `limits` changed. |

All six carry `transactionSignature`, `slot` and `occurredAtMs`; the two bucket
events also carry the bucket state fields. Their typed payloads are
`BucketIncident`, `BucketDefaultFinalized`, `FacilityChanged`,
`MakerStatusChanged`, `MarketRiskChanged` and `MarketControlsChanged`.

The local keypair in this quickstart is for laptop tests or a tightly controlled
key-file deployment. Production remote/HSM integrations use
`CallbackMakerSigner` and never pass private-key bytes to the maker process:

```python
from olive_solana import CallbackMakerSigner, SignerContext


async def sign_with_remote_provider(
    digest: bytes,
    context: SignerContext,
) -> bytes:
    if len(digest) != 32:
        raise ValueError("expected a 32-byte Olive digest")
    signature = await remote_provider.sign_ed25519(
        key_id="olive-quote-signer",
        message=digest,
        audit_context={"purpose": context.purpose},
    )
    if len(signature) != 64:
        raise ValueError("expected a 64-byte Ed25519 signature")
    return signature


signer = CallbackMakerSigner(
    public_key="<remote Ed25519 public key in canonical base58>",
    callback=sign_with_remote_provider,
)
```

The callback signs the supplied raw 32-byte `SHA-256(preimage)` digest with
ordinary Ed25519 and returns the raw 64-byte signature. Do not hash again, sign
encoded text/the full preimage, or use Ed25519ph. The remote public key may be
the finalized on-chain `MakerConfig.quoteSigner`.

An empty topic or product collection subscribes to everything authorized by
`maker.hello`. Keep `takeover_existing_session=False` for ordinary starts; enable
it only for an intentional failover.

After a silent network drop the gateway keeps your session until its lease lapses
(about 90 seconds), so an immediate reconnect gets `SESSION_ACTIVE`. Since 1.2.1 a
client that had a session of its own reports `session_release_wait` through
`on_status` and retries for the lease plus 30 seconds from the moment it lost the
connection; refusals past that, or on a first connect, still raise.

## One session per asset

Since 1.3.0 every session owns an explicit set of assets: `assets` (1-32 unique
lowercase topics) is required on `ManagedMakerOptions`, `MakerWsClientOptions` and
`MakerDoctorOptions` (`--assets` / `OLIVE_ASSETS` on the CLI), and `topics` must be a
subset. List every asset to quote them all in one session. There is no all-assets
mode. The gateway fixes the claim for the session's whole life: `maker.hello.assets`
lists it, and the client fails terminally if it differs from the request.

- Run one process per asset, each with its own store. The assets are always part of the
  store scope, so replay and pending submissions are separate per asset set. 1.3.0
  stores are not compatible with earlier ones: drain or discard an older store before
  upgrading.
- `SESSION_ACTIVE` names the sessions in the way in `conflicting_assets` on the
  `MakerWsRpcFailure` (the terminal error's `__cause__`) and in the
  `session_release_wait` status. `takeover_existing_session=True` replaces every
  overlapping session (close 4001), only for the assets you claim. The restart wait of
  1.2.1 works per asset session.
- Credit, funding and the routing pause stay maker-wide: `maker.capacity` shows
  `facility.reservedCredit` across all sessions, while its other rows and
  `maker.positions` are filtered to the session's assets.
- Stagger process starts by about one second. `FORBIDDEN` with `session_limit`
  reports `maxSessionsPerMaker`; `asset_not_claimed` means the session does not own
  the topic it used.

## Onboard from a server

If your signing key lives on a server, one command takes a maker from Olive's invitation to a passing readiness
check. Ask Olive's console for a server token (`olv_onb_...`), then:

```bash
export OLIVE_ONBOARDING_TOKEN=olv_onb_...   # or --token; the env var keeps it out of shell history
olive-maker-onboard --keypair ./maker.json --assets btc,sol
```

It registers the key as maker authority, quote signer and funding authority, waits for Olive's approval, claims
devnet test funds, approves an exit allowance (default: your USDC balance, or `--exit-allowance <usdc>`), writes
`olive.env` (0600, in `--out`, default the current directory), runs the doctor and sends its report to Olive.
Rerun it at any time: it reads the current state and continues. Before signing anything it decodes the message
itself and refuses unless it is exactly the expected registration (Olive admin pays, your key is the only other
signer, the Olive program, your own USDC account) or exactly one SPL `ApproveChecked` from your USDC account to
Olive's exit delegate. Other flags: `--api-url`, `--products 0,1`, `--yes` (skip the confirmation prompts, not the
checks), `--no-faucet`, `--rpc-url`, `--wait-timeout <seconds>`, `--json` (one JSON object per line).

By default one key does everything. Two options change where money and quoting authority sit:

- `--premium-receiver-owner <wallet>` pays premiums to that wallet's USDC account (any address, including an
  off-curve vault or multisig address). Olive creates the account in the same registration message, the wallet never
  signs, and the command checks the message creates exactly that account. Left out, premiums go to the funding
  authority.
- Separate keys: `--keypair` stays the **maker authority** and signs the registration. Name the others with
  `--quote-signer <pubkey>` (the hot key on the quoting server) and `--funding-authority <pubkey>` (the custody
  wallet that holds the funds); the command checks the message registers exactly those. It cannot sign for keys it
  does not hold, so in separate mode:
  - the exit allowance is skipped with a pointer to the console's "Sign with another tool" panel, unless you pass
    `--funding-keypair <path>` (it must match the funding authority; refused otherwise);
  - the readiness check runs only with `--quote-signer-keypair <path>` (it must match the quote signer); otherwise
    the command prints what to run on the quoting server;
  - `olive.env` names `--quote-signer-keypair` as `OLIVE_QUOTE_SIGNER_KEYPAIR` when given; otherwise it keeps
    Olive's placeholder, which you point at the hot key file. It never names the maker authority key in separate mode;
  - the test faucet pays the funding authority, so it still works without its key.

```bash
olive-maker-onboard --keypair ./authority.json --assets btc,sol \
  --quote-signer <HOT_PUBKEY> --funding-authority <VAULT_PUBKEY> --premium-receiver-owner <VAULT_PUBKEY>
```

Rerunning a separate-key maker needs the same `--quote-signer` / `--funding-authority` flags.

## Operational preflight

The doctor authenticates without subscribing, taking over a live maker, or
submitting quotes. Its convenience CLI intentionally uses a local test keypair:

```bash
olive-maker-doctor --topics btc,sol --products 0,1 \
  --exit-sources "$OLIVE_EXIT_USDC_SOURCE"

olive-maker-doctor --json > maker-doctor.json
```

Connection and deployment values default from `OLIVE_MAKER_WS_URL`,
`OLIVE_MAKER_CONFIG`, `OLIVE_PROGRAM_ID`, `OLIVE_GENESIS_HASH`,
`OLIVE_CHAIN_TAG`, and `OLIVE_QUOTE_SIGNER_KEYPAIR`.

By default the doctor reads `maker.capacity` with scope `all` and reports
`credit_facility` (limit, unfunded exposure and available credit; fail when
breached, warning with no active credit), `bucket_margin` (requirement versus
locked and prefunded per open bucket; fail with the default-eligible time when a
shortfall is running, warning when a bucket still needs funding before its
deadline), `routing` (fail while Olive has paused new entry requests) and
`true_up` (fail while a settled bucket owes a true-up). The same
checks are available as `margin_checks(capacity, now_seconds)`. Pass
`--exit-only` (`exit_only=True`) to read only the exit-payment scope.

Remote/HSM deployments MUST NOT export their private key to use the CLI. Call
`run_maker_doctor(MakerDoctorOptions(..., signer=signer, assets=["btc"]))` programmatically with
the same `CallbackMakerSigner` used by the managed maker, and retain
`report.to_dict()` as the machine-readable preflight record.

## Strike grid

Entry strikes sit on a fixed grid of round levels per market. The step depends
on how far the strike is from the request's reference price (`referencePriceE8`):

| Market | within 5% | 5% to 15% | beyond 15% |
| --- | --- | --- | --- |
| BTC | $1,000 | $2,000 | $5,000 |
| SOL | $1 | $2 | $5 |
| GOLD / PAXG | $25 | $50 | $100 |
| SP500 / SPYX | $5 | $10 | $10 (one tier beyond 5%) |

- Symbols are case-insensitive, and both the app names (GOLD, SP500) and the
  on-chain names (PAXG, SPYX) work.
- **Re-striking.** If the price falls so far that the finest step is wider than
  5% of spot, every step is divided by ten until it fits: SOL at $15 strikes on
  $0.10 / $0.20 / $0.50, BTC at $15,000 on $100 / $200 / $500.
- **Boundary tolerance.** Near a tier boundary the API accepts a strike that is
  a multiple of either neighbouring tier's step, within 1 percentage point
  (`STRIKE_GRID_SLACK_PCT`). With BTC at $98,000, $103,000 is 5.1% out, where
  the step is $2,000, but it is accepted because it is within 1 point of the
  $1,000 tier.
- A market without a configured grid gets one tier with a 1-2-5 step near 0.1%
  of spot.

The API rejects an off-grid entry RFQ (`INVALID_STRIKE`, reason
`strike_off_grid`), so a maker should never receive one. The SDK lets you
check anyway, with exact decimal arithmetic (no binary floats):

```python
from olive_solana import (
    STRIKE_GRID_SLACK_PCT, entry_strike_grid_warning, grid_levels, grid_step,
    nearest_strike, on_grid, strike_grid_for,
)

grid = strike_grid_for("BTC", "112480")          # tuple of StrikeTier(within_pct, step)
on_grid(grid, "112480", "113000", STRIKE_GRID_SLACK_PCT)   # True
grid_step(grid, "112480", "130000")              # Decimal('5000')
nearest_strike(grid, 0, "112480")                # Decimal('113000'), call side
nearest_strike(grid, 1, "112480")                # Decimal('112000'), put side
grid_levels(grid, "112480", "100000", "125000")  # every level in the range

entry_strike_grid_warning(rfq.request)           # None when on grid, else a message
```

Prices can be `Decimal`, `int`, `str`, `Fraction` or (converted through their
decimal text) `float`. `entry_strike_grid_warning` reads `underlyingSymbol`,
`strikeE8` and `referencePriceE8` and judges the strike exactly as the API does.
The grid matches the TypeScript SDK; `schemas/strike-grid.golden.json` holds
the shared test vectors.

Conformance runs can pass the entry requests they received as
`run_maker_conformance_suite(..., observed_entry_requests=[...])`. Any off-grid
request is listed as a `[WARN]` line under `entry_quoting`; it does not fail
the report, because it points at a gateway or fixture problem, not at the
maker.

## Wire types and schema

The complete required/optional DTO catalog is published at
[`maker-websocket-v2.schema.json`](https://olive.ag/docs/schemas/maker-websocket-v2.schema.json)
and included in the wheel at
`olive_solana/schemas/maker-websocket-v2.schema.json`. Python exports typed RFQ,
quote, selected, filled, decline, and error/result shapes from `olive_solana`.
Missing required fields fail before maker callbacks run; unknown additive server
fields are preserved, while outbound quotes remain strict. The normative
semantics and signing preimages are in the
[Maker WebSocket API v2](https://olive.ag/docs/developers/maker-websocket-spec).

## Reliability contract

- Wire integers remain canonical decimal strings.
- Entry nonces are durable; V6 exit quotes have no wire nonce and are replay-safe
  because a successful exit closes the bound position.
- Complete quote, digest, and signature are committed before submission.
- Restart retries only the exact stored quote and signature.
- Durable events are stored before `event.ack`; local handler completion is
  persisted independently.
- `quote.selected` and `exit_quote.selected` are not fills. Only finalized
  `trade.filled` and `exit.filled` are canonical execution signals.
- Signatures and private signer data are never included in SDK status events.

The low-level `MakerWsClient` remains available for makers requiring custom
orchestration. Releases must pass the shared fixture/digest suite and all
fail-closed maker conformance scenarios.

## Development

```bash
python -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/ruff check .
.venv/bin/mypy src
.venv/bin/pytest
.venv/bin/python -m build
```

## Release

PyPI releases use trusted publishing from
`.github/workflows/publish-python-sdk.yml`; no long-lived API token is stored in
GitHub. Configure the PyPI publisher for repository `macols77/olive`, workflow
`publish-python-sdk.yml`, and environment `pypi`, and protect the `pypi`
environment with required reviewers.

Pushing a `python-sdk-v<pyproject version>` tag only builds and verifies; it
never uploads. The build job:

- requires the tag's commit to be on `origin/main` (merge first, then tag the
  merged commit);
- when a `coordinated_tag` (`solana-sdk-v*`) is given, requires it to point at
  the same commit, and otherwise warns with the TypeScript tags found on that
  commit;
- requires the SDK's program id to equal the program's `declare_id!` and to be
  live (executable) on devnet;
- requires the tag to equal `python-sdk-v<pyproject version>`;
- runs ruff, mypy and pytest, builds and `twine check`s the wheel and sdist,
  and reruns the tests against the built wheel in a clean environment.

To publish, run the workflow manually (**Run workflow**) with `release_tag` set
to the existing tag, `publish` typed as `PUBLISH`, and `coordinated_tag` set to
the sibling TypeScript tag for a coordinated release. The publish job then waits
for the `pypi` environment's reviewers and uploads both distributions. The same
dispatch retries a failed release.

## Upgrading from 0.x

- The program is V12. Entry quotes sign under `OLIVE_ENTRY_QUOTE_V12`
  (0.4.x had separate `..._CREDIT_ENTRY_QUOTE_V11` and `..._JIT_ENTRY_QUOTE_V11`
  domains); exit quotes stay `OLIVE_EXIT_QUOTE_V6`.
- The WebSocket is `/maker/v3/ws` with `protocolVersion` `3.0` (was
  `/maker/v1/ws`, `1.0`). The server refuses 0.x clients.
- `maker.capacity` rows are `topic`, `product`, `marketConfig`, `available`,
  `unavailableReasons` and `projectionSlot`: `collateralMode`, `facility`,
  `fundingMandate`, `collateralSource`, `sourceSlot`, `availableForNewQuotes`
  and the JIT limits are gone, and there is no `unavailableRoutes`. With scope `all` the result adds `facility`,
  `margin`, `markets` and `buckets`; size with `capacity_entry_route` or
  `capacity_max_size` instead of `availableForNewQuotes`.
- Bucket objects and bucket events carry `index` (0 to 7), and
  `bucket_address` takes it as a required argument. Do not assume one bucket
  per market and maturity.
- Bucket slots report `addon` (the slot's time-value add-on), not a per-slot
  requirement; the bucket's `required` is the netted total.
- Entry strikes are on the strike grid; see "Strike grid".
- Remove `entry_collateral_modes` and the `collateral_mode=` argument of
  `rfq.quote(...)`; the jit_funding module and its mandate helpers are gone.
- Entry RFQs carry `strikeNotionalUsdc` instead of `expectedMaxPayout` and no
  `underlyingMint`; exit RFQs and positions carry `strikeNotional` instead of
  `maxPayout`, and exit RFQs name their `bucket`. Positions report
  `marginStatus` and a `bucket` with `phase`, `required`, `locked`,
  `prefunded`, `unfunded` and `shortfallSince`.
- `trade.filled`, `exit.filled`, `position.redeemed` and `position.expired`
  report the V12 program events (bucket requirement and facility exposure,
  released notional, gross payout and haircut phase).
- Implement `on_incident` (or `on_event`): `bucket.incident` is a
  money-at-risk event, and an unhandled one is never acknowledged and blocks
  new entries. The five informational events (`bucket.default_finalized`,
  `facility.changed`, `maker.status_changed`, `market.risk_changed`,
  `market.controls_changed`) have optional handlers.
- PostgreSQL stores keep schema version 2. Quotes signed before the V12 program
  do not verify: start a fresh store for the V12 deployment.
