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
17 changes: 13 additions & 4 deletions kalshi/resources/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,23 @@ def _validate_max_pages(max_pages: int | None) -> None:
raise ValueError(f"max_pages must be positive or None, got {max_pages}")


def _join_tickers(value: list[str] | tuple[str, ...] | str | None) -> str | None:
"""Serialize the `tickers` query param (spec: comma-joined string, not explode:true).

List/tuple elements must be non-empty and comma-free; pre-joined strings pass through.
def _join_tickers(
value: list[str] | tuple[str, ...] | str | None,
*,
max_items: int | None = None,
) -> str | None:
"""Serialize a comma-joined ticker query param (spec: not explode:true).

List/tuple elements must be non-empty and comma-free; pre-joined strings
pass through. ``max_items`` (when set) caps the list length per spec —
e.g. ``MultipleEventTickerQuery`` declares a max of 10. Pre-joined string
inputs skip the cap (caller already opted into wire-format ownership).
"""
if not value:
return None
if isinstance(value, (list, tuple)):
if max_items is not None and len(value) > max_items:
raise ValueError(f"too many tickers: {len(value)} > spec max {max_items}")
for i, elem in enumerate(value):
if not elem:
raise ValueError(
Expand Down
73 changes: 72 additions & 1 deletion kalshi/resources/fcm.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from kalshi.models.common import Page
from kalshi.models.orders import Order, OrderStatusLiteral
from kalshi.models.portfolio import PositionsResponse, SettlementStatusLiteral
from kalshi.models.portfolio import MarketPosition, PositionsResponse, SettlementStatusLiteral
from kalshi.resources._base import (
AsyncResource,
SyncResource,
Expand Down Expand Up @@ -165,6 +165,44 @@ def positions(
data = self._get("/fcm/positions", params=params, extra_headers=extra_headers)
return PositionsResponse.model_validate(data)

def positions_all(
self,
*,
subtrader_id: str,
ticker: str | None = None,
event_ticker: str | None = None,
count_filter: str | None = None,
settlement_status: SettlementStatusLiteral | None = None,
limit: int | None = None,
max_pages: int | None = None,
extra_headers: dict[str, str] | None = None,
) -> Iterator[MarketPosition]:
"""Auto-paginate ``/fcm/positions``, yielding each ``MarketPosition``.

Mirrors :meth:`PortfolioResource.positions_all`. ``event_positions``
from the response envelope are intentionally not yielded; see that
docstring for the rationale.
"""
self._require_auth()
_validate_max_pages(max_pages)
params = _fcm_positions_params(
subtrader_id=subtrader_id,
ticker=ticker,
event_ticker=event_ticker,
count_filter=count_filter,
settlement_status=settlement_status,
limit=limit,
cursor=None,
)
return self._list_all(
"/fcm/positions",
MarketPosition,
"market_positions",
params=params,
max_pages=max_pages,
extra_headers=extra_headers,
)


class AsyncFcmResource(AsyncResource):
"""Async FCM API."""
Expand Down Expand Up @@ -256,3 +294,36 @@ async def positions(
)
data = await self._get("/fcm/positions", params=params, extra_headers=extra_headers)
return PositionsResponse.model_validate(data)

def positions_all(
self,
*,
subtrader_id: str,
ticker: str | None = None,
event_ticker: str | None = None,
count_filter: str | None = None,
settlement_status: SettlementStatusLiteral | None = None,
limit: int | None = None,
max_pages: int | None = None,
extra_headers: dict[str, str] | None = None,
) -> AsyncIterator[MarketPosition]:
"""Async counterpart of :meth:`FcmResource.positions_all`. Use ``async for``."""
self._require_auth()
_validate_max_pages(max_pages)
params = _fcm_positions_params(
subtrader_id=subtrader_id,
ticker=ticker,
event_ticker=event_ticker,
count_filter=count_filter,
settlement_status=settlement_status,
limit=limit,
cursor=None,
)
return self._list_all(
"/fcm/positions",
MarketPosition,
"market_positions",
params=params,
max_pages=max_pages,
extra_headers=extra_headers,
)
34 changes: 32 additions & 2 deletions kalshi/resources/multivariate.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from collections.abc import AsyncIterator, Iterator
from typing import Any, overload

from typing_extensions import deprecated

from kalshi.models.common import Page
from kalshi.models.multivariate import (
CreateMarketInMultivariateEventCollectionRequest,
Expand All @@ -29,6 +31,28 @@

# Shared param + body builders (issue #46).

# Spec /multivariate_event_collections/{ticker}/lookup (GET):
# lookback_seconds enum is {10, 60, 300, 3600}.
_VALID_LOOKBACK_SECONDS: frozenset[int] = frozenset({10, 60, 300, 3600})

# Spec marks these endpoints as deprecated (`deprecated: true`) with the
# message reproduced below. Applied to both sync and async classes via
# `typing_extensions.deprecated`, which emits DeprecationWarning on call
# and is recognised by type checkers (PEP 702).
_DEPRECATION_MSG = "This endpoint predates RFQs and should not be used for new integrations."


def _validate_lookback_seconds(value: int) -> int:
"""Reject ``lookback_seconds`` outside the spec enum {10, 60, 300, 3600}.

