Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 81 additions & 7 deletions .github/workflows/spec-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ jobs:
> /tmp/drift.log 2>&1 || true
# Keep the last 200 lines so the body stays inside GitHub's limits.
tail -n 200 /tmp/drift.log > /tmp/drift.tail
# Failing-set fingerprint: sha256 over the sorted, unique failing test
# IDs. Deterministic upstream yields an identical set every night, so
# the tracker step below re-comments only when this fingerprint CHANGES
# (kills the daily "Still failing" spam that made #467 noisy).
grep -oE '^FAILED [^ ]+' /tmp/drift.log | awk '{print $2}' | sort -u \
> /tmp/fail_ids.txt || true
fail_fp=$(sha256sum /tmp/fail_ids.txt | awk '{print $1}')
echo "fail_fp=${fail_fp}" >> "$GITHUB_OUTPUT"

- name: Ensure spec-drift label exists
env:
Expand All @@ -100,11 +108,12 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
FAIL_FP: ${{ steps.repro.outputs.fail_fp }}
run: |
set -euo pipefail

# Stable marker (NOT a content fingerprint): exactly one open tracker
# at a time. Re-runs append a dated comment instead of duplicating.
# at a time. Re-runs comment only when the FAILING SET changes.
readonly MARKER='nightly-spec-drift-tracker'
today=$(date -u '+%Y-%m-%d')

Expand All @@ -120,34 +129,99 @@ jobs:
# heredoc) so upstream-sourced log text is never shell-expanded.
{
printf '## Nightly strict contract tests failed\n\n'
printf 'The scheduled `Spec Drift Detection` run promoted additive drift to an error.\n'
printf 'This means upstream OpenAPI/AsyncAPI changed and the SDK contract suite no longer matches.\n\n'
printf '**To resolve:** locally run `uv run python scripts/sync_spec.py` + `uv run python scripts/generate.py`, reconcile models/maps, and open a PR with `Closes #<this issue>`.\n\n'
printf 'The scheduled `Spec Drift Detection` run found the SDK contract suite no longer matches upstream OpenAPI/AsyncAPI.\n\n'
printf '**To resolve:** locally run `uv run python scripts/sync_spec.py` + `uv run python scripts/generate.py`, reconcile models/maps, and open a PR with `Closes #<this issue>`. This tracker auto-closes on the next green scheduled run.\n\n'
printf '**To silence** while a reconcile is in flight, add the `spec-drift-ack` label — nightly runs then stop commenting until the failing set changes.\n\n'
printf -- '- Failing run: %s\n\n' "${RUN_URL}"
printf '### Failing test output (last 200 lines)\n\n```\n'
cat /tmp/drift.tail
printf '\n```\n'
} > /tmp/body.md

if [ -n "${existing}" ]; then
# Ack/snooze gate: a maintainer already on this drift can add the
# `spec-drift-ack` (or `snoozed`) label to silence nightly noise
# without closing the tracker.
labels=$(gh issue view "${existing}" --repo "${GITHUB_REPOSITORY}" \
--json labels --jq '[.labels[].name] | join(",")')
case ",${labels}," in
*,spec-drift-ack,*|*,snoozed,*)
echo "Tracker #${existing} is ack'd/snoozed; not commenting."
exit 0 ;;
esac

# Only comment when the FAILING SET changed. A deterministic upstream
# produces an identical set every night; re-posting it is spam.
last_fp=$(gh issue view "${existing}" --repo "${GITHUB_REPOSITORY}" \
--json body,comments --jq '[.body, (.comments[].body)] | .[]' \
| grep -oE 'fail-fingerprint:[0-9a-f]+' | tail -1 | cut -d: -f2 || true)
if [ "${last_fp}" = "${FAIL_FP}" ]; then
echo "Failing set unchanged (fingerprint ${FAIL_FP}); no new comment."
exit 0
fi

{
printf 'Still failing as of **%s** ([run](%s)).\n\n' "${today}" "${RUN_URL}"
printf 'Failing set changed as of **%s** ([run](%s)).\n\n' "${today}" "${RUN_URL}"
printf '### Latest failing output (last 200 lines)\n\n```\n'
cat /tmp/drift.tail
printf '\n```\n'
printf '\n<!-- fail-fingerprint:%s -->\n' "${FAIL_FP}"
} > /tmp/comment.md
echo "Tracker already open as #${existing}; appending dated comment."
echo "Failing set changed (${last_fp:-none} -> ${FAIL_FP}); appending comment."
gh issue comment "${existing}" \
--repo "${GITHUB_REPOSITORY}" \
--body-file /tmp/comment.md
exit 0
fi

# Hidden marker so the dedup query above matches future runs.
# Hidden markers so future runs match this tracker (MARKER) and can
# diff the failing set (fail-fingerprint).
printf '\n<!-- spec-drift-bot: do not edit\n%s\n-->\n' "${MARKER}" >> /tmp/body.md
printf '<!-- fail-fingerprint:%s -->\n' "${FAIL_FP}" >> /tmp/body.md

gh issue create \
--repo "${GITHUB_REPOSITORY}" \
--label spec-drift \
--title "Nightly spec-drift: strict contract tests failing (since ${today})" \
--body-file /tmp/body.md

# Companion to report-failure: when a scheduled strict run goes GREEN, the
# drift has been reconciled — close the open nightly tracker so it doesn't
# linger open forever (the old workflow only ever opened/commented). Least
# privilege: issues:write lives only on this job, and it runs no third-party
# actions and no untrusted upstream code (pure `gh` against the repo), so it
# needs neither a checkout nor SHA-pinning.
report-success:
needs: drift-check
if: success() && github.event_name == 'schedule'
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Close resolved nightly drift tracker
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
readonly MARKER='nightly-spec-drift-tracker'
today=$(date -u '+%Y-%m-%d')

existing=$(gh issue list \
--repo "${GITHUB_REPOSITORY}" \
--state open \
--label spec-drift \
--limit 200 \
--json number,body \
--jq '[.[] | select(.body | contains("'"${MARKER}"'"))] | first | .number // empty')

if [ -z "${existing}" ]; then
echo "No open nightly tracker; nothing to close."
exit 0
fi

gh issue close "${existing}" \
--repo "${GITHUB_REPOSITORY}" \
--reason completed \
--comment "Resolved — strict contract suite green as of ${today} ([run](${RUN_URL})). Auto-closed by the nightly spec-drift workflow."
42 changes: 37 additions & 5 deletions .github/workflows/spec-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -225,14 +225,16 @@ jobs:
"${PERPS_ASYNCAPI_SHA}" "${PERPS_SCM_SHA}" | sha256sum | awk '{print $1}')
export FINGERPRINT

# Dedup: skip if an open spec-drift issue already embeds this fingerprint.
# Dedup: skip if an open weekly (spec-sync-bot) drift issue already
# embeds this fingerprint. Scoped to spec-sync-bot so the nightly
# tracker (spec-drift-bot) is never mistaken for a weekly snapshot.
existing=$(gh issue list \
--repo "${GITHUB_REPOSITORY}" \
--state open \
--label spec-drift \
--limit 200 \
--json number,body \
--jq '[.[] | select(.body | contains("fingerprint:'"${FINGERPRINT}"'"))] | first | .number // empty')
--jq '[.[] | select(.body | contains("spec-sync-bot")) | select(.body | contains("fingerprint:'"${FINGERPRINT}"'"))] | first | .number // empty')

if [ -n "${existing}" ]; then
echo "Drift fingerprint ${FINGERPRINT} already tracked in issue #${existing}; nothing to do."
Expand All @@ -246,10 +248,40 @@ jobs:
python3 scripts/render_drift_body.py > "${body_file}"

today=$(date -u '+%Y-%m-%d')
title="Spec drift ${today}: openapi ${OLD_VERSION} → ${NEW_VERSION}"
short_fp=${FINGERPRINT:0:8}
# Lead the title with a CONTENT signal, not just info.version: Kalshi
# changes spec content in place without bumping the version, so a bare
# "3.23.0 → 3.23.0" reads like a no-op false alarm. Tag content-only
# changes and always surface the path delta + short fingerprint.
if [ "${OLD_VERSION}" = "${NEW_VERSION}" ]; then
title="Spec drift ${today}: openapi ${NEW_VERSION} [content-changed] (paths ${OLD_PATH_COUNT}→${NEW_PATH_COUNT}, fp ${short_fp})"
else
title="Spec drift ${today}: openapi ${OLD_VERSION} → ${NEW_VERSION} (paths ${OLD_PATH_COUNT}→${NEW_PATH_COUNT}, fp ${short_fp})"
fi

gh issue create \
new_url=$(gh issue create \
--repo "${GITHUB_REPOSITORY}" \
--label spec-drift \
--title "${title}" \
--body-file "${body_file}"
--body-file "${body_file}")
echo "Opened ${new_url}"

# Supersede older weekly drift snapshots whose fingerprint no longer
# matches live upstream — keep exactly one open weekly issue instead of
# piling up one per upstream micro-revision. Scoped to spec-sync-bot so
# the nightly tracker is never touched here; the freshly-created issue
# carries the current fingerprint and is therefore excluded.
stale=$(gh issue list \
--repo "${GITHUB_REPOSITORY}" \
--state open \
--label spec-drift \
--limit 200 \
--json number,body \
--jq '[.[] | select(.body | contains("spec-sync-bot")) | select((.body | contains("fingerprint:'"${FINGERPRINT}"'")) | not) | .number] | .[]')
for n in ${stale}; do
gh issue close "${n}" \
--repo "${GITHUB_REPOSITORY}" \
--reason "not planned" \
--comment "Superseded by ${new_url} — upstream specs moved again, so this drift snapshot is stale. Reconcile against current upstream."
echo "Superseded #${n}"
done
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,56 @@

