diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2758b18..0a2c8d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,8 @@ jobs: - name: Example help smoke run: | uv run predxt --help + uv run predxt demo --help + uv run predxt explore --help uv run predxt parse-fixture --help uv run predxt stream polymarket --help uv run python examples/polymarket_market_stream.py --help @@ -65,6 +67,10 @@ jobs: /tmp/predxt-smoke/bin/python - <<'PY' from importlib.metadata import version from pathlib import Path + import json + import subprocess + import sys + import tempfile import tomllib import predxt @@ -77,4 +83,13 @@ jobs: assert version("predxt") == project_version assert predxt.__version__ == project_version + with tempfile.TemporaryDirectory() as demo_dir: + result = subprocess.run( + [sys.executable, "-m", "predxt.cli", "demo", "--json"], + cwd=demo_dir, check=True, capture_output=True, text=True, + ) + demo = json.loads(result.stdout) + assert demo["synthetic"] is True + assert demo["best_bid"] == 0.42 + assert demo["best_ask"] == 0.44 PY diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c8f678..55e8d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to `predxt` are documented here. +## 0.3.0 + +- add `predxt demo`, a built-in synthetic orderbook example that works without + network access or repository fixtures +- add `predxt explore polymarket` to search, explicitly select a market/outcome, + read a bounded REST snapshot, and print the corresponding WebSocket command +- fix Polymarket text search to use Gamma public-search and flatten/deduplicate + event markets; add keyword-only `active_only` filtering and reject nonpositive limits +- document installation and first-run errors, and verify the offline demo from + an installed wheel outside the source checkout + ## 0.2.2 - 2026-09-11 - fixed graceful websocket stream exhaustion for Polymarket, Kalshi, and diff --git a/README.md b/README.md index 305a967..c07264c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ not a trading, execution, account, or financial-advice library. ## Install ```bash -pip install predxt +pip install "predxt>=0.3.0" ``` For local development: @@ -25,39 +25,42 @@ uv sync --group dev uv run pytest -q -s ``` -## 60-second Polymarket demo +## Your first orderbook -Polymarket market websockets are public. Use any valid Polymarket CLOB asset id: +This walkthrough requires Python 3.12 or later and predxt 0.3.0 or later. + +Start with a built-in synthetic example. It works from any directory, with no +credentials, files to download, or network connection: ```bash -predxt stream polymarket --asset-id 1234567890 --limit 5 --jsonl +predxt demo ``` -Or from Python: - -```python -import asyncio - -from predxt.polymarket import PolymarketWsClient - - -async def main() -> None: - client = PolymarketWsClient() - await client.connect() - await client.subscribe( - ["market"], - {"assets_ids": ["1234567890"], "initial_dump": True}, - ) +```text +SYNTHETIC DEMO — no network requests +Market: Example market +Outcome: Yes + BID PRICE SIZE | ASK PRICE SIZE + 0.4200 100 | 0.4400 80 + 0.4100 50 | 0.4500 120 +Spread: 0.0200 +``` - async for message in client.messages(): - print(message.event_type, message.asset_id, message.raw_data) - break +Then find a real Polymarket market by name and choose its outcome: - await client.close() +```bash +predxt explore polymarket --query "bitcoin" +``` +Choose a market number and an outcome number at the prompts. The CLI reads a +REST snapshot, displays the top five levels on each side, and prints a ready-to-run +WebSocket command for that outcome. Public Polymarket market data needs no API key. +Each API request has a 10-second deadline; empty results and unavailable markets +produce a clear message. The snapshot's fetch time is local receipt time. -asyncio.run(main()) -``` +See the [first-run guide](docs/first-run.md) for scriptable selection, JSON output, +and troubleshooting. Kalshi and Opinion remain available through the existing +REST and WebSocket clients. ## Venue matrix @@ -131,7 +134,13 @@ do not need to access private task attributes. ## CLI -Offline parser demo: +Built-in demo: + +```bash +predxt demo --json +``` + +Parse a fixture from a repository checkout: ```bash predxt parse-fixture --venue polymarket --jsonl tests/fixtures/polymarket_order_books.json @@ -182,5 +191,5 @@ uv run twine check dist/* ## Release -Releases use SemVer and tags like `v0.1.0`. See +Releases use SemVer and tags in the `vX.Y.Z` format. See [`docs/releasing.md`](docs/releasing.md). diff --git a/docs/cli.md b/docs/cli.md index 0a198d4..369451f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,7 +1,29 @@ # CLI +## First run + +The first-run commands require predxt 0.3.0 or later: + +```bash +pip install "predxt>=0.3.0" +``` + +```bash +predxt demo +predxt demo --json +predxt explore polymarket --query "bitcoin" +``` + +`demo` is synthetic and never connects to a venue. `explore` asks you to select +a market and outcome, displays one REST snapshot, and prints a WebSocket command. +Use `--market-id ID --outcome-index N --json` for noninteractive selection. +`--timeout` sets the deadline for each API request (10 seconds by default). +See [Your first orderbook](first-run.md). + ## Parse offline fixtures +This command uses a fixture from the repository checkout: + ```bash predxt parse-fixture --venue polymarket --jsonl tests/fixtures/polymarket_order_books.json ``` diff --git a/docs/first-run.md b/docs/first-run.md new file mode 100644 index 0000000..4fffb10 --- /dev/null +++ b/docs/first-run.md @@ -0,0 +1,96 @@ +# Your first orderbook + +The commands below require Python 3.12 or later and predxt 0.3.0 or later: + +```bash +pip install "predxt>=0.3.0" +``` + +## 1. See a result without credentials or network + +```bash +predxt demo +``` + +This displays a **synthetic** two-sided book. Its best bid is 0.42, best ask is +0.44, and spread is 0.02. These are example values, not market quotes. The demo +is included in the installed package and works outside the repository. + +To inspect the structured output: + +```bash +predxt demo --json +``` + +The output includes `synthetic: true`, the book, and best bid/ask. No network +client is created, and no source-checkout fixture is required. + +## 2. Find a real market + +```bash +predxt explore polymarket --query "bitcoin" +``` + +In an interactive terminal, the command: + +1. Searches Polymarket's public Gamma API and lists up to ten open orderbook markets. +2. Asks for the market number you want to inspect. +3. Fetches current details and asks for an outcome number. +4. Uses that outcome's token ID to fetch its CLOB orderbook. +5. Prints the top five bid/ask levels and a command for streaming WebSocket events. + +The display is one REST snapshot, not a continuously updating monitor. The fetch +time is measured locally when the response arrives; it is not a guarantee that +the venue's quote is fresh. An empty or one-sided book is shown explicitly. + +The printed `predxt stream` command includes the chosen token ID. Copy it to +receive normalized events and raw payloads. It stops after five messages; +an inactive stream may wait, so use Ctrl-C to stop it manually. + +## 3. Use the result in a script + +The selection menus go to stderr. Pass a Gamma market ID from the search results +and a **1-based** outcome index to skip interactive prompts: + +```bash +predxt explore polymarket --market-id "$MARKET_ID" --outcome-index 1 --json +``` + +Set `MARKET_ID` to the market you selected. The JSON response includes +`synthetic: false`, the market title, outcome label, normalized snapshot, +best bid/ask, and the snapshot's original `raw_data`. It emits one JSON document +on stdout. Inspect outcome labels in the interactive view before selecting by index. + +Gamma market IDs identify questions; CLOB token IDs identify the individual +outcomes whose orderbooks you read. The CLI handles that distinction for you. + +## Network and selection errors + +- Each API request has a wall-clock deadline, defaulting to 10 seconds. Change it + with `--timeout 5`. Human selection time is separate from request timeouts. +- Empty results: try a different query. The command reads the first search page; + it does not crawl the whole catalog or guarantee exhaustive results. +- Closed markets or incomplete outcome identifiers: select a different market. + Details are checked again after selection because market status can change. +- HTTP 403 or other API errors: check venue availability. The command exits with + an error and suggests `predxt demo`; it never substitutes synthetic output for + a live result automatically. +- Without a terminal, the search prints market IDs and explains how to pass + `--market-id` and `--outcome-index`. It does not guess your selections. + +Success returns exit code 0, API/selection errors return 1, invalid command-line +arguments return 2, and interrupting the interactive flow returns 130. + +## Python search + +`PolymarketRestClient.search_markets(query, limit=20, active_only=False)` uses +Gamma's documented `/public-search` endpoint for non-empty queries, flattens +the nested event markets, and removes duplicate market IDs. `active_only=True` +requests active results and filters out closed or unconfirmed-active rows. +An empty query lists markets through `/markets`. `limit` must be positive. + +Responses retain the existing `MarketSummary` model and each raw market row. +Use `get_market(market_id)` for outcome labels and token IDs, then +`get_orderbook(token_id)` for that outcome's snapshot. + +Source: [Polymarket search API](https://docs.polymarket.com/api-reference/search/search-markets-events-and-profiles). diff --git a/docs/index.md b/docs/index.md index 19f8e78..058d681 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,24 +17,23 @@ advice. ## Quick install ```bash -pip install predxt +pip install "predxt>=0.3.0" ``` -## Quick stream +## First orderbook + +These commands require Python 3.12 or later and predxt 0.3.0 or later. ```bash -predxt stream polymarket --asset-id 1234567890 --limit 5 --jsonl +predxt demo +predxt explore polymarket --query "bitcoin" ``` -## Quick REST snapshot - -```python -from predxt.polymarket import PolymarketRestClient +`demo` shows a labelled synthetic orderbook with no network access. `explore` +lets you select a real market and outcome, shows a REST snapshot, then prints +a WebSocket command with the selected token ID already filled in. -client = PolymarketRestClient() -book = await client.get_orderbook("CLOB_TOKEN_ID") -await client.close() -``` +Read the [first-run guide](first-run.md) for expected output and error handling. ## Core model diff --git a/docs/releasing.md b/docs/releasing.md index 6df43a4..1952808 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -2,10 +2,13 @@ `predxt` uses SemVer. Release tags use the `vX.Y.Z` format. -## First release +## Prepare the release -1. Confirm the version in `pyproject.toml` and `src/predxt/__init__.py`. -2. Update `CHANGELOG.md`. +1. Update the version in `pyproject.toml` and `src/predxt/__init__.py` together. + Run `uv lock --offline` and check that only the local `predxt` package version + changed in `uv.lock`. +2. Add a versioned entry to `CHANGELOG.md`. Update installation requirements in + the README, first-run docs, and LLM guidance when new commands require the release. 3. Run local validation: ```bash @@ -15,10 +18,22 @@ uv run pytest -q -s uv build uv run twine check dist/* + uv run mkdocs build --strict ``` -4. Push `main`. -5. Confirm PyPI Trusted Publishing is configured: +4. Install the built wheel in a fresh environment and run `predxt demo --json` + from outside the repository. Confirm its version matches the release and + its output is marked synthetic. Test `predxt explore polymarket` against the + public API when first-run behavior changes; record any external API failure + separately from the local tests. +5. Open the release PR and verify CI on the final commit. Keep dependency upgrades + separate from a version-only release preparation. + +## Publish + +After the release PR is merged and CI passes on `main`: + +1. Confirm PyPI Trusted Publishing is configured: - PyPI project name: `predxt` - Owner: `hzprotocol` @@ -26,17 +41,23 @@ - Workflow: `release.yml` - Environment: `pypi` -6. Create and push a tag: +2. From a clean checkout of the released `main`, create and push the tag matching + the package version. The tag push starts the release workflow: ```bash - git tag v0.1.0 - git push origin v0.1.0 + release_version=$(uv run python -c "import predxt; print(predxt.__version__)") + git tag "v${release_version}" + git push origin "v${release_version}" ``` -7. The release workflow builds distributions first. The publish job waits for +3. The release workflow builds distributions first. The publish job waits for the GitHub `pypi` environment approval, then creates the GitHub release and publishes to PyPI with Trusted Publishing. +4. Confirm the GitHub release and exact PyPI version exist. Install that version + in a fresh environment and run the offline demo. Check that the published + documentation shows the matching first-run instructions before sharing them. + ## PyPI Use PyPI Trusted Publishing for GitHub Actions. Configure the PyPI project named diff --git a/llms-full.txt b/llms-full.txt index b6010f2..16459a8 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -69,6 +69,28 @@ must not inspect or cancel private message task attributes. ### CLI +First-run commands require predxt 0.3.0 or later: + +```bash +predxt demo +predxt explore polymarket --query "bitcoin" +``` + +The synthetic demo is built into the installed package and never accesses the +network. Explore performs read-only Polymarket search, explicit market/outcome +selection, and one REST orderbook request. It then prints a WebSocket command. +`--market-id ID --outcome-index N --json` skips interactive selection and emits +one JSON document including raw_data. Each request has a deadline controlled by +`--timeout` (default 10 seconds). Errors exit nonzero; synthetic data is never +presented as a live fallback. Read `docs/first-run.md` for the full workflow. + +Polymarket search now uses `/public-search` for non-empty queries, flattens nested +event markets, deduplicates IDs, and supports keyword-only `active_only=True`. +An empty query lists markets. `limit` must be positive; results come from the +first search page, not an exhaustive catalog scan. Public models are unchanged. + +Existing commands (fixture paths require the repository checkout): + ```bash predxt parse-fixture --venue polymarket --jsonl tests/fixtures/polymarket_order_books.json predxt stream polymarket --asset-id 1234567890 --limit 10 --jsonl diff --git a/llms.txt b/llms.txt index c9499ae..4f7389c 100644 --- a/llms.txt +++ b/llms.txt @@ -22,7 +22,7 @@ market-data snapshots. ## Install ```bash -pip install predxt +pip install "predxt>=0.3.0" ``` ## Core APIs @@ -48,6 +48,25 @@ REST clients expose `search_markets`, `get_market`, `get_orderbook`, and ## CLI +First-run commands require predxt 0.3.0 or later: + +```bash +predxt demo +predxt explore polymarket --query "bitcoin" +``` + +`demo` uses built-in synthetic data and no network. `explore` selects an open +market and outcome, reads one REST snapshot, and prints a WebSocket command. +Use `--market-id ID --outcome-index N --json` without an interactive terminal. +Each request has a deadline (`--timeout`, default 10 seconds). API failures never +silently fall back to synthetic output. See `docs/first-run.md`. + +Polymarket `search_markets(query, limit=20, active_only=False)` uses Gamma +public-search for non-empty queries and lists markets for empty queries. It +returns up to limit unique market rows from the first page, retaining raw_data. + +Existing commands (the fixture path requires a checkout): + ```bash predxt parse-fixture --venue polymarket --jsonl tests/fixtures/polymarket_order_books.json predxt stream polymarket --asset-id 1234567890 --limit 10 --jsonl diff --git a/mkdocs.yml b/mkdocs.yml index ea2bbc9..3b97556 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,6 +14,7 @@ markdown_extensions: - pymdownx.superfences nav: - Home: index.md + - First run: first-run.md - Concepts: concepts.md - Venues: venues.md - Events: events.md diff --git a/pyproject.toml b/pyproject.toml index 93d7306..b985d35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "predxt" -version = "0.2.2" +version = "0.3.0" description = "Read-only prediction market data clients for Polymarket, Kalshi, and Opinion" readme = "README.md" requires-python = ">=3.12" diff --git a/src/predxt/__init__.py b/src/predxt/__init__.py index 7d65464..b12f5d7 100644 --- a/src/predxt/__init__.py +++ b/src/predxt/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.2.2" +__version__ = "0.3.0" from .base import BaseWsClient, HealthMetrics, VenueMessage, build_venue_message from .events import ( diff --git a/src/predxt/cli.py b/src/predxt/cli.py index 740c393..6e0d595 100644 --- a/src/predxt/cli.py +++ b/src/predxt/cli.py @@ -12,6 +12,7 @@ from predxt.base import VenueMessage, build_venue_message from predxt.events import typed_event_from_message +from predxt.first_run import demo, explore, positive_int, positive_seconds from predxt.kalshi import KalshiWsClient from predxt.kalshi.parser import parse_message as parse_kalshi_message from predxt.opinion import OpinionWsClient @@ -23,6 +24,13 @@ def main(argv: list[str] | None = None) -> int: parser = _build_parser() args = parser.parse_args(argv) + if args.command == "demo": + return demo(json_output=args.json) + if args.command == "explore": + try: + return asyncio.run(explore(args)) + except KeyboardInterrupt: + return 130 if args.command == "parse-fixture": return _parse_fixture(args) if args.command == "stream": @@ -38,6 +46,35 @@ def _build_parser() -> argparse.ArgumentParser: ) subcommands = parser.add_subparsers(dest="command") + demo_parser = subcommands.add_parser( + "demo", help="Show a synthetic orderbook without files, keys, or network." + ) + demo_parser.add_argument("--json", action="store_true") + + explore_parser = subcommands.add_parser( + "explore", help="Find a market, choose an outcome, and read its orderbook." + ) + explore_parser.add_argument("venue", choices=["polymarket"]) + source = explore_parser.add_mutually_exclusive_group() + source.add_argument( + "--query", default="bitcoin", help="Search text (default: bitcoin)." + ) + source.add_argument("--market-id", help="Gamma market ID; skips market selection.") + explore_parser.add_argument( + "--outcome-index", + type=positive_int, + help="1-based outcome number; skips selection.", + ) + explore_parser.add_argument( + "--timeout", + type=positive_seconds, + default=10.0, + help="Maximum seconds for each API request (default: 10).", + ) + explore_parser.add_argument( + "--json", action="store_true", help="Print the snapshot as JSON." + ) + fixture = subcommands.add_parser( "parse-fixture", help="Parse offline JSON fixture messages with a venue parser.", @@ -48,13 +85,17 @@ def _build_parser() -> argparse.ArgumentParser: choices=["polymarket", "kalshi", "opinion"], required=True, ) - fixture.add_argument("--jsonl", action="store_true", help="Emit one JSON object per line.") + fixture.add_argument( + "--jsonl", action="store_true", help="Emit one JSON object per line." + ) stream = subcommands.add_parser("stream", help="Stream venue websocket messages.") stream_subcommands = stream.add_subparsers(dest="venue", required=True) polymarket = stream_subcommands.add_parser("polymarket", help="Stream Polymarket.") - polymarket.add_argument("--asset-id", dest="asset_ids", action="append", required=True) + polymarket.add_argument( + "--asset-id", dest="asset_ids", action="append", required=True + ) _add_stream_common_args(polymarket) kalshi = stream_subcommands.add_parser("kalshi", help="Stream Kalshi.") @@ -62,7 +103,9 @@ def _build_parser() -> argparse.ArgumentParser: _add_stream_common_args(kalshi) opinion = stream_subcommands.add_parser("opinion", help="Stream Opinion.") - opinion.add_argument("--market-id", dest="market_ids", action="append", required=True) + opinion.add_argument( + "--market-id", dest="market_ids", action="append", required=True + ) _add_stream_common_args(opinion) return parser @@ -70,7 +113,9 @@ def _build_parser() -> argparse.ArgumentParser: def _add_stream_common_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--limit", type=int, default=10) - parser.add_argument("--jsonl", action="store_true", help="Emit one JSON object per line.") + parser.add_argument( + "--jsonl", action="store_true", help="Emit one JSON object per line." + ) def _parse_fixture(args: argparse.Namespace) -> int: diff --git a/src/predxt/first_run.py b/src/predxt/first_run.py new file mode 100644 index 0000000..31ed703 --- /dev/null +++ b/src/predxt/first_run.py @@ -0,0 +1,239 @@ +"""Small, bounded first-run CLI flows; all network operations read market data.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import math +import sys +from collections.abc import Awaitable +from dataclasses import asdict +from datetime import datetime, timezone +from typing import TypeVar + +from predxt.events import BookLevel, OrderBookSnapshot +from predxt.models import MarketDetail, VenueApiError +from predxt.orderbook import OrderBookState +from predxt.polymarket import PolymarketRestClient + +T = TypeVar("T") + + +def positive_int(value: str) -> int: + number = int(value) + if number < 1: + raise argparse.ArgumentTypeError("must be a positive integer") + return number + + +def positive_seconds(value: str) -> float: + number = float(value) + if not math.isfinite(number) or number <= 0: + raise argparse.ArgumentTypeError("must be a finite positive number") + return number + + +def demo(*, json_output: bool = False) -> int: + """Show synthetic data from the installed package, without files or network.""" + book = OrderBookSnapshot( + venue="polymarket", + event_type="demo_orderbook", + timestamp_ms=0, + market_id="demo-market", + asset_id="demo-yes", + bids=[BookLevel(0.42, 100), BookLevel(0.41, 50)], + asks=[BookLevel(0.44, 80), BookLevel(0.45, 120)], + raw_data={"synthetic": True}, + ) + _show_book( + book, + title="Example market", + outcome="Yes", + synthetic=True, + json_output=json_output, + ) + if not json_output: + print('\nRead a real market: predxt explore polymarket --query "bitcoin"') + return 0 + + +async def explore(args: argparse.Namespace) -> int: + client = PolymarketRestClient(timeout=args.timeout) + try: + market_id = args.market_id + if market_id is None: + if not args.query.strip(): + raise ValueError("Enter a search query or use --market-id.") + markets = await _bounded( + client.search_markets(args.query, limit=10, active_only=True), + args.timeout, + ) + markets = [m for m in markets if m.raw_data.get("enableOrderBook") is True] + if not markets: + raise ValueError( + "No open orderbook markets in these search results. " + "Try a different --query." + ) + for index, candidate in enumerate(markets, 1): + print( + f"{index}. {_text(candidate.title or candidate.market_id)} " + f"(market ID: {_text(candidate.market_id)})", + file=sys.stderr, + ) + selected = _choose(len(markets), "market", None) + market_id = markets[selected].market_id + + market = await _bounded(client.get_market(market_id), args.timeout) + _check_market(market) + for index, outcome in enumerate(market.outcomes, 1): + print(f"{index}. {_text(outcome)}", file=sys.stderr) + selected = _choose(len(market.outcomes), "outcome", args.outcome_index) + token_id = market.token_ids[selected] + book = await _bounded(client.get_orderbook(token_id), args.timeout) + if book.asset_id != token_id: + raise ValueError( + "The returned orderbook does not match the chosen outcome." + ) + _show_book( + book, + title=market.title or market.market_id, + outcome=market.outcomes[selected], + synthetic=False, + json_output=args.json, + ) + if not args.json: + print("\nContinue with WebSocket events (Ctrl-C to stop):") + print(f"predxt stream polymarket --asset-id {token_id} --limit 5 --jsonl") + return 0 + except TimeoutError: + print( + f"Timed out after {args.timeout:g}s waiting for Polymarket. " + "Try predxt demo for an offline example.", + file=sys.stderr, + ) + return 1 + except VenueApiError as exc: + status = f" (HTTP {exc.status_code})" if exc.status_code else "" + print( + f"Polymarket request failed{status}. " + "Check venue availability; try predxt demo for an offline example.", + file=sys.stderr, + ) + return 1 + except EOFError: + print("Selection ended before a number was entered.", file=sys.stderr) + return 1 + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + finally: + await client.close() + + +async def _bounded(operation: Awaitable[T], timeout: float) -> T: + async with asyncio.timeout(timeout): + return await operation + + +def _choose(count: int, label: str, requested: int | None) -> int: + if requested is None: + if not sys.stdin.isatty(): + raise ValueError( + "Interactive selection needs a terminal. For scripts, pass " + "--market-id ID --outcome-index N (1-based)." + ) + print(f"Choose {label} [1-{count}]: ", file=sys.stderr, end="", flush=True) + try: + requested = int(input()) + except ValueError: + raise ValueError( + f"Enter the {label} number between 1 and {count}." + ) from None + if not 1 <= requested <= count: + raise ValueError(f"Choose the {label} number between 1 and {count}.") + return requested - 1 + + +def _check_market(market: MarketDetail) -> None: + raw = market.raw_data + if ( + raw.get("active") is not True + or raw.get("closed") is not False + or raw.get("enableOrderBook") is not True + ): + raise ValueError( + "This market has no open orderbook. Search for another market." + ) + if ( + not market.outcomes + or len(market.outcomes) != len(market.token_ids) + or len(set(market.token_ids)) != len(market.token_ids) + or any( + not token.isascii() or not token.isdecimal() for token in market.token_ids + ) + ): + raise ValueError("This market has incomplete outcome/token identifiers.") + # Reject independently filtered arrays that could silently pair the wrong + # label with a token. The CLI needs the original one-to-one association. + outcomes = raw.get("outcomes") + tokens = raw.get("clobTokenIds", raw.get("clob_token_ids")) + if isinstance(outcomes, str): + outcomes = json.loads(outcomes) + if isinstance(tokens, str): + tokens = json.loads(tokens) + if outcomes != market.outcomes or tokens != market.token_ids: + raise ValueError("This market has ambiguous outcome/token identifiers.") + + +def _text(value: str) -> str: + return "".join(char if char.isprintable() else " " for char in value) + + +def _show_book( + book: OrderBookSnapshot, + *, + title: str, + outcome: str, + synthetic: bool, + json_output: bool, +) -> None: + state = OrderBookState() + state.apply(book) + if json_output: + print( + json.dumps( + { + "synthetic": synthetic, + "title": title, + "outcome": outcome, + "book": asdict(book), + "best_bid": state.best_bid, + "best_ask": state.best_ask, + }, + sort_keys=True, + ) + ) + return + print("SYNTHETIC DEMO — no network requests" if synthetic else "REST SNAPSHOT") + print(f"Market: {_text(title)}\nOutcome: {_text(outcome)}") + if not synthetic: + fetched = datetime.fromtimestamp(book.timestamp_ms / 1000, tz=timezone.utc) + print(f"Fetched at: {fetched.isoformat()} (local receipt time)") + print(f"{'BID PRICE':>10} {'SIZE':>12} | {'ASK PRICE':>10} {'SIZE':>12}") + levels = state.snapshot(depth=5) + for index in range(max(len(levels["bids"]), len(levels["asks"]))): + sides = [] + for side in ("bids", "asks"): + if index < len(levels[side]): + level = levels[side][index] + sides.append(f"{level['price']:10.4f} {level['size']:12g}") + else: + sides.append(f"{'—':>10} {'—':>12}") + print(" | ".join(sides)) + if not state.bids and not state.asks: + print("The orderbook is empty; no bid or ask is available.") + elif state.best_bid is not None and state.best_ask is not None: + print(f"Spread: {state.best_ask - state.best_bid:.4f}") + else: + print("Only one side of the orderbook is available; spread is unavailable.") diff --git a/src/predxt/polymarket/rest.py b/src/predxt/polymarket/rest.py index f343ccd..9abd96b 100644 --- a/src/predxt/polymarket/rest.py +++ b/src/predxt/polymarket/rest.py @@ -30,13 +30,59 @@ def __init__( ) self.gamma_url = gamma_url.rstrip("/") - async def search_markets(self, query: str, limit: int = 20) -> list[MarketSummary]: - payload = await self._get_json( - f"{self.gamma_url}/markets", - params={"search": query, "limit": limit}, - ) - rows = _extract_rows(payload, "markets") - return [_summary(row) for row in rows[:limit]] + async def search_markets( + self, query: str, limit: int = 20, *, active_only: bool = False + ) -> list[MarketSummary]: + """Return up to limit unique markets from the first Gamma search page. + + Gamma searches events and nests matching markets under those events. + An empty query lists markets instead. Raw market rows remain available. + """ + if limit < 1: + raise ValueError("limit must be positive") + params: dict[str, Any] + if query.strip(): + params = { + "q": query.strip(), + "limit_per_type": limit, + "search_profiles": False, + "search_tags": False, + } + if active_only: + params.update(events_status="active", keep_closed_markets=0) + payload = await self._get_json( + f"{self.gamma_url}/public-search", params=params + ) + rows = [ + row + for event in _extract_rows(payload, "events") + if isinstance(event, dict) + for row in _extract_rows(event, "markets") + ] + else: + params = {"limit": limit} + if active_only: + params.update(active=True, closed=False) + payload = await self._get_json(f"{self.gamma_url}/markets", params=params) + rows = _extract_rows(payload, "markets") + + results: list[MarketSummary] = [] + seen: set[str] = set() + for row in rows: + if not isinstance(row, dict): + continue + if active_only and ( + row.get("active") is not True or row.get("closed") is not False + ): + continue + market = _summary(row) + if not market.market_id or market.market_id in seen: + continue + results.append(market) + seen.add(market.market_id) + if len(results) >= limit: + break + return results async def get_market(self, market_id: str) -> MarketDetail: payload = await self._get_json(f"{self.gamma_url}/markets/{market_id}") @@ -52,7 +98,9 @@ async def get_orderbook(self, market_id_or_token_id: str) -> OrderBookSnapshot: venue="polymarket", event_type="rest_orderbook", timestamp_ms=current_timestamp_ms(), - market_id=as_text(payload.get("market")) if isinstance(payload, dict) else None, + market_id=as_text(payload.get("market")) + if isinstance(payload, dict) + else None, asset_id=( as_text(payload.get("asset_id")) if isinstance(payload, dict) else None ), diff --git a/tests/test_first_run.py b/tests/test_first_run.py new file mode 100644 index 0000000..2e5487b --- /dev/null +++ b/tests/test_first_run.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import asyncio +import json + +import httpx +import pytest + +from predxt import first_run +from predxt.cli import main +from predxt.polymarket import PolymarketRestClient + + +def market(**changes): + return { + "id": "42", + "question": "Example question?", + "active": True, + "closed": False, + "enableOrderBook": True, + "outcomes": '["Yes", "No"]', + "clobTokenIds": '["111", "222"]', + **changes, + } + + +def book(**changes): + return { + "market": "condition-42", + "asset_id": "222", + "bids": [{"price": "0.40", "size": "10"}, {"price": "0.42", "size": "20"}], + "asks": [{"price": "0.46", "size": "30"}, {"price": "0.44", "size": "40"}], + **changes, + } + + +def install_transport(monkeypatch, handler): + clients = [] + requests = [] + + async def checked(request): + requests.append(request) + assert request.method == "GET" + return await handler(request) + + class TestClient(PolymarketRestClient): + def _ensure_client(self): + if self._client is None: + self._client = httpx.AsyncClient(transport=httpx.MockTransport(checked)) + clients.append(self._client) + return self._client + + monkeypatch.setattr(first_run, "PolymarketRestClient", TestClient) + return requests, clients + + +def test_demo_works_outside_repository_without_network(monkeypatch, tmp_path, capsys): + def no_network(*args, **kwargs): + pytest.fail("Offline demo attempted to construct a network client") + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(first_run, "PolymarketRestClient", no_network) + assert main(["demo", "--json"]) == 0 + data = json.loads(capsys.readouterr().out) + assert data["synthetic"] is True + assert data["best_bid"] == 0.42 + assert data["best_ask"] == 0.44 + assert data["book"]["raw_data"] == {"synthetic": True} + + +def test_explore_search_selection_and_book(monkeypatch, capsys): + async def handler(request): + if request.url.path == "/public-search": + assert request.url.params["q"] == "weather" + assert request.url.params["events_status"] == "active" + assert request.url.params["search_profiles"] == "false" + return httpx.Response(200, json={"events": [{"markets": [market()]}]}) + if request.url.path == "/markets/42": + return httpx.Response(200, json=market()) + if request.url.path == "/book": + assert request.url.params["token_id"] == "222" + return httpx.Response(200, json=book()) + pytest.fail(f"Unexpected endpoint {request.url.path}") + + requests, clients = install_transport(monkeypatch, handler) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + answers = iter(["1", "2"]) + monkeypatch.setattr("builtins.input", lambda: next(answers)) + assert main(["explore", "polymarket", "--query", "weather"]) == 0 + output = capsys.readouterr() + assert "Outcome: No" in output.out + assert "REST SNAPSHOT" in output.out + assert "Spread: 0.0200" in output.out + assert "--asset-id 222" in output.out + assert len(requests) == 3 + assert all(client.is_closed for client in clients) + + +@pytest.mark.parametrize( + "levels, message", + [ + ({"bids": [], "asks": []}, "orderbook is empty"), + ({"bids": []}, "Only one side"), + ], +) +def test_explore_empty_and_one_sided_book(monkeypatch, capsys, levels, message): + async def handler(request): + return httpx.Response( + 200, json=market() if request.url.path == "/markets/42" else book(**levels) + ) + + install_transport(monkeypatch, handler) + assert ( + main(["explore", "polymarket", "--market-id", "42", "--outcome-index", "2"]) + == 0 + ) + assert message in capsys.readouterr().out + + +def test_explore_json_preserves_raw_book_and_has_no_prompts_on_stdout( + monkeypatch, capsys +): + async def handler(request): + return httpx.Response( + 200, json=market() if request.url.path == "/markets/42" else book() + ) + + install_transport(monkeypatch, handler) + assert ( + main( + [ + "explore", + "polymarket", + "--market-id", + "42", + "--outcome-index", + "2", + "--json", + ] + ) + == 0 + ) + data = json.loads(capsys.readouterr().out) + assert data["synthetic"] is False + assert data["outcome"] == "No" + assert data["book"]["raw_data"] == book() + + +@pytest.mark.parametrize( + "changes", + [ + {"closed": True}, + {"active": False}, + {"enableOrderBook": False}, + {"clobTokenIds": '["111"]'}, + {"clobTokenIds": '["111", "111"]'}, + {"clobTokenIds": '["111", "bad; command"]'}, + {"outcomes": "[]"}, + {"outcomes": '["Yes", "", "No"]', "clobTokenIds": '["111", "222", ""]'}, + ], +) +def test_invalid_market_stops_before_book_request(monkeypatch, capsys, changes): + async def handler(request): + assert request.url.path == "/markets/42" + return httpx.Response(200, json=market(**changes)) + + requests, clients = install_transport(monkeypatch, handler) + assert ( + main(["explore", "polymarket", "--market-id", "42", "--outcome-index", "1"]) + == 1 + ) + assert len(requests) == 1 + assert all(client.is_closed for client in clients) + assert "Error:" in capsys.readouterr().err + + +def test_wrong_book_identity_is_not_displayed(monkeypatch, capsys): + async def handler(request): + return httpx.Response( + 200, + json=market() + if request.url.path == "/markets/42" + else book(asset_id="999"), + ) + + install_transport(monkeypatch, handler) + assert ( + main(["explore", "polymarket", "--market-id", "42", "--outcome-index", "2"]) + == 1 + ) + output = capsys.readouterr() + assert not output.out + assert "does not match" in output.err + + +@pytest.mark.parametrize("payload", [{}, {"events": None}, {"events": []}]) +def test_no_results_are_actionable(monkeypatch, capsys, payload): + async def handler(request): + return httpx.Response(200, json=payload) + + requests, _ = install_transport(monkeypatch, handler) + assert main(["explore", "polymarket"]) == 1 + assert len(requests) == 1 + assert "Try a different --query" in capsys.readouterr().err + + +def test_noninteractive_search_lists_ids_and_explains_arguments(monkeypatch, capsys): + async def handler(request): + return httpx.Response(200, json={"events": [{"markets": [market()]}]}) + + requests, _ = install_transport(monkeypatch, handler) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + assert main(["explore", "polymarket"]) == 1 + output = capsys.readouterr() + assert "market ID: 42" in output.err + assert "--market-id ID --outcome-index N" in output.err + assert len(requests) == 1 + + +@pytest.mark.parametrize("failure", ["timeout", "forbidden"]) +def test_network_failures_are_bounded_and_close_client(monkeypatch, capsys, failure): + async def handler(request): + if failure == "timeout": + await asyncio.Event().wait() + return httpx.Response(403, json={"error": "private response detail"}) + + requests, clients = install_transport(monkeypatch, handler) + assert main(["explore", "polymarket", "--timeout", "0.01"]) == 1 + output = capsys.readouterr() + assert "predxt demo" in output.err + assert "private response detail" not in output.err + assert len(requests) == 1 + assert all(client.is_closed for client in clients) + + +@pytest.mark.parametrize( + "args", + [ + ["--timeout", "0"], + ["--timeout", "nan"], + ["--timeout", "inf"], + ["--outcome-index", "0"], + ["--query", "x", "--market-id", "42"], + ], +) +def test_invalid_arguments_fail_before_network(monkeypatch, args): + def no_network(*args, **kwargs): + pytest.fail("Invalid arguments caused network access") + + monkeypatch.setattr(first_run, "PolymarketRestClient", no_network) + with pytest.raises(SystemExit) as error: + main(["explore", "polymarket", *args]) + assert error.value.code == 2 + + +@pytest.mark.parametrize("answer", ["0", "3", "abc"]) +def test_invalid_outcome_selection_stops_before_book(monkeypatch, capsys, answer): + async def handler(request): + assert request.url.path == "/markets/42" + return httpx.Response(200, json=market()) + + requests, clients = install_transport(monkeypatch, handler) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda: answer) + assert main(["explore", "polymarket", "--market-id", "42"]) == 1 + assert len(requests) == 1 + assert all(client.is_closed for client in clients) + + +@pytest.mark.asyncio +async def test_search_flattens_deduplicates_and_filters_active_markets(): + async def handler(request): + assert request.url.path == "/public-search" + assert request.url.params["limit_per_type"] == "2" + return httpx.Response( + 200, + json={ + "events": [ + {"markets": [market(id="closed", closed=True), market()]}, + {"markets": [market(), None, market(id="43"), market(id="44")]}, + ] + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: + client = PolymarketRestClient(client=http) + results = await client.search_markets("example", limit=2, active_only=True) + assert [row.market_id for row in results] == ["42", "43"] + assert results[0].raw_data == market() + + +@pytest.mark.asyncio +async def test_empty_search_uses_market_listing(): + async def handler(request): + assert request.url.path == "/markets" + assert "search" not in request.url.params + return httpx.Response(200, json=[market()]) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: + client = PolymarketRestClient(client=http) + assert len(await client.search_markets("")) == 1 + + +@pytest.mark.parametrize( + "failure, expected_code", [(EOFError, 1), (KeyboardInterrupt, 130)] +) +def test_interrupted_selection_closes_client( + monkeypatch, capsys, failure, expected_code +): + async def handler(request): + assert request.url.path == "/markets/42" + return httpx.Response(200, json=market()) + + def stop_input(): + raise failure + + requests, clients = install_transport(monkeypatch, handler) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", stop_input) + assert main(["explore", "polymarket", "--market-id", "42"]) == expected_code + assert len(requests) == 1 + assert all(client.is_closed for client in clients) + if failure is EOFError: + assert "Selection ended" in capsys.readouterr().err + + +@pytest.mark.asyncio +@pytest.mark.parametrize("limit", [0, -1]) +async def test_invalid_search_limit_fails_before_network(limit): + async def no_network(request): + pytest.fail("Invalid limit caused a network request") + + async with httpx.AsyncClient(transport=httpx.MockTransport(no_network)) as http: + client = PolymarketRestClient(client=http) + with pytest.raises(ValueError, match="limit must be positive"): + await client.search_markets("example", limit=limit) diff --git a/tests/test_rest_clients.py b/tests/test_rest_clients.py index 6451ad5..136306f 100644 --- a/tests/test_rest_clients.py +++ b/tests/test_rest_clients.py @@ -12,17 +12,24 @@ @pytest.mark.asyncio async def test_polymarket_rest_client_reads_markets_and_orderbook() -> None: async def handler(request: httpx.Request) -> httpx.Response: - if request.url.host == "gamma.test" and request.url.path == "/markets": + if request.url.host == "gamma.test" and request.url.path == "/public-search": + assert request.url.params["q"] == "rain" return httpx.Response( 200, - json=[ - { - "id": "1", - "question": "Will it rain?", - "clobTokenIds": '["yes-token", "no-token"]', - "outcomes": '["Yes", "No"]', - } - ], + json={ + "events": [ + { + "markets": [ + { + "id": "1", + "question": "Will it rain?", + "clobTokenIds": '["yes-token", "no-token"]', + "outcomes": '["Yes", "No"]', + } + ] + } + ] + }, ) if request.url.host == "gamma.test" and request.url.path == "/markets/1": return httpx.Response( @@ -67,7 +74,9 @@ async def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio -async def test_kalshi_rest_client_reads_markets_and_implied_yes_asks(monkeypatch) -> None: +async def test_kalshi_rest_client_reads_markets_and_implied_yes_asks( + monkeypatch, +) -> None: monkeypatch.setattr( "predxt.kalshi.auth._sign_rsa_pss", lambda **_kwargs: "signed-token", diff --git a/uv.lock b/uv.lock index 4e52557..2ea88b4 100644 --- a/uv.lock +++ b/uv.lock @@ -850,7 +850,7 @@ wheels = [ [[package]] name = "predxt" -version = "0.2.2" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "cryptography" },