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
33 changes: 17 additions & 16 deletions kalshi/models/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@

from kalshi.types import DollarDecimal, FixedPointCount, StrictInt

# Literal aliases for fixed-enum kwargs on order resource methods.
# Literal aliases for fixed-enum kwargs on order resource methods and the
# matching V1 / V2 request models.
# Source of truth: OpenAPI spec v3.13.0 (specs/openapi.yaml).
# The Pydantic request models leave these fields as ``str`` to remain tolerant
# of spec drift; the static-type narrowing happens at the resource-method
# boundary where users actually pass values.
# V1 ``CreateOrderRequest`` (#270) and ``AmendOrderRequest`` (#312) plus V2
# ``CreateOrderV2Request`` all carry these narrowed types so a typo fails
# at construction rather than as a 400 from the server.
SideLiteral = Literal["yes", "no"]
"""Order side. Spec: CreateOrderRequest.side / AmendOrderRequest.side enum."""

Expand Down Expand Up @@ -191,8 +192,8 @@ class CreateOrderRequest(BaseModel):
self_trade_prevention_type: SelfTradePreventionTypeLiteral | None = None
order_group_id: str | None = None
cancel_order_on_pause: bool | None = None
subaccount: StrictInt | None = None
exchange_index: StrictInt | None = None
subaccount: StrictInt | None = Field(default=None, ge=0)
exchange_index: StrictInt | None = Field(default=None, ge=0)

model_config = {"extra": "forbid"}

Expand Down Expand Up @@ -250,8 +251,8 @@ class AmendOrderRequest(BaseModel):
"""

ticker: str
side: str
action: str
side: SideLiteral
action: ActionLiteral
yes_price: DollarDecimal | None = Field(
default=None,
serialization_alias="yes_price_dollars",
Expand All @@ -266,8 +267,8 @@ class AmendOrderRequest(BaseModel):
)
client_order_id: str | None = None
updated_client_order_id: str | None = None
subaccount: StrictInt | None = None
exchange_index: StrictInt | None = None
subaccount: StrictInt | None = Field(default=None, ge=0)
exchange_index: StrictInt | None = Field(default=None, ge=0)

model_config = {"extra": "forbid"}

Expand All @@ -288,8 +289,8 @@ class DecreaseOrderRequest(BaseModel):

reduce_by: StrictInt | None = None
reduce_to: StrictInt | None = None
subaccount: StrictInt | None = None
exchange_index: StrictInt | None = None
subaccount: StrictInt | None = Field(default=None, ge=0)
exchange_index: StrictInt | None = Field(default=None, ge=0)

model_config = {"extra": "forbid"}

Expand Down Expand Up @@ -332,8 +333,8 @@ class BatchCancelOrdersRequestOrder(BaseModel):
"""

order_id: str
subaccount: StrictInt | None = None
exchange_index: StrictInt | None = None
subaccount: StrictInt | None = Field(default=None, ge=0)
exchange_index: StrictInt | None = Field(default=None, ge=0)

model_config = {"extra": "forbid"}

Expand Down Expand Up @@ -481,7 +482,7 @@ class CreateOrderV2Request(BaseModel):
reduce_only: bool | None = None
subaccount: StrictInt | None = Field(default=None, ge=0)
order_group_id: str | None = None
exchange_index: StrictInt | None = None
exchange_index: StrictInt | None = Field(default=None, ge=0)

model_config = {"extra": "forbid"}

Expand Down Expand Up @@ -623,7 +624,7 @@ class BatchCancelOrdersV2RequestOrder(BaseModel):

order_id: str
subaccount: StrictInt | None = Field(default=None, ge=0)
exchange_index: StrictInt | None = None
exchange_index: StrictInt | None = Field(default=None, ge=0)

model_config = {"extra": "forbid"}

