diff --git a/README.md b/README.md index c47e254..6d7e90b 100644 --- a/README.md +++ b/README.md @@ -37,15 +37,23 @@ prices = client.data.prices(["market-a", "market-b", "market-c"], days=30) # Historical L2 orderbook book = client.data.orderbook( venue="polymarket", - market="will-btc-reach-100k", start="2026-06-01T00:00:00Z", end="2026-06-01T01:00:00Z", + market_id="will-btc-reach-100k", limit=5000, ) +# Spot OHLCV candles (BTC, ETH, SOL) +klines = client.data.spot_klines( + symbol="BTC", + start="2026-06-01T00:00:00Z", + end="2026-06-01T01:00:00Z", + interval=300, # seconds +) + # Run a backtest on a thematic basket result = client.backtests.run( - strategy_id="crypto_updown_roll_timing", + strategy_id="wsc_crypto_updown_delta_hedged_roll", dataset_id="clickhouse:ito_hot.platform_orderbook_l2", venues=["polymarket"], date_range={"start": "2026-05-01T00:00:00Z", "end": "2026-06-01T00:00:00Z"}, @@ -84,13 +92,14 @@ print(f"P&L: ${result['data']['metrics']['pnl_usd']:.2f}") | `client.markets.get(id)` | Single market detail | | `client.markets.history(id)` | Daily price series | -### Research Data (2 endpoints) +### Research Data (3 endpoints) | Method | Description | |--------|-------------| -| `client.data.orderbook(venue, market)` | Historical L2 snapshots | +| `client.data.orderbook(venue, start, end)` | Historical L2 snapshots | +| `client.data.spot_klines(symbol, start, end)` | Spot OHLCV candles (BTC/ETH/SOL) | | `client.data.prices(market_ids)` | Bulk daily close prices | -### Backtesting (9 endpoints) +### Backtesting (11 endpoints) | Method | Description | |--------|-------------| | `client.backtests.strategies()` | Available strategies | @@ -98,9 +107,12 @@ print(f"P&L: ${result['data']['metrics']['pnl_usd']:.2f}") | `client.backtests.custom_strategies()` | List custom strategies | | `client.backtests.datasets()` | Available datasets | | `client.backtests.execution_models()` | Fill models | +| `client.backtests.orderbook(start, end)` | Sample dataset L2 rows | +| `client.backtests.dataset(symbol, start, end)` | Assemble replay dataset | | `client.backtests.validate(...)` | Dry-run validation | | `client.backtests.plan(...)` | Multi-window experiment | | `client.backtests.submit(...)` | Submit for execution | +| `client.backtests.get_result(run_id)` | Poll run status/result | | `client.backtests.run(...)` | Submit + poll to completion | ## Features diff --git a/pyproject.toml b/pyproject.toml index a7d09e2..1be4c2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ito-markets" -version = "0.1.0" +version = "0.1.1" description = "Python SDK for the Ito Markets public API — prediction market data, baskets, and backtesting." readme = "README.md" license = "MIT" diff --git a/src/ito/__init__.py b/src/ito/__init__.py index 85d5b84..eb21393 100644 --- a/src/ito/__init__.py +++ b/src/ito/__init__.py @@ -1,5 +1,7 @@ """Ito Markets Python SDK — prediction market data, baskets, and backtesting.""" +from importlib.metadata import PackageNotFoundError, version + from ito.client import ItoClient from ito.exceptions import ( ItoAPIError, @@ -18,4 +20,7 @@ "ItoValidationError", ] -__version__ = "0.1.0" +try: + __version__ = version("ito-markets") +except PackageNotFoundError: # running from a source tree without an install + __version__ = "0.0.0" diff --git a/src/ito/_http.py b/src/ito/_http.py index 0b65533..71b99a6 100644 --- a/src/ito/_http.py +++ b/src/ito/_http.py @@ -3,6 +3,7 @@ from __future__ import annotations import time +from importlib.metadata import PackageNotFoundError, version from typing import Any import httpx @@ -15,11 +16,17 @@ ItoValidationError, ) +try: + _SDK_VERSION = version("ito-markets") +except PackageNotFoundError: # running from a source tree without an install + _SDK_VERSION = "0.0.0" + DEFAULT_BASE_URL = "https://itomarkets.com/api/v1" DEFAULT_TIMEOUT = 30.0 MAX_RETRIES = 3 RETRY_BACKOFF_FACTOR = 1.0 -RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504} +# 429 is handled by its own branch in _request, so it is intentionally absent here. +RETRYABLE_STATUS_CODES = {500, 502, 503, 504} class HttpTransport: @@ -40,7 +47,7 @@ def __init__( base_url=self._base_url, headers={ "Authorization": f"Bearer {api_key}", - "User-Agent": "ito-python/0.1.0", + "User-Agent": f"ito-python/{_SDK_VERSION}", "Accept": "application/json", }, timeout=timeout, diff --git a/src/ito/client.py b/src/ito/client.py index e38ace5..20cd69c 100644 --- a/src/ito/client.py +++ b/src/ito/client.py @@ -29,7 +29,12 @@ class ItoClient: prices = client.markets.history("will-x-happen", days=90) # Research data - orderbook = client.data.orderbook(venue="polymarket", market="will-x-happen") + orderbook = client.data.orderbook( + venue="polymarket", + start="2026-06-01T00:00:00Z", + end="2026-06-01T01:00:00Z", + market_id="will-x-happen", + ) bulk_prices = client.data.prices(["market-a", "market-b"], days=30) # Backtesting diff --git a/src/ito/resources/backtests.py b/src/ito/resources/backtests.py index f6061c6..979821b 100644 --- a/src/ito/resources/backtests.py +++ b/src/ito/resources/backtests.py @@ -6,6 +6,7 @@ from typing import Any from ito._http import HttpTransport +from ito.exceptions import ItoAPIError class Backtests: @@ -122,6 +123,68 @@ def get_result(self, run_id: str) -> dict[str, Any]: """Get run status or result artifact (202 while running, 200 when complete).""" return self._http.get(f"/backtests/atop/{run_id}") + def orderbook( + self, + start: str, + end: str, + source: str | None = None, + venue: str | None = None, + market_id: str | None = None, + limit: int = 5000, + offset: int = 0, + ) -> dict[str, Any]: + """Sample historical L2 rows from a registered dataset for inspection. + + Args: + start: ISO 8601 start timestamp (required). + end: ISO 8601 end timestamp (required). + source: Dataset alias — 'platform_l2' or 'kalshi_pmxt' (default platform_l2). + venue: Optional venue filter. + market_id: Optional market identifier filter. + limit: Max rows (default 5000, max 50000). + offset: Pagination offset (default 0). + """ + return self._http.get( + "/backtests/atop/orderbook", + params={ + "start": start, + "end": end, + "source": source, + "venue": venue, + "market_id": market_id, + "limit": limit, + "offset": offset, + }, + ) + + def dataset( + self, + symbol: str, + start: str, + end: str, + interval: int = 300, + include_l2: bool = False, + ) -> dict[str, Any]: + """Assemble a backtest dataset (L2 windows + spot klines) for replay. + + Args: + symbol: Asset symbol — 'BTC' or 'ETH'. + start: ISO 8601 start timestamp (required). + end: ISO 8601 end timestamp (required). + interval: Window size in seconds — 300 (5m) or 900 (15m). + include_l2: Include raw L2 book state (default False). + """ + return self._http.post( + "/backtests/atop/dataset", + json={ + "symbol": symbol, + "start": start, + "end": end, + "interval": interval, + "include_l2": include_l2, + }, + ) + def run( self, strategy_id: str, @@ -137,7 +200,8 @@ def run( ) -> dict[str, Any]: """Submit and poll until completion (convenience wrapper). - Returns the final result when status is 'succeeded' or 'failed'. + Returns the final result when the run status is 'succeeded'. + Raises ItoAPIError if the run ends in 'failed' or 'error'. Raises TimeoutError if timeout exceeded. """ submission = self.submit( @@ -159,11 +223,19 @@ def run( raise TimeoutError(f"Backtest {run_id} did not complete within {timeout}s") result = self.get_result(run_id) - status = result.get("data", {}).get("status", "") + data = result.get("data", {}) + status = data.get("status", "") - if status in ("succeeded", "failed", "error"): + if status == "succeeded": return result + if status in ("failed", "error"): + detail = data.get("error") or data.get("message") or "no detail provided" + raise ItoAPIError( + f"Backtest {run_id} ended with status {status!r}: {detail}", + response_body=result, + ) + time.sleep(poll_interval) @staticmethod diff --git a/src/ito/resources/data.py b/src/ito/resources/data.py index 34057a4..153302a 100644 --- a/src/ito/resources/data.py +++ b/src/ito/resources/data.py @@ -1,4 +1,4 @@ -"""Research data endpoints: orderbook snapshots, bulk prices.""" +"""Research data endpoints: orderbook snapshots, spot klines, bulk prices.""" from __future__ import annotations @@ -8,7 +8,7 @@ class Data: - """Raw data access for quantitative research — orderbook L2 and bulk prices.""" + """Raw data access for quantitative research — orderbook L2, spot klines, bulk prices.""" def __init__(self, http: HttpTransport) -> None: self._http = http @@ -16,27 +16,58 @@ def __init__(self, http: HttpTransport) -> None: def orderbook( self, venue: str, - market: str, - start: str | None = None, - end: str | None = None, - limit: int = 1000, + start: str, + end: str, + market_id: str | None = None, + limit: int = 5000, + offset: int = 0, ) -> dict[str, Any]: - """Historical L2 orderbook snapshots (max 24h window per request). + """Historical L2 orderbook snapshots (max 168h / 7-day window per request). Args: venue: One of 'polymarket', 'kalshi', 'hyperliquid'. - market: Market identifier / ticker. - start: ISO 8601 start time (default: 1 hour ago). - end: ISO 8601 end time (default: now). - limit: Max rows (default 1000, max 10000). + start: ISO 8601 start timestamp (required). + end: ISO 8601 end timestamp (required). + market_id: Optional filter by market identifier (condition_id, ticker). + limit: Max rows (default 5000, max 50000). + offset: Pagination offset (default 0). """ return self._http.get( "/data/orderbook", params={ "venue": venue, - "market": market, "start": start, "end": end, + "market_id": market_id, + "limit": limit, + "offset": offset, + }, + ) + + def spot_klines( + self, + symbol: str, + start: str, + end: str, + interval: int = 300, + limit: int = 5000, + ) -> dict[str, Any]: + """Historical spot OHLCV candles for major cryptoassets. + + Args: + symbol: Asset symbol — 'BTC', 'ETH', or 'SOL'. + start: ISO 8601 start timestamp (required). + end: ISO 8601 end timestamp (required). + interval: Candle interval in seconds, 60–86400 (default 300 = 5m). + limit: Max rows (default 5000, max 50000). + """ + return self._http.get( + "/data/spot/klines", + params={ + "symbol": symbol, + "start": start, + "end": end, + "interval": interval, "limit": limit, }, ) diff --git a/tests/test_client.py b/tests/test_client.py index e17e9e0..ebc598a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,11 +2,20 @@ from __future__ import annotations +import json + import pytest import httpx import respx -from ito import ItoClient, ItoAuthError, ItoNotFoundError, ItoRateLimitError, ItoValidationError +from ito import ( + ItoAPIError, + ItoAuthError, + ItoClient, + ItoNotFoundError, + ItoRateLimitError, + ItoValidationError, +) BASE = "https://itomarkets.com/api/v1" @@ -117,15 +126,38 @@ def test_history(self, client: ItoClient): class TestData: @respx.mock def test_orderbook(self, client: ItoClient): - respx.get(f"{BASE}/data/orderbook").mock( + route = respx.get(f"{BASE}/data/orderbook").mock( return_value=httpx.Response(200, json={ "success": True, "data": {"venue": "polymarket", "snapshots": [{"bid_price": 0.60}]}, "meta": {"rows": 1} }) ) - result = client.data.orderbook(venue="polymarket", market="will-x-happen", limit=100) + result = client.data.orderbook( + venue="polymarket", + start="2026-06-01T00:00:00Z", + end="2026-06-01T01:00:00Z", + market_id="will-x-happen", + ) assert result["data"]["snapshots"][0]["bid_price"] == 0.60 + assert route.calls.last.request.url.params["market_id"] == "will-x-happen" + + @respx.mock + def test_spot_klines(self, client: ItoClient): + route = respx.get(f"{BASE}/data/spot/klines").mock( + return_value=httpx.Response(200, json={ + "success": True, + "data": {"symbol": "BTC", "interval": 300, "klines": [{"close": 97267.4}]}, + "meta": {"rows": 1} + }) + ) + result = client.data.spot_klines( + symbol="BTC", + start="2026-06-01T00:00:00Z", + end="2026-06-01T01:00:00Z", + ) + assert result["data"]["klines"][0]["close"] == 97267.4 + assert route.calls.last.request.url.params["interval"] == "300" @respx.mock def test_prices(self, client: ItoClient): @@ -192,6 +224,80 @@ def test_get_result(self, client: ItoClient): result = client.backtests.get_result("atop_123") assert result["data"]["status"] == "succeeded" + @respx.mock + def test_orderbook(self, client: ItoClient): + route = respx.get(f"{BASE}/backtests/atop/orderbook").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"rows": [{"best_bid": 0.6}], "total": 1} + }) + ) + result = client.backtests.orderbook( + start="2026-06-01T00:00:00Z", + end="2026-06-01T01:00:00Z", + source="platform_l2", + ) + assert result["data"]["rows"][0]["best_bid"] == 0.6 + assert route.calls.last.request.url.params["source"] == "platform_l2" + + @respx.mock + def test_dataset(self, client: ItoClient): + route = respx.post(f"{BASE}/backtests/atop/dataset").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"symbol": "BTC", "windows": [{}], "spot_klines": [{}]} + }) + ) + result = client.backtests.dataset( + symbol="BTC", + start="2026-02-01T00:00:00Z", + end="2026-02-08T00:00:00Z", + include_l2=True, + ) + assert result["data"]["symbol"] == "BTC" + body = json.loads(route.calls.last.request.content) + assert body["include_l2"] is True + + @respx.mock + def test_run_returns_result_on_success(self, client: ItoClient): + respx.post(f"{BASE}/backtests/atop").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"run_id": "atop_ok", "status": "accepted"} + }) + ) + respx.get(f"{BASE}/backtests/atop/atop_ok").mock( + return_value=httpx.Response(200, json={ + "success": True, + "data": {"run_id": "atop_ok", "status": "succeeded", "metrics": {"pnl_usd": 100}}, + }) + ) + result = client.backtests.run( + strategy_id="wsc_crypto", + dataset_id="clickhouse:ito_hot.platform_orderbook_l2", + venues=["polymarket"], + date_range={"start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z"}, + ) + assert result["data"]["metrics"]["pnl_usd"] == 100 + + @respx.mock + def test_run_raises_on_failed_status(self, client: ItoClient): + respx.post(f"{BASE}/backtests/atop").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"run_id": "atop_bad", "status": "accepted"} + }) + ) + respx.get(f"{BASE}/backtests/atop/atop_bad").mock( + return_value=httpx.Response(200, json={ + "success": True, + "data": {"run_id": "atop_bad", "status": "failed", "error": "dataset window empty"}, + }) + ) + with pytest.raises(ItoAPIError, match="dataset window empty"): + client.backtests.run( + strategy_id="wsc_crypto", + dataset_id="clickhouse:ito_hot.platform_orderbook_l2", + venues=["polymarket"], + date_range={"start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z"}, + ) + class TestErrorHandling: @respx.mock @@ -217,7 +323,7 @@ def test_400_raises_validation_error(self, client: ItoClient): return_value=httpx.Response(400, json={"message": "venue parameter is required"}) ) with pytest.raises(ItoValidationError): - client.data.orderbook(venue="", market="x") + client.data.orderbook(venue="", start="2026-06-01T00:00:00Z", end="2026-06-01T01:00:00Z") @respx.mock def test_429_raises_rate_limit_error(self, client: ItoClient):