diff --git a/kalshi/fix/messages/__init__.py b/kalshi/fix/messages/__init__.py index ef73f76..9b4f7f7 100644 --- a/kalshi/fix/messages/__init__.py +++ b/kalshi/fix/messages/__init__.py @@ -1,13 +1,28 @@ """Typed FIX message models (FIX Dictionary v1.03). -Foundation phase exposes the session-layer (admin) messages plus the base -framework. Application messages (order entry, drop copy, market data, -RFQ/settlement) are added in later phases — see GH #402. +Exposes the base framework (scalar + repeating-group fields), the session-layer +(admin) messages, and the shared repeating-group entry components. Application +messages (order entry, drop copy, market data, RFQ/settlement) are added in later +phases — see GH #402. """ from __future__ import annotations -from kalshi.fix.messages.base import FixMessage, FixType, fixfield +from kalshi.fix.messages.base import ( + FixGroup, + FixGroupMeta, + FixMessage, + FixType, + fixfield, + groupfield, +) +from kalshi.fix.messages.components import ( + CollateralAmountChange, + MarketSettlementParty, + MiscFee, + MultivariateSelectedLeg, + Party, +) from kalshi.fix.messages.session import ( Heartbeat, Logon, @@ -19,14 +34,22 @@ ) __all__ = [ + "CollateralAmountChange", + "FixGroup", + "FixGroupMeta", "FixMessage", "FixType", "Heartbeat", "Logon", "Logout", + "MarketSettlementParty", + "MiscFee", + "MultivariateSelectedLeg", + "Party", "Reject", "ResendRequest", "SequenceReset", "TestRequest", "fixfield", + "groupfield", ] diff --git a/kalshi/fix/messages/base.py b/kalshi/fix/messages/base.py index 2ee5fbf..7242321 100644 --- a/kalshi/fix/messages/base.py +++ b/kalshi/fix/messages/base.py @@ -1,27 +1,31 @@ """Typed FIX message framework: Pydantic models that round-trip to wire fields. -Each message subclasses :class:`FixMessage` and declares its body fields with -:func:`fixfield`, attaching the FIX tag number and wire type to the Pydantic -field via ``json_schema_extra``. The base then drives: - -* :meth:`FixMessage.to_body_fields` — ordered ``(tag, value)`` pairs for the - message body (everything after the standard header), with length-prefixed - data fields (``RawData``/``Signature``) auto-emitting their length field. -* :meth:`FixMessage.from_raw` — build a typed model from a decoded - :class:`~kalshi.fix.codec.RawMessage`, reading only the tags it declares - (unknown/header tags are ignored, so inbound messages with extra fields - parse cleanly). +Each message subclasses :class:`FixMessage` and declares its fields with +:func:`fixfield` (scalars) and :func:`groupfield` (repeating groups). Scalar +metadata (tag + wire type) rides on ``json_schema_extra``; group metadata (the +``NumInGroup`` tag + entry model) rides on an :class:`Annotated` +:class:`FixGroupMeta`. The shared base (:class:`_FixFieldModel`) drives: + +* :meth:`to_body_fields` — ordered ``(tag, value)`` pairs in declaration order, + with length-prefixed data fields auto-emitting their length field and repeating + groups expanding to ``NumInGroup`` + each entry's fields (nesting supported). +* :meth:`from_raw` — build a typed model from a decoded + :class:`~kalshi.fix.codec.RawMessage`. Scalars are read by tag; groups are + parsed *positionally* (each entry delimited by its first field's tag), which + recurses for nested groups. The standard header (``SenderCompID``/``TargetCompID``/``MsgSeqNum``/ ``SendingTime``) and ``MsgType`` are owned by the session/connection layer, not the message body — see :mod:`kalshi.fix.session`. -Scope note: scalar fields only. Repeating groups (``NoPartyIDs``, ``NoMiscFees``, -…) land with the order-entry / settlement message phases; see GH #402. +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 confusion with a same-tagged field inside a group. """ from __future__ import annotations +from dataclasses import dataclass from decimal import Decimal from enum import StrEnum from typing import Any, ClassVar, Self @@ -63,18 +67,55 @@ class FixType(StrEnum): _DECIMAL_TYPES = frozenset({FixType.PRICE, FixType.QTY, FixType.AMT, FixType.DECIMAL}) +@dataclass(frozen=True) +class FixGroupMeta: + """``Annotated`` metadata marking a repeating-group field. + + Declare a group as ``Annotated[list[EntryModel], FixGroupMeta(count_tag, + EntryModel)]`` where ``count_tag`` is the ``NumInGroup`` tag and + ``EntryModel`` is the :class:`FixGroup` subclass describing one entry. + """ + + num_in_group_tag: int + entry_model: type[_FixFieldModel] + + +@dataclass(frozen=True) +class _ScalarSpec: + name: str + tag: int + fix_type: FixType + + +@dataclass(frozen=True) +class _GroupSpec: + name: str + count_tag: int + entry_model: type[_FixFieldModel] + + +_FieldSpec = _ScalarSpec | _GroupSpec + + def fixfield(tag: int, fix_type: FixType, *, default: Any = ..., **kwargs: Any) -> Any: - """Declare a FIX-mapped Pydantic field. + """Declare a scalar FIX field. ``tag`` is the FIX tag number, ``fix_type`` its wire type. Omit ``default`` - for a required field (Pydantic treats ``...`` as required). The metadata - rides on ``json_schema_extra`` so the base can introspect tag + type in - declaration order. + for a required field (Pydantic treats ``...`` as required). """ extra: dict[str, Any] = {"fix_tag": int(tag), "fix_type": fix_type.value} return Field(default=default, json_schema_extra=extra, **kwargs) +def groupfield() -> Any: + """Declare a repeating-group field (defaults to an empty list). + + The ``NumInGroup`` tag and entry model ride on the field's + :class:`Annotated` :class:`FixGroupMeta`; this only supplies the default. + """ + return Field(default_factory=list) + + def _to_wire(value: Any, fix_type: FixType) -> str: """Convert a Python field value to its FIX wire string.""" if fix_type is FixType.BOOLEAN: @@ -112,77 +153,177 @@ def _from_wire(raw: str, fix_type: FixType) -> Any: return raw -# Per-class cache of the introspected FIX field map. The map is deterministic -# per message class and, once application messages land, is read on every -# encode/decode of every order and execution report — so caching it avoids -# re-walking ``model_fields`` on the hot path. Classes live for the process -# lifetime, so a plain dict keyed by class is fine. -_FIX_FIELDS_CACHE: dict[type[FixMessage], list[tuple[str, int, FixType]]] = {} +def _first_value(pairs: list[tuple[int, str]], tag: int) -> str | None: + """First value for ``tag`` in ``pairs``, or ``None``.""" + for t, v in pairs: + if t == tag: + return v + return None -class FixMessage(BaseModel): - """Base for all typed FIX messages. +def _index_of(pairs: list[tuple[int, str]], tag: int) -> int | None: + """Index of the first pair with ``tag``, or ``None``.""" + for i, (t, _) in enumerate(pairs): + if t == tag: + return i + return None - Subclasses set the ``MSG_TYPE`` class var and declare body fields with - :func:`fixfield`. ``extra="forbid"`` makes a typo in an outbound message - fail at construction; inbound messages are built via :meth:`from_raw`, which - only passes declared tags, so server-added fields never reach the - constructor. - """ - MSG_TYPE: ClassVar[MsgType] +def _parse_group( + pairs: list[tuple[int, str]], + start: int, + count: int, + entry_model: type[_FixFieldModel], +) -> tuple[list[_FixFieldModel], int]: + """Parse up to ``count`` group entries from ``pairs`` beginning at ``start``. + + Each entry begins at the entry model's delimiter (first) tag and runs until + the next delimiter or a tag the entry does not own — which naturally captures + nested groups (their tags are part of the entry's member set). Returns the + parsed entries and the index just past the group. + """ + delim = entry_model._first_tag() + member_tags = entry_model._member_tags() + entries: list[_FixFieldModel] = [] + i = start + n = len(pairs) + while len(entries) < count and i < n and pairs[i][0] == delim: + entry_pairs = [pairs[i]] + i += 1 + while i < n and pairs[i][0] != delim and pairs[i][0] in member_tags: + entry_pairs.append(pairs[i]) + i += 1 + entries.append(entry_model._from_pairs(entry_pairs)) + return entries, i + + +# Per-class caches of the introspected layout / member-tag set. Both are +# deterministic per class and read on every encode/decode, so caching avoids +# re-walking ``model_fields``. Classes live for the process lifetime, so plain +# dicts keyed by class are fine. +_LAYOUT_CACHE: dict[type[_FixFieldModel], list[_FieldSpec]] = {} +_MEMBER_TAGS_CACHE: dict[type[_FixFieldModel], frozenset[int]] = {} + + +class _FixFieldModel(BaseModel): + """Shared machinery for FIX messages and repeating-group entries. + + ``extra="forbid"`` makes a typo in an outbound model fail at construction; + inbound models are built via :meth:`from_raw` / :meth:`_from_pairs`, which + pass only declared tags, so server-added fields never reach the constructor. + """ model_config = ConfigDict(extra="forbid") @classmethod - def _fix_fields(cls) -> list[tuple[str, int, FixType]]: - """``(attr_name, tag, fix_type)`` for each FIX-mapped field, in declaration order. - - Cached per class (see :data:`_FIX_FIELDS_CACHE`). Callers iterate the - result and must not mutate it. - """ - cached = _FIX_FIELDS_CACHE.get(cls) + def _layout(cls) -> list[_FieldSpec]: + """Ordered scalar/group field specs in declaration order (cached).""" + cached = _LAYOUT_CACHE.get(cls) if cached is not None: return cached - out: list[tuple[str, int, FixType]] = [] + specs: list[_FieldSpec] = [] for name, field in cls.model_fields.items(): + group_meta = next( + (m for m in field.metadata if isinstance(m, FixGroupMeta)), None + ) + if group_meta is not None: + specs.append(_GroupSpec(name, group_meta.num_in_group_tag, group_meta.entry_model)) + continue extra = field.json_schema_extra if isinstance(extra, dict) and "fix_tag" in extra: tag = int(extra["fix_tag"]) # type: ignore[arg-type] - fix_type = FixType(str(extra["fix_type"])) - out.append((name, tag, fix_type)) - _FIX_FIELDS_CACHE[cls] = out - return out + specs.append(_ScalarSpec(name, tag, FixType(str(extra["fix_type"])))) + _LAYOUT_CACHE[cls] = specs + return specs + + @classmethod + def _member_tags(cls) -> frozenset[int]: + """Every tag this model can contain, transitively through nested groups.""" + cached = _MEMBER_TAGS_CACHE.get(cls) + if cached is not None: + return cached + tags: set[int] = set() + for spec in cls._layout(): + if isinstance(spec, _GroupSpec): + tags.add(spec.count_tag) + tags |= spec.entry_model._member_tags() + else: + tags.add(spec.tag) + length_tag = _DATA_TO_LENGTH.get(spec.tag) + if length_tag is not None: + tags.add(length_tag) + frozen = frozenset(tags) + _MEMBER_TAGS_CACHE[cls] = frozen + return frozen + + @classmethod + def _first_tag(cls) -> int: + """The delimiter tag — the first declared field's tag (group entry start).""" + for spec in cls._layout(): + return spec.tag if isinstance(spec, _ScalarSpec) else spec.count_tag + raise ValueError(f"{cls.__name__} declares no FIX fields") def to_body_fields(self) -> list[tuple[int, str]]: - """Ordered ``(tag, value)`` body fields (excludes the standard header). + """Ordered ``(tag, value)`` fields in declaration order. - ``None`` values are omitted. A data field auto-emits its length field - immediately before it. + ``None`` scalars and empty groups are omitted; a data field auto-emits + its length field; a group emits ``NumInGroup`` then each entry's fields. """ out: list[tuple[int, str]] = [] - for name, tag, fix_type in self._fix_fields(): - value = getattr(self, name) - if value is None: - continue - wire = _to_wire(value, fix_type) - length_tag = _DATA_TO_LENGTH.get(tag) - if length_tag is not None: - out.append((length_tag, str(len(wire.encode("latin-1"))))) - out.append((tag, wire)) + for spec in self._layout(): + value = getattr(self, spec.name) + if isinstance(spec, _GroupSpec): + if not value: + continue + out.append((spec.count_tag, str(len(value)))) + for entry in value: + out.extend(entry.to_body_fields()) + else: + if value is None: + continue + wire = _to_wire(value, spec.fix_type) + length_tag = _DATA_TO_LENGTH.get(spec.tag) + if length_tag is not None: + out.append((length_tag, str(len(wire.encode("latin-1"))))) + out.append((spec.tag, wire)) return out @classmethod def from_raw(cls, raw: RawMessage) -> Self: - """Build a typed message from a decoded :class:`RawMessage`. + """Build a typed model from a decoded :class:`RawMessage`.""" + return cls._from_pairs(raw.pairs) + + @classmethod + def _from_pairs(cls, pairs: list[tuple[int, str]]) -> Self: + """Build a typed model from an ordered ``(tag, value)`` slice. - Reads only the tags this model declares; all other fields (header, - trailer, server extensions) are ignored. + Scalars are read by tag; groups are parsed positionally from the + ``NumInGroup`` field onward (recursing for nested groups). """ kwargs: dict[str, Any] = {} - for name, tag, fix_type in cls._fix_fields(): - value = raw.get(tag) - if value is None: - continue - kwargs[name] = _from_wire(value, fix_type) + for spec in cls._layout(): + if isinstance(spec, _GroupSpec): + idx = _index_of(pairs, spec.count_tag) + if idx is None: + continue + try: + count = int(pairs[idx][1]) + except ValueError: + continue + entries, _ = _parse_group(pairs, idx + 1, count, spec.entry_model) + kwargs[spec.name] = entries + else: + value = _first_value(pairs, spec.tag) + if value is not None: + kwargs[spec.name] = _from_wire(value, spec.fix_type) return cls(**kwargs) + + +class FixMessage(_FixFieldModel): + """Base for all typed FIX messages. Subclasses set the ``MSG_TYPE`` class var.""" + + MSG_TYPE: ClassVar[MsgType] + + +class FixGroup(_FixFieldModel): + """Base for one entry of a repeating group (no ``MsgType`` of its own).""" diff --git a/kalshi/fix/messages/components.py b/kalshi/fix/messages/components.py new file mode 100644 index 0000000..54d31b9 --- /dev/null +++ b/kalshi/fix/messages/components.py @@ -0,0 +1,67 @@ +"""Repeating-group entry models shared across FIX message flows (GH #423). + +Each class is one entry of a FIX repeating group. Messages embed them via +``Annotated[list[Entry], FixGroupMeta(NumInGroupTag, Entry)] = groupfield()``. +Prices/amounts use :data:`~kalshi.types.DollarDecimal` and quantities +:data:`~kalshi.types.FixedPointCount` so values coerce to ``Decimal`` without +float drift (the SDK money-precision invariant). The order-entry, market-data, +and settlement phases reuse these. +""" + +from __future__ import annotations + +from typing import Annotated + +from kalshi.fix.messages.base import FixGroup, FixGroupMeta, FixType, fixfield, groupfield +from kalshi.fix.tags import Tag +from kalshi.types import DollarDecimal, FixedPointCount + + +class Party(FixGroup): + """One ``NoPartyIDs`` (453) entry — delimiter ``PartyID`` (448).""" + + party_id: str = fixfield(Tag.PARTY_ID, FixType.STRING) + party_role: int | None = fixfield(Tag.PARTY_ROLE, FixType.INT, default=None) + + +class MiscFee(FixGroup): + """One ``NoMiscFees`` (136) entry — delimiter ``MiscFeeAmt`` (137).""" + + misc_fee_amt: DollarDecimal = fixfield(Tag.MISC_FEE_AMT, FixType.AMT) + misc_fee_curr: str = fixfield(Tag.MISC_FEE_CURR, FixType.CURRENCY) + misc_fee_type: str = fixfield(Tag.MISC_FEE_TYPE, FixType.CHAR) + misc_fee_basis: int | None = fixfield(Tag.MISC_FEE_BASIS, FixType.INT, default=None) + + +class CollateralAmountChange(FixGroup): + """One ``NoCollateralAmountChanges`` (1703) entry — delimiter (1704).""" + + collateral_amount_change: DollarDecimal = fixfield(Tag.COLLATERAL_AMOUNT_CHANGE, FixType.AMT) + collateral_amount_type: str = fixfield(Tag.COLLATERAL_AMOUNT_TYPE, FixType.STRING) + + +class MultivariateSelectedLeg(FixGroup): + """One ``NoMultivariateSelectedLegs`` (20181) entry — delimiter (20182).""" + + event_ticker: str = fixfield(Tag.MULTIVARIATE_SELECTED_EVENT_TICKER, FixType.STRING) + market_ticker: str = fixfield(Tag.MULTIVARIATE_SELECTED_MARKET_TICKER, FixType.STRING) + side: str = fixfield(Tag.MULTIVARIATE_SELECTED_SIDE, FixType.STRING) + + +class MarketSettlementParty(FixGroup): + """One ``NoMarketSettlementPartyIDs`` (20108) entry — delimiter (20109). + + Nests ``NoCollateralAmountChanges`` (1703) and ``NoMiscFees`` (136). + """ + + party_id: str = fixfield(Tag.MARKET_SETTLEMENT_PARTY_ID, FixType.STRING) + party_role: int | None = fixfield(Tag.MARKET_SETTLEMENT_PARTY_ROLE, FixType.INT, default=None) + long_qty: FixedPointCount | None = fixfield(Tag.LONG_QTY, FixType.QTY, default=None) + short_qty: FixedPointCount | None = fixfield(Tag.SHORT_QTY, FixType.QTY, default=None) + collateral_amount_changes: Annotated[ + list[CollateralAmountChange], + FixGroupMeta(Tag.NO_COLLATERAL_AMOUNT_CHANGES, CollateralAmountChange), + ] = groupfield() + misc_fees: Annotated[ + list[MiscFee], FixGroupMeta(Tag.NO_MISC_FEES, MiscFee) + ] = groupfield() diff --git a/tests/fix/test_groups.py b/tests/fix/test_groups.py new file mode 100644 index 0000000..9714cda --- /dev/null +++ b/tests/fix/test_groups.py @@ -0,0 +1,190 @@ +"""Tests for FIX repeating-group support (GH #423).""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Annotated + +from kalshi.fix.codec import SOH, decode, encode +from kalshi.fix.enums import MsgType +from kalshi.fix.messages.base import ( + FixGroupMeta, + FixMessage, + FixType, + fixfield, + groupfield, +) +from kalshi.fix.messages.components import ( + CollateralAmountChange, + MarketSettlementParty, + MiscFee, + MultivariateSelectedLeg, + Party, +) +from kalshi.fix.tags import Tag + + +class _OrderWithParties(FixMessage): + """Scalar, then a flat NoPartyIDs group, then a trailing scalar.""" + + MSG_TYPE = MsgType.NEW_ORDER_SINGLE + + cl_ord_id: str = fixfield(Tag.CL_ORD_ID, FixType.STRING) + parties: Annotated[list[Party], FixGroupMeta(Tag.NO_PARTY_IDS, Party)] = groupfield() + symbol: str = fixfield(Tag.SYMBOL, FixType.STRING) + + +class _WithLegs(FixMessage): + MSG_TYPE = MsgType.QUOTE_REQUEST + + quote_req_id: str = fixfield(Tag.QUOTE_REQ_ID, FixType.STRING) + legs: Annotated[ + list[MultivariateSelectedLeg], + FixGroupMeta(Tag.NO_MULTIVARIATE_SELECTED_LEGS, MultivariateSelectedLeg), + ] = groupfield() + + +class _Settlement(FixMessage): + """A nested group: NoMarketSettlementPartyIDs -> collateral + misc fees.""" + + MSG_TYPE = MsgType.MARKET_SETTLEMENT_REPORT + + report_id: str = fixfield(Tag.MARKET_SETTLEMENT_REPORT_ID, FixType.STRING) + parties: Annotated[ + list[MarketSettlementParty], + FixGroupMeta(Tag.NO_MARKET_SETTLEMENT_PARTY_IDS, MarketSettlementParty), + ] = groupfield() + + +def _roundtrip(msg: FixMessage) -> FixMessage: + full = [ + (int(Tag.MSG_TYPE), msg.MSG_TYPE.value), + (int(Tag.MSG_SEQ_NUM), "1"), + (int(Tag.SENDING_TIME), "20250101-00:00:00.000"), + *msg.to_body_fields(), + ] + return type(msg).from_raw(decode(encode(full))) + + +def test_flat_group_roundtrip_multiple_entries() -> None: + msg = _OrderWithParties( + cl_ord_id="abc", + symbol="KXTEST", + parties=[Party(party_id="0", party_role=24), Party(party_id="7", party_role=24)], + ) + back = _roundtrip(msg) + assert back == msg + assert [p.party_id for p in back.parties] == ["0", "7"] + # The trailing scalar after the group must survive (group boundary correct). + assert back.symbol == "KXTEST" + + +def test_group_wire_layout() -> None: + msg = _OrderWithParties( + cl_ord_id="abc", symbol="KXTEST", parties=[Party(party_id="0", party_role=24)] + ) + body = dict(msg.to_body_fields()) + assert body[int(Tag.NO_PARTY_IDS)] == "1" # NumInGroup count + # Count precedes the entry's delimiter, which precedes the trailing scalar. + tags = [t for t, _ in msg.to_body_fields()] + assert tags.index(int(Tag.NO_PARTY_IDS)) < tags.index(int(Tag.PARTY_ID)) + assert tags.index(int(Tag.PARTY_ID)) < tags.index(int(Tag.SYMBOL)) + + +def test_empty_group_is_omitted() -> None: + msg = _OrderWithParties(cl_ord_id="x", symbol="Y") + tags = {t for t, _ in msg.to_body_fields()} + assert int(Tag.NO_PARTY_IDS) not in tags + assert _roundtrip(msg) == msg + + +def test_single_entry_group() -> None: + msg = _WithLegs( + quote_req_id="q1", + legs=[MultivariateSelectedLeg(event_ticker="E", market_ticker="M", side="yes")], + ) + back = _roundtrip(msg) + assert back == msg + assert back.legs[0].side == "yes" + + +def test_decimal_group_field_coerces_without_float_drift() -> None: + # A str amount coerces to Decimal (DollarDecimal) and round-trips exactly. + fee = MiscFee(misc_fee_amt="0.02", misc_fee_curr="USD", misc_fee_type="4", misc_fee_basis=0) # type: ignore[arg-type] + assert fee.misc_fee_amt == Decimal("0.02") + assert (int(Tag.MISC_FEE_AMT), "0.02") in fee.to_body_fields() + + +def test_nested_group_roundtrip() -> None: + msg = _Settlement( + report_id="R1", + parties=[ + MarketSettlementParty( + party_id="0", + party_role=24, + long_qty=Decimal("10.00"), + collateral_amount_changes=[ + CollateralAmountChange( + collateral_amount_change=Decimal("1.50"), + collateral_amount_type="BALANCE", + ) + ], + misc_fees=[ + MiscFee( + misc_fee_amt=Decimal("0.02"), + misc_fee_curr="USD", + misc_fee_type="4", + misc_fee_basis=0, + ) + ], + ), + MarketSettlementParty( + party_id="1", + party_role=24, + collateral_amount_changes=[ + CollateralAmountChange( + collateral_amount_change=Decimal("2.00"), + collateral_amount_type="PAYOUT", + ) + ], + ), + ], + ) + back = _roundtrip(msg) + assert back == msg + # First party: one collateral change + one misc fee. + assert back.parties[0].collateral_amount_changes[0].collateral_amount_type == "BALANCE" + assert back.parties[0].misc_fees[0].misc_fee_amt == Decimal("0.02") + assert back.parties[0].long_qty == Decimal("10.00") + # Second party: collateral only, empty nested misc-fees group. + assert back.parties[1].misc_fees == [] + assert back.parties[1].collateral_amount_changes[0].collateral_amount_type == "PAYOUT" + + +def test_nested_group_wire_structure() -> None: + msg = _Settlement( + report_id="R1", + parties=[ + MarketSettlementParty( + party_id="0", + collateral_amount_changes=[ + CollateralAmountChange( + collateral_amount_change=Decimal("1.50"), + collateral_amount_type="BALANCE", + ) + ], + ) + ], + ) + wire = encode( + [ + (int(Tag.MSG_TYPE), "UMS"), + (int(Tag.MSG_SEQ_NUM), "1"), + (int(Tag.SENDING_TIME), "20250101-00:00:00.000"), + *msg.to_body_fields(), + ] + ) + rendered = wire.replace(SOH, b"|").decode() + # Outer count, outer delimiter, inner count, inner delimiter — in order. + assert "20108=1|20109=0|" in rendered + assert "1703=1|1704=1.50|1705=BALANCE|" in rendered