Instrument expiry lifecycle, self-sufficient trading token, Python SDK - #22
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.