All notable changes to kalshi-sdk will be documented in this file.

## 7.0.0 — 2026-07-10

Syncs the upstream OpenAPI/AsyncAPI specs **3.23.0 → 3.24.0** (Closes #467, #470).
One **breaking** rename on the subaccounts surface, a soft-deprecation, three new
endpoints, and additive drift fixes. Kalshi changed spec content without always
bumping `info.version`, so this also absorbs in-place edits made under the same string.

### Changed (breaking)

- **Position-transfer price is now fixed-point dollars, not integer cents.** The
`price_cents` field/kwarg (integer cents, 0-100) on
`ApplySubaccountPositionTransferRequest`, `SubaccountTransfer`, and
`subaccounts.transfer_position()` (sync + async) is renamed **`price`** and
typed `OrderPrice` / `DollarDecimal` (fixed-point dollars, `Decimal`). Pass
`price=Decimal("0.50")` where you previously passed `price_cents=50`. The old
client-side cap (`le=100` cents) is gone — `OrderPrice` guards only
non-negativity and the `$0.0001` tick, leaving the upper bound to the server
(matches `CreateOrderRequest`). Upstream renamed the wire field `price_cents`
→ `price` (`FixedPointDollars`) in 3.24.0.

### Deprecated

- **`exchange.announcements()`** (sync + async) now emits a `DeprecationWarning`.
Kalshi removed `GET /exchange/announcements` and the `Announcement` schema from
the spec in 3.24.0, so the live endpoint 404s. The method and the `Announcement`
model are **retained** (soft-deprecated) pending confirmation the removal is
permanent — upstream has transiently dropped endpoints as publishing glitches
before (see #452). A future major release removes them once confirmed.

### Added

- **`communications.quotes.get_for_rfq(rfq_id, quote_id)`** (sync + async) —
`GET /communications/rfqs/{rfq_id}/quotes/{quote_id}`, the RFQ-scoped
get-a-quote (returns `GetQuoteResponse`, the same payload as the flat
`quotes.get`).
- **`klear.margin.active_obligations()`** (sync + async) —
`GET /margin/active_obligations`, all currently-active settlement obligations
(`GetActiveMarginObligationsResponse`; the plural sibling of the single-obligation
`active_obligation()`).
- **`klear.margin.settlement_estimate_by_asset_class()`** (sync + async) —
`GET /margin/settlement_estimate_by_asset_class`, next-settlement estimates keyed
by asset class (`GetSettlementEstimateByAssetClassResponse` +
`AssetClassSettlementEstimate`).
- **`SubaccountNettingConfig.exchange_index`** (`int`, required) — exchange index
of the subaccount.
- **`portfolio.balance()`** (sync + async) gains an optional **`exchange_index`**
query param — target a specific exchange shard (defaults to 0 server-side).
- **`ObligationEntry.asset_class`** (perps SCM / Klear; `AssetClassLiteral` =
`Literal["Crypto"]`, required) — asset class of the settlement obligation.

## 6.0.0 — 2026-07-04

Syncs the upstream OpenAPI/AsyncAPI specs **3.22.0 → 3.23.0** (#463). The
Expand Down
2 changes: 1 addition & 1 deletion kalshi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,4 +379,4 @@
"Withdrawal",
]

__version__ = "6.0.0"
__version__ = "7.0.0"
21 changes: 17 additions & 4 deletions kalshi/_contract_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ class ContractEntry:
spec_schema="MarketOrderbookFp",
notes="Spec uses 'MarketOrderbookFp', SDK uses 'Orderbook'",
),
ContractEntry(
sdk_model="kalshi.models.exchange.Announcement",
spec_schema="Announcement",
),
# Announcement intentionally unmapped: spec sync 3.24.0 removed the
# `Announcement` schema and GET /exchange/announcements. The SDK model +
# method are soft-deprecated (retained pending confirmation the removal is
# permanent), so they no longer have a spec schema to drift-check against.
# See _SOFT_DEPRECATED_MODELS in tests/test_contracts.py.
ContractEntry(
sdk_model="kalshi.models.historical.HistoricalCutoff",
spec_schema="GetHistoricalCutoffResponse",
Expand Down Expand Up @@ -827,6 +828,10 @@ class ContractEntry:
sdk_model="kalshi.perps.klear.models.margin.GetActiveMarginObligationResponse",
spec_schema="GetActiveMarginObligationResponse",
),
ContractEntry(
sdk_model="kalshi.perps.klear.models.margin.GetActiveMarginObligationsResponse",
spec_schema="GetActiveMarginObligationsResponse",
),
ContractEntry(
sdk_model="kalshi.perps.klear.models.margin.MarketSettlementEstimate",
spec_schema="MarketSettlementEstimate",
Expand All @@ -839,6 +844,14 @@ class ContractEntry:
sdk_model="kalshi.perps.klear.models.margin.GetSettlementEstimateResponse",
spec_schema="GetSettlementEstimateResponse",
),
ContractEntry(
sdk_model="kalshi.perps.klear.models.margin.AssetClassSettlementEstimate",
spec_schema="AssetClassSettlementEstimate",
),
ContractEntry(
sdk_model="kalshi.perps.klear.models.margin.GetSettlementEstimateByAssetClassResponse",
spec_schema="GetSettlementEstimateByAssetClassResponse",
),
ContractEntry(
sdk_model="kalshi.perps.klear.models.margin.GetSettlementBalanceResponse",
spec_schema="GetSettlementBalanceResponse",
Expand Down
33 changes: 21 additions & 12 deletions kalshi/models/subaccounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from pydantic import BaseModel, Field

from kalshi.types import DollarDecimal, StrictInt
from kalshi.types import DollarDecimal, OrderPrice, StrictInt


class CreateSubaccountRequest(BaseModel):
Expand Down Expand Up @@ -58,15 +58,20 @@ class ApplySubaccountTransferRequest(BaseModel):


class ApplySubaccountPositionTransferRequest(BaseModel):
"""Body for POST /portfolio/subaccounts/positions/transfer (spec v3.23.0).
"""Body for POST /portfolio/subaccounts/positions/transfer (spec v3.24.0).

Moves an open **position** (contracts) between subaccounts — distinct from
the cash-only :class:`ApplySubaccountTransferRequest`. ``price_cents`` is the
per-contract price in cents (``0``-``100``) used to set the cost basis on the
destination subaccount; ``count`` is the number of contracts and must be
positive. ``from_subaccount`` / ``to_subaccount`` use ``0`` for the primary
account; the SDK enforces only the lower bound (``ge=0``), leaving the upper
bound to the server (mirrors :class:`ApplySubaccountTransferRequest`).
the cash-only :class:`ApplySubaccountTransferRequest`. ``price`` is the
per-contract price in **fixed-point dollars** (``0``-``1.0``) used to set the
cost basis on the destination subaccount; ``count`` is the number of contracts
and must be positive. ``from_subaccount`` / ``to_subaccount`` use ``0`` for the
primary account; the SDK enforces only the lower bound (``ge=0``), leaving the
upper bound to the server (mirrors :class:`ApplySubaccountTransferRequest`).

Spec v3.24.0 renamed ``price_cents`` (integer cents) → ``price``
(``FixedPointDollars``); pass a ``Decimal`` in dollars, e.g. ``Decimal("0.50")``
for a 50¢ cost basis. Uses :data:`~kalshi.types.OrderPrice` so a negative or
sub-$0.0001-tick value fails at construction rather than as a server 400.
"""

client_transfer_id: UUID
Expand All @@ -75,7 +80,7 @@ class ApplySubaccountPositionTransferRequest(BaseModel):
market_ticker: str
side: Literal["yes", "no"]
count: StrictInt = Field(gt=0)
price_cents: StrictInt = Field(ge=0, le=100)
price: OrderPrice

model_config = {"extra": "forbid"}

Expand Down Expand Up @@ -128,8 +133,10 @@ class SubaccountTransfer(BaseModel):

Spec v3.23.0 split transfers into two kinds via ``transfer_type``: ``cash``
(money moved; ``amount_cents`` set) and ``position`` (contracts moved). The
``market_ticker`` / ``side`` / ``count`` / ``price_cents`` fields are
populated only for ``position`` transfers, so they are optional here.
``market_ticker`` / ``side`` / ``count`` / ``price`` fields are populated only
for ``position`` transfers, so they are optional here. Spec v3.24.0 renamed the
per-contract ``price_cents`` (integer cents) → ``price``
(``FixedPointDollars``), surfacing as a ``Decimal`` in dollars.
"""

transfer_id: str
Expand All @@ -144,7 +151,7 @@ class SubaccountTransfer(BaseModel):
market_ticker: str | None = None
side: Literal["yes", "no"] | None = None
count: int | None = None
price_cents: int | None = None
price: DollarDecimal | None = None

model_config = {"extra": "allow"}

Expand All @@ -163,6 +170,8 @@ class SubaccountNettingConfig(BaseModel):

subaccount_number: int
enabled: bool
# Spec v3.24.0: exchange index of the subaccount (required).
exchange_index: int

model_config = {"extra": "allow"}

Expand Down
Loading