feat(fix): repeating-group support in the message framework (closes #423)#430
Conversation
) - FixGroupMeta + groupfield(): declare a repeating group as Annotated[list[Entry], FixGroupMeta(NumInGroupTag, Entry)] - extract a shared _FixFieldModel base (FixMessage + FixGroup); layout-driven to_body_fields emits scalars and groups in declaration order (NumInGroup + each entry's fields, with nesting), and positional from_raw parses entries delimited by the entry's first-field tag, recursing for nested groups - components.py: shared entry models Party, MiscFee, CollateralAmountChange, MultivariateSelectedLeg, and the nested MarketSettlementParty (NoMarketSettlementPartyIDs -> NoCollateralAmountChanges + NoMiscFees). Amounts use DollarDecimal, quantities FixedPointCount (no float drift). - 7 round-trip tests: multi-entry, empty-group omission, trailing-scalar boundary, single entry, decimal coercion, nested round-trip, nested wire structure. ruff + mypy --strict clean; 104 FIX tests pass. Closes #423. Part of #402. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review: feat(fix): repeating-group supportOverviewSolid implementation of FIX repeating-group support. The architecture is clean: extracting shared encode/decode machinery into IssuesMedium: Public API exposes a private type@dataclass(frozen=True)
class FixGroupMeta:
num_in_group_tag: int
entry_model: type[_FixFieldModel] # _FixFieldModel is private
Minor: Count/entry mismatch silently toleratedentries, _ = _parse_group(pairs, idx + 1, count, spec.entry_model)
kwargs[spec.name] = entries # no assertion that len(entries) == countIf Minor: ValueError on bad count is swallowed silentlytry:
count = int(pairs[idx][1])
except ValueError:
continue # entire group silently droppedWhen Minor:
|
* feat(fix): order-entry message flow (closes #424) Order-entry (OE) FIX messages over the shared codec/session engine, for both the prediction and margin products. Outbound (typed enum vocabulary, required fields enforced): - NewOrderSingle (35=D), OrderCancelRequest (35=F), OrderCancelReplaceRequest (35=G), OrderMassCancelRequest (35=q) - Side / OrdType / TimeInForce / ExecInst (POST_ONLY) / SelfTradePreventionType round-trip to the wire; NoPartyIDs group via the #423 components; Price rides the FIX PRICE field (integer cents on prediction by default, fixed-point dollars on margin / UseDollars=Y); quantities are fractional decimals. Inbound (fields optional; char/int code fields raw for robustness): - ExecutionReport (35=8) with NoMiscFees / NoPartyIDs / NoCollateralAmountChanges groups, position/fee/collateral detail on ExecType=Trade - OrderCancelReject (35=9), OrderMassCancelReport (35=r), BusinessMessageReject (35=j) - decode_app_message(raw) dispatches an inbound RawMessage (from FixSession.on_message) to its typed model. Also folds in the PR #430 review follow-ups: FixGroupMeta/_GroupSpec now type entry_model as the public FixGroup (not the private base), and group decoding logs (debug) on a non-integer or short NumInGroup instead of silently dropping. 13 new tests (round-trips incl. groups, enum/price wire values, dispatch, inbound robustness, and a session send/receive integration test). ruff + mypy --strict clean; 117 FIX tests pass. Closes #424. Part of #402. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(fix): address PR #431 review — top-level export + test gaps - export APP_MESSAGE_MODELS from kalshi.fix top-level __all__ (was only exported from kalshi.fix.messages) - comment the Kalshi compound "int;int" exec_id format on ExecutionReport - tests: malformed group decode (short-count + non-integer NumInGroup debug paths), decode_app_message dispatch for OrderCancelReject / OrderMassCancelReport / BusinessMessageReject, a quantity-only OrderCancelReplaceRequest, and the NewOrderSingle empty-parties default Two review items were intentionally NOT changed — they apply generic FIX 5.0SP2 requiredness, not the Kalshi dictionary v1.03: - TransactTime(60) is absent from the Kalshi NewOrderSingle/Cancel/Replace messages (and the order-entry doc example omits tag 60); adding it would send a tag those messages do not define. - Price is required='N' in OrderCancelReplaceRequest (G); a quantity-only amend is valid ("required if changing price"), so price stays optional. ruff + mypy --strict clean; 122 FIX tests pass. Part of #424 / #402. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(fix): address PR #431 2nd review — robust decode + immutable registry - decode_app_message now catches ValidationError / ValueError / ArithmeticError and returns None (logged) so a malformed inbound message is never raised into the consumer's on_message; regression test added - APP_MESSAGE_MODELS wrapped in MappingProxyType (read-only dispatch registry) - comment that AllocAccount (79) is INT per Kalshi dictionary v1.03 (subaccount number 0-32), not the FIX-standard STRING - note the intentional TransactTime (60) omission on NewOrderSingle (absent from the Kalshi 35=D message) Verified against dictionary v1.03, left unchanged: - AllocAccount (79) is INT in the Kalshi dict. - OrderMassCancelReport (35=r) has no TotalAffectedOrders (533); tag 533 is not in the Kalshi dictionary at all. - NewOrderSingle field/group order matches the dictionary's 35=D layout. ruff + mypy --strict clean; 123 FIX tests pass. Part of #424 / #402. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(fix): cover decode_app_message None-msg-type; format nit (#431 review) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Repeating-group support for the typed FIX message framework — the prerequisite for the order-entry, market-data, and settlement message phases. Builds on the merged foundation (#422).
What's included
FixGroupMeta+groupfield()— declare a repeating group asAnnotated[list[Entry], FixGroupMeta(NumInGroupTag, Entry)] = groupfield(). The entry-model class rides onAnnotatedmetadata (it isn't JSON-serializable, so it can't live onjson_schema_extralike scalar metadata does)._FixFieldModelbase (now underFixMessageand a newFixGroup):to_body_fields()is layout-driven — scalars and groups are emitted in declaration order; a group emitsNumInGroupthen each entry's fields; empty groups are omitted; nesting works.from_raw()parses scalars by tag and groups positionally — each entry is delimited by the entry model's first-field tag and bounded by the entry's member-tag set, which naturally captures (and recurses into) nested groups.messages/components.py— shared entry models reused by later phases:Party(NoPartyIDs),MiscFee(NoMiscFees),CollateralAmountChange(NoCollateralAmountChanges),MultivariateSelectedLeg(NoMultivariateSelectedLegs), and the nestedMarketSettlementParty(NoMarketSettlementPartyIDs → NoCollateralAmountChanges + NoMiscFees). Amounts useDollarDecimal, quantitiesFixedPointCount.Design note
Positional group decoding assumes group-member tags are distinct from top-level scalar tags — true for the Kalshi FIX dictionary v1.03 — so a scalar is found by first occurrence without colliding with a same-tagged field inside a group.
Tests (
tests/fix/test_groups.py, 7 new)Flat group round-trip (multiple entries) with a trailing scalar after the group (boundary correctness); on-wire layout (count → delimiter → trailing scalar order); empty-group omission; single-entry group;
DollarDecimalcoercion from a string (no float drift); nested-group round-trip (incl. a party with an empty nested misc-fees group); and a nested-group on-wire structure assertion.Verification
uv run ruff check .— cleanuv run mypy kalshi/(strict) — clean (149 files)uv run pytest tests/fix/— 104 passedCloses #423. Part of #402.
🤖 Generated with Claude Code