Skip to content

Instrument expiry lifecycle, self-sufficient trading token, Python SDK - #22

Merged
maxkuttner merged 7 commits into
mainfrom
feat/expiry-lifecycle-and-python-sdk
Aug 15, 2026
Merged

Instrument expiry lifecycle, self-sufficient trading token, Python SDK#22
maxkuttner merged 7 commits into
mainfrom
feat/expiry-lifecycle-and-python-sdk

Conversation

@maxkuttner

@maxkuttner maxkuttner commented Aug 14, 2026

Copy link
Copy Markdown
Owner

No description provided.

Max added 7 commits August 7, 2026 11:48
The /orders/submit and other user endpoints go through auth_middleware, which
accepts both Basic (key_id:secret) and a Bearer trading token (key_id.secret),
but their utoipa annotations only declared basic_auth — so Scalar/Swagger showed
username:password as the sole auth option. Declare bearer_token alongside
basic_auth on all six user endpoints and add descriptions to both schemes.

Also add #[schema(example = ...)] to SubmitOrderRequest fields so the generated
docs show a runnable body (valid UUIDs, BIGINT instrument_id) instead of empty
placeholder strings.
The hardcoded SPY 260717 contracts expired on 2026-07-17, so the gateway
answered SymbolResolutionFailed for both. Live symbology only resolves
currently-listed contracts; the historical API still has them, which is why
this looks like a data gap and is not one.

Defaults now point at the Sep-2026 monthly ATM straddle, verified listed
against the OPRA definition schema. They will go stale too, so symbols are
overridable with OPRA_SYMBOLS and the header carries the curl that lists
what is actually listed today.
Without an explicit expiration_date_gte, /v2/options/contracts returns only
the nearest one or two expiries *and* omits next_page_token — so the
pagination loop saw a complete response and stopped. Every far-dated
contract was silently missing.

Masked until now because the retired Databento-sourced rows did carry the
far expiries, so the catalog looked complete while the broker path had been
truncating since July. A SPY sync goes from 720 contracts across 2 expiries
to 14,368 across 36, out to 2028-12-15.
setup::catalog drops already-expired contracts at ingest, but nothing aged a
row out afterwards: an option seeded while live stayed ACTIVE forever once
its expiry passed, and re-running the sync could not help — it is an upsert,
so a contract that stops being listed leaves its stale row untouched. The
quote feed kept resubscribing to dead OSI symbols and the order path kept
accepting them.

expiry_date could not answer the question on its own. It is a bare DATE, and
the value is a venue-local calendar date (Alpaca sends "YYYY-MM-DD"), so
comparing it to current_date silently depends on the DB server's timezone —
and an option trades until 16:00 local on that date, not until midnight.

So the timezone lives on the venue's calendar, not per broker: instrument is
keyed (symbol, venue) and shared across brokers, so a broker-scoped timezone
would give one row two different UTC expiries. calendar.timezone was already
declared the authority in 0001; it was simply never seeded or read. The
conversion runs in Postgres, which carries the IANA database — 16:00 New
York is 20:00Z in summer and 21:00Z in winter, so a stored offset would be
wrong for half the year.

expiry_date stays as the OSI truth; expires_at is derived beside it. The
sweep then needs no timezone reasoning at all: expires_at < now().

Retiring an instrument is all this does. The existing status = 'ACTIVE'
filters on the order and subscription paths do the rest, so nothing else
became expiry-aware. What happens to an open order or a held position when
its contract expires is deliberately left alone — the broker cancels the
order and the custodian resolves the position, and neither is something the
OMS should infer. Preflight reports a stranded position rather than acting
on one.

Backfill lives in the sweep rather than a migration because db-migrate runs
before db-seed: a migration would join an empty calendar and update nothing.
Recomputing on every pass also re-heals after a calendar is corrected.

Verified against the live catalog: 28,990 contracts dated, 13,138 retired,
DST boundary confirmed (2026-09-18 -> 20:00Z, 2026-12-18 -> 21:00Z), and a
HALTED contract with a past expiry left untouched.
…iews