Server returns 400 for anything else; failing client-side saves the
round trip and surfaces a more actionable error (mirrors the limit
bounds check added in #229).
"""
if value not in _VALID_LOOKBACK_SECONDS:
raise ValueError(f"lookback_seconds must be one of {{10, 60, 300, 3600}}, got {value}.")
return value


def _list_collections_params(
*,
Expand Down Expand Up @@ -178,6 +202,7 @@ def create_market(
with_market_payload: bool | None = ...,
extra_headers: dict[str, str] | None = None,
) -> CreateMarketResponse: ...
@deprecated(_DEPRECATION_MSG)
def create_market(
self,
collection_ticker: str,
Expand Down Expand Up @@ -216,6 +241,7 @@ def lookup_tickers(
selected_markets: builtins.list[TickerPair],
extra_headers: dict[str, str] | None = None,
) -> LookupTickersResponse: ...
@deprecated(_DEPRECATION_MSG)
def lookup_tickers(
self,
collection_ticker: str,
Expand All @@ -236,14 +262,15 @@ def lookup_tickers(
)
return _parse_lookup_tickers_response(data)

@deprecated(_DEPRECATION_MSG)
def lookup_history(
self,
collection_ticker: str,
*,
lookback_seconds: int,
extra_headers: dict[str, str] | None = None,
) -> builtins.list[LookupPoint]:
params = _params(lookback_seconds=lookback_seconds)
params = _params(lookback_seconds=_validate_lookback_seconds(lookback_seconds))
data = self._get(
f"/multivariate_event_collections/{_seg(collection_ticker, name='collection_ticker')}/lookup", # noqa: E501
params=params,
Expand Down Expand Up @@ -334,6 +361,7 @@ async def create_market(
with_market_payload: bool | None = ...,
extra_headers: dict[str, str] | None = None,
) -> CreateMarketResponse: ...
@deprecated(_DEPRECATION_MSG)
async def create_market(
self,
collection_ticker: str,
Expand Down Expand Up @@ -372,6 +400,7 @@ async def lookup_tickers(
selected_markets: builtins.list[TickerPair],
extra_headers: dict[str, str] | None = None,
) -> LookupTickersResponse: ...
@deprecated(_DEPRECATION_MSG)
async def lookup_tickers(
self,
collection_ticker: str,
Expand All @@ -392,14 +421,15 @@ async def lookup_tickers(
)
return _parse_lookup_tickers_response(data)

@deprecated(_DEPRECATION_MSG)
async def lookup_history(
self,
collection_ticker: str,
*,
lookback_seconds: int,
extra_headers: dict[str, str] | None = None,
) -> builtins.list[LookupPoint]:
params = _params(lookback_seconds=lookback_seconds)
params = _params(lookback_seconds=_validate_lookback_seconds(lookback_seconds))
data = await self._get(
f"/multivariate_event_collections/{_seg(collection_ticker, name='collection_ticker')}/lookup", # noqa: E501
params=params,
Expand Down
13 changes: 7 additions & 6 deletions kalshi/resources/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ def _build_decrease_body(
def _list_orders_params(
*,
ticker: str | None,
event_ticker: str | None,
event_ticker: builtins.list[str] | str | None,
status: OrderStatusLiteral | None,
min_ts: int | None,
max_ts: int | None,
Expand All @@ -262,7 +262,8 @@ def _list_orders_params(
limit = _validate_limit(limit, hi=1000)
return _params(
ticker=ticker,
event_ticker=event_ticker,
# Spec MultipleEventTickerQuery: comma-joined, max 10.
event_ticker=_join_tickers(event_ticker, max_items=10),
status=status,
min_ts=min_ts,
max_ts=max_ts,
Expand Down Expand Up @@ -447,7 +448,7 @@ def list(
self,
*,
ticker: str | None = None,
event_ticker: str | None = None,
event_ticker: builtins.list[str] | str | None = None,
status: OrderStatusLiteral | None = None,
min_ts: int | None = None,
max_ts: int | None = None,
Expand Down Expand Up @@ -475,7 +476,7 @@ def list_all(
self,
*,
ticker: str | None = None,
event_ticker: str | None = None,
event_ticker: builtins.list[str] | str | None = None,
status: OrderStatusLiteral | None = None,
min_ts: int | None = None,
max_ts: int | None = None,
Expand Down Expand Up @@ -1013,7 +1014,7 @@ async def list(
self,
*,
ticker: str | None = None,
event_ticker: str | None = None,
event_ticker: builtins.list[str] | str | None = None,
status: OrderStatusLiteral | None = None,
min_ts: int | None = None,
max_ts: int | None = None,
Expand Down Expand Up @@ -1041,7 +1042,7 @@ def list_all(
self,
*,
ticker: str | None = None,
event_ticker: str | None = None,
event_ticker: builtins.list[str] | str | None = None,
status: OrderStatusLiteral | None = None,
min_ts: int | None = None,
max_ts: int | None = None,
Expand Down
71 changes: 71 additions & 0 deletions kalshi/resources/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from kalshi.models.portfolio import (
Balance,
Deposit,
MarketPosition,
PositionsResponse,
Settlement,
TotalRestingOrderValue,
Expand Down Expand Up @@ -101,6 +102,45 @@ def positions(
data = self._get("/portfolio/positions", params=params, extra_headers=extra_headers)
return PositionsResponse.model_validate(data)

def positions_all(
self,
*,
limit: int | None = None,
count_filter: str | None = None,
ticker: str | None = None,
event_ticker: str | None = None,
subaccount: int | None = None,
max_pages: int | None = None,
extra_headers: dict[str, str] | None = None,
) -> Iterator[MarketPosition]:
"""Auto-paginate ``/portfolio/positions``, yielding each ``MarketPosition``.

Mirrors :meth:`settlements_all`. The endpoint response also carries
``event_positions`` (aggregate roll-ups over the same underlying
markets); those are *not* surfaced here because page boundaries cut
the aggregate arbitrarily and concatenating across pages would not
recompute a meaningful event-level total. Callers that need the
event view should iterate :meth:`positions` page-by-page.
"""
self._require_auth()
_validate_max_pages(max_pages)
params = _positions_params(
limit=limit,
cursor=None,
count_filter=count_filter,
ticker=ticker,
event_ticker=event_ticker,
subaccount=subaccount,
)
return self._list_all(
"/portfolio/positions",
MarketPosition,
"market_positions",
params=params,
max_pages=max_pages,
extra_headers=extra_headers,
)

def settlements(
self,
*,
Expand Down Expand Up @@ -284,6 +324,37 @@ async def positions(
data = await self._get("/portfolio/positions", params=params, extra_headers=extra_headers)
return PositionsResponse.model_validate(data)

def positions_all(
self,
*,
limit: int | None = None,
count_filter: str | None = None,
ticker: str | None = None,
event_ticker: str | None = None,
subaccount: int | None = None,
max_pages: int | None = None,
extra_headers: dict[str, str] | None = None,
) -> AsyncIterator[MarketPosition]:
"""Async counterpart of :meth:`PortfolioResource.positions_all`. Use ``async for``."""
self._require_auth()
_validate_max_pages(max_pages)
params = _positions_params(
limit=limit,
cursor=None,
count_filter=count_filter,
ticker=ticker,
event_ticker=event_ticker,
subaccount=subaccount,
)
return self._list_all(
"/portfolio/positions",
MarketPosition,
"market_positions",
params=params,
max_pages=max_pages,
extra_headers=extra_headers,
)

async def settlements(
self,
*,
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dependencies = [
"pydantic>=2.0,<3",
"cryptography>=43,<49",
"websockets>=14,<17",
"typing-extensions>=4.5",
]

[project.optional-dependencies]
Expand Down Expand Up @@ -74,6 +75,13 @@ markers = [
"integration: tests that hit the real Kalshi demo API (require credentials)",
"integration_real_api_only: endpoints the demo server cannot service (auth-gated roles, demo-broken). Skipped under the default integration run; enable explicitly with -m integration_real_api_only against a prod-like account.",
]
filterwarnings = [
# `kalshi.resources.multivariate.{lookup_tickers,lookup_history,create_market}`
# are flagged `@deprecated` per spec (the endpoints predate RFQs). Tests
# that exercise them on purpose would drown CI in noise; user callers
# still see the warning in their own code.
"ignore:This endpoint predates RFQs.*:DeprecationWarning",
]

[dependency-groups]
dev = [
Expand Down
Loading
Loading