From 5ef447487fc54ca48079de6b273115bf1c153e4d Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 22 May 2026 10:56:57 -0500 Subject: [PATCH 1/3] BREAKING(v3): relocate fills to PortfolioResource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fills` and `fills_all` (sync + async) move from `OrdersResource` to `PortfolioResource`, grouping them with the rest of the `/portfolio/*` family (`settlements`, `deposits`, `withdrawals`). The endpoint URL `GET /portfolio/fills`, response shape `Page[Fill]`, and filters (`ticker`, `order_id`, `min_ts`, `max_ts`, `cursor`, `subaccount`) are unchanged — only the access path on the client moves. `OrdersResource.fills` / `OrdersResource.fills_all` (and async equivalents) remain in v3.0.0 as `@typing_extensions.deprecated` thin forwarders that delegate to the same `/portfolio/fills` primitive and emit a single `DeprecationWarning` per call. They will be removed in a future release. Tests cover the new location (happy path + cursor/filter parity, sync and async) and the deprecation behavior of the old location (single warning per call, behavior preserved). Docs in `docs/resources/portfolio.md` add a `Fills` section; `docs/resources/orders.md` keeps a one-line forwarder note pointing at the new location. Closes #351 --- docs/resources/orders.md | 12 +-- docs/resources/portfolio.md | 26 ++++++ kalshi/resources/orders.py | 47 +++++++++- kalshi/resources/portfolio.py | 153 ++++++++++++++++++++++++++++++++ tests/test_async_orders.py | 50 +++++++++++ tests/test_orders.py | 48 ++++++++++ tests/test_portfolio.py | 159 ++++++++++++++++++++++++++++++++++ 7 files changed, 489 insertions(+), 6 deletions(-) diff --git a/docs/resources/orders.md b/docs/resources/orders.md index df504f4..01f4c75 100644 --- a/docs/resources/orders.md +++ b/docs/resources/orders.md @@ -19,7 +19,7 @@ Every method here requires auth. | `batch_cancel(orders)` | `DELETE /portfolio/orders/batched` | never | | `amend(order_id, ...)` | `POST /portfolio/orders/{order_id}/amend` | never | | `decrease(order_id, *, reduce_by, reduce_to)` | `POST /portfolio/orders/{order_id}/decrease` | never | -| `fills(...)` / `fills_all(...)` | `GET /portfolio/fills` | yes | +| ~~`fills(...)` / `fills_all(...)`~~ | moved to `PortfolioResource` in v3.0.0 — see [Portfolio › Fills](portfolio.md#fills); the old methods remain as deprecated aliases until removal in a future release. | | `queue_positions(*, market_tickers, event_ticker)` | `GET /portfolio/orders/queue_positions` | yes | | `queue_position(order_id)` | `GET /portfolio/orders/{order_id}/queue_position` | yes | @@ -198,19 +198,21 @@ order = client.orders.decrease("ord_abc", reduce_to=5) # decrease size to 5 Passing neither or both raises `ValidationError` at construction (XOR enforced by the model). -## List orders and fills +## List orders ```python for order in client.orders.list_all(status="resting"): print(order.order_id, order.ticker, order.remaining_count) - -for fill in client.orders.fills_all(ticker="KXPRES-24-DJT"): - print(fill.fill_id, fill.price, fill.count, fill.is_taker) ``` `status` accepts an `OrderStatusLiteral`: `"resting"`, `"canceled"`, `"executed"`. `min_ts` / `max_ts` (Unix seconds) bound by created time. +Fills (`fills` / `fills_all`) moved to `PortfolioResource` in v3.0.0 — see +[Portfolio › Fills](portfolio.md#fills). `client.orders.fills(...)` / +`client.orders.fills_all(...)` still work in v3.0.0 but emit a +`DeprecationWarning` and will be removed in a future release. + ## Queue position ```python diff --git a/docs/resources/portfolio.md b/docs/resources/portfolio.md index d1c9bd9..d0f7830 100644 --- a/docs/resources/portfolio.md +++ b/docs/resources/portfolio.md @@ -10,6 +10,7 @@ Auth required throughout. | `balance(*, subaccount=None)` | `GET /portfolio/balance` | | `positions(*, ...)` | `GET /portfolio/positions` | | `settlements(...)` / `settlements_all(...)` | `GET /portfolio/settlements` | +| `fills(...)` / `fills_all(...)` | `GET /portfolio/fills` | | `total_resting_order_value()` | `GET /portfolio/summary/total_resting_order_value` (FCM only) | | `deposits(*, limit, cursor)` / `deposits_all(*, limit, max_pages)` | `GET /portfolio/deposits` | | `withdrawals(*, limit, cursor)` / `withdrawals_all(*, limit, max_pages)` | `GET /portfolio/withdrawals` | @@ -115,6 +116,31 @@ for s in client.portfolio.settlements_all(): Standard `Page[Settlement]` pagination — see [Pagination](../pagination.md). +## Fills + +```python +page = client.portfolio.fills( + ticker="KXPRES-24-DJT", + min_ts=1_700_000_000, + max_ts=1_800_000_000, + limit=200, +) +for f in page: + print(f.trade_id, f.order_id, f.yes_price, f.count, f.is_taker) + +# Or auto-paginate: +for f in client.portfolio.fills_all(ticker="KXPRES-24-DJT"): + ... +``` + +Standard `Page[Fill]` pagination — see [Pagination](../pagination.md). + +!!! note "Moved from `OrdersResource` in v3.0.0 (issue #351)" + `client.orders.fills(...)` / `client.orders.fills_all(...)` still work + in v3.0.0 but emit a `DeprecationWarning` and will be removed in a + future release. Update call sites to `client.portfolio.fills(...)` / + `client.portfolio.fills_all(...)`. + ## Total resting order value ```python diff --git a/kalshi/resources/orders.py b/kalshi/resources/orders.py index 3aa37d7..1f4823c 100644 --- a/kalshi/resources/orders.py +++ b/kalshi/resources/orders.py @@ -7,6 +7,8 @@ from decimal import Decimal from typing import Any, overload +from typing_extensions import deprecated + from kalshi.errors import KalshiError from kalshi.models.common import Page from kalshi.models.orders import ( @@ -582,6 +584,9 @@ def batch_cancel( raise KalshiError("Expected BatchCancelOrdersResponse body, got 204 No Content.") return BatchCancelOrdersResponse.model_validate(data) + @deprecated( + "OrdersResource.fills is deprecated; use client.portfolio.fills instead." + ) def fills( self, *, @@ -594,6 +599,12 @@ def fills( subaccount: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Fill]: + """List trade fills. + + .. deprecated:: 3.0.0 + Use :meth:`PortfolioResource.fills` instead. This forwarder + remains for one release and will be removed in a future version. + """ self._require_auth() params = _fills_params( ticker=ticker, @@ -604,10 +615,15 @@ def fills( cursor=cursor, subaccount=subaccount, ) + # NOTE: still hits /portfolio/fills directly — the canonical + # implementation now lives on PortfolioResource (issue #351). return self._list( "/portfolio/fills", Fill, "fills", params=params, extra_headers=extra_headers ) + @deprecated( + "OrdersResource.fills_all is deprecated; use client.portfolio.fills_all instead." + ) def fills_all( self, *, @@ -620,6 +636,12 @@ def fills_all( max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Iterator[Fill]: + """Auto-paginate trade fills. + + .. deprecated:: 3.0.0 + Use :meth:`PortfolioResource.fills_all` instead. This forwarder + remains for one release and will be removed in a future version. + """ self._require_auth() _validate_max_pages(max_pages) params = _fills_params( @@ -631,6 +653,8 @@ def fills_all( cursor=None, subaccount=subaccount, ) + # NOTE: still hits /portfolio/fills directly — the canonical + # implementation now lives on PortfolioResource (issue #351). return self._list_all( "/portfolio/fills", Fill, @@ -1125,6 +1149,9 @@ async def batch_cancel( raise KalshiError("Expected BatchCancelOrdersResponse body, got 204 No Content.") return BatchCancelOrdersResponse.model_validate(data) + @deprecated( + "AsyncOrdersResource.fills is deprecated; use client.portfolio.fills instead." + ) async def fills( self, *, @@ -1137,6 +1164,12 @@ async def fills( subaccount: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Fill]: + """List trade fills (async). + + .. deprecated:: 3.0.0 + Use :meth:`AsyncPortfolioResource.fills` instead. This forwarder + remains for one release and will be removed in a future version. + """ self._require_auth() params = _fills_params( ticker=ticker, @@ -1147,10 +1180,15 @@ async def fills( cursor=cursor, subaccount=subaccount, ) + # NOTE: still hits /portfolio/fills directly — the canonical + # implementation now lives on AsyncPortfolioResource (issue #351). return await self._list( "/portfolio/fills", Fill, "fills", params=params, extra_headers=extra_headers ) + @deprecated( + "AsyncOrdersResource.fills_all is deprecated; use client.portfolio.fills_all instead." + ) def fills_all( self, *, @@ -1163,7 +1201,12 @@ def fills_all( max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[Fill]: - """Returns an async iterator — use ``async for``.""" + """Auto-paginate trade fills (async). Use ``async for``. + + .. deprecated:: 3.0.0 + Use :meth:`AsyncPortfolioResource.fills_all` instead. This forwarder + remains for one release and will be removed in a future version. + """ self._require_auth() _validate_max_pages(max_pages) params = _fills_params( @@ -1175,6 +1218,8 @@ def fills_all( cursor=None, subaccount=subaccount, ) + # NOTE: still hits /portfolio/fills directly — the canonical + # implementation now lives on AsyncPortfolioResource (issue #351). return self._list_all( "/portfolio/fills", Fill, diff --git a/kalshi/resources/portfolio.py b/kalshi/resources/portfolio.py index 6f4194a..97df501 100644 --- a/kalshi/resources/portfolio.py +++ b/kalshi/resources/portfolio.py @@ -6,6 +6,7 @@ from typing import Any from kalshi.models.common import Page +from kalshi.models.orders import Fill from kalshi.models.portfolio import ( Balance, Deposit, @@ -67,6 +68,27 @@ def _settlements_params( subaccount=subaccount, ) +def _fills_params( + *, + ticker: str | None, + order_id: str | None, + min_ts: int | None, + max_ts: int | None, + limit: int | None, + cursor: str | None, + subaccount: int | None, +) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000) + return _params( + ticker=ticker, + order_id=order_id, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + subaccount=subaccount, + ) + class PortfolioResource(SyncResource): """Sync portfolio API.""" @@ -203,6 +225,71 @@ def settlements_all( extra_headers=extra_headers, ) + def fills( + self, + *, + ticker: str | None = None, + order_id: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + cursor: str | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[Fill]: + """List trade fills (``GET /portfolio/fills``). + + Moved from :class:`OrdersResource` in v3.0.0 (issue #351) to group + with the rest of the ``/portfolio/*`` family (``settlements``, + ``deposits``, ``withdrawals``). + """ + self._require_auth() + params = _fills_params( + ticker=ticker, + order_id=order_id, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + subaccount=subaccount, + ) + return self._list( + "/portfolio/fills", Fill, "fills", params=params, extra_headers=extra_headers + ) + + def fills_all( + self, + *, + ticker: str | None = None, + order_id: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + subaccount: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Iterator[Fill]: + """Auto-paginate trade fills. Moved from :class:`OrdersResource` in v3.0.0.""" + self._require_auth() + _validate_max_pages(max_pages) + params = _fills_params( + ticker=ticker, + order_id=order_id, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=None, + subaccount=subaccount, + ) + return self._list_all( + "/portfolio/fills", + Fill, + "fills", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + def total_resting_order_value( self, *, extra_headers: dict[str, str] | None = None ) -> TotalRestingOrderValue: @@ -426,6 +513,72 @@ def settlements_all( extra_headers=extra_headers, ) + async def fills( + self, + *, + ticker: str | None = None, + order_id: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + cursor: str | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[Fill]: + """List trade fills (``GET /portfolio/fills``, async). + + Moved from :class:`AsyncOrdersResource` in v3.0.0 (issue #351). + """ + self._require_auth() + params = _fills_params( + ticker=ticker, + order_id=order_id, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + subaccount=subaccount, + ) + return await self._list( + "/portfolio/fills", Fill, "fills", params=params, extra_headers=extra_headers + ) + + def fills_all( + self, + *, + ticker: str | None = None, + order_id: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + subaccount: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AsyncIterator[Fill]: + """Auto-paginate trade fills (async). Use ``async for``. + + Moved from :class:`AsyncOrdersResource` in v3.0.0 (issue #351). + """ + self._require_auth() + _validate_max_pages(max_pages) + params = _fills_params( + ticker=ticker, + order_id=order_id, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=None, + subaccount=subaccount, + ) + return self._list_all( + "/portfolio/fills", + Fill, + "fills", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + async def total_resting_order_value( self, *, extra_headers: dict[str, str] | None = None ) -> TotalRestingOrderValue: diff --git a/tests/test_async_orders.py b/tests/test_async_orders.py index 9077eda..1537a3a 100644 --- a/tests/test_async_orders.py +++ b/tests/test_async_orders.py @@ -1762,3 +1762,53 @@ async def test_batch_cancel_v2( orders=[BatchCancelOrdersV2RequestOrder(order_id="ord-a")], ), ) + + +# ── Issue #351: deprecated forwarders for fills on AsyncOrdersResource ─────── + + +class TestIssue351AsyncOrdersFillsDeprecated: + @respx.mock + @pytest.mark.asyncio + async def test_issue_351_orders_fills_emits_deprecation_warning( + self, orders: AsyncOrdersResource + ) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( + return_value=httpx.Response( + 200, + json={ + "fills": [ + fill_dict( + trade_id="t1", order_id="o1", yes_price_dollars="0.5", count_fp="5" + ) + ], + "cursor": "", + }, + ) + ) + with pytest.warns(DeprecationWarning, match=r"AsyncOrdersResource\.fills") as record: + page = await orders.fills() + assert len(page) == 1 + assert page.items[0].trade_id == "t1" + assert len([w for w in record if issubclass(w.category, DeprecationWarning)]) == 1 + + @respx.mock + @pytest.mark.asyncio + async def test_issue_351_orders_fills_all_emits_deprecation_warning( + self, orders: AsyncOrdersResource + ) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( + return_value=httpx.Response( + 200, + json={ + "fills": [fill_dict(trade_id="a", yes_price_dollars="0.5")], + "cursor": "", + }, + ) + ) + with pytest.warns( + DeprecationWarning, match=r"AsyncOrdersResource\.fills_all" + ) as record: + ids = [f.trade_id async for f in orders.fills_all()] + assert ids == ["a"] + assert len([w for w in record if issubclass(w.category, DeprecationWarning)]) == 1 diff --git a/tests/test_orders.py b/tests/test_orders.py index 8c4620a..6b3dd0a 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -2034,3 +2034,51 @@ def test_accepts_valid_v1_yes_no_side(self) -> None: assert req.side == "no" assert req.action == "sell" + + +# ── Issue #351: deprecated forwarders for fills on OrdersResource ──────────── + + +class TestIssue351OrdersFillsDeprecated: + @respx.mock + def test_issue_351_orders_fills_emits_deprecation_warning( + self, orders: OrdersResource + ) -> None: + """Old location still works and emits exactly one DeprecationWarning per call.""" + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( + return_value=httpx.Response( + 200, + json={ + "fills": [ + fill_dict( + trade_id="t1", order_id="o1", yes_price_dollars="0.5", count_fp="5" + ) + ], + "cursor": "", + }, + ) + ) + with pytest.warns(DeprecationWarning, match=r"OrdersResource\.fills") as record: + page = orders.fills() + assert len(page) == 1 + assert page.items[0].trade_id == "t1" + assert len([w for w in record if issubclass(w.category, DeprecationWarning)]) == 1 + + @respx.mock + def test_issue_351_orders_fills_all_emits_deprecation_warning( + self, orders: OrdersResource + ) -> None: + """Iterator forwarder still works and emits exactly one DeprecationWarning per call.""" + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( + return_value=httpx.Response( + 200, + json={ + "fills": [fill_dict(trade_id="a", yes_price_dollars="0.5")], + "cursor": "", + }, + ) + ) + with pytest.warns(DeprecationWarning, match=r"OrdersResource\.fills_all") as record: + ids = [f.trade_id for f in orders.fills_all()] + assert ids == ["a"] + assert len([w for w in record if issubclass(w.category, DeprecationWarning)]) == 1 diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 2bdad96..07ec24c 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -15,6 +15,7 @@ from kalshi.resources.portfolio import AsyncPortfolioResource, PortfolioResource from tests._model_fixtures import ( event_position_dict, + fill_dict, market_position_dict, settlement_dict, ) @@ -1130,3 +1131,161 @@ async def test_positions_all_requires_auth( with pytest.raises(AuthRequiredError): async for _ in unauth_async_portfolio.positions_all(): pass + + +# ── Issue #351: fills moved from OrdersResource to PortfolioResource ───────── + + +class TestPortfolioFills: + @respx.mock + def test_issue_351_fills_on_portfolio_resource_works( + self, portfolio: PortfolioResource + ) -> None: + """New location: client.portfolio.fills() hits /portfolio/fills and parses Page[Fill].""" + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( + return_value=httpx.Response( + 200, + json={ + "fills": [ + fill_dict( + trade_id="t1", order_id="o1", yes_price_dollars="0.5000", count_fp="5" + ) + ], + "cursor": "", + }, + ) + ) + page = portfolio.fills() + assert len(page) == 1 + assert page.items[0].trade_id == "t1" + assert page.items[0].yes_price == Decimal("0.5000") + + @respx.mock + def test_fills_cursor_and_filter_parity(self, portfolio: PortfolioResource) -> None: + """ticker / min_ts / max_ts / cursor reach the wire identically to old shape.""" + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( + return_value=httpx.Response(200, json={"fills": []}) + ) + portfolio.fills( + ticker="MKT-A", + order_id="ord-1", + min_ts=1700000000, + max_ts=1700099999, + limit=50, + cursor="abc", + subaccount=7, + ) + params = dict(route.calls[0].request.url.params) + assert params["ticker"] == "MKT-A" + assert params["order_id"] == "ord-1" + assert params["min_ts"] == "1700000000" + assert params["max_ts"] == "1700099999" + assert params["limit"] == "50" + assert params["cursor"] == "abc" + assert params["subaccount"] == "7" + + +class TestPortfolioFillsAll: + @respx.mock + def test_issue_351_fills_all_on_portfolio_resource_works( + self, portfolio: PortfolioResource + ) -> None: + """New location: client.portfolio.fills_all() auto-paginates.""" + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "fills": [fill_dict(trade_id="a", yes_price_dollars="0.50")], + "cursor": "p2", + }, + ), + httpx.Response( + 200, + json={ + "fills": [fill_dict(trade_id="b", yes_price_dollars="0.60")], + "cursor": "", + }, + ), + ] + ) + ids = [f.trade_id for f in portfolio.fills_all()] + assert ids == ["a", "b"] + + +class TestAsyncPortfolioFills: + @respx.mock + @pytest.mark.asyncio + async def test_issue_351_fills_on_portfolio_resource_works( + self, async_portfolio: AsyncPortfolioResource + ) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( + return_value=httpx.Response( + 200, + json={ + "fills": [ + fill_dict( + trade_id="t1", order_id="o1", yes_price_dollars="0.5000", count_fp="5" + ) + ], + "cursor": "", + }, + ) + ) + page = await async_portfolio.fills() + assert len(page) == 1 + assert page.items[0].trade_id == "t1" + + @respx.mock + @pytest.mark.asyncio + async def test_fills_cursor_and_filter_parity( + self, async_portfolio: AsyncPortfolioResource + ) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( + return_value=httpx.Response(200, json={"fills": []}) + ) + await async_portfolio.fills( + ticker="MKT-A", + order_id="ord-1", + min_ts=1700000000, + max_ts=1700099999, + limit=50, + cursor="abc", + subaccount=7, + ) + params = dict(route.calls[0].request.url.params) + assert params["ticker"] == "MKT-A" + assert params["order_id"] == "ord-1" + assert params["min_ts"] == "1700000000" + assert params["max_ts"] == "1700099999" + assert params["limit"] == "50" + assert params["cursor"] == "abc" + assert params["subaccount"] == "7" + + +class TestAsyncPortfolioFillsAll: + @respx.mock + @pytest.mark.asyncio + async def test_issue_351_fills_all_on_portfolio_resource_works( + self, async_portfolio: AsyncPortfolioResource + ) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "fills": [fill_dict(trade_id="a", yes_price_dollars="0.50")], + "cursor": "p2", + }, + ), + httpx.Response( + 200, + json={ + "fills": [fill_dict(trade_id="b", yes_price_dollars="0.60")], + "cursor": "", + }, + ), + ] + ) + ids = [f.trade_id async for f in async_portfolio.fills_all()] + assert ids == ["a", "b"] From c490866e3a1f42c070fc7bbd3311f1b815ca2446 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 22 May 2026 11:18:03 -0500 Subject: [PATCH 2/3] polish(tests): register PortfolioResource.fills/fills_all in METHOD_ENDPOINT_MAP --- tests/_contract_support.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 5e57e67..a28e045 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -626,6 +626,16 @@ class Exclusion: http_method="GET", path_template="/portfolio/withdrawals", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.portfolio.PortfolioResource.fills", + http_method="GET", + path_template="/portfolio/fills", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.portfolio.PortfolioResource.fills_all", + http_method="GET", + path_template="/portfolio/fills", + ), # ── series ────────────────────────────────────────────────────────────── MethodEndpointEntry( sdk_method="kalshi.resources.series.SeriesResource.list", @@ -847,6 +857,10 @@ class Exclusion: reason="paginator-handled; not a caller-facing kwarg on list_all", kind="paginator_handled", ), + ("kalshi.resources.portfolio.PortfolioResource.fills_all", "cursor"): Exclusion( + reason="paginator-handled; not a caller-facing kwarg on list_all", + kind="paginator_handled", + ), # --- batch_cancel body param (not a query/path param) --- ("kalshi.resources.orders.OrdersResource.batch_cancel", "orders"): Exclusion( reason="body param (BatchCancelOrdersRequest.orders); not query/path", @@ -1195,6 +1209,7 @@ class Exclusion: "kalshi.resources.portfolio.PortfolioResource.deposits_all", "kalshi.resources.portfolio.PortfolioResource.withdrawals_all", "kalshi.resources.portfolio.PortfolioResource.positions_all", + "kalshi.resources.portfolio.PortfolioResource.fills_all", "kalshi.resources.fcm.FcmResource.orders_all", "kalshi.resources.fcm.FcmResource.positions_all", "kalshi.resources.incentive_programs.IncentiveProgramsResource.list_all", @@ -1219,6 +1234,7 @@ class Exclusion: "kalshi.resources.portfolio.AsyncPortfolioResource.deposits_all", "kalshi.resources.portfolio.AsyncPortfolioResource.withdrawals_all", "kalshi.resources.portfolio.AsyncPortfolioResource.positions_all", + "kalshi.resources.portfolio.AsyncPortfolioResource.fills_all", "kalshi.resources.fcm.AsyncFcmResource.orders_all", "kalshi.resources.fcm.AsyncFcmResource.positions_all", "kalshi.resources.incentive_programs.AsyncIncentiveProgramsResource.list_all", From 0ff01cd37e42bc69c54d7ec21a576990c50fa6f2 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 22 May 2026 11:29:23 -0500 Subject: [PATCH 3/3] polish(v3): dedupe _fills_params to _base.py; add auth tests; trim deprecated forwarder noise --- kalshi/resources/_base.py | 23 ++++++++++++++ kalshi/resources/orders.py | 59 +++-------------------------------- kalshi/resources/portfolio.py | 23 +------------- tests/test_portfolio.py | 23 ++++++++++++++ 4 files changed, 52 insertions(+), 76 deletions(-) diff --git a/kalshi/resources/_base.py b/kalshi/resources/_base.py index 95d1bf5..0961865 100644 --- a/kalshi/resources/_base.py +++ b/kalshi/resources/_base.py @@ -87,6 +87,29 @@ def _params(**kwargs: Any) -> dict[str, Any]: return {k: v for k, v in kwargs.items() if v is not None} +def _fills_params( + *, + ticker: str | None, + order_id: str | None, + min_ts: int | None, + max_ts: int | None, + limit: int | None, + cursor: str | None, + subaccount: int | None, +) -> dict[str, Any]: + """Build query params for ``/portfolio/fills`` (shared by sync/async).""" + limit = _validate_limit(limit, hi=1000) + return _params( + ticker=ticker, + order_id=order_id, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + subaccount=subaccount, + ) + + def _bool_param(value: bool | None) -> str | None: """Serialize a tri-state bool for query params. diff --git a/kalshi/resources/orders.py b/kalshi/resources/orders.py index 1f4823c..2622cf0 100644 --- a/kalshi/resources/orders.py +++ b/kalshi/resources/orders.py @@ -45,6 +45,7 @@ AsyncResource, SyncResource, _check_request_exclusive, + _fills_params, _join_tickers, _params, _seg, @@ -276,28 +277,6 @@ def _list_orders_params( ) -def _fills_params( - *, - ticker: str | None, - order_id: str | None, - min_ts: int | None, - max_ts: int | None, - limit: int | None, - cursor: str | None, - subaccount: int | None, -) -> dict[str, Any]: - limit = _validate_limit(limit, hi=1000) - return _params( - ticker=ticker, - order_id=order_id, - min_ts=min_ts, - max_ts=max_ts, - limit=limit, - cursor=cursor, - subaccount=subaccount, - ) - - def _queue_positions_params( *, market_tickers: builtins.list[str] | str | None, @@ -599,12 +578,7 @@ def fills( subaccount: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Fill]: - """List trade fills. - - .. deprecated:: 3.0.0 - Use :meth:`PortfolioResource.fills` instead. This forwarder - remains for one release and will be removed in a future version. - """ + """List trade fills.""" self._require_auth() params = _fills_params( ticker=ticker, @@ -615,8 +589,6 @@ def fills( cursor=cursor, subaccount=subaccount, ) - # NOTE: still hits /portfolio/fills directly — the canonical - # implementation now lives on PortfolioResource (issue #351). return self._list( "/portfolio/fills", Fill, "fills", params=params, extra_headers=extra_headers ) @@ -636,12 +608,7 @@ def fills_all( max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Iterator[Fill]: - """Auto-paginate trade fills. - - .. deprecated:: 3.0.0 - Use :meth:`PortfolioResource.fills_all` instead. This forwarder - remains for one release and will be removed in a future version. - """ + """Auto-paginate trade fills.""" self._require_auth() _validate_max_pages(max_pages) params = _fills_params( @@ -653,8 +620,6 @@ def fills_all( cursor=None, subaccount=subaccount, ) - # NOTE: still hits /portfolio/fills directly — the canonical - # implementation now lives on PortfolioResource (issue #351). return self._list_all( "/portfolio/fills", Fill, @@ -1164,12 +1129,7 @@ async def fills( subaccount: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Fill]: - """List trade fills (async). - - .. deprecated:: 3.0.0 - Use :meth:`AsyncPortfolioResource.fills` instead. This forwarder - remains for one release and will be removed in a future version. - """ + """List trade fills (async).""" self._require_auth() params = _fills_params( ticker=ticker, @@ -1180,8 +1140,6 @@ async def fills( cursor=cursor, subaccount=subaccount, ) - # NOTE: still hits /portfolio/fills directly — the canonical - # implementation now lives on AsyncPortfolioResource (issue #351). return await self._list( "/portfolio/fills", Fill, "fills", params=params, extra_headers=extra_headers ) @@ -1201,12 +1159,7 @@ def fills_all( max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[Fill]: - """Auto-paginate trade fills (async). Use ``async for``. - - .. deprecated:: 3.0.0 - Use :meth:`AsyncPortfolioResource.fills_all` instead. This forwarder - remains for one release and will be removed in a future version. - """ + """Auto-paginate trade fills (async). Use ``async for``.""" self._require_auth() _validate_max_pages(max_pages) params = _fills_params( @@ -1218,8 +1171,6 @@ def fills_all( cursor=None, subaccount=subaccount, ) - # NOTE: still hits /portfolio/fills directly — the canonical - # implementation now lives on AsyncPortfolioResource (issue #351). return self._list_all( "/portfolio/fills", Fill, diff --git a/kalshi/resources/portfolio.py b/kalshi/resources/portfolio.py index 97df501..b2899b9 100644 --- a/kalshi/resources/portfolio.py +++ b/kalshi/resources/portfolio.py @@ -19,6 +19,7 @@ from kalshi.resources._base import ( AsyncResource, SyncResource, + _fills_params, _params, _validate_limit, _validate_max_pages, @@ -68,28 +69,6 @@ def _settlements_params( subaccount=subaccount, ) -def _fills_params( - *, - ticker: str | None, - order_id: str | None, - min_ts: int | None, - max_ts: int | None, - limit: int | None, - cursor: str | None, - subaccount: int | None, -) -> dict[str, Any]: - limit = _validate_limit(limit, hi=1000) - return _params( - ticker=ticker, - order_id=order_id, - min_ts=min_ts, - max_ts=max_ts, - limit=limit, - cursor=cursor, - subaccount=subaccount, - ) - - class PortfolioResource(SyncResource): """Sync portfolio API.""" diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 07ec24c..052a74a 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -1184,6 +1184,10 @@ def test_fills_cursor_and_filter_parity(self, portfolio: PortfolioResource) -> N assert params["cursor"] == "abc" assert params["subaccount"] == "7" + def test_fills_requires_auth(self, unauth_portfolio: PortfolioResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_portfolio.fills() + class TestPortfolioFillsAll: @respx.mock @@ -1212,6 +1216,10 @@ def test_issue_351_fills_all_on_portfolio_resource_works( ids = [f.trade_id for f in portfolio.fills_all()] assert ids == ["a", "b"] + def test_fills_all_requires_auth(self, unauth_portfolio: PortfolioResource) -> None: + with pytest.raises(AuthRequiredError): + list(unauth_portfolio.fills_all()) + class TestAsyncPortfolioFills: @respx.mock @@ -1262,6 +1270,13 @@ async def test_fills_cursor_and_filter_parity( assert params["cursor"] == "abc" assert params["subaccount"] == "7" + @pytest.mark.asyncio + async def test_fills_requires_auth( + self, unauth_async_portfolio: AsyncPortfolioResource + ) -> None: + with pytest.raises(AuthRequiredError): + await unauth_async_portfolio.fills() + class TestAsyncPortfolioFillsAll: @respx.mock @@ -1289,3 +1304,11 @@ async def test_issue_351_fills_all_on_portfolio_resource_works( ) ids = [f.trade_id async for f in async_portfolio.fills_all()] assert ids == ["a", "b"] + + @pytest.mark.asyncio + async def test_fills_all_requires_auth( + self, unauth_async_portfolio: AsyncPortfolioResource + ) -> None: + with pytest.raises(AuthRequiredError): + async for _ in unauth_async_portfolio.fills_all(): + pass