Metadata-Version: 2.4
Name: olive-solana-sdk
Version: 1.2.1
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/v2/ws` protocol
(`protocolVersion` `2.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 only to a session that declared the `maker.routing_notifications`
  capability in `auth.respond`: `ManagedMaker` always declares it, and
  `MakerWsClientOptions.capabilities` (default none) declares it for the low-level
  client, which should list it only if `on_event` handles both methods. A client
  that declares nothing (SDK 1.1.0) still has routing paused and sees it in
  `maker.capacity`. A gateway that predates the field rejects `auth.respond` with
  `INVALID_PARAMS`; the client then retries once without it, for that connection only
  (`on_status` reports `capabilities_unavailable`, and `capabilities_accepted` when a later
  connection has them again). 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,
            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.

## 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))` 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/v2/ws` with `protocolVersion` `2.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.
