feat(fix): RFQ / quoting message family (closes #428)#436
Conversation
FIX request-for-quote — prediction only. Spans two roles on different sessions:
RFQ creators (KalshiRT) solicit/accept quotes; market makers (KalshiRFQ) answer
them. QuoteRequest (R) and Quote (S) are bidirectional (sent outbound via helpers,
also decoded inbound as notifications).
- messages/rfq.py — 13 typed models:
- outbound: QuoteRequest (R, .create), Quote (S, .submit), QuoteCancel (Z),
QuoteConfirm (U7), AcceptQuote (UA), RFQCancel (UE, .for_req_id/.for_rfq_id)
- inbound (fields optional, codes raw — order-entry inbound convention):
QuoteRequestAck (b), QuoteStatusReport (AI), QuoteRequestReject (AG),
QuoteConfirmStatus (U8), QuoteCancelStatus (U9), RFQCancelStatus (UB),
AcceptQuoteStatus (UC)
- reuses the Party and MultivariateSelectedLeg (MVE legs) group entries
- enums.py — QuoteRequestType, QuoteStatus, QuoteRequestRejectReason, and the
four *Result enums (QuoteConfirmResult/QuoteCancelResult/RFQCancelResult/
AcceptQuoteResult). The Result suffix avoids colliding with the same-named
status messages (the dictionary names the status field and its message
identically); messages keep the spec name, value-enums are suffixed.
- dispatch registers all inbound-decodable types incl. the bidirectional R/S.
Prices (BidPx/OfferPx) ride FIX Price as DollarDecimal: makers submit integer
cents, creator notifications are fixed-point dollars — DollarDecimal parses
either without conversion. All RFQ tags already existed from the foundation.
Hardening from a pre-PR adversarial review (8 confirmed findings, all folded):
fixed a real export bug (AcceptQuote/AcceptQuoteStatus were in __all__ but never
imported into kalshi.fix); Quote.submit now requires a price and
QuoteRequest.create requires a symbol + a size (so the helpers really do enforce
the per-role required fields); documented AcceptQuote.side's inverted accept
semantics; corrected the *Result enum doc cross-references after the rename.
41 new tests: golden exact-wire fixtures (pinned to the docs examples) incl. MVE
legs, round-trips for all 13, dispatch, the four status->*Result enum
comparisons, helper-guard negatives, a public-API export-contract check, and
market-maker AND rfq-creator quote-lifecycle integration tests against the mock
acceptor.
ruff + mypy --strict clean; 223 FIX tests pass.
Closes #428. Part of #402.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — feat(fix): RFQ / quoting message familyOverviewAdds the full RFQ/quoting FIX message family (13 messages, 6 enums, dispatch wiring) in a single focused file ( IssuesMedium — Spelling inconsistency between In
These are the names callers write in comparison expressions ( Medium — The two factory classmethods enforce exactly one identifier, but Minor — The model has these three fields but Minor — assert acceptor.first("R").get(Tag.QUOTE_REQ_ID) == REQ # type: ignore[union-attr]Assigning to a local first lets mypy verify the None check: r = acceptor.first("R")
assert r is not None
assert r.get(Tag.QUOTE_REQ_ID) == REQWhat is working well
SummaryThree actionable items before merge: fix the |
- RFQCancel: add a model_validator(mode="after") rejecting the all-None case
(neither quote_req_id nor rfq_id). RFQCancel is outbound-only (not in the
inbound dispatch), so the guard can't affect inbound-parse robustness. The
for_req_id/for_rfq_id factories already enforced this; this closes the direct-
construction gap. + regression test.
- Quote.submit docstring: clarify the maker does not send sizes (the exchange
derives size from the RFQ OrderQty); bid_size/offer_size/order_qty exist on the
model only for the inbound Exchange->Creator notification.
- creator lifecycle test: drop a type: ignore by binding acceptor.first("R") to a
local with a None check.
Not changed, with rationale:
- RFQCancelResult.CANCELED spelling: kept. It mirrors the Kalshi dictionary's own
description for tag 21013 ("CANCELED"), exactly as the pre-existing
OrdStatus.CANCELED / ExecType.CANCELED mirror tags 39/150. The dictionary
itself spells the two cancel fields differently (298="CANCELLED",
21013="CANCELED"), so per-field dictionary fidelity is the traceable rule;
the review's premise that the codebase uniformly uses CANCELLED is inaccurate
(the shipped order-entry enums use CANCELED). Enum member names are not
wire-significant (the wire value is the integer).
ruff + mypy --strict clean; 224 FIX tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — feat(fix): RFQ / quoting message family (#436)OverviewAdds the full 13-message RFQ family to the FIX layer. The architecture is clean: bidirectional messages (R/S) are all-optional for inbound robustness and have enforced constructors for outbound; outbound-only messages use required typed fields; inbound-only messages are all-optional with raw int/char codes. The IssuesSpelling inconsistency:
|
…verage - enums: rename RFQCancelResult.CANCELED -> CANCELLED to match QuoteCancelResult / QuoteStatus (reversing the prior round's keep-as-CANCELED call). On reflection enum member names carry no wire semantics (the value is 0), so the choice is pure SDK ergonomics and consistency among the new cancel-result enums wins; the deliberate divergence from the dictionary's "CANCELED" description for tag 21013 is documented on the enum. (OrdStatus/ExecType.CANCELED stay — shipped + match their own dict descriptions; out of scope.) - rfq.py: note FCM on-behalf-of parties go via direct RFQCancel construction; reword the _require_identifier comment to state the outbound-only constraint positively (don't add to dispatch without revisiting). - tests: QuoteRequestType enum comparison; parametrized QuoteStatusReport wire values across all four QuoteStatus codes (covers REJECTED/CANCELLED, catches 297 value drift); AcceptQuote.side inverted wire-value pins for both sides. Not changed: QuoteStatusReport.side stays raw `str | None` (inbound raw-char convention, already documented as AcceptedSide); test_golden_rfq_cancel_and_status keeps three tight assertions in one function (reviewer noted it's not a problem). ruff + mypy --strict clean; 230 FIX tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — feat(fix): RFQ / quoting message familyOverviewSolid, well-scoped PR that adds the full RFQ message family to the FIX layer. The bidirectional / outbound-only / inbound-only split is clearly communicated, the Bugs / Correctness1.
# current
class RFQCancelStatus(FixMessage):
quote_req_id: str | None = fixfield(Tag.QUOTE_REQ_ID, ...)
rfq_cancel_status: int | None = fixfield(Tag.RFQ_CANCEL_STATUS, ...)
text: str | None = fixfield(Tag.TEXT, ...)
# suggested
rfq_id: str | None = fixfield(Tag.RFQ_ID, FixType.STRING, default=None)2.
Design / Conventions3. The model-level validator works correctly now and the comment is good, but it creates a hidden landmine: if A more robust alternative is to drop the model validator and enforce the constraint only in the # helpers raise; model stays permissive
@classmethod
def for_req_id(cls, quote_req_id: str) -> RFQCancel:
if not quote_req_id:
raise ValueError("quote_req_id must be non-empty")
return cls(quote_req_id=quote_req_id)If keeping the model validator (reasonable for now), adding a 4. All other inbound status codes use Tests5. Variable name shadow in The name 6. No test for cancel-by-rfq_id response round-trip
def test_golden_rfq_cancel_by_rfq_id_status() -> None:
assert _wire(RFQCancelStatus(rfq_id=RFQ, rfq_cancel_status=0)) == f"21023={RFQ}|21013=0"7. No negative test for The helper hard-codes Minor
SummaryThe PR is well-structured and near-complete. The primary actionable items are:
The export-contract regression test ( |
…y tripwire
- rfq.py: note QuoteStatusReport.side is a FIX CHAR (not an int like the status
codes); clarify Quote.submit's None (side absent) vs Decimal(0) (explicit zero).
- tests: add test_rfq_cancel_stays_outbound_only (tripwire asserting RFQCancel is
NOT in APP_MESSAGE_MODELS — the machine-checkable signal for its construction-
time validator); rename a shadowed `s` -> `raw_s` in the MM lifecycle test.
Not changed, with rationale:
- RFQCancelStatus.rfq_id ("must fix"): rejected. The Kalshi dictionary defines
35=UB with only QuoteReqID(131)/RFQCancelStatus(21013)/Text(58) — no RfqId
(21023) — and the docs state tag 131 "echoes back the ID from RFQCancel
request" (the single echo field regardless of which identifier was sent).
Adding rfq_id would invent a field the spec does not define for UB (generic-FIX
symmetry, not the Kalshi dialect). The dependent cancel-by-rfq_id status golden
test is likewise N/A (would fail extra="forbid").
- APP_MESSAGE_MODELS coverage: already complete — all 9 decodable RFQ types are
registered in dispatch.py (the diff did include it).
- _require_identifier validator: kept (it fixed the round-2 empty-cancel bug); the
new tripwire test is the machine-checkable guard the review asked for.
- QuoteRequest.create + MVE args: create() has no MVE parameters, so passing them
raises TypeError (not a silent malformation); MVE is direct construction.
ruff + mypy --strict clean; 231 FIX tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — feat(fix): RFQ / quoting message family (#436)Overall: This is a solid, well-scoped PR. The pre-PR adversarial-review pass caught the real export bug and the missing helper guards, and the test suite (golden-wire fixtures + lifecycle integration tests) gives strong coverage. No blockers. A few things worth addressing or at least acknowledging: Design / Correctness
The inbound-raw-types convention is intentional and documented, but Suggestion: Add one line to
The spec presumably always requires Sparse The
|
| Finding | Severity | Blocking? |
|---|---|---|
QuoteStatusReport.side comparison pattern undocumented |
Low | No |
MVE direct-construction skips no_related_sym enforcement |
Low | No |
Sparse QuoteStatus enum not documented as exhaustive/non-exhaustive |
Low | No |
RFQCancel dual-ID priority not documented |
Trivial | No |
Missing negative test for bare QuoteRequest() |
Low | No |
Lifecycle test uses RFQ where REQ is clearer |
Trivial | No |
Nothing here blocks the merge — the implementation is correct, the critical guards are in place, and the test suite is comprehensive. The suggestions above are mostly documentation/clarity improvements.
🤖 Generated with Claude Code
Summary
FIX RFQ / Quoting — the full request-for-quote message family, prediction only. RFQ spans two roles on different sessions: RFQ creators (KalshiRT) solicit and accept quotes; market makers (KalshiRFQ) answer them.
QuoteRequest(R) andQuote(S) are bidirectional — sent outbound via helpers and also decoded inbound as notifications. Builds on the foundation (#422), repeating groups (#423), and order entry (#424).Messages (
messages/rfq.py) — 13QuoteRequest.create(); carries the MVE legs + parties groupsQuote.submit()(maker)QuoteCancelQuoteConfirmAcceptQuoteRFQCancel.for_req_id()/.for_rfq_id()QuoteRequestAckQuoteStatusReportQuoteRequestRejectQuoteConfirmStatusQuoteCancelStatusRFQCancelStatusAcceptQuoteStatusOutbound-only messages type their required fields as required; inbound + bidirectional messages keep fields optional with raw
int/strcodes (the order-entry inbound convention — a slightly off-spec server message parses rather than rejecting), and the helper constructors enforce the per-role required fields on the outbound path. Reuses the existingPartyandMultivariateSelectedLeggroup entries.Name-collision resolution
The dictionary names the status field and its carrying message identically for four cases (e.g. tag 21010 and message 35=U8 are both
QuoteConfirmStatus). The message classes keep the spec name; the value enums are suffixedResult—QuoteConfirmResult/QuoteCancelResult/RFQCancelResult/AcceptQuoteResult— somsg.quote_confirm_status == QuoteConfirmResult.REJECTEDworks without shadowing. New enums also:QuoteRequestType,QuoteStatus,QuoteRequestRejectReason.Pricing
BidPx/OfferPxride the FIXPricefield asDollarDecimal: a maker submits integer cents (132=75), a creator notification is fixed-point dollars (132=0.7500) —DollarDecimalparses either with no float drift and no SDK-side conversion (units follow the session, like order entry). All RFQ tags already existed from the foundation;client.rfq()is the session factory.Pre-PR adversarial review hardening
A multi-agent review (4 lenses → per-finding skeptical verification, 9 findings → 8 confirmed) ran before this PR; all confirmed findings are folded in:
AcceptQuote/AcceptQuoteStatuswere inkalshi.fix.__all__but never imported into the namespace —from kalshi.fix import AcceptQuoteandimport *raised. Added the import + a regression test asserting every__all__name is bound (mypy can't catch this).Quote.submitrequires at least one ofbid_px/offer_px;QuoteRequest.createrequires asymboland at least one oforder_qty/cash_order_qty.AcceptQuote.sideinversion documented — for AcceptQuote,Side.BUY_YESaccepts the maker's NO quote andSide.SELL_NOthe YES quote (the field reuses the shared FIX Side; the member names read opposite to the accept semantics).*Resultenum doc cross-references after the collision rename.Tests (
tests/fix/test_rfq.py, 41 new)Golden exact-wire fixtures pinned to the docs examples (incl. the MVE-legs
QuoteRequest); round-trips for all 13 messages (with parties group); the four status →*Resultenum comparisons; helper-guard negatives (submitno-price,createno-size); a public-API export-contract check; dispatch over every inbound-decodable type; and both the market-maker (R → S → PENDING/ACCEPTED → U7 → U8) and rfq-creator (R → b → S → UA → UC → UE → UB) quote-lifecycle integration tests against the mock acceptor.Verification
uv run ruff check .— cleanuv run mypy kalshi/(strict) — clean (156 files)uv run pytest tests/fix/— 223 passedCloses #428. Part of #402.
🤖 Generated with Claude Code