From a436eb9700f3bef1231f18197a931900acbb9864 Mon Sep 17 00:00:00 2001 From: Arsh Date: Wed, 8 Apr 2026 20:27:50 -0700 Subject: [PATCH] Add historical data endpoints Support for Kalshi's /historical/* API: - cutoff timestamps, markets, candlesticks, fills, orders, trades - Sync and async implementations with pagination - Models for historical candlestick OHLC data - Accessible via client.history - Bump version to 1.0.3 --- pykalshi/__init__.py | 14 +- pykalshi/_async/client.py | 5 + pykalshi/_async/history.py | 186 ++++++++++++++++ pykalshi/_sync/client.py | 5 + pykalshi/_sync/history.py | 188 ++++++++++++++++ pykalshi/enums.py | 1 + pykalshi/history.py | 4 + pykalshi/models.py | 43 ++++ pyproject.toml | 2 +- scripts/generate_sync.py | 2 + tests/integration/test_history.py | 126 +++++++++++ tests/test_history.py | 354 ++++++++++++++++++++++++++++++ 12 files changed, 928 insertions(+), 2 deletions(-) create mode 100644 pykalshi/_async/history.py create mode 100644 pykalshi/_sync/history.py create mode 100644 pykalshi/history.py create mode 100644 tests/integration/test_history.py create mode 100644 tests/test_history.py diff --git a/pykalshi/__init__.py b/pykalshi/__init__.py index 811c71d..4598a55 100644 --- a/pykalshi/__init__.py +++ b/pykalshi/__init__.py @@ -4,7 +4,7 @@ A clean, modular interface for the Kalshi trading API. """ -__version__ = "1.0.2" +__version__ = "1.0.3" import logging @@ -16,6 +16,7 @@ from .orders import Order, AsyncOrder from .portfolio import Portfolio, AsyncPortfolio from .exchange import Exchange, AsyncExchange +from .history import History, AsyncHistory from .api_keys import APIKeys, AsyncAPIKeys from .communications import Communications, AsyncCommunications from .feed import ( @@ -70,6 +71,10 @@ AssociatedEventModel, RfqModel, QuoteModel, + HistoricalCutoffResponse, + HistoricalCandlestick, + HistoricalBidAsk, + HistoricalPrice, ) from .orderbook import OrderbookManager from .rate_limiter import RateLimiter, NoOpRateLimiter, AsyncRateLimiter, AsyncNoOpRateLimiter @@ -106,6 +111,8 @@ "AsyncPortfolio", "Exchange", "AsyncExchange", + "History", + "AsyncHistory", "APIKeys", "AsyncAPIKeys", "Communications", @@ -152,6 +159,11 @@ "QueuePositionModel", "OrderGroupModel", "ForecastPercentileHistory", + # Historical Models + "HistoricalCutoffResponse", + "HistoricalCandlestick", + "HistoricalBidAsk", + "HistoricalPrice", # MVE & Communications Models "MveSelectedLeg", "MveCollectionModel", diff --git a/pykalshi/_async/client.py b/pykalshi/_async/client.py index 65a09d9..4d3f050 100644 --- a/pykalshi/_async/client.py +++ b/pykalshi/_async/client.py @@ -22,6 +22,7 @@ from .exchange import AsyncExchange from .api_keys import AsyncAPIKeys from .communications import AsyncCommunications +from .history import AsyncHistory from ..exceptions import RateLimitError from .._utils import normalize_ticker, normalize_tickers @@ -208,6 +209,10 @@ def api_keys(self) -> AsyncAPIKeys: def communications(self) -> AsyncCommunications: return AsyncCommunications(self) + @cached_property + def history(self) -> AsyncHistory: + return AsyncHistory(self) + def feed(self) -> AsyncFeed: """Create a new async real-time data feed.""" from ..afeed import AsyncFeed diff --git a/pykalshi/_async/history.py b/pykalshi/_async/history.py new file mode 100644 index 0000000..71b75ff --- /dev/null +++ b/pykalshi/_async/history.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from urllib.parse import urlencode + +from .markets import AsyncMarket +from .orders import AsyncOrder +from ..enums import CandlestickPeriod +from ..dataframe import DataFrameList +from .._utils import normalize_ticker +from ..models import ( + MarketModel, OrderModel, FillModel, TradeModel, + HistoricalCutoffResponse, HistoricalCandlestick, +) + +if TYPE_CHECKING: + from .client import AsyncKalshiClient + + +class AsyncHistory: + """Access to historical data that has rolled off the live API.""" + + def __init__(self, client: AsyncKalshiClient) -> None: + self._client = client + + async def get_cutoff(self) -> HistoricalCutoffResponse: + """Get boundary timestamps between live and historical data.""" + data = await self._client.get("/historical/cutoff") + return HistoricalCutoffResponse.model_validate(data) + + async def get_markets( + self, + *, + tickers: str | None = None, + event_ticker: str | None = None, + mve_filter: str | None = None, + limit: int = 100, + cursor: str | None = None, + fetch_all: bool = False, + **extra_params, + ) -> DataFrameList[AsyncMarket]: + """Get historical (settled) markets. + + Args: + tickers: Comma-separated ticker list. + event_ticker: Filter by event ticker. + mve_filter: "exclude" to exclude multivariate markets. + limit: Results per page (max 1000). + cursor: Pagination cursor. + fetch_all: Automatically fetch all pages. + """ + params = { + "tickers": tickers, + "event_ticker": normalize_ticker(event_ticker), + "mve_filter": mve_filter, + "limit": limit, + "cursor": cursor, + **extra_params, + } + data = await self._client.paginated_get("/historical/markets", "markets", params, fetch_all) + return DataFrameList(AsyncMarket(self._client, MarketModel.model_validate(m)) for m in data) + + async def get_market(self, ticker: str) -> AsyncMarket: + """Get a single historical market by ticker.""" + response = await self._client.get(f"/historical/markets/{ticker.upper()}") + model = MarketModel.model_validate(response["market"]) + return AsyncMarket(self._client, model) + + async def get_candlesticks( + self, + ticker: str, + *, + start_ts: int, + end_ts: int, + period: CandlestickPeriod = CandlestickPeriod.ONE_HOUR, + ) -> list[HistoricalCandlestick]: + """Get historical candlestick data for a settled market. + + Args: + ticker: Market ticker. + start_ts: Start Unix timestamp (seconds). + end_ts: End Unix timestamp (seconds). + period: Candlestick interval (1min, 1hr, 1day). + """ + query = urlencode({ + "start_ts": start_ts, + "end_ts": end_ts, + "period_interval": period.value, + }) + response = await self._client.get( + f"/historical/markets/{ticker.upper()}/candlesticks?{query}" + ) + return [ + HistoricalCandlestick.model_validate(c) + for c in response.get("candlesticks", []) + ] + + async def get_fills( + self, + *, + ticker: str | None = None, + max_ts: int | None = None, + limit: int = 100, + cursor: str | None = None, + fetch_all: bool = False, + **extra_params, + ) -> DataFrameList[FillModel]: + """Get historical fills (requires authentication). + + Args: + ticker: Filter by market ticker. + max_ts: Filter fills before this Unix timestamp. + limit: Results per page (max 1000). + cursor: Pagination cursor. + fetch_all: Automatically fetch all pages. + """ + params = { + "ticker": normalize_ticker(ticker), + "max_ts": max_ts, + "limit": limit, + "cursor": cursor, + **extra_params, + } + data = await self._client.paginated_get("/historical/fills", "fills", params, fetch_all) + return DataFrameList(FillModel.model_validate(f) for f in data) + + async def get_orders( + self, + *, + ticker: str | None = None, + max_ts: int | None = None, + limit: int = 100, + cursor: str | None = None, + fetch_all: bool = False, + **extra_params, + ) -> DataFrameList[AsyncOrder]: + """Get historical orders (requires authentication). + + Args: + ticker: Filter by market ticker. + max_ts: Filter orders updated before this Unix timestamp. + limit: Results per page (max 1000). + cursor: Pagination cursor. + fetch_all: Automatically fetch all pages. + """ + params = { + "ticker": normalize_ticker(ticker), + "max_ts": max_ts, + "limit": limit, + "cursor": cursor, + **extra_params, + } + data = await self._client.paginated_get("/historical/orders", "orders", params, fetch_all) + return DataFrameList(AsyncOrder(self._client, OrderModel.model_validate(d)) for d in data) + + async def get_trades( + self, + *, + ticker: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int = 100, + cursor: str | None = None, + fetch_all: bool = False, + **extra_params, + ) -> DataFrameList[TradeModel]: + """Get historical public trades. + + Args: + ticker: Filter by market ticker. + min_ts: Filter trades after this Unix timestamp. + max_ts: Filter trades before this Unix timestamp. + limit: Results per page (max 1000). + cursor: Pagination cursor. + fetch_all: Automatically fetch all pages. + """ + params = { + "ticker": normalize_ticker(ticker), + "min_ts": min_ts, + "max_ts": max_ts, + "limit": limit, + "cursor": cursor, + **extra_params, + } + data = await self._client.paginated_get("/historical/trades", "trades", params, fetch_all) + return DataFrameList(TradeModel.model_validate(t) for t in data) diff --git a/pykalshi/_sync/client.py b/pykalshi/_sync/client.py index 5d97905..6554b8f 100644 --- a/pykalshi/_sync/client.py +++ b/pykalshi/_sync/client.py @@ -24,6 +24,7 @@ from .exchange import Exchange from .api_keys import APIKeys from .communications import Communications +from .history import History from ..exceptions import RateLimitError from .._utils import normalize_ticker, normalize_tickers @@ -210,6 +211,10 @@ def api_keys(self) -> APIKeys: def communications(self) -> Communications: return Communications(self) + @cached_property + def history(self) -> History: + return History(self) + def feed(self) -> Feed: """Create a new async real-time data feed.""" from ..feed import Feed diff --git a/pykalshi/_sync/history.py b/pykalshi/_sync/history.py new file mode 100644 index 0000000..99497fd --- /dev/null +++ b/pykalshi/_sync/history.py @@ -0,0 +1,188 @@ +# AUTO-GENERATED from pykalshi/_async/history.py — do not edit manually. +# Re-run: python scripts/generate_sync.py +from __future__ import annotations + +from typing import TYPE_CHECKING +from urllib.parse import urlencode + +from .markets import Market +from .orders import Order +from ..enums import CandlestickPeriod +from ..dataframe import DataFrameList +from .._utils import normalize_ticker +from ..models import ( + MarketModel, OrderModel, FillModel, TradeModel, + HistoricalCutoffResponse, HistoricalCandlestick, +) + +if TYPE_CHECKING: + from .client import KalshiClient + + +class History: + """Access to historical data that has rolled off the live API.""" + + def __init__(self, client: KalshiClient) -> None: + self._client = client + + def get_cutoff(self) -> HistoricalCutoffResponse: + """Get boundary timestamps between live and historical data.""" + data = self._client.get("/historical/cutoff") + return HistoricalCutoffResponse.model_validate(data) + + def get_markets( + self, + *, + tickers: str | None = None, + event_ticker: str | None = None, + mve_filter: str | None = None, + limit: int = 100, + cursor: str | None = None, + fetch_all: bool = False, + **extra_params, + ) -> DataFrameList[Market]: + """Get historical (settled) markets. + + Args: + tickers: Comma-separated ticker list. + event_ticker: Filter by event ticker. + mve_filter: "exclude" to exclude multivariate markets. + limit: Results per page (max 1000). + cursor: Pagination cursor. + fetch_all: Automatically fetch all pages. + """ + params = { + "tickers": tickers, + "event_ticker": normalize_ticker(event_ticker), + "mve_filter": mve_filter, + "limit": limit, + "cursor": cursor, + **extra_params, + } + data = self._client.paginated_get("/historical/markets", "markets", params, fetch_all) + return DataFrameList(Market(self._client, MarketModel.model_validate(m)) for m in data) + + def get_market(self, ticker: str) -> Market: + """Get a single historical market by ticker.""" + response = self._client.get(f"/historical/markets/{ticker.upper()}") + model = MarketModel.model_validate(response["market"]) + return Market(self._client, model) + + def get_candlesticks( + self, + ticker: str, + *, + start_ts: int, + end_ts: int, + period: CandlestickPeriod = CandlestickPeriod.ONE_HOUR, + ) -> list[HistoricalCandlestick]: + """Get historical candlestick data for a settled market. + + Args: + ticker: Market ticker. + start_ts: Start Unix timestamp (seconds). + end_ts: End Unix timestamp (seconds). + period: Candlestick interval (1min, 1hr, 1day). + """ + query = urlencode({ + "start_ts": start_ts, + "end_ts": end_ts, + "period_interval": period.value, + }) + response = self._client.get( + f"/historical/markets/{ticker.upper()}/candlesticks?{query}" + ) + return [ + HistoricalCandlestick.model_validate(c) + for c in response.get("candlesticks", []) + ] + + def get_fills( + self, + *, + ticker: str | None = None, + max_ts: int | None = None, + limit: int = 100, + cursor: str | None = None, + fetch_all: bool = False, + **extra_params, + ) -> DataFrameList[FillModel]: + """Get historical fills (requires authentication). + + Args: + ticker: Filter by market ticker. + max_ts: Filter fills before this Unix timestamp. + limit: Results per page (max 1000). + cursor: Pagination cursor. + fetch_all: Automatically fetch all pages. + """ + params = { + "ticker": normalize_ticker(ticker), + "max_ts": max_ts, + "limit": limit, + "cursor": cursor, + **extra_params, + } + data = self._client.paginated_get("/historical/fills", "fills", params, fetch_all) + return DataFrameList(FillModel.model_validate(f) for f in data) + + def get_orders( + self, + *, + ticker: str | None = None, + max_ts: int | None = None, + limit: int = 100, + cursor: str | None = None, + fetch_all: bool = False, + **extra_params, + ) -> DataFrameList[Order]: + """Get historical orders (requires authentication). + + Args: + ticker: Filter by market ticker. + max_ts: Filter orders updated before this Unix timestamp. + limit: Results per page (max 1000). + cursor: Pagination cursor. + fetch_all: Automatically fetch all pages. + """ + params = { + "ticker": normalize_ticker(ticker), + "max_ts": max_ts, + "limit": limit, + "cursor": cursor, + **extra_params, + } + data = self._client.paginated_get("/historical/orders", "orders", params, fetch_all) + return DataFrameList(Order(self._client, OrderModel.model_validate(d)) for d in data) + + def get_trades( + self, + *, + ticker: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int = 100, + cursor: str | None = None, + fetch_all: bool = False, + **extra_params, + ) -> DataFrameList[TradeModel]: + """Get historical public trades. + + Args: + ticker: Filter by market ticker. + min_ts: Filter trades after this Unix timestamp. + max_ts: Filter trades before this Unix timestamp. + limit: Results per page (max 1000). + cursor: Pagination cursor. + fetch_all: Automatically fetch all pages. + """ + params = { + "ticker": normalize_ticker(ticker), + "min_ts": min_ts, + "max_ts": max_ts, + "limit": limit, + "cursor": cursor, + **extra_params, + } + data = self._client.paginated_get("/historical/trades", "trades", params, fetch_all) + return DataFrameList(TradeModel.model_validate(t) for t in data) diff --git a/pykalshi/enums.py b/pykalshi/enums.py index 5814b22..9de3b0c 100644 --- a/pykalshi/enums.py +++ b/pykalshi/enums.py @@ -13,6 +13,7 @@ class Action(str, Enum): class OrderType(str, Enum): LIMIT = "limit" + MARKET = "market" class OrderStatus(str, Enum): diff --git a/pykalshi/history.py b/pykalshi/history.py new file mode 100644 index 0000000..910c072 --- /dev/null +++ b/pykalshi/history.py @@ -0,0 +1,4 @@ +from ._sync.history import History as History +from ._async.history import AsyncHistory as AsyncHistory + +__all__ = ["History", "AsyncHistory"] diff --git a/pykalshi/models.py b/pykalshi/models.py index 619327f..a3b0e33 100644 --- a/pykalshi/models.py +++ b/pykalshi/models.py @@ -5,6 +5,49 @@ from .enums import OrderStatus, Side, Action, OrderType, MarketStatus +class HistoricalCutoffResponse(BaseModel): + """Boundary timestamps separating live from historical data.""" + market_settled_ts: str + trades_created_ts: str + orders_updated_ts: str + + model_config = ConfigDict(extra="ignore") + + +class HistoricalBidAsk(BaseModel): + """OHLC for bid/ask levels in historical candlesticks.""" + open: str | None = None + high: str | None = None + low: str | None = None + close: str | None = None + + model_config = ConfigDict(extra="ignore") + + +class HistoricalPrice(BaseModel): + """OHLC + mean/previous for trade prices in historical candlesticks.""" + open: str | None = None + high: str | None = None + low: str | None = None + close: str | None = None + mean: str | None = None + previous: str | None = None + + model_config = ConfigDict(extra="ignore") + + +class HistoricalCandlestick(BaseModel): + """A single candlestick from the historical candlesticks endpoint.""" + end_period_ts: int + yes_bid: HistoricalBidAsk | None = None + yes_ask: HistoricalBidAsk | None = None + price: HistoricalPrice | None = None + volume: str | None = None + open_interest: str | None = None + + model_config = ConfigDict(extra="ignore") + + class MveSelectedLeg(BaseModel): """A single leg in a multivariate event combo.""" event_ticker: str diff --git a/pyproject.toml b/pyproject.toml index 9eba00d..d0f8376 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pykalshi" -version = "1.0.2" +version = "1.0.3" description = "A typed Python client for the Kalshi prediction markets API with WebSocket streaming, automatic retries, and ergonomic interfaces" readme = "README.md" license = "MIT" diff --git a/scripts/generate_sync.py b/scripts/generate_sync.py index 50b40bb..cf56c94 100644 --- a/scripts/generate_sync.py +++ b/scripts/generate_sync.py @@ -25,6 +25,7 @@ "api_keys.py", "communications.py", "mve.py", + "history.py", ] # Class renames: AsyncX -> X (applied with word boundaries) @@ -39,6 +40,7 @@ "AsyncAPIKeys": "APIKeys", "AsyncCommunications": "Communications", "AsyncMveCollection": "MveCollection", + "AsyncHistory": "History", "AsyncRateLimiterProtocol": "RateLimiterProtocol", "AsyncFeed": "Feed", } diff --git a/tests/integration/test_history.py b/tests/integration/test_history.py new file mode 100644 index 0000000..8d05793 --- /dev/null +++ b/tests/integration/test_history.py @@ -0,0 +1,126 @@ +"""Integration tests for historical data endpoints.""" + +import pytest +from pykalshi.models import HistoricalCutoffResponse, HistoricalCandlestick +from pykalshi.enums import CandlestickPeriod + + +class TestHistoricalCutoff: + """Tests for /historical/cutoff.""" + + def test_get_cutoff(self, client): + """Cutoff returns valid timestamps.""" + cutoff = client.history.get_cutoff() + + assert isinstance(cutoff, HistoricalCutoffResponse) + assert cutoff.market_settled_ts + assert cutoff.trades_created_ts + assert cutoff.orders_updated_ts + + +class TestHistoricalMarkets: + """Tests for /historical/markets.""" + + def test_get_markets(self, client): + """Get historical markets returns list.""" + markets = client.history.get_markets(limit=5) + + assert isinstance(markets, list) + if markets: + assert hasattr(markets[0], "ticker") + assert hasattr(markets[0], "status") + + def test_get_single_market(self, client): + """Get a single historical market by ticker.""" + markets = client.history.get_markets(limit=1) + if not markets: + pytest.skip("No historical markets available") + + market = client.history.get_market(markets[0].ticker) + assert market.ticker == markets[0].ticker + + def test_get_markets_pagination(self, client): + """Verify pagination returns data and cursor works.""" + first_page = client.history.get_markets(limit=5) + if len(first_page) < 5: + pytest.skip("Not enough historical markets to test pagination") + + # Just verify we can get a second page (don't fetch_all — could be thousands) + second_page = client.history.get_markets(limit=5) + assert len(second_page) > 0 + + +class TestHistoricalCandlesticks: + """Tests for /historical/markets/{ticker}/candlesticks.""" + + def test_get_candlesticks(self, client): + """Get candlesticks for a historical market.""" + markets = client.history.get_markets(limit=10) + if not markets: + pytest.skip("No historical markets available") + + for market in markets: + try: + candles = client.history.get_candlesticks( + market.ticker, + start_ts=0, + end_ts=2000000000, + period=CandlestickPeriod.ONE_DAY, + ) + assert isinstance(candles, list) + if candles: + assert isinstance(candles[0], HistoricalCandlestick) + assert candles[0].end_period_ts > 0 + return + except Exception: + continue + + pytest.skip("No historical markets with candlestick data found") + + +class TestHistoricalFills: + """Tests for /historical/fills (authenticated).""" + + def test_get_fills(self, client): + """Get historical fills returns list.""" + fills = client.history.get_fills(limit=5) + + assert isinstance(fills, list) + if fills: + assert hasattr(fills[0], "trade_id") + assert hasattr(fills[0], "ticker") + + +class TestHistoricalOrders: + """Tests for /historical/orders (authenticated).""" + + def test_get_orders(self, client): + """Get historical orders returns list.""" + orders = client.history.get_orders(limit=5) + + assert isinstance(orders, list) + if orders: + assert hasattr(orders[0], "ticker") + assert hasattr(orders[0], "status") + + +class TestHistoricalTrades: + """Tests for /historical/trades.""" + + def test_get_trades(self, client): + """Get historical trades returns list.""" + trades = client.history.get_trades(limit=5) + + assert isinstance(trades, list) + if trades: + assert hasattr(trades[0], "trade_id") + assert hasattr(trades[0], "yes_price_dollars") + + def test_get_trades_with_ticker(self, client): + """Get historical trades filtered by ticker.""" + markets = client.history.get_markets(limit=1) + if not markets: + pytest.skip("No historical markets available") + + trades = client.history.get_trades(ticker=markets[0].ticker, limit=5) + assert isinstance(trades, list) diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..d93496f --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,354 @@ +"""Tests for historical data endpoints.""" + +import pytest +from unittest.mock import ANY + +from pykalshi import Market, Order, History +from pykalshi.enums import CandlestickPeriod +from pykalshi.models import ( + HistoricalCutoffResponse, HistoricalCandlestick, FillModel, TradeModel, +) + + +class TestHistoricalCutoff: + """Tests for the /historical/cutoff endpoint.""" + + def test_get_cutoff(self, client, mock_response): + """Test fetching historical cutoff timestamps.""" + client._session.request.return_value = mock_response({ + "market_settled_ts": "2026-01-15T00:00:00Z", + "trades_created_ts": "2026-01-15T00:00:00Z", + "orders_updated_ts": "2026-01-15T00:00:00Z", + }) + + cutoff = client.history.get_cutoff() + + assert isinstance(cutoff, HistoricalCutoffResponse) + assert cutoff.market_settled_ts == "2026-01-15T00:00:00Z" + assert cutoff.trades_created_ts == "2026-01-15T00:00:00Z" + assert cutoff.orders_updated_ts == "2026-01-15T00:00:00Z" + + +class TestHistoricalMarkets: + """Tests for the /historical/markets endpoints.""" + + def test_get_markets(self, client, mock_response): + """Test listing historical markets.""" + client._session.request.return_value = mock_response({ + "markets": [ + {"ticker": "OLD-MKT-A", "status": "finalized", "title": "Old Market A"}, + {"ticker": "OLD-MKT-B", "status": "finalized", "title": "Old Market B"}, + ], + "cursor": "", + }) + + markets = client.history.get_markets() + + assert len(markets) == 2 + assert all(isinstance(m, Market) for m in markets) + assert markets[0].ticker == "OLD-MKT-A" + + def test_get_markets_with_filters(self, client, mock_response): + """Test listing historical markets with filters.""" + client._session.request.return_value = mock_response({ + "markets": [], + "cursor": "", + }) + + client.history.get_markets(event_ticker="KXTEST", limit=50) + + call_url = client._session.request.call_args.args[1] + assert "event_ticker=KXTEST" in call_url + assert "limit=50" in call_url + + def test_get_markets_pagination(self, client, mock_response): + """Test historical markets pagination with fetch_all.""" + client._session.request.side_effect = [ + mock_response({ + "markets": [{"ticker": "M1"}], + "cursor": "page2", + }), + mock_response({ + "markets": [{"ticker": "M2"}], + "cursor": "", + }), + ] + + markets = client.history.get_markets(fetch_all=True) + + assert len(markets) == 2 + assert client._session.request.call_count == 2 + + def test_get_single_market(self, client, mock_response): + """Test fetching a single historical market.""" + client._session.request.return_value = mock_response({ + "market": { + "ticker": "OLD-MKT-A", + "status": "finalized", + "title": "Old Market A", + "settlement_value_dollars": "1.00", + } + }) + + market = client.history.get_market("OLD-MKT-A") + + assert isinstance(market, Market) + assert market.ticker == "OLD-MKT-A" + assert market.settlement_value_dollars == "1.00" + + def test_get_market_not_found(self, client, mock_response): + """Test fetching non-existent historical market.""" + from pykalshi.exceptions import ResourceNotFoundError + + client._session.request.return_value = mock_response( + {"message": "Market not found"}, status_code=404 + ) + + with pytest.raises(ResourceNotFoundError): + client.history.get_market("NONEXISTENT") + + +class TestHistoricalCandlesticks: + """Tests for the /historical/markets/{ticker}/candlesticks endpoint.""" + + def test_get_candlesticks(self, client, mock_response): + """Test fetching historical candlesticks.""" + client._session.request.return_value = mock_response({ + "ticker": "OLD-MKT-A", + "candlesticks": [ + { + "end_period_ts": 1704067200, + "yes_bid": {"open": "0.40", "high": "0.45", "low": "0.38", "close": "0.43"}, + "yes_ask": {"open": "0.55", "high": "0.60", "low": "0.52", "close": "0.57"}, + "price": {"open": "0.50", "high": "0.55", "low": "0.48", "close": "0.53", "mean": "0.51", "previous": "0.49"}, + "volume": "100.00", + "open_interest": "500.00", + }, + ], + }) + + candles = client.history.get_candlesticks( + "OLD-MKT-A", start_ts=1704000000, end_ts=1704100000, + ) + + assert len(candles) == 1 + c = candles[0] + assert isinstance(c, HistoricalCandlestick) + assert c.end_period_ts == 1704067200 + assert c.volume == "100.00" + assert c.price.open == "0.50" + assert c.price.mean == "0.51" + assert c.yes_bid.high == "0.45" + + def test_get_candlesticks_url_params(self, client, mock_response): + """Test candlestick URL parameters are correct.""" + client._session.request.return_value = mock_response({ + "candlesticks": [], + }) + + client.history.get_candlesticks( + "test-ticker", + start_ts=1000, + end_ts=2000, + period=CandlestickPeriod.ONE_DAY, + ) + + call_url = client._session.request.call_args.args[1] + assert "/historical/markets/TEST-TICKER/candlesticks" in call_url + assert "start_ts=1000" in call_url + assert "end_ts=2000" in call_url + assert "period_interval=1440" in call_url + + def test_get_candlesticks_empty(self, client, mock_response): + """Test candlesticks returns empty list when no data.""" + client._session.request.return_value = mock_response({ + "candlesticks": [], + }) + + candles = client.history.get_candlesticks( + "OLD-MKT-A", start_ts=1, end_ts=2, + ) + + assert candles == [] + + +class TestHistoricalFills: + """Tests for the /historical/fills endpoint (authenticated).""" + + def test_get_fills(self, client, mock_response): + """Test fetching historical fills.""" + client._session.request.return_value = mock_response({ + "fills": [ + { + "trade_id": "f-001", + "ticker": "OLD-MKT-A", + "order_id": "o-001", + "side": "yes", + "action": "buy", + "count_fp": "10.00", + "yes_price_dollars": "0.55", + "no_price_dollars": "0.45", + "is_taker": True, + "created_time": "2025-12-01T00:00:00Z", + }, + ], + "cursor": "", + }) + + fills = client.history.get_fills() + + assert len(fills) == 1 + assert isinstance(fills[0], FillModel) + assert fills[0].trade_id == "f-001" + assert fills[0].ticker == "OLD-MKT-A" + assert fills[0].count_fp == "10.00" + + def test_get_fills_with_filters(self, client, mock_response): + """Test historical fills with ticker and max_ts filters.""" + client._session.request.return_value = mock_response({ + "fills": [], + "cursor": "", + }) + + client.history.get_fills(ticker="KXTEST", max_ts=1704000000, limit=50) + + call_url = client._session.request.call_args.args[1] + assert "ticker=KXTEST" in call_url + assert "max_ts=1704000000" in call_url + assert "limit=50" in call_url + + +class TestHistoricalOrders: + """Tests for the /historical/orders endpoint (authenticated).""" + + def test_get_orders(self, client, mock_response): + """Test fetching historical orders.""" + client._session.request.return_value = mock_response({ + "orders": [ + { + "order_id": "o-001", + "ticker": "OLD-MKT-A", + "status": "executed", + "action": "buy", + "side": "yes", + "yes_price_dollars": "0.55", + "initial_count_fp": "10.00", + "fill_count_fp": "10.00", + "remaining_count_fp": "0.00", + }, + ], + "cursor": "", + }) + + orders = client.history.get_orders() + + assert len(orders) == 1 + assert isinstance(orders[0], Order) + assert orders[0].ticker == "OLD-MKT-A" + assert orders[0].status.value == "executed" + + def test_get_orders_with_filters(self, client, mock_response): + """Test historical orders with ticker and max_ts filters.""" + client._session.request.return_value = mock_response({ + "orders": [], + "cursor": "", + }) + + client.history.get_orders(ticker="KXTEST", max_ts=1704000000, limit=50) + + call_url = client._session.request.call_args.args[1] + assert "ticker=KXTEST" in call_url + assert "max_ts=1704000000" in call_url + assert "limit=50" in call_url + + def test_get_orders_pagination(self, client, mock_response): + """Test historical orders pagination with fetch_all.""" + client._session.request.side_effect = [ + mock_response({ + "orders": [{"order_id": "o-1", "ticker": "T", "status": "executed"}], + "cursor": "page2", + }), + mock_response({ + "orders": [{"order_id": "o-2", "ticker": "T", "status": "canceled"}], + "cursor": "", + }), + ] + + orders = client.history.get_orders(fetch_all=True) + + assert len(orders) == 2 + assert client._session.request.call_count == 2 + + +class TestHistoricalTrades: + """Tests for the /historical/trades endpoint.""" + + def test_get_trades(self, client, mock_response): + """Test fetching historical trades.""" + client._session.request.return_value = mock_response({ + "trades": [ + { + "trade_id": "t-001", + "ticker": "OLD-MKT-A", + "count_fp": "10.00", + "yes_price_dollars": "0.55", + "no_price_dollars": "0.45", + "taker_side": "yes", + "created_time": "2025-12-01T00:00:00Z", + }, + ], + "cursor": "", + }) + + trades = client.history.get_trades() + + assert len(trades) == 1 + assert isinstance(trades[0], TradeModel) + assert trades[0].trade_id == "t-001" + assert trades[0].yes_price_dollars == "0.55" + + def test_get_trades_with_filters(self, client, mock_response): + """Test historical trades with ticker and timestamp filters.""" + client._session.request.return_value = mock_response({ + "trades": [], + "cursor": "", + }) + + client.history.get_trades( + ticker="KXTEST", min_ts=1700000000, max_ts=1704000000, limit=50, + ) + + call_url = client._session.request.call_args.args[1] + assert "ticker=KXTEST" in call_url + assert "min_ts=1700000000" in call_url + assert "max_ts=1704000000" in call_url + assert "limit=50" in call_url + + def test_get_trades_pagination(self, client, mock_response): + """Test historical trades pagination.""" + client._session.request.side_effect = [ + mock_response({ + "trades": [{"trade_id": "t-1", "ticker": "T", "count_fp": "1.00", "yes_price_dollars": "0.50", "no_price_dollars": "0.50"}], + "cursor": "page2", + }), + mock_response({ + "trades": [{"trade_id": "t-2", "ticker": "T", "count_fp": "2.00", "yes_price_dollars": "0.60", "no_price_dollars": "0.40"}], + "cursor": "", + }), + ] + + trades = client.history.get_trades(fetch_all=True) + + assert len(trades) == 2 + assert client._session.request.call_count == 2 + + +class TestHistoryAccessor: + """Tests for client.history accessor.""" + + def test_history_is_cached_property(self, client): + """Test that client.history returns the same instance.""" + h1 = client.history + h2 = client.history + assert h1 is h2 + assert isinstance(h1, History)