Expand Down
8 changes: 4 additions & 4 deletions kalshi/resources/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,8 @@ def create(
*,
ticker: str,
side: SideLiteral,
action: ActionLiteral | None = ...,
count: int | None = ...,
action: ActionLiteral,
count: int,
yes_price: float | str | int | None = ...,
no_price: float | str | int | None = ...,
client_order_id: str | None = ...,
Expand Down Expand Up @@ -899,8 +899,8 @@ async def create(
*,
ticker: str,
side: SideLiteral,
action: ActionLiteral | None = ...,
count: int | None = ...,
action: ActionLiteral,
count: int,
yes_price: float | str | int | None = ...,
no_price: float | str | int | None = ...,
client_order_id: str | None = ...,
Expand Down
2 changes: 1 addition & 1 deletion tests/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ async def test_orders_create_raises_auth_required(self) -> None:

resource = AsyncOrdersResource(transport)
with pytest.raises(AuthRequiredError):
await resource.create(ticker="TEST", side="yes")
await resource.create(ticker="TEST", side="yes", action="buy", count=1)

@pytest.mark.asyncio
async def test_portfolio_balance_raises_auth_required(self) -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_async_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,7 @@ async def test_amend_validation_error(self, orders: AsyncOrdersResource) -> None
return_value=httpx.Response(400, json={"message": "invalid"})
)
with pytest.raises(KalshiValidationError):
await orders.amend("ord-123", ticker="T", side="bad", action="buy", yes_price=0.50)
await orders.amend("ord-123", ticker="T", side="yes", action="buy", yes_price=0.50)

@pytest.mark.asyncio
async def test_amend_requires_price_or_count(self, orders: AsyncOrdersResource) -> None:
Expand Down
52 changes: 51 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -872,7 +872,7 @@ def test_orders_create_raises_auth_required(self) -> None:

resource = OrdersResource(transport)
with pytest.raises(AuthRequiredError):
resource.create(ticker="TEST", side="yes")
resource.create(ticker="TEST", side="yes", action="buy", count=1)

def test_orders_list_raises_auth_required(self) -> None:
config = KalshiConfig(
Expand Down Expand Up @@ -1467,3 +1467,53 @@ def test_sync_transport_close_is_idempotent(self, transport: SyncTransport) -> N
transport.close()
transport.close() # triple-close OK
assert transport._closed is True


class TestIssue350OrdersCreateOverloadRequiresActionCount:
"""#350: orders.create() kwarg overload requires ``action`` and ``count``.

v2.5 (#242) removed the silent ``count=1`` / ``action="buy"`` defaults
at runtime, raising ``TypeError`` when either is missing. The kwarg
overload still advertised them as ``... | None = ...``, so mypy
accepted the missing-arg shape silently. This test pins both the
runtime guard and the type-system fence: removing ``action`` or
``count`` triggers a ``call-overload`` mypy error and a runtime
``TypeError`` before any HTTP traffic.
"""

def test_issue_350_orders_create_overload_requires_action_count(
self, test_auth: KalshiAuth
) -> None:
config = KalshiConfig(
base_url="https://test.kalshi.com/trade-api/v2",
timeout=5.0,
max_retries=0,
)
transport = SyncTransport(test_auth, config)
from kalshi.resources.orders import OrdersResource

resource = OrdersResource(transport)

# Runtime guard from #242: missing ``action`` raises before HTTP.
with pytest.raises(TypeError, match=r"action"):
resource.create( # type: ignore[call-overload]
ticker="TEST",
side="yes",
count=1,
)

# Runtime guard: missing ``count`` raises before HTTP.
with pytest.raises(TypeError, match=r"count"):
resource.create( # type: ignore[call-overload]
ticker="TEST",
side="yes",
action="buy",
)

# The ``# type: ignore[call-overload]`` markers above demonstrate the
# static fence: mypy --strict refuses the missing-arg shapes; if the
# overload were re-loosened to ``ActionLiteral | None = ...`` again,
# mypy would emit ``unused-ignore`` on these lines and fail the
# repo-wide strict check.

transport.close()
137 changes: 137 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import ClassVar

import pytest
from pydantic import ValidationError

from kalshi.models.communications import RFQ, Quote
from kalshi.models.events import Event, EventMetadata
Expand Down Expand Up @@ -1683,3 +1684,139 @@ def test_strict_int_rejects_bool_on_request_models(
for bool_value in (True, False):
with pytest.raises(ValidationError, match=r"bool"):
model_cls(**{**other_kwargs, field_name: bool_value})


class TestIssue312AmendOrderRequestLiteralNarrowing:
"""#312: AmendOrderRequest.side/.action narrowed to Literal aliases.

Mirrors the v2.5 #270 narrowing on CreateOrderRequest. A typo like
``side="yess"`` now fails at construction rather than at the server's
400 response after a signed HTTP round-trip.
"""

def test_issue_312_amend_order_request_rejects_invalid_side(self) -> None:
from kalshi.models.orders import AmendOrderRequest

with pytest.raises(ValidationError):
AmendOrderRequest(
ticker="MKT",
side="yess", # type: ignore[arg-type]
action="buy",
count=Decimal(1),
)

def test_issue_312_amend_order_request_rejects_invalid_action(self) -> None:
from kalshi.models.orders import AmendOrderRequest

with pytest.raises(ValidationError):
AmendOrderRequest(
ticker="MKT",
side="yes",
action="buyy", # type: ignore[arg-type]
count=Decimal(1),
)

def test_issue_312_amend_order_request_accepts_valid_literals(self) -> None:
from kalshi.models.orders import AmendOrderRequest

req = AmendOrderRequest(ticker="MKT", side="no", action="sell")
assert req.side == "no"
assert req.action == "sell"


class TestIssue326V1SubaccountGeZero:
"""#326: V1 order request models gain ``ge=0`` on subaccount-shaped ints.

Achieves parity with V2 + communications + order_groups + subaccount-
transfer. Negative ``subaccount``/``exchange_index`` previously survived
construction, was signed by the SDK, and was 400'd by the server.

Also sweeps the V2 ``exchange_index`` slots missed by the original
V2 hardening.
"""

@pytest.mark.parametrize(
("model_path", "field_name", "other_kwargs"),
[
# V1 CreateOrderRequest
(
"kalshi.models.orders:CreateOrderRequest",
"subaccount",
{"ticker": "MKT", "side": "yes", "action": "buy", "count": 1},
),
(
"kalshi.models.orders:CreateOrderRequest",
"exchange_index",
{"ticker": "MKT", "side": "yes", "action": "buy", "count": 1},
),
# V1 AmendOrderRequest
(
"kalshi.models.orders:AmendOrderRequest",
"subaccount",
{"ticker": "MKT", "side": "yes", "action": "buy"},
),
(
"kalshi.models.orders:AmendOrderRequest",
"exchange_index",
{"ticker": "MKT", "side": "yes", "action": "buy"},
),
# V1 DecreaseOrderRequest
(
"kalshi.models.orders:DecreaseOrderRequest",
"subaccount",
{"reduce_by": 1},
),
(
"kalshi.models.orders:DecreaseOrderRequest",
"exchange_index",
{"reduce_by": 1},
),
# V1 BatchCancelOrdersRequestOrder
(
"kalshi.models.orders:BatchCancelOrdersRequestOrder",
"subaccount",
{"order_id": "abc"},
),
(
"kalshi.models.orders:BatchCancelOrdersRequestOrder",
"exchange_index",
{"order_id": "abc"},
),
# V2 exchange_index slots that were missed in #295
(
"kalshi.models.orders:CreateOrderV2Request",
"exchange_index",
{
"ticker": "MKT",
"client_order_id": "c1",
"side": "bid",
"count": 1,
"price": "0.50",
"time_in_force": "good_till_canceled",
"self_trade_prevention_type": "maker",
},
),
(
"kalshi.models.orders:BatchCancelOrdersV2RequestOrder",
"exchange_index",
{"order_id": "abc"},
),
],
)
def test_issue_326_v1_subaccount_ge_zero(
self,
model_path: str,
field_name: str,
other_kwargs: dict[str, object],
) -> None:
import importlib

module_name, class_name = model_path.split(":")
model_cls = getattr(importlib.import_module(module_name), class_name)

with pytest.raises(ValidationError, match=r"greater than or equal to 0"):
model_cls(**{**other_kwargs, field_name: -1})

# Zero (primary subaccount / first shard) remains valid.
instance = model_cls(**{**other_kwargs, field_name: 0})
assert getattr(instance, field_name) == 0
2 changes: 1 addition & 1 deletion tests/test_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,7 @@ def test_amend_validation_error(self, orders: OrdersResource) -> None:
return_value=httpx.Response(400, json={"message": "invalid side"})
)
with pytest.raises(KalshiValidationError):
orders.amend("ord-300", ticker="T", side="invalid", action="buy", yes_price=0.50)
orders.amend("ord-300", ticker="T", side="yes", action="buy", yes_price=0.50)

def test_amend_requires_price_or_count(self, orders: OrdersResource) -> None:
with pytest.raises(ValueError, match="requires at least one"):
Expand Down
Loading