Skip to content

Commit fc4af90

Browse files
TexasCodingclaude
andauthored
feat(fix): order-entry message flow (closes #424) (#431)
* 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>
1 parent 0ff1009 commit fc4af90

6 files changed

Lines changed: 736 additions & 10 deletions

File tree

kalshi/fix/__init__.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,32 +64,56 @@
6464
KalshiFixError,
6565
)
6666
from kalshi.fix.messages import (
67+
APP_MESSAGE_MODELS,
68+
BusinessMessageReject,
69+
CollateralAmountChange,
70+
ExecutionReport,
71+
FixGroup,
72+
FixGroupMeta,
6773
FixMessage,
6874
Heartbeat,
6975
Logon,
7076
Logout,
77+
MarketSettlementParty,
78+
MiscFee,
79+
MultivariateSelectedLeg,
80+
NewOrderSingle,
81+
OrderCancelReject,
82+
OrderCancelReplaceRequest,
83+
OrderCancelRequest,
84+
OrderMassCancelReport,
85+
OrderMassCancelRequest,
86+
Party,
7187
Reject,
7288
ResendRequest,
7389
SequenceReset,
7490
TestRequest,
91+
decode_app_message,
92+
groupfield,
7593
)
7694
from kalshi.fix.session import FixSession, FixSessionState
7795
from kalshi.fix.tags import Tag
7896

7997
# Sorted (ruff RUF022); grouping is by the imports above, not by ``__all__`` order.
8098
__all__ = [
99+
"APP_MESSAGE_MODELS",
81100
"BEGIN_STRING_FIXT11",
82101
"SOH",
83102
"ApplVerID",
103+
"BusinessMessageReject",
104+
"CollateralAmountChange",
84105
"EncryptMethod",
85106
"ExecInst",
86107
"ExecType",
108+
"ExecutionReport",
87109
"FixClient",
88110
"FixCodecError",
89111
"FixConfig",
90112
"FixConnection",
91113
"FixConnectionError",
92114
"FixEnvironment",
115+
"FixGroup",
116+
"FixGroupMeta",
93117
"FixLogonError",
94118
"FixMessage",
95119
"FixParser",
@@ -105,9 +129,19 @@
105129
"KalshiFixError",
106130
"Logon",
107131
"Logout",
132+
"MarketSettlementParty",
133+
"MiscFee",
108134
"MsgType",
135+
"MultivariateSelectedLeg",
136+
"NewOrderSingle",
109137
"OrdStatus",
110138
"OrdType",
139+
"OrderCancelReject",
140+
"OrderCancelReplaceRequest",
141+
"OrderCancelRequest",
142+
"OrderMassCancelReport",
143+
"OrderMassCancelRequest",
144+
"Party",
111145
"RawMessage",
112146
"Reject",
113147
"ResendRequest",
@@ -119,5 +153,7 @@
119153
"TestRequest",
120154
"TimeInForce",
121155
"decode",
156+
"decode_app_message",
122157
"encode",
158+
"groupfield",
123159
]

kalshi/fix/messages/__init__.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Typed FIX message models (FIX Dictionary v1.03).
22
33
Exposes the base framework (scalar + repeating-group fields), the session-layer
4-
(admin) messages, and the shared repeating-group entry components. Application
5-
messages (order entry, drop copy, market data, RFQ/settlement) are added in later
6-
phases — see GH #402.
4+
(admin) messages, the shared repeating-group entry components, and the
5+
order-entry message flow. Market-data, drop-copy, and RFQ/settlement flows are
6+
added in later phases — see GH #402.
77
"""
88

99
from __future__ import annotations
@@ -23,6 +23,18 @@
2323
MultivariateSelectedLeg,
2424
Party,
2525
)
26+
from kalshi.fix.messages.order_entry import (
27+
APP_MESSAGE_MODELS,
28+
BusinessMessageReject,
29+
ExecutionReport,
30+
NewOrderSingle,
31+
OrderCancelReject,
32+
OrderCancelReplaceRequest,
33+
OrderCancelRequest,
34+
OrderMassCancelReport,
35+
OrderMassCancelRequest,
36+
decode_app_message,
37+
)
2638
from kalshi.fix.messages.session import (
2739
Heartbeat,
2840
Logon,
@@ -34,7 +46,10 @@
3446
)
3547

3648
__all__ = [
49+
"APP_MESSAGE_MODELS",
50+
"BusinessMessageReject",
3751
"CollateralAmountChange",
52+
"ExecutionReport",
3853
"FixGroup",
3954
"FixGroupMeta",
4055
"FixMessage",
@@ -45,11 +60,18 @@
4560
"MarketSettlementParty",
4661
"MiscFee",
4762
"MultivariateSelectedLeg",
63+
"NewOrderSingle",
64+
"OrderCancelReject",
65+
"OrderCancelReplaceRequest",
66+
"OrderCancelRequest",
67+
"OrderMassCancelReport",
68+
"OrderMassCancelRequest",
4869
"Party",
4970
"Reject",
5071
"ResendRequest",
5172
"SequenceReset",
5273
"TestRequest",
74+
"decode_app_message",
5375
"fixfield",
5476
"groupfield",
5577
]

kalshi/fix/messages/base.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
from __future__ import annotations
2727

28+
import logging
2829
from dataclasses import dataclass
2930
from decimal import Decimal
3031
from enum import StrEnum
@@ -37,6 +38,8 @@
3738
from kalshi.fix.enums import MsgType
3839
from kalshi.fix.tags import DATA_LENGTH_FIELDS
3940

41+
logger = logging.getLogger("kalshi.fix")
42+
4043
# Reverse of DATA_LENGTH_FIELDS: data_tag -> length_tag. Used by to_body_fields
4144
# to auto-emit the length field immediately before a data field.
4245
_DATA_TO_LENGTH: dict[int, int] = {data: length for length, data in DATA_LENGTH_FIELDS.items()}
@@ -77,7 +80,7 @@ class FixGroupMeta:
7780
"""
7881

7982
num_in_group_tag: int
80-
entry_model: type[_FixFieldModel]
83+
entry_model: type[FixGroup]
8184

8285

8386
@dataclass(frozen=True)
@@ -91,7 +94,7 @@ class _ScalarSpec:
9194
class _GroupSpec:
9295
name: str
9396
count_tag: int
94-
entry_model: type[_FixFieldModel]
97+
entry_model: type[FixGroup]
9598

9699

97100
_FieldSpec = _ScalarSpec | _GroupSpec
@@ -173,8 +176,8 @@ def _parse_group(
173176
pairs: list[tuple[int, str]],
174177
start: int,
175178
count: int,
176-
entry_model: type[_FixFieldModel],
177-
) -> tuple[list[_FixFieldModel], int]:
179+
entry_model: type[FixGroup],
180+
) -> tuple[list[FixGroup], int]:
178181
"""Parse up to ``count`` group entries from ``pairs`` beginning at ``start``.
179182
180183
Each entry begins at the entry model's delimiter (first) tag and runs until
@@ -184,7 +187,7 @@ def _parse_group(
184187
"""
185188
delim = entry_model._first_tag()
186189
member_tags = entry_model._member_tags()
187-
entries: list[_FixFieldModel] = []
190+
entries: list[FixGroup] = []
188191
i = start
189192
n = len(pairs)
190193
while len(entries) < count and i < n and pairs[i][0] == delim:
@@ -194,6 +197,14 @@ def _parse_group(
194197
entry_pairs.append(pairs[i])
195198
i += 1
196199
entries.append(entry_model._from_pairs(entry_pairs))
200+
if len(entries) < count:
201+
# Malformed FIX: NumInGroup promised more entries than were delivered.
202+
logger.debug(
203+
"FIX group %s: NumInGroup=%d but only %d entries parsed",
204+
entry_model.__name__,
205+
count,
206+
len(entries),
207+
)
197208
return entries, i
198209

199210

@@ -258,7 +269,12 @@ def _member_tags(cls) -> frozenset[int]:
258269

259270
@classmethod
260271
def _first_tag(cls) -> int:
261-
"""The delimiter tag — the first declared field's tag (group entry start)."""
272+
"""The delimiter tag — the first declared field's tag (group entry start).
273+
274+
Falls back to a leading group's ``NumInGroup`` tag, but no FIX group
275+
places a nested group as an entry's first field, so in practice this is
276+
always a scalar tag.
277+
"""
262278
for spec in cls._layout():
263279
return spec.tag if isinstance(spec, _ScalarSpec) else spec.count_tag
264280
raise ValueError(f"{cls.__name__} declares no FIX fields")
@@ -309,6 +325,11 @@ def _from_pairs(cls, pairs: list[tuple[int, str]]) -> Self:
309325
try:
310326
count = int(pairs[idx][1])
311327
except ValueError:
328+
logger.debug(
329+
"FIX group %s: non-integer NumInGroup %r",
330+
spec.entry_model.__name__,
331+
pairs[idx][1],
332+
)
312333
continue
313334
entries, _ = _parse_group(pairs, idx + 1, count, spec.entry_model)
314335
kwargs[spec.name] = entries

0 commit comments

Comments
 (0)