Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions kalshi/models/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,11 @@ class CreateOrderRequest(BaseModel):
it. Callers passing ``type="market"`` (or similar) now get a
``ValidationError`` at construction time.

``ticker``, ``side``, and ``action`` are all required by the spec.
Pre-v2.3.0 the SDK defaulted ``action`` to ``"buy"`` as a convenience;
that default has been removed to match the spec required-set (#172).
``ticker``, ``side``, ``action``, and ``count`` are all required by the
spec. Pre-v2.3.0 the SDK defaulted ``action`` to ``"buy"`` (#172); the
follow-up (#242) also removed the silent ``count=1`` default — both are
money-risk drivers, since a missing arg would otherwise translate into
a real 1-contract BUY rather than a clear error.

See ``kalshi.resources.orders.OrdersResource.create`` for the
user-facing method that builds this model internally.
Expand All @@ -171,7 +173,7 @@ class CreateOrderRequest(BaseModel):
ticker: str
side: str
action: str
count: FixedPointCount = Field(default=Decimal("1"), serialization_alias="count_fp")
count: FixedPointCount = Field(serialization_alias="count_fp")
yes_price: DollarDecimal | None = Field(
default=None,
serialization_alias="yes_price_dollars",
Expand All @@ -196,16 +198,26 @@ class CreateOrderRequest(BaseModel):

@field_validator("buy_max_cost", mode="before")
@classmethod
def _reject_decimal_and_float_buy_max_cost(cls, v: object) -> object:
"""Reject Decimal and float inputs on buy_max_cost.
def _reject_decimal_float_and_bool_buy_max_cost(cls, v: object) -> object:
"""Reject Decimal, float, and bool inputs on buy_max_cost.

Spec says integer cents. Accepting Decimal would silently coerce
callers who pass Decimal('5.00') (expecting $5.00 under the old
DollarDecimal semantics) into 5 cents — data corruption with no
error. Reject at the boundary.
error. Bool is an ``int`` subclass, so ``True`` would otherwise
slip through as 1 (= 1 cent cap), the same class of bug #225
closed for ``DollarDecimal`` / ``FixedPointCount``. Reject at
the boundary (#243).

int and int-shaped strings are fine (Pydantic coerces normally).
"""
if isinstance(v, bool):
raise ValueError(
"buy_max_cost must be int (cents), not bool — "
"bool is an int subclass, so True would otherwise slip "
"through as 1 (= 1 cent cap). Pass cents directly "
"(e.g., 500 for $5.00)."
)
if isinstance(v, Decimal):
raise ValueError(
"buy_max_cost must be int (cents), not Decimal. "
Expand Down
26 changes: 22 additions & 4 deletions kalshi/resources/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,19 @@ def _build_create_order_body(
exchange_index=exchange_index,
)
if request is None:
if ticker is None or side is None:
if ticker is None or side is None or count is None or action is None:
raise TypeError(
"create() requires `ticker` and `side` (or pass `request=...`)"
"create() requires `ticker`, `side`, `count`, and `action` "
"(or pass `request=...`). Pre-#242 the SDK silently defaulted "
"missing `count` to 1 contract and missing `action` to \"buy\" — "
"that has been removed: a missing arg would otherwise translate "
"into a real 1-contract BUY on the wire."
)
request = CreateOrderRequest(
ticker=ticker,
side=side,
action=action if action is not None else "buy",
count=to_decimal(count if count is not None else 1),
action=action,
count=to_decimal(count),
yes_price=to_decimal(yes_price) if yes_price is not None else None,
no_price=to_decimal(no_price) if no_price is not None else None,
client_order_id=client_order_id,
Expand Down Expand Up @@ -372,6 +376,13 @@ def create(
the OpenAPI spec. Callers passing ``type="limit"`` now get a
``TypeError``.

#242 (v2.5): on the kwarg path, ``count`` and ``action`` are now
REQUIRED — passing neither raises ``TypeError`` before any HTTP
request. Previously the SDK silently defaulted to ``count=1`` and
``action="buy"``, which converted a missing-arg bug into a real
1-contract BUY fill. The ``request=CreateOrderRequest(...)``
overload is unaffected (the model itself now declares them required).

v1.1 (#56): pass a pre-built ``request=CreateOrderRequest(...)`` instead
of individual kwargs. Mutually exclusive with the kwarg form.
"""
Expand Down Expand Up @@ -834,6 +845,13 @@ async def create(
the OpenAPI spec. Callers passing ``type="limit"`` now get a
``TypeError``.

#242 (v2.5): on the kwarg path, ``count`` and ``action`` are now
REQUIRED — passing neither raises ``TypeError`` before any HTTP
request. Previously the SDK silently defaulted to ``count=1`` and
``action="buy"``, which converted a missing-arg bug into a real
1-contract BUY fill. The ``request=CreateOrderRequest(...)``
overload is unaffected (the model itself now declares them required).

v1.1 (#56): pass a pre-built ``request=CreateOrderRequest(...)`` instead
of individual kwargs. Mutually exclusive with the kwarg form.
"""
Expand Down
105 changes: 94 additions & 11 deletions tests/test_async_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async def test_no_phantom_type_in_wire(
return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})
)

await client.orders.create(ticker="MKT", side="yes", yes_price=0.5)
await client.orders.create(ticker="MKT", side="yes", action="buy", count=1, yes_price=0.5)

body = json.loads(route.calls[0].request.content)
assert "type" not in body
Expand All @@ -97,7 +97,7 @@ async def test_count_fp_not_count_in_wire(
return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})
)

await client.orders.create(ticker="MKT", side="yes", yes_price=0.5, count=3)
await client.orders.create(ticker="MKT", side="yes", action="buy", yes_price=0.5, count=3)

body = json.loads(route.calls[0].request.content)
assert "count_fp" in body
Expand All @@ -118,6 +118,8 @@ async def test_time_in_force_reaches_wire(
await client.orders.create(
ticker="MKT",
side="yes",
action="buy",
count=1,
yes_price=0.5,
time_in_force="fill_or_kill",
)
Expand All @@ -139,6 +141,8 @@ async def test_post_only_reduce_only_reach_wire(
await client.orders.create(
ticker="MKT",
side="yes",
action="buy",
count=1,
yes_price=0.5,
post_only=True,
reduce_only=False,
Expand All @@ -163,6 +167,8 @@ async def test_buy_max_cost_int_cents_wire(
await client.orders.create(
ticker="MKT",
side="yes",
action="buy",
count=1,
yes_price=0.5,
buy_max_cost=500,
)
Expand All @@ -185,6 +191,8 @@ async def test_subaccount_order_group_cancel_on_pause_stp_wire(
await client.orders.create(
ticker="MKT",
side="yes",
action="buy",
count=1,
yes_price=0.5,
subaccount=2,
order_group_id="grp-x",
Expand All @@ -208,6 +216,79 @@ async def test_type_kwarg_removed(self, client: AsyncKalshiClient) -> None:
type="market", # type: ignore[call-arg]
)

@respx.mock
@pytest.mark.asyncio
async def test_missing_count_and_action_raises_before_http(
self,
client: AsyncKalshiClient,
respx_mock: respx.MockRouter,
) -> None:
"""#242: async kwarg-form create() with missing ``count`` / ``action``
must raise ``TypeError`` BEFORE any HTTP request is dispatched."""
route = respx_mock.post(
"https://demo-api.kalshi.co/trade-api/v2/portfolio/orders"
).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}))

with pytest.raises(TypeError, match=r"count.*action"):
await client.orders.create(ticker="X", side="yes")
with pytest.raises(TypeError, match=r"count.*action"):
await client.orders.create(ticker="X", side="yes", action="buy")
with pytest.raises(TypeError, match=r"count.*action"):
await client.orders.create(ticker="X", side="yes", count=10)

assert route.call_count == 0

@respx.mock
@pytest.mark.asyncio
async def test_explicit_count_action_kwargs_still_work(
self,
client: AsyncKalshiClient,
respx_mock: respx.MockRouter,
) -> None:
"""#242: regression — explicit ``count`` + ``action`` kwargs still build
and dispatch normally."""
route = respx_mock.post(
"https://demo-api.kalshi.co/trade-api/v2/portfolio/orders"
).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}))

await client.orders.create(
ticker="X", side="yes", count=10, action="buy", yes_price="0.5",
)

assert route.call_count == 1
body = json.loads(route.calls[0].request.content)
assert body["count_fp"] == "10"
assert body["action"] == "buy"
assert body["yes_price_dollars"] == "0.5"

@respx.mock
@pytest.mark.asyncio
async def test_request_overload_unaffected_by_242(
self,
client: AsyncKalshiClient,
respx_mock: respx.MockRouter,
) -> None:
"""#242: ``request=CreateOrderRequest(...)`` path unaffected by the
kwarg-overload guard."""
route = respx_mock.post(
"https://demo-api.kalshi.co/trade-api/v2/portfolio/orders"
).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}))

await client.orders.create(
request=CreateOrderRequest(
ticker="X",
side="yes",
count=Decimal("10"),
action="buy",
yes_price=Decimal("0.5"),
)
)

assert route.call_count == 1
body = json.loads(route.calls[0].request.content)
assert body["count_fp"] == "10"
assert body["action"] == "buy"


class TestAsyncOrdersCreate:
@respx.mock
Expand All @@ -228,7 +309,9 @@ async def test_create_limit_order(self, orders: AsyncOrdersResource) -> None:
},
)
)
order = await orders.create(ticker="TEST-MKT", side="yes", count=10, yes_price=0.65)
order = await orders.create(
ticker="TEST-MKT", side="yes", action="buy", count=10, yes_price=0.65
)
assert order.order_id == "ord-123"
assert order.yes_price == Decimal("0.6500")
assert order.count == 10
Expand All @@ -244,7 +327,7 @@ async def test_create_order_no_price(self, orders: AsyncOrdersResource) -> None:
},
)
)
order = await orders.create(ticker="TEST-MKT", side="yes")
order = await orders.create(ticker="TEST-MKT", side="yes", action="buy", count=1)
assert order.order_id == "ord-456"

@respx.mock
Expand All @@ -256,7 +339,7 @@ async def test_decimal_price_conversion(self, orders: AsyncOrdersResource) -> No
json={"order": order_dict(order_id="ord-789", ticker="T")},
)
)
await orders.create(ticker="T", side="yes", yes_price=0.65)
await orders.create(ticker="T", side="yes", action="buy", count=1, yes_price=0.65)

body = json.loads(route.calls[0].request.content)
assert body["yes_price_dollars"] == "0.65"
Expand All @@ -269,7 +352,7 @@ async def test_validation_error(self, orders: AsyncOrdersResource) -> None:
return_value=httpx.Response(400, json={"message": "invalid ticker"})
)
with pytest.raises(KalshiValidationError):
await orders.create(ticker="INVALID", side="yes")
await orders.create(ticker="INVALID", side="yes", action="buy", count=1)


class TestAsyncOrdersGet:
Expand Down Expand Up @@ -484,8 +567,8 @@ async def test_batch_create(self, orders: AsyncOrdersResource) -> None:
)
)
reqs = [
CreateOrderRequest(ticker="A", side="yes", action="buy"),
CreateOrderRequest(ticker="B", side="no", action="buy"),
CreateOrderRequest(ticker="A", side="yes", action="buy", count=1),
CreateOrderRequest(ticker="B", side="no", action="buy", count=1),
]
from kalshi.models.orders import BatchCreateOrdersResponse

Expand Down Expand Up @@ -527,7 +610,7 @@ async def test_batch_create_partial_failure_does_not_crash(
)
)
result = await orders.batch_create(
[CreateOrderRequest(ticker="A", side="yes", action="buy")]
[CreateOrderRequest(ticker="A", side="yes", action="buy", count=1)]
)
assert result.orders[0].order is not None
assert result.orders[1].order is None
Expand Down Expand Up @@ -1187,8 +1270,8 @@ async def test_wraps_orders_key(self, orders: AsyncOrdersResource) -> None:

await orders.batch_create(
[
CreateOrderRequest(ticker="A", side="yes", action="buy"),
CreateOrderRequest(ticker="B", side="no", action="buy"),
CreateOrderRequest(ticker="A", side="yes", action="buy", count=1),
CreateOrderRequest(ticker="B", side="no", action="buy", count=1),
]
)

Expand Down
Loading
Loading