Skip to content

feat(fix): repeating-group support in the message framework (closes #423)#430

Merged
TexasCoding merged 1 commit into
mainfrom
feat/423-fix-repeating-groups
Jun 6, 2026
Merged

feat(fix): repeating-group support in the message framework (closes #423)#430
TexasCoding merged 1 commit into
mainfrom
feat/423-fix-repeating-groups

Conversation

@TexasCoding

Copy link
Copy Markdown
Owner

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 as
    Annotated[list[Entry], FixGroupMeta(NumInGroupTag, Entry)] = groupfield(). The entry-model class rides on Annotated metadata (it isn't JSON-serializable, so it can't live on json_schema_extra like scalar metadata does).
  • Shared _FixFieldModel base (now under FixMessage and a new FixGroup):
    • to_body_fields() is layout-driven — scalars and groups are emitted in declaration order; a group emits NumInGroup then 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 nested MarketSettlementParty (NoMarketSettlementPartyIDs → NoCollateralAmountChanges + NoMiscFees). Amounts use DollarDecimal, quantities FixedPointCount.

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; DollarDecimal coercion 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 . — clean
  • uv run mypy kalshi/ (strict) — clean (149 files)
  • uv run pytest tests/fix/104 passed

Closes #423. Part of #402.

🤖 Generated with Claude Code

)

- 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>
@claude

claude Bot commented Jun 6, 2026

Copy link
Copy Markdown

Code Review: feat(fix): repeating-group support

Overview

Solid implementation of FIX repeating-group support. The architecture is clean: extracting shared encode/decode machinery into _FixFieldModel, riding group metadata on Annotated rather than json_schema_extra (smart given JSON-serialization constraints), and positional decoding bounded by a member-tag set. The documented assumption (group tags are distinct from top-level tags) is realistic for the Kalshi FIX dictionary.


Issues

Medium: Public API exposes a private type

@dataclass(frozen=True)
class FixGroupMeta:
    num_in_group_tag: int
    entry_model: type[_FixFieldModel]   # _FixFieldModel is private

FixGroupMeta is exported from __init__.py, but entry_model is annotated with the private _FixFieldModel. Users writing strict-typed code will touch an implementation detail they should not see. The intended constraint is type[FixGroup] — consider reordering so FixGroup is defined before FixGroupMeta and using that, or use type[BaseModel] as a looser bound.

Minor: Count/entry mismatch silently tolerated

entries, _ = _parse_group(pairs, idx + 1, count, spec.entry_model)
kwargs[spec.name] = entries   # no assertion that len(entries) == count

If NumInGroup says 3 but the wire only delivers 2 complete entries (malformed FIX), the model is silently built with 2 entries. A warnings.warn or custom FixDecodeWarning when len(entries) != count would surface this during debugging.

Minor: ValueError on bad count is swallowed silently

try:
    count = int(pairs[idx][1])
except ValueError:
    continue   # entire group silently dropped

When NumInGroup is not a valid integer the whole group is dropped without any signal. A debug-level log or re-raise as FixDecodeError would make protocol bugs visible.

Minor: _first_tag() undocumented edge case

_first_tag() returns spec.count_tag when the first layout entry is a _GroupSpec. That is technically valid (count tag becomes the delimiter), but no FIX standard puts a nested group as the first field of a group entry. The docstring says "the first declared field's tag" without covering this case — a one-line note avoids future confusion.


Suggestions

Tag-uniqueness assumption — a cheap runtime guard:

# assert no overlap between top-level scalar tags and group member tags
# under __debug__ (fires once per class; free in production with -O)

This converts a silent wrong-parse into an early, obvious error if a future dictionary revision violates the assumption.

_parse_group dual-termination deserves a note in the docstring:

The loop terminates on both the count guard and tag-not-in-member-set. The count upper bound guards against pathological data, but the boundary logic would work for valid FIX without it. A one-liner prevents the next reader from wondering why both conditions exist.


Test Coverage

7 tests cover the happy paths well. Gaps worth a follow-up issue:

  • Malformed NumInGroup value (e.g., "abc") — confirm silent-skip is intentional.
  • NumInGroup says 3 but only 2 entries on the wire — what does the caller receive?
  • Empty entry model hitting _first_tag() — confirms the ValueError fires correctly.

Nits

  • components.py: MiscFee.misc_fee_curr and misc_fee_type are required with no default. Per FIX spec 137/138/139 are all required on the wire, so this is correct — a brief comment would help since misc_fee_basis (891) being optional could confuse a future editor.
  • The # type: ignore[arg-type] in test_decimal_group_field_coerces_without_float_drift is expected and fine; Pydantic's str -> DollarDecimal coercion is invisible to mypy.

Summary

Approve with minor follow-ups. The encoding/decoding logic is correct, the caching strategy is sound, and the positional-decoding algorithm handles nesting cleanly. The private-type leak in FixGroupMeta.entry_model is the one thing worth addressing before merge; the rest can live as issues.

@TexasCoding
TexasCoding merged commit 0ff1009 into main Jun 6, 2026
5 checks passed
@TexasCoding
TexasCoding deleted the feat/423-fix-repeating-groups branch June 6, 2026 00:26
TexasCoding added a commit that referenced this pull request Jun 6, 2026
* 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>
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.

[FIX] Repeating-group support in the typed message framework

1 participant