A trading token could not actually trade. Submit takes instrument_id as a
stringified BIGINT, and the only symbol->id lookups were /admin/instruments
and /admin/symbology/resolve; listing orders meant /admin/orders. Closing
either gap with the admin token was not an option — that one shared secret
also mints trading tokens and edits risk limits, so a trading script would
have been able to issue itself credentials and disable the guardrails meant
to constrain it.

Nothing new was needed. instrument is uniquely keyed (symbol, venue), and
Symbol@Venue is already this codebase's name for instrument identity; this
only makes it addressable on the wire. Submit now accepts instrument_id,
symbol + venue, or symbol as SYMBOL@VENUE. An unqualified symbol resolves
only when unique — 1,510 active tickers are listed under more than one MIC —
and the 422 names the candidates rather than guessing a venue to trade on.

principal_portfolio_grant already carried can_trade/can_view/can_allocate
and get_portfolio_positions already enforced it, so GET /portfolios and
GET /orders are that same check widened from one portfolio to the granted
set. The blotter scopes by portfolio grant rather than by submitter: it
shows a portfolio's activity, which is what a blotter is for, not a personal
history. Admin and trading share one query builder behind a scope parameter
so the two views cannot drift.

Also closes a pre-existing hole: get_order and orders_cancel took no
AuthContext, so any valid trading token could read or cancel any order in
the system, including another principal's. Both now require a grant on the
order's portfolio — can_view to read, can_trade to cancel. A missing order
stays 404 and an unentitled one is 403; order ids are UUIDs, so confirming
one exists tells an attacker who already guessed it nothing.

Verified with three principals holding different grants: scoped listing
(5 rows vs 8 vs 17 for admin), 403 on another portfolio's order and cancel,
404 preserved, and all three instrument reference forms resolving to the
same order.
Before broker-first seeding (0018), scripts/seed_instruments.py built the
catalog from Databento definition files. Those rows carry Databento's strict
21-char OSI and — because that model had no notion of one — no
broker_instrument routing handle, so they cannot be ordered or closed. They
still match the OPRA feed's candidates() filter, so the quote feed kept
subscribing to contracts the OMS could never trade.

Not a migration, for two reasons: a catalog seeded fresh has none of these,
so it would be a no-op; and the guard reads oms.position and oms.order_state,
which the migration runner cannot see — it applies public-schema files as
mdm_master, which has no access to the oms schema.

Rows still referenced by a position or an order are kept. History has to
stay resolvable, and a stranded position is something preflight reports
rather than something a cleanup silently erases.

Dry-run then applied here: 27,250 deleted, 2 kept.
Lets the OMS be driven from a script or a REPL with one trading token, so
manual use does not have to wait for a UI. Deliberately has no admin
surface: the admin token covers principal management, broker connections and
risk limits, and none of that belongs in something that sends orders.

Three properties of the wire protocol shape the client, and each is the kind
of thing that is easy to get wrong and expensive when wrong:

  * Submit answers 204 with an empty body, so the client generates order_id
    and that id is the idempotency key. A 409 on retry therefore means the
    order was already accepted, and is swallowed rather than raised —
    raising would push callers toward resubmitting under a new id, which is
    how you end up with the position twice. A 502 is not swallowed: the OMS
    kept the order and only the broker leg failed.
  * Cancel answers 202 when the order is live at a broker and the cancel is
    confirmed asynchronously; only 204 means done.
  * Every error is text/plain, so nothing calls .json() on a failure.

wait_for polls because the OMS has no push channel for order events.

Includes an argparse CLI (oms orders list/get/cancel, positions,
portfolios, submit) — the cheapest possible blotter — and a smoke-test
example that sends a resting limit buy and cancels it. The order rests 20%
below the market on purpose: a market order fills instantly and would leave
nothing to cancel.

22 tests against a stubbed transport, plus a live run against Binance
testnet: submitted, routed, cancel returned pending, polled to canceled.
@maxkuttner
maxkuttner merged commit c10c9f4 into main Aug 15, 2026
1 check passed
@maxkuttner
maxkuttner deleted the feat/expiry-lifecycle-and-python-sdk branch August 15, 2026 11:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant