diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..95ddb73 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[dev]" + - run: ruff check src/ tests/ + - run: pytest -q --tb=short diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ff04928 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,36 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +permissions: + id-token: write # OIDC for Trusted Publishers + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install build + - run: python -m build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20d040a --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.venv/ +venv/ +env/ +.env +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +htmlcov/ +.coverage +*.so +.DS_Store diff --git a/LICENSE b/LICENSE index f9b543c..aae8a08 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Itô Markets +Copyright (c) 2026 Ito Markets Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 830f7ef..c47e254 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,125 @@ -# ito-python -SDK for Itô Markets - Python +# ito-markets — Python SDK for Ito Markets + +Thin, typed Python wrapper for the [Ito Markets public API](https://institutional.itomarkets.com/docs/public-api). Access prediction market baskets, individual market data, historical orderbook snapshots, and run backtests — all with a single `pip install`. + +## Install + +```bash +pip install ito-markets +# or with pandas support: +pip install ito-markets[pandas] +``` + +## Quick Start + +```python +from ito import ItoClient + +client = ItoClient("ito_...") # your API key from Settings + +# List all baskets +baskets = client.baskets.list() +for b in baskets["data"]: + print(f"{b['basket_id']}: ${b['stats']['current_price']:.2f}") + +# Get a single market +market = client.markets.get("will-btc-reach-100k") +print(market["data"]["title"], market["data"]["last_price"]) + +# Price history +history = client.markets.history("will-btc-reach-100k", days=90) +for point in history["data"]["series"]: + print(point["date"], point["close_price"]) + +# Bulk prices for multiple markets +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", + limit=5000, +) + +# Run a backtest on a thematic basket +result = client.backtests.run( + strategy_id="crypto_updown_roll_timing", + dataset_id="clickhouse:ito_hot.platform_orderbook_l2", + venues=["polymarket"], + date_range={"start": "2026-05-01T00:00:00Z", "end": "2026-06-01T00:00:00Z"}, + basket_id="middle-east-conflict", # only markets in this basket + params={"roll_trigger": "liquidity_spread_score"}, +) +print(f"P&L: ${result['data']['metrics']['pnl_usd']:.2f}") +``` + +## API Key + +1. Go to [institutional.itomarkets.com](https://institutional.itomarkets.com) +2. Sign up / log in +3. Go to **Settings** -> generate an API key +4. Use the key (starts with `ito_`) in the client constructor + +## Available Endpoints + +### Baskets (9 endpoints) +| Method | Description | +|--------|-------------| +| `client.baskets.list()` | All baskets with current prices | +| `client.baskets.get(id)` | Single basket details | +| `client.baskets.price(id)` | Current price + underlyer snapshot | +| `client.baskets.history(id)` | Price time series | +| `client.baskets.chart(id)` | OHLC chart data | +| `client.baskets.metrics(id)` | Returns, volatility | +| `client.baskets.underlyers(id)` | Component markets | +| `client.baskets.overrides()` | Manual overrides | +| `client.baskets.volume_daily()` | Daily traded volume | + +### Markets (3 endpoints) +| Method | Description | +|--------|-------------| +| `client.markets.search()` | Filtered listing by volume | +| `client.markets.get(id)` | Single market detail | +| `client.markets.history(id)` | Daily price series | + +### Research Data (2 endpoints) +| Method | Description | +|--------|-------------| +| `client.data.orderbook(venue, market)` | Historical L2 snapshots | +| `client.data.prices(market_ids)` | Bulk daily close prices | + +### Backtesting (9 endpoints) +| Method | Description | +|--------|-------------| +| `client.backtests.strategies()` | Available strategies | +| `client.backtests.create_strategy(...)` | Create custom strategy | +| `client.backtests.custom_strategies()` | List custom strategies | +| `client.backtests.datasets()` | Available datasets | +| `client.backtests.execution_models()` | Fill models | +| `client.backtests.validate(...)` | Dry-run validation | +| `client.backtests.plan(...)` | Multi-window experiment | +| `client.backtests.submit(...)` | Submit for execution | +| `client.backtests.run(...)` | Submit + poll to completion | + +## Features + +- **Typed**: Full type hints for IDE autocompletion +- **Retries**: Automatic retry with exponential backoff on 429/5xx +- **Errors**: Typed exceptions (`ItoAuthError`, `ItoRateLimitError`, etc.) +- **Context manager**: `with ItoClient(...) as client:` for clean resource management +- **Lightweight**: Only dependency is `httpx` + +## Development + +```bash +git clone https://github.com/Ito-Markets/ito-python.git +cd ito-python +pip install -e ".[dev]" +pytest +``` + +## License + +MIT diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a7d09e2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ito-markets" +version = "0.1.0" +description = "Python SDK for the Ito Markets public API — prediction market data, baskets, and backtesting." +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +authors = [ + {name = "Ito Markets", email = "dev@itomarkets.com"}, +] +keywords = ["prediction-markets", "backtesting", "quant", "research", "polymarket", "kalshi"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Information Analysis", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.25,<1.0", +] + +[project.optional-dependencies] +pandas = ["pandas>=2.0"] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "respx>=0.21", + "ruff>=0.4", +] + +[project.urls] +Homepage = "https://itomarkets.com" +Documentation = "https://institutional.itomarkets.com/docs/public-api" +Repository = "https://github.com/Ito-Markets/ito-python" +Changelog = "https://github.com/Ito-Markets/ito-python/releases" + +[tool.hatch.build.targets.wheel] +packages = ["src/ito"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py310" +line-length = 100 diff --git a/src/ito/__init__.py b/src/ito/__init__.py new file mode 100644 index 0000000..85d5b84 --- /dev/null +++ b/src/ito/__init__.py @@ -0,0 +1,21 @@ +"""Ito Markets Python SDK — prediction market data, baskets, and backtesting.""" + +from ito.client import ItoClient +from ito.exceptions import ( + ItoAPIError, + ItoAuthError, + ItoNotFoundError, + ItoRateLimitError, + ItoValidationError, +) + +__all__ = [ + "ItoClient", + "ItoAPIError", + "ItoAuthError", + "ItoNotFoundError", + "ItoRateLimitError", + "ItoValidationError", +] + +__version__ = "0.1.0" diff --git a/src/ito/_http.py b/src/ito/_http.py new file mode 100644 index 0000000..0b65533 --- /dev/null +++ b/src/ito/_http.py @@ -0,0 +1,162 @@ +"""HTTP transport layer with retry logic and error mapping.""" + +from __future__ import annotations + +import time +from typing import Any + +import httpx + +from ito.exceptions import ( + ItoAPIError, + ItoAuthError, + ItoNotFoundError, + ItoRateLimitError, + ItoValidationError, +) + +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} + + +class HttpTransport: + """Handles HTTP requests with retries and error mapping.""" + + def __init__( + self, + api_key: str, + base_url: str = DEFAULT_BASE_URL, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = MAX_RETRIES, + ) -> None: + self._api_key = api_key + self._base_url = base_url.rstrip("/") + self._timeout = timeout + self._max_retries = max_retries + self._client = httpx.Client( + base_url=self._base_url, + headers={ + "Authorization": f"Bearer {api_key}", + "User-Agent": "ito-python/0.1.0", + "Accept": "application/json", + }, + timeout=timeout, + ) + + def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + return self._request("GET", path, params=params) + + def post(self, path: str, json: dict[str, Any] | None = None) -> dict[str, Any]: + return self._request("POST", path, json=json) + + def _request( + self, + method: str, + path: str, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + ) -> dict[str, Any]: + last_exc: Exception | None = None + + for attempt in range(self._max_retries + 1): + try: + response = self._client.request( + method, + path, + params=_clean_params(params), + json=json, + ) + + if response.status_code == 429: + retry_after = _parse_retry_after(response) + if attempt < self._max_retries: + time.sleep(retry_after or (RETRY_BACKOFF_FACTOR * (2**attempt))) + continue + raise ItoRateLimitError( + retry_after=retry_after, + status_code=429, + response_body=_safe_json(response), + ) + + if response.status_code in RETRYABLE_STATUS_CODES and attempt < self._max_retries: + time.sleep(RETRY_BACKOFF_FACTOR * (2**attempt)) + continue + + return self._handle_response(response) + + except httpx.TransportError as e: + last_exc = e + if attempt < self._max_retries: + time.sleep(RETRY_BACKOFF_FACTOR * (2**attempt)) + continue + + raise ItoAPIError( + f"Request failed after {self._max_retries + 1} attempts: {last_exc}", + status_code=None, + ) + + def _handle_response(self, response: httpx.Response) -> dict[str, Any]: + body = _safe_json(response) + + if response.status_code == 401: + msg = body.get("message", "Unauthorized") if body else "Unauthorized" + raise ItoAuthError(msg, status_code=401, response_body=body) + + if response.status_code == 403: + msg = body.get("message", "Forbidden") if body else "Forbidden" + raise ItoAuthError(msg, status_code=403, response_body=body) + + if response.status_code == 404: + msg = body.get("message", "Not found") if body else "Not found" + raise ItoNotFoundError(msg, status_code=404, response_body=body) + + if response.status_code == 400: + msg = body.get("message", "Bad request") if body else "Bad request" + raise ItoValidationError(msg, status_code=400, response_body=body) + + if response.status_code >= 400: + msg = body.get("message", f"HTTP {response.status_code}") if body else f"HTTP {response.status_code}" + raise ItoAPIError(msg, status_code=response.status_code, response_body=body) + + if body is None: + raise ItoAPIError( + f"Non-JSON response (status {response.status_code})", + status_code=response.status_code, + ) + + return body + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> "HttpTransport": + return self + + def __exit__(self, *_: Any) -> None: + self.close() + + +def _clean_params(params: dict[str, Any] | None) -> dict[str, Any] | None: + if params is None: + return None + return {k: v for k, v in params.items() if v is not None} + + +def _parse_retry_after(response: httpx.Response) -> float | None: + val = response.headers.get("Retry-After") + if val is None: + return None + try: + return float(val) + except ValueError: + return None + + +def _safe_json(response: httpx.Response) -> dict[str, Any] | None: + try: + return response.json() + except Exception: + return None diff --git a/src/ito/client.py b/src/ito/client.py new file mode 100644 index 0000000..e38ace5 --- /dev/null +++ b/src/ito/client.py @@ -0,0 +1,81 @@ +"""Main client — entry point for all Ito API interactions.""" + +from __future__ import annotations + +from ito._http import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, MAX_RETRIES, HttpTransport +from ito.resources.backtests import Backtests +from ito.resources.baskets import Baskets +from ito.resources.data import Data +from ito.resources.markets import Markets + + +class ItoClient: + """Ito Markets API client. + + Usage:: + + from ito import ItoClient + + client = ItoClient("ito_...") + + # Baskets + baskets = client.baskets.list() + price = client.baskets.price("ai-frontier") + history = client.baskets.history("ai-frontier") + + # Markets + markets = client.markets.search(category="crypto") + market = client.markets.get("will-x-happen") + prices = client.markets.history("will-x-happen", days=90) + + # Research data + orderbook = client.data.orderbook(venue="polymarket", market="will-x-happen") + bulk_prices = client.data.prices(["market-a", "market-b"], days=30) + + # Backtesting + strategies = client.backtests.strategies() + result = client.backtests.run( + 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-05-02T00:00:00Z"}, + basket_id="middle-east-conflict", # restrict to thematic basket + ) + + Args: + api_key: Your Ito API key (starts with 'ito_'). + base_url: API base URL (default: https://itomarkets.com/api/v1). + timeout: Request timeout in seconds (default: 30). + max_retries: Max retry attempts for transient errors (default: 3). + """ + + def __init__( + self, + api_key: str, + base_url: str = DEFAULT_BASE_URL, + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = MAX_RETRIES, + ) -> None: + self._http = HttpTransport( + api_key=api_key, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + ) + self.baskets = Baskets(self._http) + self.markets = Markets(self._http) + self.data = Data(self._http) + self.backtests = Backtests(self._http) + + def close(self) -> None: + """Close the underlying HTTP connection pool.""" + self._http.close() + + def __enter__(self) -> "ItoClient": + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def __repr__(self) -> str: + return f"ItoClient(base_url={self._http._base_url!r})" diff --git a/src/ito/exceptions.py b/src/ito/exceptions.py new file mode 100644 index 0000000..30a1855 --- /dev/null +++ b/src/ito/exceptions.py @@ -0,0 +1,47 @@ +"""Exception hierarchy for the Ito SDK.""" + +from __future__ import annotations + +from typing import Any + + +class ItoAPIError(Exception): + """Base exception for all Ito API errors.""" + + def __init__( + self, + message: str, + status_code: int | None = None, + response_body: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.response_body = response_body + + def __repr__(self) -> str: + return f"{type(self).__name__}(status={self.status_code}, message={self.args[0]!r})" + + +class ItoAuthError(ItoAPIError): + """401 Unauthorized — missing or invalid API key.""" + + +class ItoRateLimitError(ItoAPIError): + """429 Too Many Requests — rate limit exceeded.""" + + def __init__( + self, + message: str = "Rate limit exceeded", + retry_after: float | None = None, + **kwargs: Any, + ) -> None: + super().__init__(message, **kwargs) + self.retry_after = retry_after + + +class ItoNotFoundError(ItoAPIError): + """404 Not Found — resource does not exist.""" + + +class ItoValidationError(ItoAPIError): + """400 Bad Request — validation failed.""" diff --git a/src/ito/py.typed b/src/ito/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/ito/resources/__init__.py b/src/ito/resources/__init__.py new file mode 100644 index 0000000..bdc52d0 --- /dev/null +++ b/src/ito/resources/__init__.py @@ -0,0 +1,8 @@ +"""API resource namespaces.""" + +from ito.resources.backtests import Backtests +from ito.resources.baskets import Baskets +from ito.resources.data import Data +from ito.resources.markets import Markets + +__all__ = ["Backtests", "Baskets", "Data", "Markets"] diff --git a/src/ito/resources/backtests.py b/src/ito/resources/backtests.py new file mode 100644 index 0000000..f6061c6 --- /dev/null +++ b/src/ito/resources/backtests.py @@ -0,0 +1,194 @@ +"""Backtest endpoints: strategies, datasets, execution models, run lifecycle.""" + +from __future__ import annotations + +import time +from typing import Any + +from ito._http import HttpTransport + + +class Backtests: + """Run ATOP strategy backtests on hosted replay datasets.""" + + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def strategies(self) -> dict[str, Any]: + """List dispatchable strategy templates with parameter schemas.""" + return self._http.get("/backtests/atop/strategies") + + def create_strategy( + self, + strategy_id: str, + name: str, + template_strategy_id: str, + venues: list[str], + default_params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Create a custom strategy from an approved template.""" + return self._http.post( + "/backtests/atop/strategies", + json={ + "strategy_id": strategy_id, + "name": name, + "template_strategy_id": template_strategy_id, + "venues": venues, + "default_params": default_params or {}, + }, + ) + + def custom_strategies(self) -> dict[str, Any]: + """List custom strategies created through the API.""" + return self._http.get("/backtests/atop/custom-strategies") + + def datasets(self) -> dict[str, Any]: + """List registered ClickHouse replay datasets.""" + return self._http.get("/backtests/atop/datasets") + + def execution_models(self) -> dict[str, Any]: + """List fill/execution models for replay runs.""" + return self._http.get("/backtests/atop/execution-models") + + def validate( + self, + strategy_id: str, + dataset_id: str, + venues: list[str], + date_range: dict[str, str], + params: dict[str, Any] | None = None, + execution: dict[str, Any] | None = None, + budgets: dict[str, Any] | None = None, + basket_id: str | None = None, + ) -> dict[str, Any]: + """Validate a run spec without dispatching (dry run). + + Args: + strategy_id: Strategy to run. + dataset_id: ClickHouse dataset identifier. + venues: List of venues (e.g. ['polymarket']). + date_range: {'start': ISO8601, 'end': ISO8601}. + params: Strategy-specific parameters. + execution: Execution model config (fill_model, latency_ms, etc.). + budgets: Resource budgets (max_wall_clock_seconds, max_rows, max_cost_usd). + basket_id: Optional — restrict replay to markets in this basket. + """ + body = self._build_spec( + strategy_id, dataset_id, venues, date_range, params, execution, budgets, basket_id + ) + return self._http.post("/backtests/atop/validate", json=body) + + def plan( + self, + name: str, + base_spec: dict[str, Any], + windows: list[dict[str, Any]], + ) -> dict[str, Any]: + """Validate a multi-window experiment plan without dispatching jobs.""" + return self._http.post( + "/backtests/atop/plan", + json={"name": name, "base_spec": base_spec, "windows": windows}, + ) + + def submit( + self, + strategy_id: str, + dataset_id: str, + venues: list[str], + date_range: dict[str, str], + params: dict[str, Any] | None = None, + execution: dict[str, Any] | None = None, + budgets: dict[str, Any] | None = None, + basket_id: str | None = None, + ) -> dict[str, Any]: + """Submit a backtest for execution. Returns run_id to poll. + + Args: + strategy_id: Strategy to run. + dataset_id: ClickHouse dataset identifier. + venues: List of venues (e.g. ['polymarket']). + date_range: {'start': ISO8601, 'end': ISO8601}. + params: Strategy-specific parameters. + execution: Execution model config. + budgets: Resource budgets. + basket_id: Optional — restrict replay to markets in this basket. + """ + body = self._build_spec( + strategy_id, dataset_id, venues, date_range, params, execution, budgets, basket_id + ) + return self._http.post("/backtests/atop", json=body) + + 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 run( + self, + strategy_id: str, + dataset_id: str, + venues: list[str], + date_range: dict[str, str], + params: dict[str, Any] | None = None, + execution: dict[str, Any] | None = None, + budgets: dict[str, Any] | None = None, + basket_id: str | None = None, + poll_interval: float = 5.0, + timeout: float = 3600.0, + ) -> dict[str, Any]: + """Submit and poll until completion (convenience wrapper). + + Returns the final result when status is 'succeeded' or 'failed'. + Raises TimeoutError if timeout exceeded. + """ + submission = self.submit( + strategy_id=strategy_id, + dataset_id=dataset_id, + venues=venues, + date_range=date_range, + params=params, + execution=execution, + budgets=budgets, + basket_id=basket_id, + ) + + run_id = submission["data"]["run_id"] + start = time.monotonic() + + while True: + if time.monotonic() - start > timeout: + raise TimeoutError(f"Backtest {run_id} did not complete within {timeout}s") + + result = self.get_result(run_id) + status = result.get("data", {}).get("status", "") + + if status in ("succeeded", "failed", "error"): + return result + + time.sleep(poll_interval) + + @staticmethod + def _build_spec( + strategy_id: str, + dataset_id: str, + venues: list[str], + date_range: dict[str, str], + params: dict[str, Any] | None, + execution: dict[str, Any] | None, + budgets: dict[str, Any] | None, + basket_id: str | None, + ) -> dict[str, Any]: + spec: dict[str, Any] = { + "strategy_id": strategy_id, + "dataset_id": dataset_id, + "venues": venues, + "date_range": date_range, + } + if params: + spec["params"] = params + if execution: + spec["execution"] = execution + if budgets: + spec["budgets"] = budgets + if basket_id: + spec["basket_id"] = basket_id + return spec diff --git a/src/ito/resources/baskets.py b/src/ito/resources/baskets.py new file mode 100644 index 0000000..3f9327f --- /dev/null +++ b/src/ito/resources/baskets.py @@ -0,0 +1,72 @@ +"""Basket endpoints: catalog, pricing, history, analytics.""" + +from __future__ import annotations + +from typing import Any + +from ito._http import HttpTransport + + +class Baskets: + """Access prediction market basket data — NAV pricing, history, composition.""" + + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def list(self, page: int = 1, per_page: int = 20) -> dict[str, Any]: + """List all baskets with current price and 24h change.""" + return self._http.get("/baskets", params={"page": page, "per_page": per_page}) + + def get(self, basket_id: str) -> dict[str, Any]: + """Get details and composition stats for one basket.""" + return self._http.get(f"/baskets/{basket_id}") + + def price(self, basket_id: str) -> dict[str, Any]: + """Current price plus the latest per-underlyer snapshot.""" + return self._http.get(f"/baskets/{basket_id}/price") + + def history(self, basket_id: str, hours: int | None = None) -> dict[str, Any]: + """Time-ordered price series for charting.""" + return self._http.get(f"/baskets/{basket_id}/history", params={"hours": hours}) + + def chart(self, basket_id: str, period: str = "1d") -> dict[str, Any]: + """Aggregated chart data for a period (1d, 1w, 1m, 3m, 6m, ytd, 1y, all).""" + return self._http.get(f"/baskets/{basket_id}/chart", params={"period": period}) + + def metrics(self, basket_id: str) -> dict[str, Any]: + """Period returns, intraday change, and 30d volatility.""" + return self._http.get(f"/baskets/{basket_id}/metrics") + + def underlyers( + self, basket_id: str, as_of: str | None = None, fast: bool = False + ) -> dict[str, Any]: + """Component market prices, weights, and stats.""" + return self._http.get( + f"/baskets/{basket_id}/underlyers", + params={"as_of": as_of, "fast": 1 if fast else None}, + ) + + def overrides( + self, + basket_name: str | None = None, + platform: str | None = None, + page: int = 1, + per_page: int = 20, + ) -> dict[str, Any]: + """Manual price/composition overrides currently applied.""" + return self._http.get( + "/baskets/overrides", + params={ + "basket_name": basket_name, + "platform": platform, + "page": page, + "per_page": per_page, + }, + ) + + def volume_daily(self, days: int = 30, basket_id: str | None = None) -> dict[str, Any]: + """Total traded volume aggregated by UTC day.""" + return self._http.get( + "/baskets/volume/daily", + params={"days": days, "basketId": basket_id}, + ) diff --git a/src/ito/resources/data.py b/src/ito/resources/data.py new file mode 100644 index 0000000..34057a4 --- /dev/null +++ b/src/ito/resources/data.py @@ -0,0 +1,61 @@ +"""Research data endpoints: orderbook snapshots, bulk prices.""" + +from __future__ import annotations + +from typing import Any + +from ito._http import HttpTransport + + +class Data: + """Raw data access for quantitative research — orderbook L2 and bulk prices.""" + + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def orderbook( + self, + venue: str, + market: str, + start: str | None = None, + end: str | None = None, + limit: int = 1000, + ) -> dict[str, Any]: + """Historical L2 orderbook snapshots (max 24h 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). + """ + return self._http.get( + "/data/orderbook", + params={ + "venue": venue, + "market": market, + "start": start, + "end": end, + "limit": limit, + }, + ) + + def prices( + self, + market_ids: list[str], + days: int = 30, + ) -> dict[str, Any]: + """Bulk daily close prices for multiple markets. + + Args: + market_ids: List of market identifiers (max 50). + days: Lookback window in days (default 30, max 365). + """ + return self._http.get( + "/data/prices", + params={ + "market_ids": ",".join(market_ids), + "days": days, + }, + ) diff --git a/src/ito/resources/markets.py b/src/ito/resources/markets.py new file mode 100644 index 0000000..d5de824 --- /dev/null +++ b/src/ito/resources/markets.py @@ -0,0 +1,45 @@ +"""Market endpoints: search, detail, price history.""" + +from __future__ import annotations + +from typing import Any + +from ito._http import HttpTransport + + +class Markets: + """Search, discover, and analyze individual prediction markets.""" + + def __init__(self, http: HttpTransport) -> None: + self._http = http + + def search( + self, + platform: str | None = None, + category: str | None = None, + expiration: str | None = None, + limit: int | None = None, + ) -> dict[str, Any]: + """Filtered listing of open markets sorted by volume.""" + return self._http.get( + "/markets/search", + params={ + "platform": platform, + "category": category, + "expiration": expiration, + "limit": limit, + }, + ) + + def get(self, market_id: str, platform: str | None = None) -> dict[str, Any]: + """Full metadata for a single market.""" + return self._http.get(f"/markets/{market_id}", params={"platform": platform}) + + def history( + self, market_id: str, days: int = 90, platform: str | None = None + ) -> dict[str, Any]: + """Daily close price time series for a single market.""" + return self._http.get( + f"/markets/{market_id}/history", + params={"days": days, "platform": platform}, + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..e17e9e0 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,244 @@ +"""Tests for the Ito SDK client — validates request construction and error handling.""" + +from __future__ import annotations + +import pytest +import httpx +import respx + +from ito import ItoClient, ItoAuthError, ItoNotFoundError, ItoRateLimitError, ItoValidationError + + +BASE = "https://itomarkets.com/api/v1" + + +@pytest.fixture +def client(): + c = ItoClient("ito_test_key_123", max_retries=0) + yield c + c.close() + + +class TestBaskets: + @respx.mock + def test_list(self, client: ItoClient): + respx.get(f"{BASE}/baskets").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": [{"basket_id": "ai-frontier"}], "meta": {"total": 1} + }) + ) + result = client.baskets.list() + assert result["success"] is True + assert result["data"][0]["basket_id"] == "ai-frontier" + + @respx.mock + def test_get(self, client: ItoClient): + respx.get(f"{BASE}/baskets/ai-frontier").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"basket_id": "ai-frontier", "name": "AI Frontier"} + }) + ) + result = client.baskets.get("ai-frontier") + assert result["data"]["name"] == "AI Frontier" + + @respx.mock + def test_price(self, client: ItoClient): + respx.get(f"{BASE}/baskets/ai-frontier/price").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"current_price": 42.5} + }) + ) + result = client.baskets.price("ai-frontier") + assert result["data"]["current_price"] == 42.5 + + @respx.mock + def test_history(self, client: ItoClient): + respx.get(f"{BASE}/baskets/ai-frontier/history").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"basket_prices": [42.1, 42.3]} + }) + ) + result = client.baskets.history("ai-frontier", hours=24) + assert len(result["data"]["basket_prices"]) == 2 + + @respx.mock + def test_metrics(self, client: ItoClient): + respx.get(f"{BASE}/baskets/ai-frontier/metrics").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"volatility": 11.4} + }) + ) + result = client.baskets.metrics("ai-frontier") + assert result["data"]["volatility"] == 11.4 + + @respx.mock + def test_underlyers(self, client: ItoClient): + respx.get(f"{BASE}/baskets/ai-frontier/underlyers").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"underlyers": [{"identifier": "m1", "weight": 0.5}]} + }) + ) + result = client.baskets.underlyers("ai-frontier") + assert result["data"]["underlyers"][0]["weight"] == 0.5 + + +class TestMarkets: + @respx.mock + def test_search(self, client: ItoClient): + respx.get(f"{BASE}/markets/search").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": [{"market_id": "will-x-happen"}] + }) + ) + result = client.markets.search(category="crypto") + assert result["data"][0]["market_id"] == "will-x-happen" + + @respx.mock + def test_get(self, client: ItoClient): + respx.get(f"{BASE}/markets/will-x-happen").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"market_id": "will-x-happen", "status": "open"} + }) + ) + result = client.markets.get("will-x-happen") + assert result["data"]["status"] == "open" + + @respx.mock + def test_history(self, client: ItoClient): + respx.get(f"{BASE}/markets/will-x-happen/history").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"series": [{"date": "2026-01-01", "close_price": 0.55}]} + }) + ) + result = client.markets.history("will-x-happen", days=30) + assert len(result["data"]["series"]) == 1 + + +class TestData: + @respx.mock + def test_orderbook(self, client: ItoClient): + 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) + assert result["data"]["snapshots"][0]["bid_price"] == 0.60 + + @respx.mock + def test_prices(self, client: ItoClient): + respx.get(f"{BASE}/data/prices").mock( + return_value=httpx.Response(200, json={ + "success": True, + "data": {"m1": [{"date": "2026-01-01", "close_price": 0.5}]}, + "meta": {"market_count": 1} + }) + ) + result = client.data.prices(["m1", "m2"], days=30) + assert "m1" in result["data"] + + +class TestBacktests: + @respx.mock + def test_strategies(self, client: ItoClient): + respx.get(f"{BASE}/backtests/atop/strategies").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"strategies": [{"strategy_id": "wsc_crypto"}]} + }) + ) + result = client.backtests.strategies() + assert result["data"]["strategies"][0]["strategy_id"] == "wsc_crypto" + + @respx.mock + def test_validate(self, client: ItoClient): + respx.post(f"{BASE}/backtests/atop/validate").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"status": "validated"} + }) + ) + result = client.backtests.validate( + 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"}, + basket_id="middle-east-conflict", + ) + assert result["data"]["status"] == "validated" + + @respx.mock + def test_submit(self, client: ItoClient): + respx.post(f"{BASE}/backtests/atop").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"run_id": "atop_123", "status": "accepted"} + }) + ) + result = client.backtests.submit( + 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"]["run_id"] == "atop_123" + + @respx.mock + def test_get_result(self, client: ItoClient): + respx.get(f"{BASE}/backtests/atop/atop_123").mock( + return_value=httpx.Response(200, json={ + "success": True, "data": {"run_id": "atop_123", "status": "succeeded", "metrics": {"pnl_usd": 100}} + }) + ) + result = client.backtests.get_result("atop_123") + assert result["data"]["status"] == "succeeded" + + +class TestErrorHandling: + @respx.mock + def test_401_raises_auth_error(self, client: ItoClient): + respx.get(f"{BASE}/baskets").mock( + return_value=httpx.Response(401, json={"message": "Invalid API key"}) + ) + with pytest.raises(ItoAuthError) as exc_info: + client.baskets.list() + assert exc_info.value.status_code == 401 + + @respx.mock + def test_404_raises_not_found(self, client: ItoClient): + respx.get(f"{BASE}/markets/nonexistent").mock( + return_value=httpx.Response(404, json={"message": "Market not found"}) + ) + with pytest.raises(ItoNotFoundError): + client.markets.get("nonexistent") + + @respx.mock + def test_400_raises_validation_error(self, client: ItoClient): + respx.get(f"{BASE}/data/orderbook").mock( + return_value=httpx.Response(400, json={"message": "venue parameter is required"}) + ) + with pytest.raises(ItoValidationError): + client.data.orderbook(venue="", market="x") + + @respx.mock + def test_429_raises_rate_limit_error(self, client: ItoClient): + respx.get(f"{BASE}/baskets").mock( + return_value=httpx.Response(429, json={"message": "Rate limit exceeded"}, + headers={"Retry-After": "60"}) + ) + with pytest.raises(ItoRateLimitError) as exc_info: + client.baskets.list() + assert exc_info.value.retry_after == 60.0 + + +class TestClientLifecycle: + def test_context_manager(self): + with ItoClient("ito_test") as client: + assert client.baskets is not None + assert client.markets is not None + assert client.data is not None + assert client.backtests is not None + + def test_repr(self): + client = ItoClient("ito_test") + assert "itomarkets.com" in repr(client) + client.close()