diff --git a/.github/workflows/provider-contracts.yml b/.github/workflows/provider-contracts.yml index 2d1f23fa..8ff71266 100644 --- a/.github/workflows/provider-contracts.yml +++ b/.github/workflows/provider-contracts.yml @@ -56,7 +56,11 @@ jobs: tests/integration/test_binance_public.py::TestBinancePublicProvider::test_fetch_daily_spot_btc tests/integration/test_coingecko.py::TestCoinGeckoProvider::test_fetch_ohlcv_btc tests/integration/test_kalshi.py::TestKalshiProvider::test_list_markets + tests/integration/test_kalshi.py::TestKalshiResolvedHistory + tests/integration/test_forecastex.py + tests/integration/test_polymarket_us.py tests/integration/test_polymarket.py::TestPolymarketProvider::test_get_market_by_slug + tests/integration/test_polymarket.py::TestPolymarketResolvedHistory tests/integration/test_yahoo.py::TestYahooFinanceProvider::test_fetch_ohlcv_stock_daily tests/integration/test_release_provider_contracts.py diff --git a/docs/FEATURES.md b/docs/FEATURES.md index c6f24891..24fcb031 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -24,7 +24,7 @@ - [x] DataBento (institutional, trial) - [x] Wiki Prices (historical 1962-2018) - [x] Tiingo, Finnhub, Polygon, Twelve Data, CryptoCompare, Oanda -- [x] FRED, AQR, Fama-French, Kalshi, Polymarket, OKX, Binance public bulk, ITCH sample +- [x] FRED, AQR, Fama-French, Kalshi, Polymarket, Polymarket US, ForecastEx, OKX, Binance public bulk, ITCH sample - [x] Synthetic and mock providers for testing ### Storage (Complete) diff --git a/docs/getting-started/provider-selection.md b/docs/getting-started/provider-selection.md index 40f8c31c..14163be9 100644 --- a/docs/getting-started/provider-selection.md +++ b/docs/getting-started/provider-selection.md @@ -26,7 +26,7 @@ data = SyntheticProvider(seed=42).fetch_ohlcv( | Futures and options | Databento, Binance, OKX | Historical or licensed data may be metered | | [Macroeconomic series](../providers/macro.md) | FRED, FXMacroData | FRED requires an API key for normal use | | [Research factors](../providers/factors.md) | Fama-French, AQR | Network access and source-specific terms | -| [Prediction markets](../providers/prediction_markets.md) | Kalshi, Polymarket | Network access and changing public endpoints | +| [Prediction markets](../providers/prediction_markets.md) | Kalshi, ForecastEx, Polymarket US, Polymarket | Network access and changing public endpoints | | Frozen equity history | Wiki Prices | Local historical dataset ending in 2018 | | CFTC positioning | COT | Install the `cot` extra | diff --git a/docs/providers/forecastex.md b/docs/providers/forecastex.md new file mode 100644 index 00000000..faaf41df --- /dev/null +++ b/docs/providers/forecastex.md @@ -0,0 +1,98 @@ +# ForecastEx Provider + +**Provider**: `ForecastExProvider` +**Website**: [forecastex.com](https://forecastex.com/data) +**API Key**: Not required +**Free Tier**: Free daily files from 2024-08-01 + +--- + +## Overview + +ForecastEx LLC is a CFTC-registered exchange (DCM) and clearinghouse (DCO) affiliated with +Interactive Brokers. Each Forecast Market lists a YES and a NO contract that settle at $1.00 or +$0.00. ForecastEx publishes daily CSV files in the public S3 bucket `forecastex-public-data`; +the provider reads them directly. + +| File | Content | Provider method | +|------|---------|-----------------| +| `prices/daily_prices_YYYYMMDD.csv` | Daily open, high, low, close, settlement price, paired quantity, open interest and VWAP per contract side | `fetch_prices()` | +| `pairs/pairs_YYYYMMDD.csv` | Every matched YES/NO pair (trade); the current day's file is refreshed every 10 minutes | `fetch_trades()` | +| `daily_summary/summary_YYYYMMDD.csv` | Product names and categories (from 2025-01-01) | `fetch_products()` | + +All methods take an inclusive range of trading dates (Central Time). Days without a published +file are skipped, and a range with no files returns an empty, typed frame. + +--- + +## Quick Start + +```python +from ml4t.data.providers import ForecastExProvider + +provider = ForecastExProvider() + +markets = provider.fetch_markets("2026-09-15", "2026-09-16", product="FFDEC") +trades = provider.fetch_trades("2026-09-16", "2026-09-16", product="FFDEC") +prices = provider.fetch_prices("2026-09-16", "2026-09-16", product="FFDEC") + +provider.close() +``` + +`event_contract` (the `ticker` column) encodes product, date and threshold, for example +`FFDEC_091626_E25`. The `product` column is the prefix before the first underscore. Suffix +semantics come from each product's terms PDF under `regulatory/` in the same bucket. + +--- + +## Resolution Outcome + +The files carry no outcome column. `fetch_markets()` derives it: + +1. It also reads the prices file for the day after `end`. A contract absent from the next + published file has expired. +2. If the YES `settlement_price` on the contract's last row is 1.00 or 0.00, that is the payout: + `status="resolved"`, `result` is `"yes"` or `"no"`, and `settlement_value` is the payout. +3. Contracts that expire after the daily file is cut, such as hourly temperature products, drop + out with their last daily mark instead of the payout. They are reported as + `status="expired"` with a null `result`, because the files do not publish their outcome. +4. Contracts still listed in the next file, or trading on `end` when the next file is not yet + published, are `status="open"`. + +`settlement_ts` is the contract's expiration time; ForecastEx publishes no separate settlement +timestamp. The clearinghouse settles daily at 13:00 CT for contracts resolved in the previous +24 hours. + +| Column | Meaning | +|--------|---------| +| `ticker`, `product`, `expiration` | Event contract, product code, expiration (UTC) | +| `status` | `resolved`, `expired` or `open` | +| `first_date`, `last_date` | First and last trading date seen in the range | +| `result`, `settlement_value`, `settlement_ts` | Normalized outcome, YES payout, expiration time | +| `last_close`, `volume` | Last daily close and paired contracts in the range | + +--- + +## Trades + +`fetch_trades()` returns one row per matched pair with the shared prediction-market trade columns +`trade_id`, `ticker`, `timestamp` (UTC), `price` (YES price), `count`, `taker_side` and +`is_block_trade`, plus `no_price`, `product` and `expiration`. ForecastEx pairs have no aggressor +side, so `taker_side` is always null, and `is_block_trade` is always false. + +--- + +## Limits + +- No order-book history; the live book is available only through an FCM such as IBKR. +- No documented rate limit; the provider defaults to 10 requests per second. +- High and low are 0.00 on days without trades, while open and close carry the daily marks. + +--- + +## See Also + +- [Prediction-market data sources](prediction_markets.md) +- [Kalshi Provider](kalshi.md) +- [Polymarket US Provider](polymarket_us.md) +- [ForecastEx data page](https://forecastex.com/data) diff --git a/docs/providers/index.md b/docs/providers/index.md index 003058e0..3148daa0 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -23,6 +23,8 @@ For the wider vendor landscape, including sources the library does not wrap, sta | [Wiki Prices](wiki_prices.md) | Historical | Static | Thread | No | | [Kalshi](kalshi.md) | Prediction Markets | Public data | Thread | No | | [Polymarket](polymarket.md) | Prediction Markets | Public data | Thread | No | +| [Polymarket US](polymarket_us.md) | Prediction Markets | Public data | Thread | No | +| [ForecastEx](forecastex.md) | Prediction Markets | Daily files | Thread | No | | [Binance Public](binance_public.md) | Crypto | Bulk downloads | Thread | No | | [NASDAQ ITCH](nasdaq_itch.md) | Tick Data | Sample data | Thread | No | | [EODHD](eodhd.md) | Global Stocks | 20 calls/day | Native | Yes | @@ -80,7 +82,7 @@ provider's capabilities before passing it to `DataManager` or `async_batch_load( | Requirement | Providers to Evaluate | |-------------|-----------------------| -| No credential | Yahoo Finance, CoinGecko, Fama-French, AQR, Kalshi, Polymarket, Binance Public, NASDAQ ITCH | +| No credential | Yahoo Finance, CoinGecko, Fama-French, AQR, Kalshi, Polymarket, Polymarket US, ForecastEx, Binance Public, NASDAQ ITCH | | US equities | Yahoo Finance, Alpaca, Tiingo, Massive | | Global equities | EODHD, Finnhub, Twelve Data | | Futures and options | Databento, Massive | @@ -88,7 +90,7 @@ provider's capabilities before passing it to `DataManager` or `async_batch_load( | Foreign exchange | Oanda, Twelve Data, FXMacroData | | Economic series | FRED, FXMacroData; external official sources are compared in [Macroeconomic Data Sources](macro.md) | | Academic factors | Fama-French, AQR; external libraries are compared in [Research Factor Data Sources](factors.md) | -| Prediction markets | Kalshi, Polymarket; external markets are compared in [Prediction-Market Data Sources](prediction_markets.md) | +| Prediction markets | Kalshi, ForecastEx, Polymarket US, Polymarket; external markets are compared in [Prediction-Market Data Sources](prediction_markets.md) | | Equity fundamentals | Yahoo Finance, EODHD, Finnhub, Massive; other vendors and the SEC routes are compared in [Fundamental Data Sources](fundamentals.md) | Provider access, coverage, retention, and redistribution terms can differ by account tier. Confirm diff --git a/docs/providers/kalshi.md b/docs/providers/kalshi.md index d85807cc..06094848 100644 --- a/docs/providers/kalshi.md +++ b/docs/providers/kalshi.md @@ -49,6 +49,114 @@ provider.close() --- +## Resolved Markets and Outcomes + +`fetch_markets()` enumerates every market matching the filters, following Kalshi's cursor +pagination across two tiers: the live `GET /markets` endpoint and the archive +`GET /historical/markets`, which alone holds markets settled before the cutoff reported by +`GET /historical/cutoff` (`get_historical_cutoff()`). The archive accepts only one of +`event_ticker`, `series_ticker` or `mve_filter=exclude`, so the remaining filters (status, +close-time window, the other multivariate selections) are applied on the client. Archived +markets are all settled, so the archive is skipped for other statuses. + +`mve_filter` selects multivariate combo (parlay) markets, whose tickers start with `KXMVE`: +None includes them (Kalshi's default), `"exclude"` drops them and `"only"` keeps only them. The +archive rejects `mve_filter=only`, so selecting combos there pages through the whole archive and +filters on the client; use `max_pages` to sample. + +```python +from ml4t.data.providers import KalshiProvider + +provider = KalshiProvider() + +fed = provider.fetch_markets(status="settled", series_ticker="KXFEDDECISION") +print(fed.select("ticker", "result", "settlement_value", "settlement_ts", "source")) + +old = provider.fetch_markets(event_ticker="FED-23DEC") +window = provider.fetch_markets( + status="settled", min_close_ts="2026-01-01", max_close_ts="2026-06-30", + mve_filter="exclude", +) +combos = provider.fetch_markets(status="settled", mve_filter="only", max_pages=1) +provider.close() +``` + +| Column | Meaning | +|--------|---------| +| `ticker`, `event_ticker`, `title`, `status`, `market_type` | Market identity and lifecycle status | +| `series` | The `series_ticker` filter when given, otherwise the event-ticker prefix (legacy events such as `FED-23DEC` belong to a differently named series, `KXFED`) | +| `open_time`, `close_time` | Trading window (UTC) | +| `result` | `yes`, `no`, `other` (scalar settlement) or null while unresolved | +| `result_raw` | Kalshi's own `result` label | +| `settlement_value` | YES payout in dollars | +| `settlement_ts` | Settlement time (UTC) | +| `expiration_value`, `last_price`, `previous_price`, `volume`, `open_interest` | Settlement source value and last market statistics (prices in dollars, volume in contracts) | +| `source` | `live` or `historical` tier | + +`iter_market_frames(chunk_size=100_000, ...)` streams the same normalized rows as frames of at +most `chunk_size` rows, for listings too large to hold in memory (the full settled listing runs to +millions of markets). `iter_markets()` yields the raw market dictionaries instead. `list_markets()` keeps returning a +single page of raw dictionaries. + +--- + +## Price History Across Tiers + +`fetch_candles(ticker, start_ts, end_ts, period="1h", series_ticker=None, tier=None)` returns +candlesticks for one market from whichever tier holds it. Markets settled before the historical +cutoff are served only by `GET /historical/markets/{ticker}/candlesticks`; the others by +`GET /series/{series}/markets/{ticker}/candlesticks`, which needs the market's series. + +- Pass `tier` (the `source` column of `fetch_markets()`) to go straight to the right endpoint. +- Pass `series_ticker` (the `series` column, or the event's series) for live markets. Without + it, the ticker prefix is tried first; if both tiers answer 404, the series is looked up from + the market's event and the live endpoint is retried. +- Without `tier`, the live endpoint is tried first and the archive on a 404. +- Ranges longer than Kalshi's 5000-candle limit are split into several requests. + +```python +candles = provider.fetch_candles( + "KXFEDDECISION-26JUL-H0", "2026-07-26", "2026-07-29", period="1h", + series_ticker="KXFEDDECISION", tier="historical", +) +``` + +| Column | Meaning | +|--------|---------| +| `timestamp` | End of the candle period (UTC) | +| `open`, `high`, `low`, `close`, `mean`, `previous` | Trade prices in dollars; null when nothing traded (except `previous`) | +| `yes_bid_close`, `yes_ask_close` | Closing YES bid and ask in dollars | +| `volume`, `open_interest` | Contracts | +| `source` | `live` or `historical` tier | + +Live candles carry `*_dollars`/`*_fp` fields and archive candles unsuffixed dollar strings; +both are returned in dollars (0-1). `fetch_ohlcv()` uses the same tier routing, so it also +works for archived markets such as `FED-23DEC-T5.25`, and it keeps its OHLCV schema. + +--- + +## Trade History + +`fetch_trades(ticker, min_ts=None, max_ts=None)` pages through `GET /markets/trades` and +`GET /historical/trades`, skipping a tier the time window cannot reach, and returns one row per +trade sorted by time: + +| Column | Meaning | +|--------|---------| +| `trade_id`, `ticker` | Trade and market identifiers | +| `timestamp` | Trade time (UTC) | +| `price` | YES price in dollars (0-1) | +| `count` | Contracts; fractional counts are allowed | +| `taker_side` | `yes` or `no`; falls back to `taker_outcome_side` | +| `is_block_trade` | Block-trade flag | +| `taker_outcome_side`, `taker_book_side` | Outcome side and book side (`bid`/`ask`) of the taker, null when absent | +| `source` | `live` or `historical` tier | + +Prices and counts are read from the fixed-point `*_dollars` and `*_fp` fields; legacy unsuffixed +fields are also accepted (integers as cents, strings as dollars). + +--- + ## Regulatory Status Kalshi is regulated by the CFTC (Commodity Futures Trading Commission) as a Designated Contract Market (DCM). @@ -60,4 +168,6 @@ Kalshi is regulated by the CFTC (Commodity Futures Trading Commission) as a Desi - [Prediction-market data sources](prediction_markets.md) - [Kalshi Developer Docs](https://kalshi.com/developer) - [Polymarket Provider](polymarket.md) +- [Polymarket US Provider](polymarket_us.md) +- [ForecastEx Provider](forecastex.md) - [Provider reference](index.md) diff --git a/docs/providers/polymarket.md b/docs/providers/polymarket.md index 6d267338..e6ad974c 100644 --- a/docs/providers/polymarket.md +++ b/docs/providers/polymarket.md @@ -153,6 +153,90 @@ print(f"Volume: ${meta.get('volume', 0):,.2f}") print(f"End Date: {meta.get('endDate')}") ``` +## Resolved Markets and Outcomes + +`fetch_markets()` enumerates every market matching the filters through Gamma's +`GET /markets/keyset`, which pages by cursor (`GET /markets` refuses offsets above 2000). Pages +hold at most 100 markets, in ascending Gamma id order. The server applies `closed`, the minimum +lifetime volume and the scheduled end-date window; the provider applies them again on the client. + +```python +from ml4t.data.providers import PolymarketProvider + +provider = PolymarketProvider() + +resolved = provider.fetch_markets( + min_volume=100_000, end_date_min="2025-01-01", end_date_max="2025-12-31T23:59:59Z" +) +print(resolved.select("ticker", "question", "yes_outcome", "result", "settlement_ts")) + +for frame in provider.iter_market_frames(chunk_size=50_000, min_volume=10_000): + ... # write each frame out before the next pages are requested +provider.close() +``` + +Gamma markets are binary, and their outcome labels, CLOB token ids and final prices share one +order. The first outcome is treated as the YES side whatever its label: in a "Trump vs. Boden" +market `yes_outcome` is "Trump", and `result == "yes"` means the first outcome won. + +| Column | Meaning | +|--------|---------| +| `ticker` | Condition id (the identifier `fetch_trades` takes) | +| `market_id`, `slug`, `question` | Gamma id, URL slug and question text | +| `event_slug`, `event_title` | The market's event | +| `category`, `tags` | Gamma category (mostly null on recent markets) and tag labels | +| `yes_outcome`, `no_outcome`, `yes_token_id`, `no_token_id` | First and second outcome labels and their CLOB tokens (the identifiers `fetch_candles` takes) | +| `closed`, `open_time`, `close_time` | Closed flag, `startDate` and scheduled `endDate` (UTC) | +| `result` | `yes` for final prices `[1, 0]`, `no` for `[0, 1]`, `void` for a 50/50 resolution, `other` for a UMA-resolved market with other prices, null otherwise | +| `settlement_value` | Final payout of the first outcome token | +| `settlement_ts` | `closedTime`, else `umaEndDate` (UTC); null while unresolved | +| `uma_resolution_status`, `outcome_prices` | Raw UMA status and final or current prices | +| `volume` | Lifetime volume in USDC | +| `neg_risk`, `neg_risk_market_id` | Negative-risk (mutually exclusive outcomes) grouping | +| `fees_enabled`, `fee_type`, `maker_base_fee`, `taker_base_fee`, `fee_schedule` | Gamma's raw fee fields; `fee_schedule` as JSON text | + +Closed markets carry final prices even while their UMA status is still `proposed`; keep rows with +`uma_resolution_status == "resolved"` to exclude resolutions that could still be disputed. +`settlement_ts` often precedes `close_time`, because many markets resolve before their scheduled +end. Gamma timestamps that cannot be parsed (`umaEndDate` is `"NOW*()"` on some 2024 markets) are +null. `iter_markets()` yields the raw Gamma dictionaries instead. + +## Price History of Resolved Markets + +`fetch_candles(token_id, start, end, fidelity_minutes=60)` returns `timestamp` (UTC), `token_id` +and `price`, one row per CLOB price sample; these are samples, not OHLC bars. The CLOB's +`GET /prices-history` answers `interval=max` with an empty history for resolved markets at any +fidelity finer than one day, and rejects explicit `startTs`/`endTs` spans longer than 15 days with +HTTP 400. The provider sends explicit bounds in windows of at most 15 days, which serves resolved +markets down to one-minute fidelity. An unknown token returns an empty frame, not an error. + +```python +market = resolved.row(0, named=True) +end = market["settlement_ts"] +prices = provider.fetch_candles( + market["yes_token_id"], end.timestamp() - 30 * 86_400, end, fidelity_minutes=1 +) +``` + +## Trade History + +`fetch_trades(condition_id, start=None, end=None)` reads the data API's +`GET /trades?market=`, which returns taker trades newest first, takes inclusive +`start`/`end` bounds in Unix seconds, serves at most 10,000 trades per request and rejects offsets +above 10,000. Beyond that depth the provider moves `end` to the oldest second seen and continues, +skipping the trades of that second it already returned. + +| Column | Meaning | +|--------|---------| +| `trade_id` | Hash of transaction, token, wallet, side, size, price and time (the API has no trade id) | +| `ticker`, `timestamp` | Condition id and trade time (UTC, whole seconds) | +| `price` | YES price: the traded token's price, or one minus it for the second outcome | +| `count` | Shares | +| `taker_side` | `yes` when the taker bought the first outcome or sold the second, else `no` | +| `is_block_trade` | Always false | +| `outcome`, `outcome_index`, `side`, `outcome_price` | Traded outcome label and index, taker `BUY`/`SELL`, and the traded token's price | +| `asset`, `proxy_wallet`, `transaction_hash` | Token id, taker wallet and on-chain transaction | + ## Supported Frequencies | Frequency | API Interval | Notes | @@ -167,26 +251,14 @@ print(f"End Date: {meta.get('endDate')}") ### Date Range Limits -The CLOB API has date range limits, especially for high-frequency data: -- **7 days** is safe for all frequencies -- Longer ranges may return `HTTP 400: interval is too long` error - -```python -# Safe: 7-day range -df = provider.fetch_ohlcv("market-slug", "2025-12-01", "2025-12-07") - -# May fail: 30-day range with hourly data -df = provider.fetch_ohlcv("market-slug", "2025-11-01", "2025-12-01", frequency="hourly") -``` +The CLOB rejects price-history spans longer than 15 days. `fetch_ohlcv()` requests 14-day chunks +and `fetch_candles()` 15-day windows, so any range works; long ranges at fine frequencies take +one request per chunk. ### Closed Markets -Markets that have resolved (closed) may have limited or no recent price data: - -```python -# Get only active, non-resolved markets -markets = provider.list_markets(active=True, closed=False) -``` +`list_markets()` returns one page of the offset listing, which stops at offset 2000. Use +`fetch_markets()` or `iter_market_frames()` to enumerate closed markets. ## Example Use Cases @@ -233,6 +305,7 @@ print(combined.group_by("symbol").agg(pl.col("close").last())) - **CLOB Timeseries**: https://docs.polymarket.com/developers/clob-api/price-history - **Gamma Markets API**: https://docs.polymarket.com/developers/gamma-markets-api/get-markets +- **Data API Trades**: https://docs.polymarket.com/api-reference/core/get-trades-for-a-user-or-markets - **py-clob-client**: https://github.com/Polymarket/py-clob-client ## Technical Notes @@ -271,6 +344,11 @@ provider.close() # Close when done See [Prediction-Market Data Sources](prediction_markets.md) for source and market-mechanism comparisons. +### 2026-10-08 +- Added `fetch_markets()`, `iter_markets()` and `iter_market_frames()` for resolved markets with normalized outcomes +- Added `fetch_candles()` for windowed price history of resolved markets and `fetch_trades()` +- Fixed `fetch_ohlcv()` returning no rows for past ranges and reading dates in the local time zone + ### 2025-12-07 - Fixed token resolution for new API format (`clobTokenIds` JSON string) - Fixed `get_token_prices()` to use `outcomes`/`outcomePrices` arrays diff --git a/docs/providers/polymarket_us.md b/docs/providers/polymarket_us.md new file mode 100644 index 00000000..878f2188 --- /dev/null +++ b/docs/providers/polymarket_us.md @@ -0,0 +1,95 @@ +# Polymarket US Provider + +**Provider**: `PolymarketUSProvider` +**Website**: [polymarket.us](https://polymarket.us) +**API Key**: Not required +**Free Tier**: Free public gateway + +--- + +## Overview + +Polymarket US is the CFTC-regulated exchange (DCM and DCO) operated by QCX LLC d/b/a Polymarket +US. It is a separate venue from the global, crypto-settled Polymarket served by +[`PolymarketProvider`](polymarket.md), with separate markets, order books and identifiers. +Markets are identified by slug. + +The provider reads the public gateway `https://gateway.polymarket.us`: + +| Endpoint | Content | Provider method | +|----------|---------|-----------------| +| `GET /v1/markets` | Markets with status, sides, outcomes and final prices; offset pagination, at most 500 per page | `iter_markets()`, `fetch_markets()` | +| `GET /v1/markets/{slug}/settlement` | Settlement price of a resolved market | `get_settlement()` | +| `GET /v1/price-history` | Book-derived YES and NO display prices | `fetch_price_history()` | + +There is no public trade history: the trade-report endpoints return only the caller's own trades. + +--- + +## Quick Start + +```python +from ml4t.data.providers import PolymarketUSProvider + +provider = PolymarketUSProvider() + +resolved = provider.fetch_markets(closed=True, categories=["macro"]) +print(resolved.select("ticker", "result", "settlement_value", "winning_outcome")) + +history = provider.fetch_price_history(resolved["ticker"][0]) +minutes = provider.fetch_price_history( + resolved["ticker"][0], start="2026-05-11", end="2026-05-12" +) + +provider.close() +``` + +`fetch_markets()` filters on `closed`, `active`, `categories` (`sports`, `macro`, `politics`, +...), `slugs` and an end-date window. `max_pages` caps the number of pages for sampling. + +--- + +## Resolution Outcome + +Each market is one instrument: the long market side is the YES contract and the short side the +NO contract. For a market with status `MARKET_STATUS_RESOLVED`, the final long-side price in +`marketSides` is the payout per YES contract: + +| Payout | `result` | +|--------|----------| +| 1 | `yes` | +| 0 | `no` | +| Other, e.g. 0.5 on a cancelled game | `other` | +| Market not resolved | null | + +`winning_outcome` names the winning side (`"Yes"`, `"No"`, or a team for sports markets) and +`yes_outcome` names the long side. `settlement_ts` is always null because the gateway publishes +no settlement time. `get_settlement(slug)` cross-checks the payout and returns None when the +gateway answers 404, which it does for unknown and unsettled markets. + +!!! warning "Outcome labels are not ordered like the prices" + `outcomePrices` is long-side first, but the `outcomes` labels are sometimes listed in the + other order, for example `["No","Yes"]` with `["1","0"]` for a market whose YES side won. + The provider reads the outcome from the long market side and never zips labels with prices. + +--- + +## Price History + +Without `start` and `end`, `fetch_price_history()` requests the full history +(`fixedInterval=INTERVAL_ALL`: three-hourly points for newer markets, daily points for longer +ones). With both, it requests 24-hour windows at one-minute fidelity, the gateway's limit for +custom ranges. Columns: `timestamp` (UTC), `ticker`, `long_price` (YES display price, from the +best ask) and `short_price` (NO display price, one minus the best bid). These are quotes, not +trades, and the two can sum to more than 1. The public limit is 20 requests per second per IP, +which is the provider's default rate limit. + +--- + +## See Also + +- [Prediction-market data sources](prediction_markets.md) +- [Polymarket Provider](polymarket.md) (global venue) +- [Kalshi Provider](kalshi.md) +- [ForecastEx Provider](forecastex.md) +- [Polymarket US API docs](https://docs.polymarket.us/api-reference/introduction) diff --git a/docs/providers/prediction_markets.md b/docs/providers/prediction_markets.md index a059084f..94363972 100644 --- a/docs/providers/prediction_markets.md +++ b/docs/providers/prediction_markets.md @@ -11,15 +11,24 @@ an implied market probability only after accounting for spread, liquidity, and c | Source and product | Coverage or history | Acquisition | Access | Material research caveat | ml4t-data provider | |--------------------|---------------------|-------------|--------|--------------------------|--------------------| | [Kalshi API](https://docs.kalshi.com/getting_started/quick_start_market_data) | Regulated event contracts across economics, politics, climate, technology, and other categories | REST and WebSocket APIs | Public market data; credentials for authenticated endpoints | Market tickers encode series and contract terms; use settled outcomes and rule changes, not titles alone | `KalshiProvider` | +| [ForecastEx daily files](https://forecastex.com/data) | CFTC-regulated YES/NO event contracts affiliated with Interactive Brokers; daily prices and every matched pair from 2024-08-01 | Daily CSV files in a public S3 bucket | Free, no credentials; trading through an FCM such as IBKR | No outcome column: the outcome is the final settlement price of a contract that leaves the daily file, and intraday contracts can leave before their payout is published | `ForecastExProvider` | +| [Polymarket US gateway](https://docs.polymarket.us/api-reference/introduction) | CFTC-regulated exchange of QCX LLC, separate from global Polymarket; markets from November 2025, mostly sports at first | Public REST gateway | Public market data; trading needs a KYC'd US account | No public trade history; price history is book-derived quotes, and outcome labels are not ordered like the final prices | `PolymarketUSProvider` | | [Polymarket APIs](https://docs.polymarket.com/) | Binary and multi-outcome markets represented by outcome tokens on Polygon | Gamma and CLOB REST APIs plus WebSocket market data | Public market data; trading has additional requirements | Slugs, condition IDs, and outcome token IDs are different identifiers; YES and NO prices include spread | `PolymarketProvider` | | [Manifold API and dumps](https://docs.manifold.markets/) | User-created social prediction markets across many question types | REST API and downloadable data dumps | Public data under documented licensing | Play-money markets, user-defined resolution, and automated-market-maker mechanics are not directly comparable with regulated cash markets | No | -`KalshiProvider` lists series and markets and converts native candlesticks to the package OHLCV -schema. `PolymarketProvider` resolves slugs or condition IDs to outcome tokens and aggregates price -history. Public data access does not imply that trading is available in every jurisdiction. +`KalshiProvider` lists series and markets, converts native candlesticks to the package OHLCV +schema, and enumerates resolved markets and trades across its live and archive tiers. +`ForecastExProvider`, `PolymarketUSProvider` and `PolymarketProvider` return markets with the +same normalized resolution columns (`result`, `settlement_value`, `settlement_ts`), and the +Kalshi, ForecastEx and Polymarket trade frames share their leading columns. `PolymarketProvider` +also resolves slugs or condition IDs to outcome tokens of the global venue and returns their +price history. Public data access does not +imply that trading is available in every jurisdiction. ## Selection Notes - Use Kalshi when regulated contract definitions and exchange settlement are required. +- Use ForecastEx for IBKR-tradable event contracts and complete daily trade files. +- Use Polymarket US for the US-regulated Polymarket venue; it has no public trade history. - Use Polymarket for crypto-settled CLOB markets and on-chain outcome tokens. - Use Manifold for social forecasting research where play-money incentives are acceptable. diff --git a/mkdocs.yml b/mkdocs.yml index 8e359727..c722079b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -146,6 +146,8 @@ nav: - Wiki Prices: providers/wiki_prices.md - Kalshi: providers/kalshi.md - Polymarket: providers/polymarket.md + - Polymarket US: providers/polymarket_us.md + - ForecastEx: providers/forecastex.md - Binance Public: providers/binance_public.md - NASDAQ ITCH: providers/nasdaq_itch.md - EODHD: providers/eodhd.md diff --git a/src/ml4t/data/config/models.py b/src/ml4t/data/config/models.py index 4b60cae6..ebba2c21 100644 --- a/src/ml4t/data/config/models.py +++ b/src/ml4t/data/config/models.py @@ -55,6 +55,8 @@ class ProviderType(StrEnum): FAMA_FRENCH = "fama_french" KALSHI = "kalshi" POLYMARKET = "polymarket" + POLYMARKET_US = "polymarket_us" + FORECASTEX = "forecastex" COINGECKO = "coingecko" BINANCE = "binance" BINANCE_PUBLIC = "binance_public" diff --git a/src/ml4t/data/providers/AGENTS.md b/src/ml4t/data/providers/AGENTS.md index 47818efe..326efc40 100644 --- a/src/ml4t/data/providers/AGENTS.md +++ b/src/ml4t/data/providers/AGENTS.md @@ -12,6 +12,7 @@ service limitations are documented in `docs/providers/`. | `protocols.py` | Structural contracts used by callers and type checking | | `registry.py` | Provider discovery, capabilities, aliases, and factory metadata | | `mixins/` | Shared HTTP-session and rate-limit behavior | +| `prediction_markets.py` | Shared resolution-outcome vocabulary and trade schema for event-contract adapters | | `synthetic.py`, `mock.py` | Deterministic offline and testing providers | | Other top-level modules | One external or packaged data source per adapter | diff --git a/src/ml4t/data/providers/__init__.py b/src/ml4t/data/providers/__init__.py index c2ccaf12..937fcba2 100644 --- a/src/ml4t/data/providers/__init__.py +++ b/src/ml4t/data/providers/__init__.py @@ -74,6 +74,16 @@ except ImportError: PolymarketProvider = None # type: ignore +try: + from ml4t.data.providers.polymarket_us import PolymarketUSProvider +except ImportError: + PolymarketUSProvider = None # type: ignore + +try: + from ml4t.data.providers.forecastex import ForecastExProvider +except ImportError: + ForecastExProvider = None # type: ignore + # Crypto providers from ml4t.data.providers.coingecko import CoinGeckoProvider @@ -151,6 +161,8 @@ # Prediction market providers "KalshiProvider", "PolymarketProvider", + "PolymarketUSProvider", + "ForecastExProvider", # Crypto providers "CoinGeckoProvider", "BinanceProvider", diff --git a/src/ml4t/data/providers/base.py b/src/ml4t/data/providers/base.py index 75fbfe94..c84c5604 100644 --- a/src/ml4t/data/providers/base.py +++ b/src/ml4t/data/providers/base.py @@ -31,10 +31,12 @@ class LightweightProvider(RateLimitMixin, ValidationMixin): from __future__ import annotations +import math import os from abc import ABC, abstractmethod from typing import Any, ClassVar +import httpx import polars as pl import structlog from tenacity import ( @@ -45,7 +47,12 @@ class LightweightProvider(RateLimitMixin, ValidationMixin): wait_exponential, ) -from ml4t.data.core.exceptions import NetworkError +from ml4t.data.core.exceptions import ( + DataValidationError, + NetworkError, + RateLimitError, + SymbolNotFoundError, +) from ml4t.data.providers.mixins.circuit_breaker import CircuitBreaker, CircuitBreakerMixin from ml4t.data.providers.mixins.rate_limit import RateLimitMixin from ml4t.data.providers.mixins.session import SessionMixin @@ -73,6 +80,29 @@ def _provider_error_is_retryable(error: BaseException) -> bool: return isinstance(error, NetworkError) and error.retryable +# Shared retry policy: three attempts, exponential backoff, provider ``retry_after`` honored. +provider_retry = retry( + stop=stop_after_attempt(3), + wait=_provider_retry_wait, + retry=retry_if_exception(_provider_error_is_retryable), + reraise=True, +) + + +def retry_after_seconds(response: httpx.Response) -> float | None: + """Return a non-negative ``Retry-After`` delay in seconds, or None when absent or invalid.""" + value = response.headers.get("Retry-After") + if value is None: + return None + try: + delay = float(value) + except ValueError: + return None + if not math.isfinite(delay): + return None + return max(delay, 0.0) + + # Re-export for backward compatibility __all__ = [ "BaseProvider", @@ -81,6 +111,8 @@ def _provider_error_is_retryable(error: BaseException) -> bool: "circuit_breaker", "OHLCVProvider", "ProviderCapabilities", + "provider_retry", + "retry_after_seconds", ] @@ -196,12 +228,7 @@ def capabilities(self) -> ProviderCapabilities: rate_limit=self.DEFAULT_RATE_LIMIT, ) - @retry( - stop=stop_after_attempt(3), - wait=_provider_retry_wait, - retry=retry_if_exception(_provider_error_is_retryable), - reraise=True, - ) + @provider_retry def fetch_ohlcv( self, symbol: str, @@ -271,6 +298,94 @@ def _fetch_and_process(): return validated_data + def _classify_error_response(self, response: httpx.Response) -> Exception | None: + """Return a provider-specific exception for an error response, or None for the default. + + Override to mark a service-specific failure as transient (for example an edge-layer + rejection). The default classification in ``_request`` applies when this returns None. + """ + return None + + @provider_retry + def _request( + self, + url: str, + *, + resource: str, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> httpx.Response: + """Rate-limited GET that maps transport and HTTP failures to shared exceptions. + + Transient failures (transport errors, HTTP 429 and 5xx) raise retryable errors and are + retried by the shared ``provider_retry`` policy, honoring ``Retry-After``. + + Args: + url: Absolute request URL. + resource: Human-readable name of the requested resource, used in errors. + params: Query parameters. + headers: Request headers. + + Returns: + The successful (2xx) response. + + Raises: + RateLimitError: HTTP 429. + SymbolNotFoundError: HTTP 404; ``symbol`` is ``resource``. + NetworkError: Transport failure, HTTP 5xx (retryable) or other HTTP errors. + """ + self._acquire_rate_limit() + try: + response = self.session.get(url, params=params, headers=headers) + except httpx.HTTPError as err: + raise NetworkError( + provider=self.name, message=f"Request failed for {resource}: {err}" + ) from err + + if 200 <= response.status_code < 300: + return response + custom = self._classify_error_response(response) + if custom is not None: + raise custom + if response.status_code == 429: + raise RateLimitError(provider=self.name, retry_after=retry_after_seconds(response)) + if response.status_code == 404: + raise SymbolNotFoundError(provider=self.name, symbol=resource) + message = f"HTTP {response.status_code} for {resource}: {response.text[:200]}" + raise NetworkError( + provider=self.name, + message=message, + retryable=response.status_code >= 500, + ) + + def _request_json( + self, + url: str, + *, + resource: str, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> dict[str, Any]: + """Run ``_request`` and parse a JSON object body. + + Raises: + DataValidationError: The body is not valid JSON or not a JSON object. + """ + response = self._request(url, resource=resource, params=params, headers=headers) + try: + payload = response.json() + except ValueError as err: + raise DataValidationError( + provider=self.name, + message=f"Malformed JSON response for {resource}", + ) from err + if not isinstance(payload, dict): + raise DataValidationError( + provider=self.name, + message=f"Expected a JSON object for {resource}, got {type(payload).__name__}", + ) + return payload + def _create_empty_dataframe(self) -> pl.DataFrame: """Create an empty DataFrame with canonical OHLCV schema. diff --git a/src/ml4t/data/providers/forecastex.py b/src/ml4t/data/providers/forecastex.py new file mode 100644 index 00000000..c42758aa --- /dev/null +++ b/src/ml4t/data/providers/forecastex.py @@ -0,0 +1,438 @@ +"""ForecastEx event-contract data provider. + +ForecastEx LLC is a CFTC-registered exchange (DCM) and clearinghouse (DCO) affiliated with +Interactive Brokers. It publishes free daily CSV files from 2024-08-01 in the public S3 bucket +``forecastex-public-data`` (also served by ``https://forecastex.com/api/download``): + +- ``prices/daily_prices_YYYYMMDD.csv``: one row per contract side (YES/NO) and trading date with + daily open/high/low/close, settlement price (the daily mark, and the final payout on the last + row), paired quantity, open interest and VWAP. +- ``pairs/pairs_YYYYMMDD.csv``: every matched YES/NO pair, i.e. the trade history. The current + day's file is refreshed every 10 minutes. +- ``daily_summary/summary_YYYYMMDD.csv`` (from 2025-01-01): product names and categories. + +No authentication is needed and no rate limit is documented. The files carry no explicit +outcome column: a contract has expired when it is absent from the next day's prices file, and its +YES ``settlement_price`` on its last row is the payout (1.00 yes, 0.00 no) when the file was cut +after resolution. See ``ForecastExProvider.fetch_markets`` for intraday contracts. + +Example: + >>> from ml4t.data.providers.forecastex import ForecastExProvider + >>> provider = ForecastExProvider() + >>> markets = provider.fetch_markets("2026-09-14", "2026-09-16") + >>> trades = provider.fetch_trades("2026-09-16", "2026-09-16", product="FFDEC") + >>> provider.close() +""" + +from __future__ import annotations + +import io +from datetime import date, datetime, timedelta +from typing import Any, ClassVar + +import polars as pl +import structlog + +from ml4t.data.core.exceptions import DataValidationError, SymbolNotFoundError +from ml4t.data.providers.base import BaseProvider +from ml4t.data.providers.prediction_markets import ( + RESOLUTION_SCHEMA, + RESULT_NO, + RESULT_YES, + TRADE_SCHEMA, + UTC_DATETIME, + empty_frame, + result_from_payout, +) + +logger = structlog.get_logger() + +DateArg = str | date | datetime + +PRICES_SCHEMA: dict[str, pl.DataType] = { + "date": pl.Date(), + "ticker": pl.Utf8(), + "product": pl.Utf8(), + "side": pl.Utf8(), + "expiration": UTC_DATETIME, + "open": pl.Float64(), + "high": pl.Float64(), + "low": pl.Float64(), + "close": pl.Float64(), + "settlement_price": pl.Float64(), + "volume": pl.Float64(), + "open_interest": pl.Float64(), + "vwap": pl.Float64(), +} + +FORECASTEX_TRADE_SCHEMA: dict[str, pl.DataType] = { + **TRADE_SCHEMA, + "no_price": pl.Float64(), + "product": pl.Utf8(), + "expiration": UTC_DATETIME, +} + +MARKET_SCHEMA: dict[str, pl.DataType] = { + "ticker": pl.Utf8(), + "product": pl.Utf8(), + "expiration": UTC_DATETIME, + "status": pl.Utf8(), + "first_date": pl.Date(), + "last_date": pl.Date(), + **RESOLUTION_SCHEMA, + "last_close": pl.Float64(), + "volume": pl.Float64(), +} + +PRODUCTS_SCHEMA: dict[str, pl.DataType] = { + "date": pl.Date(), + "product": pl.Utf8(), + "product_name": pl.Utf8(), + "product_category": pl.Utf8(), + "total_pairs": pl.Int64(), +} + +_PRICES_COLUMNS = ( + "event_contract", + "subtype", + "expiration_date", + "date", + "start_price", + "high_price", + "low_price", + "end_price", + "settlement_price", + "pair_quantity", + "open_interest", + "vwap", +) +_PAIRS_COLUMNS = ( + "pair_id", + "event_contract", + "expiration_date", + "quantity", + "yes_price", + "no_price", + "pair_time", +) +_SUMMARY_COLUMNS = ("product_id", "product_name", "product_category", "total_pairs") +_OFFSET_TS_FORMAT = "%Y-%m-%dT%H:%M:%S%.f%:z" + + +class ForecastExProvider(BaseProvider): + """ForecastEx daily prices, matched pairs (trades) and resolution outcomes. + + All methods take an inclusive date range of trading dates (Central Time). Days without a + published file (before 2024-08-01, or today's prices before publication) are skipped. + """ + + DEFAULT_RATE_LIMIT: ClassVar[tuple[int, float]] = (10, 1.0) + BASE_URL: ClassVar[str] = "https://forecastex-public-data.s3.amazonaws.com" + FIRST_DATE: ClassVar[date] = date(2024, 8, 1) + + def __init__(self, rate_limit: tuple[int, float] | None = None): + """Initialize the provider. + + Args: + rate_limit: Optional (calls, period_seconds) override. + """ + super().__init__(rate_limit=rate_limit or self.DEFAULT_RATE_LIMIT) + + @property + def name(self) -> str: + """Return provider name.""" + return "forecastex" + + @staticmethod + def _to_date(value: DateArg) -> date: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + return date.fromisoformat(str(value)) + + def _date_range(self, start: DateArg, end: DateArg) -> list[date]: + first = self._to_date(start) + last = self._to_date(end) + if last < first: + raise DataValidationError( + provider=self.name, + message=f"End date {last} is before start date {first}", + field="end", + value=last, + ) + first = max(first, self.FIRST_DATE) + return [first + timedelta(days=offset) for offset in range((last - first).days + 1)] + + def _download_csv(self, path: str, columns: tuple[str, ...]) -> pl.DataFrame | None: + """Download one CSV as all-string columns, or None when the file does not exist.""" + try: + response = self._request(f"{self.BASE_URL}/{path}", resource=path) + except SymbolNotFoundError: + return None + try: + frame = pl.read_csv(io.BytesIO(response.content), infer_schema_length=0) + except (pl.exceptions.ComputeError, pl.exceptions.NoDataError) as err: + raise DataValidationError( + provider=self.name, message=f"Malformed CSV in {path}: {err}" + ) from err + missing = [column for column in columns if column not in frame.columns] + if missing: + raise DataValidationError( + provider=self.name, + message=f"{path} lacks columns {missing}; got {frame.columns}", + field="columns", + value=frame.columns, + ) + return frame.select(columns) + + def _cast(self, frame: pl.DataFrame, exprs: list[pl.Expr], path: str) -> pl.DataFrame: + try: + return frame.select(exprs) + except pl.exceptions.PolarsError as err: + raise DataValidationError( + provider=self.name, message=f"Unparseable values in {path}: {err}" + ) from err + + @staticmethod + def _float(column: str) -> pl.Expr: + return pl.col(column).str.strip_chars().replace("", None).cast(pl.Float64) + + @staticmethod + def _offset_timestamp(column: str) -> pl.Expr: + return pl.col(column).str.to_datetime(_OFFSET_TS_FORMAT, time_zone="UTC", time_unit="us") + + @staticmethod + def _product(column: str = "event_contract") -> pl.Expr: + return pl.col(column).str.split("_").list.first() + + def _prices_for_day(self, day: date) -> pl.DataFrame | None: + path = f"prices/daily_prices_{day:%Y%m%d}.csv" + raw = self._download_csv(path, _PRICES_COLUMNS) + if raw is None: + return None + return self._cast( + raw, + [ + pl.col("date").str.to_date("%Y-%m-%d"), + pl.col("event_contract").alias("ticker"), + self._product().alias("product"), + pl.col("subtype").str.to_uppercase().alias("side"), + self._offset_timestamp("expiration_date").alias("expiration"), + self._float("start_price").alias("open"), + self._float("high_price").alias("high"), + self._float("low_price").alias("low"), + self._float("end_price").alias("close"), + self._float("settlement_price"), + self._float("pair_quantity").alias("volume"), + self._float("open_interest"), + self._float("vwap"), + ], + path, + ) + + def fetch_prices( + self, + start: DateArg, + end: DateArg, + product: str | None = None, + ) -> pl.DataFrame: + """Return daily prices for every contract side over a date range. + + Args: + start: First trading date (inclusive), ``YYYY-MM-DD`` or date. + end: Last trading date (inclusive). + product: Optional product code filter (e.g. ``"FFDEC"``), the ``event_contract`` + prefix before the first underscore. + + Returns: + DataFrame with ``PRICES_SCHEMA`` columns: ``date``, ``ticker`` (event contract), + ``product``, ``side`` (``YES``/``NO``), ``expiration`` (UTC), ``open``, ``high``, + ``low``, ``close``, ``settlement_price`` (daily mark; final payout on the last + row), ``volume`` (paired contracts), ``open_interest`` and ``vwap``. High and low + are 0.0 on days without trades. + """ + frames = [ + frame + for day in self._date_range(start, end) + if (frame := self._prices_for_day(day)) is not None + ] + if not frames: + return empty_frame(PRICES_SCHEMA) + prices = pl.concat(frames, how="vertical") + if product: + prices = prices.filter(pl.col("product") == product.upper()) + return prices.sort(["date", "ticker", "side"]) + + def fetch_trades( + self, + start: DateArg, + end: DateArg, + product: str | None = None, + ticker: str | None = None, + ) -> pl.DataFrame: + """Return every matched YES/NO pair (trade) over a date range. + + Args: + start: First file date (inclusive). + end: Last file date (inclusive). Today's file is partial (refreshed intraday). + product: Optional product code filter. + ticker: Optional event-contract filter. + + Returns: + DataFrame with ``FORECASTEX_TRADE_SCHEMA`` columns: ``trade_id`` (pair id), + ``ticker``, ``timestamp`` (UTC), ``price`` (YES price), ``count``, ``taker_side`` + (always null: ForecastEx pairs have no aggressor side), ``is_block_trade`` + (always False), ``no_price``, ``product`` and ``expiration``. + """ + frames: list[pl.DataFrame] = [] + for day in self._date_range(start, end): + path = f"pairs/pairs_{day:%Y%m%d}.csv" + raw = self._download_csv(path, _PAIRS_COLUMNS) + if raw is None: + continue + frames.append( + self._cast( + raw, + [ + pl.col("pair_id").alias("trade_id"), + pl.col("event_contract").alias("ticker"), + self._offset_timestamp("pair_time").alias("timestamp"), + self._float("yes_price").alias("price"), + self._float("quantity").alias("count"), + pl.lit(None, dtype=pl.Utf8).alias("taker_side"), + pl.lit(False).alias("is_block_trade"), + self._float("no_price"), + self._product().alias("product"), + self._offset_timestamp("expiration_date").alias("expiration"), + ], + path, + ) + ) + if not frames: + return empty_frame(FORECASTEX_TRADE_SCHEMA) + trades = pl.concat(frames, how="vertical") + if product: + trades = trades.filter(pl.col("product") == product.upper()) + if ticker: + trades = trades.filter(pl.col("ticker") == ticker) + return trades.unique(subset=["trade_id"], keep="first").sort(["timestamp", "trade_id"]) + + def fetch_markets( + self, + start: DateArg, + end: DateArg, + product: str | None = None, + ) -> pl.DataFrame: + """Return one row per contract listed in the date range, with its resolution outcome. + + The prices file for the day after ``end`` is also read: a contract absent from the + next published file has expired. When the ``settlement_price`` of its last YES row is + 1.00 or 0.00, that is the payout and the contract is ``"resolved"``. Contracts that + expire after the daily file is cut (intraday products such as hourly temperatures) + drop out with their last daily mark instead of the payout; they are reported as + ``"expired"`` with a null result, because the files do not publish their outcome. + When the next file is not yet published, contracts trading on ``end`` are ``"open"``. + + Args: + start: First trading date (inclusive). + end: Last trading date (inclusive). + product: Optional product code filter. + + Returns: + DataFrame with ``MARKET_SCHEMA`` columns: ``ticker``, ``product``, + ``expiration`` (UTC), ``status`` (``"resolved"``, ``"expired"`` or ``"open"``), + ``first_date`` and ``last_date`` seen in the range, ``result`` (``"yes"`` or + ``"no"`` when resolved, otherwise null), ``settlement_value`` (YES payout when + resolved), ``settlement_ts`` (the contract's expiration time when resolved; + ForecastEx publishes no separate settlement timestamp), ``last_close`` and total + ``volume`` in the range. + """ + days = self._date_range(start, end) + if not days: + return empty_frame(MARKET_SCHEMA) + frames = [frame for day in days if (frame := self._prices_for_day(day)) is not None] + if not frames: + return empty_frame(MARKET_SCHEMA) + next_day = self._prices_for_day(days[-1] + timedelta(days=1)) + prices = pl.concat(frames, how="vertical").filter(pl.col("side") == "YES") + if product: + prices = prices.filter(pl.col("product") == product.upper()) + if prices.is_empty(): + return empty_frame(MARKET_SCHEMA) + + # A contract has finished when a later file exists and it is missing from it. Within + # the range that is any last_date before the final day; on the final day it needs the + # next day's file. + last_day = days[-1] + listed_next = set(next_day["ticker"].to_list()) if next_day is not None else set[str]() + summary = ( + prices.sort("date") + .group_by("ticker", maintain_order=True) + .agg( + pl.col("product").last(), + pl.col("expiration").last(), + pl.col("date").first().alias("first_date"), + pl.col("date").last().alias("last_date"), + pl.col("settlement_price").last().alias("final_settlement"), + pl.col("close").last().alias("last_close"), + pl.col("volume").sum(), + ) + ) + rows: list[dict[str, Any]] = [] + for record in summary.iter_rows(named=True): + finished = record["last_date"] < last_day or ( + next_day is not None and record["ticker"] not in listed_next + ) + final = record["final_settlement"] + result = result_from_payout(final) if finished else None + if result not in (RESULT_YES, RESULT_NO): + result = None + status = "resolved" if result else ("expired" if finished else "open") + rows.append( + { + "ticker": record["ticker"], + "product": record["product"], + "expiration": record["expiration"], + "status": status, + "first_date": record["first_date"], + "last_date": record["last_date"], + "result": result, + "settlement_value": final if result else None, + "settlement_ts": record["expiration"] if result else None, + "last_close": record["last_close"], + "volume": record["volume"], + } + ) + return pl.DataFrame(rows, schema=MARKET_SCHEMA).sort(["last_date", "ticker"]) + + def fetch_products(self, day: DateArg) -> pl.DataFrame: + """Return product names and categories from one daily summary file. + + Summary files exist from 2025-01-01 and list the products traded that day. + + Returns: + DataFrame with ``PRODUCTS_SCHEMA`` columns, empty when no file exists. + """ + target = self._to_date(day) + path = f"daily_summary/summary_{target:%Y%m%d}.csv" + raw = self._download_csv(path, _SUMMARY_COLUMNS) + if raw is None: + return empty_frame(PRODUCTS_SCHEMA) + return self._cast( + raw, + [ + pl.lit(target).alias("date"), + pl.col("product_id").alias("product"), + pl.col("product_name"), + pl.col("product_category"), + pl.col("total_pairs").cast(pl.Int64), + ], + path, + ) + + def close(self) -> None: + """Close HTTP client.""" + if hasattr(self, "session"): + self.session.close() + self._log_close_event("Closed ForecastEx client") diff --git a/src/ml4t/data/providers/kalshi.py b/src/ml4t/data/providers/kalshi.py index 2385de89..f06338b2 100644 --- a/src/ml4t/data/providers/kalshi.py +++ b/src/ml4t/data/providers/kalshi.py @@ -35,9 +35,11 @@ """ import time -from datetime import datetime +from collections.abc import Iterator +from datetime import UTC, date, datetime from typing import Any, ClassVar +import httpx import polars as pl import structlog @@ -45,13 +47,71 @@ DataNotAvailableError, DataValidationError, NetworkError, - RateLimitError, SymbolNotFoundError, ) from ml4t.data.providers.base import BaseProvider +from ml4t.data.providers.prediction_markets import ( + RESOLUTION_SCHEMA, + TRADE_SCHEMA, + UTC_DATETIME, + empty_frame, + parse_utc_timestamp, + result_from_label, + to_unix_seconds, +) logger = structlog.get_logger() +TimestampArg = int | float | str | date | datetime | None + +# Normalized market columns returned by KalshiProvider.fetch_markets. +MARKET_SCHEMA: dict[str, pl.DataType] = { + "ticker": pl.Utf8(), + "event_ticker": pl.Utf8(), + "series": pl.Utf8(), + "title": pl.Utf8(), + "status": pl.Utf8(), + "market_type": pl.Utf8(), + "open_time": UTC_DATETIME, + "close_time": UTC_DATETIME, + **RESOLUTION_SCHEMA, + "result_raw": pl.Utf8(), + "expiration_value": pl.Utf8(), + "last_price": pl.Float64(), + "previous_price": pl.Float64(), + "volume": pl.Float64(), + "open_interest": pl.Float64(), + "source": pl.Utf8(), +} + +# Normalized trade columns returned by KalshiProvider.fetch_trades. +KALSHI_TRADE_SCHEMA: dict[str, pl.DataType] = { + **TRADE_SCHEMA, + "taker_outcome_side": pl.Utf8(), + "taker_book_side": pl.Utf8(), + "source": pl.Utf8(), +} + +# Normalized candle columns returned by KalshiProvider.fetch_candles. +CANDLE_SCHEMA: dict[str, pl.DataType] = { + "timestamp": UTC_DATETIME, + "ticker": pl.Utf8(), + "open": pl.Float64(), + "high": pl.Float64(), + "low": pl.Float64(), + "close": pl.Float64(), + "mean": pl.Float64(), + "previous": pl.Float64(), + "yes_bid_close": pl.Float64(), + "yes_ask_close": pl.Float64(), + "volume": pl.Float64(), + "open_interest": pl.Float64(), + "source": pl.Utf8(), +} + +_LIVE = "live" +_HISTORICAL = "historical" + class KalshiProvider(BaseProvider): """Kalshi prediction market data provider. @@ -77,6 +137,9 @@ class KalshiProvider(BaseProvider): # Kalshi API base URL (elections domain provides all markets) BASE_URL: ClassVar[str] = "https://api.elections.kalshi.com/trade-api/v2" + # Kalshi rejects candlestick requests spanning more than this many periods. + MAX_CANDLES_PER_REQUEST: ClassVar[int] = 5000 + # Public requests can be rejected transiently by Kalshi's edge layer. EDGE_403_RETRY_DELAY: ClassVar[float] = 1.0 @@ -171,100 +234,251 @@ def _fetch_raw_data( frequency: str = "daily", series_ticker: str | None = None, ) -> list[dict[str, Any]]: - """Fetch raw candlestick data from Kalshi API. + """Fetch raw candlestick records for a market from whichever tier holds it. Args: symbol: Market ticker (e.g., "KXINFL-25JAN") - start: Start date (YYYY-MM-DD) - end: End date (YYYY-MM-DD) + start: Start date (YYYY-MM-DD, UTC) + end: End date (YYYY-MM-DD, UTC, inclusive to 23:59:59) frequency: Data frequency (minute, hourly, daily) - series_ticker: Optional series ticker (auto-detected from symbol) + series_ticker: Optional series ticker (resolved when absent) Returns: - List of candlestick dictionaries from Kalshi API + List of candlestick dictionaries from the Kalshi API """ - market_ticker = symbol.upper() - - # Auto-detect series ticker if not provided - if series_ticker is None: - series_ticker = self._extract_series_ticker(market_ticker) - series_ticker = series_ticker.upper() - - # Map frequency to period_interval - period_interval = self.FREQUENCY_MAP.get(frequency.lower()) - if period_interval is None: + period_interval = self._period_minutes(frequency) + start_ts = to_unix_seconds(start) + end_ts = to_unix_seconds(end) + if start_ts is None or end_ts is None: + raise DataValidationError(provider="kalshi", message="start and end are required") + records, _ = self._fetch_candle_records( + symbol.upper(), + start_ts, + end_ts + 86_399, + period_interval, + series_ticker=series_ticker, + ) + return records + + def _period_minutes(self, period: str | int) -> int: + """Map a frequency name or a minute count to Kalshi's period_interval.""" + if isinstance(period, int) and not isinstance(period, bool): + minutes: int | None = period if period in (1, 60, 1440) else None + else: + minutes = self.FREQUENCY_MAP.get(str(period).lower()) + if minutes is None: raise DataValidationError( provider="kalshi", - message=f"Unsupported frequency '{frequency}'. " - f"Supported: {list(self.FREQUENCY_MAP.keys())}", + message=f"Unsupported frequency '{period}'. " + f"Supported: {list(self.FREQUENCY_MAP.keys())} or 1, 60, 1440", field="frequency", - value=frequency, + value=period, ) + return minutes - # Convert dates to unix timestamps - start_dt = datetime.strptime(start, "%Y-%m-%d") - end_dt = datetime.strptime(end, "%Y-%m-%d") - # Set end to end of day - end_dt = end_dt.replace(hour=23, minute=59, second=59) - - start_ts = int(start_dt.timestamp()) - end_ts = int(end_dt.timestamp()) - - # Build request - endpoint = f"{self.BASE_URL}/series/{series_ticker}/markets/{market_ticker}/candlesticks" - params = { - "start_ts": start_ts, - "end_ts": end_ts, - "period_interval": period_interval, - } - - try: - response = self.session.get( - endpoint, - params=params, + def _request_candles( + self, + ticker: str, + tier: str, + series_ticker: str | None, + windows: list[tuple[int, int]], + period_interval: int, + ) -> list[dict[str, Any]]: + """Request every window from one tier's candlestick endpoint.""" + if tier == _LIVE: + url = f"{self.BASE_URL}/series/{series_ticker}/markets/{ticker}/candlesticks" + else: + url = f"{self.BASE_URL}/historical/markets/{ticker}/candlesticks" + records: list[dict[str, Any]] = [] + for window_start, window_end in windows: + payload = self._request_json( + url, + resource=f"{tier} candlesticks for {ticker}", + params={ + "start_ts": window_start, + "end_ts": window_end, + "period_interval": period_interval, + }, headers=self._get_headers(), ) - - # Check for errors - if response.status_code == 429: - raise RateLimitError(provider="kalshi", retry_after=60.0) - if response.status_code == 404: - # Could be invalid series or market ticker - raise SymbolNotFoundError( + candles = payload.get("candlesticks") or [] + if not isinstance(candles, list) or not all(isinstance(c, dict) for c in candles): + raise DataValidationError( provider="kalshi", - symbol=market_ticker, - details={"series_ticker": series_ticker}, + message=f"Malformed candlesticks for {ticker}", + field="candlesticks", ) - if response.status_code != 200: - # Try to parse error message - try: - error_data = response.json() - error_msg = error_data.get("message", f"HTTP {response.status_code}") - except Exception: - error_msg = f"HTTP {response.status_code}: {response.text[:200]}" + records.extend(candles) + return records - raise NetworkError(provider="kalshi", message=error_msg) + def _resolve_series_ticker(self, ticker: str) -> str | None: + """Look up a live market's series through ``/markets/{ticker}`` and its event.""" + try: + market = self._request_json( + f"{self.BASE_URL}/markets/{ticker}", + resource=f"market {ticker}", + headers=self._get_headers(), + ).get("market", {}) + event_ticker = market.get("event_ticker") if isinstance(market, dict) else None + if not event_ticker: + return None + event = self._request_json( + f"{self.BASE_URL}/events/{event_ticker}", + resource=f"event {event_ticker}", + headers=self._get_headers(), + ).get("event", {}) + except SymbolNotFoundError: + return None + series = event.get("series_ticker") if isinstance(event, dict) else None + return str(series).upper() if series else None - # Parse JSON response - try: - data = response.json() - except Exception as err: - raise NetworkError( - provider="kalshi", - message="Failed to parse JSON response", - ) from err + def _fetch_candle_records( + self, + ticker: str, + start_ts: int, + end_ts: int, + period_interval: int, + series_ticker: str | None = None, + tier: str | None = None, + ) -> tuple[list[dict[str, Any]], str]: + """Fetch raw candles from the tier that holds the market. - # Extract candlesticks - candlesticks = data.get("candlesticks", []) + Without ``tier``: the live endpoint first (series from ``series_ticker`` or the + ticker prefix); on 404 the archive, which serves markets settled before the + historical cutoff; on a second 404, and only when no series was given, the series + is looked up from the market's event and the live endpoint is retried. - return candlesticks + Returns: + The raw candle records and the tier that served them. + """ + if end_ts < start_ts: + raise DataValidationError( + provider="kalshi", message="end must not be before start", field="end" + ) + if tier not in (None, _LIVE, _HISTORICAL): + raise DataValidationError( + provider="kalshi", + message=f"tier must be None, 'live' or 'historical', got {tier!r}", + field="tier", + value=tier, + ) + # Kalshi rejects requests spanning more than 5000 candles. + step = period_interval * 60 * (self.MAX_CANDLES_PER_REQUEST - 1) + windows = [ + (window_start, min(window_start + step, end_ts)) + for window_start in range(start_ts, end_ts + 1, step) + ] + if tier == _HISTORICAL: + return self._request_candles(ticker, _HISTORICAL, None, windows, period_interval), tier + guessed = (series_ticker or self._extract_series_ticker(ticker)).upper() + if tier == _LIVE: + return self._request_candles(ticker, _LIVE, guessed, windows, period_interval), tier + try: + return self._request_candles(ticker, _LIVE, guessed, windows, period_interval), _LIVE + except SymbolNotFoundError: + pass + try: + return ( + self._request_candles(ticker, _HISTORICAL, None, windows, period_interval), + _HISTORICAL, + ) + except SymbolNotFoundError: + pass + if series_ticker is None: + resolved = self._resolve_series_ticker(ticker) + if resolved and resolved != guessed: + records = self._request_candles(ticker, _LIVE, resolved, windows, period_interval) + return records, _LIVE + raise SymbolNotFoundError( + provider="kalshi", symbol=ticker, details={"series_ticker": guessed} + ) - except (RateLimitError, NetworkError, SymbolNotFoundError, DataValidationError): - raise - except Exception as err: - raise NetworkError( + def fetch_candles( + self, + ticker: str, + start_ts: TimestampArg, + end_ts: TimestampArg, + period: str | int = "1h", + series_ticker: str | None = None, + tier: str | None = None, + ) -> pl.DataFrame: + """Return candlesticks for one market from the live or the archive tier. + + Markets settled before the historical cutoff are served only by + ``GET /historical/markets/{ticker}/candlesticks``; others by + ``GET /series/{series}/markets/{ticker}/candlesticks``. Pass ``tier`` (the + ``source`` column of ``fetch_markets``) to go straight to the right endpoint; + otherwise the tiers are tried in turn (see ``_fetch_candle_records``). Ranges + longer than Kalshi's 5000-candle limit are split into several requests. + + Args: + ticker: Market ticker. + start_ts: Range start (Unix seconds, ISO string, date or datetime; naive is UTC). + end_ts: Range end. + period: ``"1m"``, ``"1h"``, ``"1d"`` (or another ``FREQUENCY_MAP`` name), or + 1, 60, 1440 minutes. + series_ticker: Series of the market (``fetch_markets``' ``series`` column, or + the event's series). Without it the ticker prefix is tried first and the + series is looked up from the event if that fails. + tier: ``"live"``, ``"historical"`` or None to detect. + + Returns: + DataFrame with ``CANDLE_SCHEMA`` columns: ``timestamp`` (UTC end of the period), + ``ticker``, trade ``open``/``high``/``low``/``close``/``mean``/``previous`` + (null when no trade occurred), ``yes_bid_close``, ``yes_ask_close`` (all in + dollars, 0-1), ``volume``, ``open_interest`` (contracts) and ``source`` (tier). + """ + market_ticker = ticker.upper() + start_seconds = to_unix_seconds(start_ts) + end_seconds = to_unix_seconds(end_ts) + if start_seconds is None or end_seconds is None: + raise DataValidationError( + provider="kalshi", message="start_ts and end_ts are required", field="start_ts" + ) + records, source = self._fetch_candle_records( + market_ticker, + start_seconds, + end_seconds, + self._period_minutes(period), + series_ticker=series_ticker, + tier=tier, + ) + rows = [self._normalize_candle(record, market_ticker, source) for record in records] + if not rows: + return empty_frame(CANDLE_SCHEMA) + return ( + pl.DataFrame(rows, schema=CANDLE_SCHEMA, orient="row") + .unique(subset=["timestamp"], keep="first") + .sort("timestamp") + ) + + def _normalize_candle( + self, candle: dict[str, Any], ticker: str, source: str + ) -> tuple[Any, ...]: + try: + price = candle.get("price") or {} + bid = candle.get("yes_bid") or {} + ask = candle.get("yes_ask") or {} + return ( + datetime.fromtimestamp(int(candle["end_period_ts"]), UTC), + ticker, + self._dollars(price, "open"), + self._dollars(price, "high"), + self._dollars(price, "low"), + self._dollars(price, "close"), + self._dollars(price, "mean"), + self._dollars(price, "previous"), + self._dollars(bid, "close"), + self._dollars(ask, "close"), + self._fixed_point(candle, "volume"), + self._fixed_point(candle, "open_interest"), + source, + ) + except (AttributeError, KeyError, TypeError, ValueError) as err: + raise DataValidationError( provider="kalshi", - message=f"Request failed for market {market_ticker}", + message=f"Malformed candlestick for {ticker}: {candle!r}", + field="candlesticks", ) from err @staticmethod @@ -275,15 +489,29 @@ def _struct_field_names(dtype: pl.DataType | None) -> set[str]: return {field.name for field in dtype.fields} @staticmethod - def _struct_price_expr(column: str, field: str, fields: set[str]) -> pl.Expr: - """Extract a probability price from a Kalshi nested struct.""" - if field in fields: - return pl.col(column).struct.field(field).cast(pl.Float64) / 100.0 + def _dollar_expr(expr: pl.Expr, dtype: pl.DataType | None) -> pl.Expr: + """Convert an unsuffixed price to dollars by its encoding. + Integers are the legacy cent encoding; strings and floats (archive candles, flat + records) are already dollars. + """ + if dtype is not None and dtype.is_integer(): + return expr.cast(pl.Float64) / 100.0 + return expr.cast(pl.Float64) + + @staticmethod + def _struct_price_expr(column: str, field: str, struct_dtype: pl.DataType | None) -> pl.Expr: + """Extract a probability price from a Kalshi nested struct.""" + if not isinstance(struct_dtype, pl.Struct): + return pl.lit(None, dtype=pl.Float64) + field_types = {item.name: item.dtype for item in struct_dtype.fields} dollar_field = f"{field}_dollars" - if dollar_field in fields: + if dollar_field in field_types: return pl.col(column).struct.field(dollar_field).cast(pl.Float64) - + if field in field_types: + return KalshiProvider._dollar_expr( + pl.col(column).struct.field(field), field_types[field] + ) return pl.lit(None, dtype=pl.Float64) def _transform_data(self, raw_data: list[dict[str, Any]], symbol: str) -> pl.DataFrame: @@ -325,28 +553,29 @@ def _transform_data(self, raw_data: list[dict[str, Any]], symbol: str) -> pl.Dat elif "volume_fp" in df.columns: volume_expr = pl.col("volume_fp").cast(pl.Float64).alias("volume") - price_fields = self._struct_field_names(df.schema.get("price")) - bid_fields = self._struct_field_names(df.schema.get("yes_bid")) - ask_fields = self._struct_field_names(df.schema.get("yes_ask")) + price_type = df.schema.get("price") + bid_type = df.schema.get("yes_bid") + ask_type = df.schema.get("yes_ask") + price_fields = self._struct_field_names(price_type) + bid_fields = self._struct_field_names(bid_type) + ask_fields = self._struct_field_names(ask_type) # Check data schema - Kalshi returns nested structs if price_fields or bid_fields or ask_fields: df = df.with_columns( [ - self._struct_price_expr("price", "open", price_fields).alias("trade_open"), - self._struct_price_expr("price", "high", price_fields).alias("trade_high"), - self._struct_price_expr("price", "low", price_fields).alias("trade_low"), - self._struct_price_expr("price", "close", price_fields).alias( - "trade_close" - ), - self._struct_price_expr("yes_bid", "open", bid_fields).alias("bid_open"), - self._struct_price_expr("yes_bid", "high", bid_fields).alias("bid_high"), - self._struct_price_expr("yes_bid", "low", bid_fields).alias("bid_low"), - self._struct_price_expr("yes_bid", "close", bid_fields).alias("bid_close"), - self._struct_price_expr("yes_ask", "open", ask_fields).alias("ask_open"), - self._struct_price_expr("yes_ask", "high", ask_fields).alias("ask_high"), - self._struct_price_expr("yes_ask", "low", ask_fields).alias("ask_low"), - self._struct_price_expr("yes_ask", "close", ask_fields).alias("ask_close"), + self._struct_price_expr("price", "open", price_type).alias("trade_open"), + self._struct_price_expr("price", "high", price_type).alias("trade_high"), + self._struct_price_expr("price", "low", price_type).alias("trade_low"), + self._struct_price_expr("price", "close", price_type).alias("trade_close"), + self._struct_price_expr("yes_bid", "open", bid_type).alias("bid_open"), + self._struct_price_expr("yes_bid", "high", bid_type).alias("bid_high"), + self._struct_price_expr("yes_bid", "low", bid_type).alias("bid_low"), + self._struct_price_expr("yes_bid", "close", bid_type).alias("bid_close"), + self._struct_price_expr("yes_ask", "open", ask_type).alias("ask_open"), + self._struct_price_expr("yes_ask", "high", ask_type).alias("ask_high"), + self._struct_price_expr("yes_ask", "low", ask_type).alias("ask_low"), + self._struct_price_expr("yes_ask", "close", ask_type).alias("ask_close"), ] ) df = df.with_columns( @@ -379,10 +608,10 @@ def _transform_data(self, raw_data: list[dict[str, Any]], symbol: str) -> pl.Dat # price is a scalar value (simple format) df = df.with_columns( [ - (pl.col("price").cast(pl.Float64) / 100.0).alias("open"), - (pl.col("price").cast(pl.Float64) / 100.0).alias("high"), - (pl.col("price").cast(pl.Float64) / 100.0).alias("low"), - (pl.col("price").cast(pl.Float64) / 100.0).alias("close"), + self._dollar_expr(pl.col("price"), df.schema["price"]).alias("open"), + self._dollar_expr(pl.col("price"), df.schema["price"]).alias("high"), + self._dollar_expr(pl.col("price"), df.schema["price"]).alias("low"), + self._dollar_expr(pl.col("price"), df.schema["price"]).alias("close"), volume_expr, pl.lit(symbol.upper()).alias("symbol"), ] @@ -825,6 +1054,579 @@ def fetch_multiple_markets( return result.sort("timestamp") + # ------------------------------------------------------------------ + # Full market enumeration, resolution outcomes and trade history + # ------------------------------------------------------------------ + + MAX_PAGE_SIZE: ClassVar[int] = 1000 + + def _classify_error_response(self, response: httpx.Response) -> Exception | None: + """Treat Kalshi's HTML 403 edge rejection of public requests as transient.""" + if self._is_unauthenticated_edge_forbidden(response): + return NetworkError( + provider="kalshi", + message="Kalshi public endpoint returned an HTML 403 (edge layer)", + retry_after=self.EDGE_403_RETRY_DELAY, + ) + return None + + def get_historical_cutoff(self) -> dict[str, datetime] | None: + """Return the live/archive boundary timestamps from ``GET /historical/cutoff``. + + Markets settled before ``market_settled_ts`` and trades created before + ``trades_created_ts`` are served only by the ``/historical/*`` endpoints. + + Returns: + Mapping of cutoff name (e.g. ``"market_settled_ts"``, ``"trades_created_ts"``) to + an aware UTC datetime, or None when the endpoint does not exist (HTTP 404). The + value is fetched once per provider instance. + """ + if not hasattr(self, "_historical_cutoff"): + try: + payload = self._request_json( + f"{self.BASE_URL}/historical/cutoff", + resource="historical cutoff", + headers=self._get_headers(), + ) + except SymbolNotFoundError: + self.logger.warning("Kalshi /historical/cutoff not found; querying both tiers") + self._historical_cutoff: dict[str, datetime] | None = None + else: + cutoff: dict[str, datetime] = {} + for key, value in payload.items(): + if not key.endswith("_ts"): + continue + try: + parsed = parse_utc_timestamp(value) + except ValueError as err: + raise DataValidationError( + provider="kalshi", + message=f"Malformed historical cutoff {key}={value!r}", + field=key, + value=value, + ) from err + if parsed is not None: + cutoff[key] = parsed + self._historical_cutoff = cutoff + return self._historical_cutoff + + def _cutoff_seconds(self, key: str) -> int | None: + cutoff = self.get_historical_cutoff() + if not cutoff or key not in cutoff: + return None + return int(cutoff[key].timestamp()) + + def _paginate( + self, + path: str, + params: dict[str, Any], + *, + items_key: str, + resource: str, + max_pages: int | None = None, + ) -> Iterator[dict[str, Any]]: + """Yield items from a cursor-paginated Kalshi endpoint until the cursor is empty.""" + cursor = "" + seen_cursors: set[str] = set() + pages = 0 + while True: + page_params = dict(params) + if cursor: + page_params["cursor"] = cursor + payload = self._request_json( + f"{self.BASE_URL}{path}", + resource=resource, + params=page_params, + headers=self._get_headers(), + ) + items = payload.get(items_key) + if not isinstance(items, list): + raise DataValidationError( + provider="kalshi", + message=f"Response for {resource} has no '{items_key}' list", + field=items_key, + ) + for item in items: + if not isinstance(item, dict): + raise DataValidationError( + provider="kalshi", + message=f"Non-object entry in '{items_key}' for {resource}", + field=items_key, + ) + yield item + pages += 1 + cursor = payload.get("cursor") or "" + if not isinstance(cursor, str): + raise DataValidationError( + provider="kalshi", message=f"Non-string cursor for {resource}", field="cursor" + ) + if not cursor or (max_pages is not None and pages >= max_pages): + return + if cursor in seen_cursors: + raise DataValidationError( + provider="kalshi", + message=f"Pagination cursor repeated for {resource}; aborting", + field="cursor", + value=cursor, + ) + seen_cursors.add(cursor) + + def iter_markets( + self, + status: str | None = None, + series_ticker: str | None = None, + event_ticker: str | None = None, + min_close_ts: TimestampArg = None, + max_close_ts: TimestampArg = None, + include_historical: bool = True, + mve_filter: str | None = None, + page_size: int = MAX_PAGE_SIZE, + max_pages: int | None = None, + ) -> Iterator[dict[str, Any]]: + """Iterate over every market matching the filters, across the live and archive tiers. + + Kalshi serves markets settled before the historical cutoff (``get_historical_cutoff``) + only from ``GET /historical/markets``, which accepts a single ``event_ticker`` or + ``series_ticker`` filter and no status or time filters. This method pages through + ``GET /markets`` and, when the filters can match archived markets, through the + archive; it applies the filters the archive cannot take on the client and drops + archive duplicates of live tickers. + + Args: + status: ``"unopened"``, ``"open"``, ``"paused"``, ``"closed"``, ``"settled"`` or + None for all. Archived markets are all settled, so the archive is only + queried for ``"settled"`` or None. + series_ticker: Series filter (e.g. ``"KXFED"``). + event_ticker: Event filter (e.g. ``"FED-23DEC"``). + min_close_ts: Keep markets closing at or after this time (Unix seconds, ISO + string, date or datetime; naive values are UTC). + max_close_ts: Keep markets closing at or before this time. + include_historical: Also page through the archive tier. + mve_filter: Multivariate (combo/parlay, ``KXMVE...``) selection: None includes + them (Kalshi's default), ``"exclude"`` drops them, ``"only"`` keeps only them. + Sent to ``/markets`` as ``mve_filter``. The archive accepts only + ``"exclude"`` and only without a ticker filter, so the rest is applied on the + client there. + page_size: Markets per request (1-1000). + max_pages: Optional cap on pages per tier, for sampling. + + Yields: + Raw Kalshi market dictionaries with an added ``"_source"`` key + (``"live"`` or ``"historical"``). + """ + min_close = to_unix_seconds(min_close_ts) + max_close = to_unix_seconds(max_close_ts) + limit = max(1, min(int(page_size), self.MAX_PAGE_SIZE)) + + def close_in_range(market: dict[str, Any]) -> bool: + if min_close is None and max_close is None: + return True + close_time = parse_utc_timestamp(market.get("close_time")) + if close_time is None: + return False + close_seconds = close_time.timestamp() + if min_close is not None and close_seconds < min_close: + return False + return not (max_close is not None and close_seconds > max_close) + + if mve_filter not in (None, "exclude", "only"): + raise DataValidationError( + provider="kalshi", + message=f"mve_filter must be None, 'exclude' or 'only', got {mve_filter!r}", + field="mve_filter", + value=mve_filter, + ) + + def mve_selected(market: dict[str, Any]) -> bool: + if mve_filter is None: + return True + multivariate = bool(market.get("mve_collection_ticker")) or str( + market.get("ticker", "") + ).startswith("KXMVE") + return multivariate == (mve_filter == "only") + + series_filter = series_ticker.upper() if series_ticker else None + live_params: dict[str, Any] = {"limit": limit} + if status: + live_params["status"] = status + if series_ticker: + live_params["series_ticker"] = series_ticker.upper() + if event_ticker: + live_params["event_ticker"] = event_ticker.upper() + if mve_filter: + live_params["mve_filter"] = mve_filter + # Kalshi accepts close-time filters only without a status or with status=closed. + if status in (None, "closed"): + if min_close is not None: + live_params["min_close_ts"] = min_close + if max_close is not None: + live_params["max_close_ts"] = max_close + + seen: set[str] = set() + for market in self._paginate( + "/markets", + live_params, + items_key="markets", + resource="markets", + max_pages=max_pages, + ): + if not close_in_range(market) or not mve_selected(market): + continue + seen.add(str(market.get("ticker"))) + yield {**market, "_source": _LIVE, "_series": series_filter} + + if not include_historical or status not in (None, "settled"): + return + settled_cutoff = self._cutoff_seconds("market_settled_ts") + if settled_cutoff is not None and min_close is not None and min_close >= settled_cutoff: + return # archived markets settled, hence closed, before the cutoff + + archive_params: dict[str, Any] = {"limit": limit} + # The archive takes exactly one of event_ticker, series_ticker, mve_filter=exclude. + if event_ticker: + archive_params["event_ticker"] = event_ticker.upper() + elif series_ticker: + archive_params["series_ticker"] = series_ticker.upper() + elif mve_filter == "exclude": + archive_params["mve_filter"] = "exclude" + event_prefix = event_ticker.upper() if event_ticker else None + + for market in self._paginate( + "/historical/markets", + archive_params, + items_key="markets", + resource="historical markets", + max_pages=max_pages, + ): + ticker = str(market.get("ticker")) + if ticker in seen: + continue + if event_prefix and str(market.get("event_ticker", "")).upper() != event_prefix: + continue + if not close_in_range(market) or not mve_selected(market): + continue + seen.add(ticker) + yield {**market, "_source": _HISTORICAL, "_series": series_filter} + + def fetch_markets( + self, + status: str | None = None, + series_ticker: str | None = None, + event_ticker: str | None = None, + min_close_ts: TimestampArg = None, + max_close_ts: TimestampArg = None, + include_historical: bool = True, + mve_filter: str | None = None, + page_size: int = MAX_PAGE_SIZE, + max_pages: int | None = None, + ) -> pl.DataFrame: + """Return every matching market with its normalized resolution outcome. + + Accepts the same filters as ``iter_markets``, which covers both the live and the + archive tier. + + Returns: + DataFrame with ``MARKET_SCHEMA`` columns, one row per market. ``result`` is + ``"yes"``/``"no"`` for a binary market resolved to that side, ``"other"`` for a + scalar settlement, and null while unresolved; ``result_raw`` keeps Kalshi's + label. ``settlement_value`` is the YES payout in dollars and ``settlement_ts`` + the UTC settlement time. ``series`` follows ``_series_of``; ``source`` names the + tier that served the row. + + Example: + >>> provider = KalshiProvider() + >>> fed = provider.fetch_markets(status="settled", series_ticker="KXFEDDECISION") + >>> fed.select("ticker", "result", "settlement_value", "settlement_ts") + """ + frames = list( + self.iter_market_frames( + status=status, + series_ticker=series_ticker, + event_ticker=event_ticker, + min_close_ts=min_close_ts, + max_close_ts=max_close_ts, + include_historical=include_historical, + mve_filter=mve_filter, + page_size=page_size, + max_pages=max_pages, + ) + ) + if not frames: + return empty_frame(MARKET_SCHEMA) + return pl.concat(frames, how="vertical") + + def iter_market_frames( + self, + chunk_size: int = 100_000, + status: str | None = None, + series_ticker: str | None = None, + event_ticker: str | None = None, + min_close_ts: TimestampArg = None, + max_close_ts: TimestampArg = None, + include_historical: bool = True, + mve_filter: str | None = None, + page_size: int = MAX_PAGE_SIZE, + max_pages: int | None = None, + ) -> Iterator[pl.DataFrame]: + """Stream the normalized markets of ``fetch_markets`` in frames of ``chunk_size`` rows. + + Use this for listings too large to hold in memory (the full settled listing runs + to millions of markets): each frame can be written out before the next page is + requested. Takes the same filters as ``iter_markets``. + + Yields: + Non-empty DataFrames with ``MARKET_SCHEMA`` columns, at most ``chunk_size`` + rows each, in listing order. Nothing is yielded when no market matches. + """ + if chunk_size < 1: + raise DataValidationError( + provider="kalshi", + message=f"chunk_size must be positive, got {chunk_size}", + field="chunk_size", + value=chunk_size, + ) + rows: list[tuple[Any, ...]] = [] + for market in self.iter_markets( + status=status, + series_ticker=series_ticker, + event_ticker=event_ticker, + min_close_ts=min_close_ts, + max_close_ts=max_close_ts, + include_historical=include_historical, + mve_filter=mve_filter, + page_size=page_size, + max_pages=max_pages, + ): + rows.append(self._normalize_market(market)) + if len(rows) >= chunk_size: + yield pl.DataFrame(rows, schema=MARKET_SCHEMA, orient="row") + rows = [] + if rows: + yield pl.DataFrame(rows, schema=MARKET_SCHEMA, orient="row") + + @staticmethod + def _dollars(record: dict[str, Any], key: str) -> float | None: + """Read a price in dollars from ``{key}_dollars`` or a legacy unsuffixed field. + + Unsuffixed JSON integers follow Kalshi's legacy cent encoding; unsuffixed strings and + floats (archive candles) are already dollars. + """ + value = record.get(f"{key}_dollars") + if value is not None and value != "": + return float(value) + value = record.get(key) + if value is None or value == "": + return None + if isinstance(value, bool): + raise ValueError(f"Boolean value for price field {key}") + if isinstance(value, int): + return value / 100.0 + return float(value) + + @staticmethod + def _fixed_point(record: dict[str, Any], key: str) -> float | None: + """Read a contract count from ``{key}_fp`` or the unsuffixed field.""" + for name in (f"{key}_fp", key): + value = record.get(name) + if value is not None and value != "": + if isinstance(value, bool): + raise ValueError(f"Boolean value for count field {name}") + return float(value) + return None + + @staticmethod + def _series_of(market: dict[str, Any]) -> str | None: + """Series of a market: the record's own field, the series filter, or the event prefix. + + Market records carry no series field. The event-ticker prefix equals the series for + current tickers (``KXFEDDECISION-26SEP``) but not for some legacy events + (``FED-23DEC`` belongs to series ``KXFED``); filtering by ``series_ticker`` gives the + authoritative value. + """ + explicit = market.get("series_ticker") or market.get("_series") + if explicit: + return str(explicit) + event = market.get("event_ticker") + return str(event).split("-", 1)[0] if event else None + + def _normalize_market(self, market: dict[str, Any]) -> tuple[Any, ...]: + ticker = market.get("ticker") + if not ticker: + raise DataValidationError( + provider="kalshi", message="Market entry without a ticker", field="ticker" + ) + try: + row = { + "ticker": str(ticker), + "event_ticker": market.get("event_ticker"), + "series": self._series_of(market), + "title": market.get("title"), + "status": market.get("status"), + "market_type": market.get("market_type"), + "open_time": parse_utc_timestamp(market.get("open_time")), + "close_time": parse_utc_timestamp(market.get("close_time")), + "result": result_from_label(market.get("result")), + "settlement_value": self._dollars(market, "settlement_value"), + "settlement_ts": parse_utc_timestamp(market.get("settlement_ts")), + "result_raw": market.get("result"), + "expiration_value": market.get("expiration_value"), + "last_price": self._dollars(market, "last_price"), + "previous_price": self._dollars(market, "previous_price"), + "volume": self._fixed_point(market, "volume"), + "open_interest": self._fixed_point(market, "open_interest"), + "source": market.get("_source"), + } + except (TypeError, ValueError) as err: + raise DataValidationError( + provider="kalshi", + message=f"Malformed market record for {ticker}: {err}", + field="markets", + value=ticker, + ) from err + return tuple(row[column] for column in MARKET_SCHEMA) + + def iter_trades( + self, + ticker: str, + min_ts: TimestampArg = None, + max_ts: TimestampArg = None, + include_historical: bool = True, + page_size: int = MAX_PAGE_SIZE, + max_pages: int | None = None, + ) -> Iterator[dict[str, Any]]: + """Iterate over the trades of one market across the live and archive tiers. + + Trades created before the ``trades_created_ts`` cutoff are served only by + ``GET /historical/trades``. The archive is skipped when ``min_ts`` is at or after + the cutoff; trades returned by both tiers are yielded once. + + Args: + ticker: Market ticker. + min_ts: Keep trades after this time (Unix seconds, ISO string, date or datetime). + max_ts: Keep trades before this time. + include_historical: Also page through the archive tier. + page_size: Trades per request (1-1000). + max_pages: Optional cap on pages per tier. + + Yields: + Raw Kalshi trade dictionaries with an added ``"_source"`` key. + """ + market_ticker = ticker.upper() + params: dict[str, Any] = { + "ticker": market_ticker, + "limit": max(1, min(int(page_size), self.MAX_PAGE_SIZE)), + } + min_seconds = to_unix_seconds(min_ts) + max_seconds = to_unix_seconds(max_ts) + if min_seconds is not None: + params["min_ts"] = min_seconds + if max_seconds is not None: + params["max_ts"] = max_seconds + + trades_cutoff = self._cutoff_seconds("trades_created_ts") if include_historical else None + seen: set[str] = set() + if trades_cutoff is None or max_seconds is None or max_seconds >= trades_cutoff: + for trade in self._paginate( + "/markets/trades", + params, + items_key="trades", + resource=f"trades for {market_ticker}", + max_pages=max_pages, + ): + seen.add(str(trade.get("trade_id"))) + yield {**trade, "_source": _LIVE} + + if not include_historical: + return + if trades_cutoff is not None and min_seconds is not None and min_seconds >= trades_cutoff: + return + for trade in self._paginate( + "/historical/trades", + params, + items_key="trades", + resource=f"historical trades for {market_ticker}", + max_pages=max_pages, + ): + trade_id = str(trade.get("trade_id")) + if trade_id in seen: + continue + seen.add(trade_id) + yield {**trade, "_source": _HISTORICAL} + + def fetch_trades( + self, + ticker: str, + min_ts: TimestampArg = None, + max_ts: TimestampArg = None, + include_historical: bool = True, + page_size: int = MAX_PAGE_SIZE, + max_pages: int | None = None, + ) -> pl.DataFrame: + """Return the trade history of one market, sorted by time. + + Accepts the same arguments as ``iter_trades``. + + Returns: + DataFrame with ``KALSHI_TRADE_SCHEMA`` columns: ``trade_id``, ``ticker``, + ``timestamp`` (UTC), ``price`` (YES price in dollars, 0-1), ``count`` + (contracts, fractional allowed), ``taker_side`` (``"yes"``/``"no"``; falls back + to ``taker_outcome_side``), ``is_block_trade``, ``taker_outcome_side``, + ``taker_book_side`` (``"bid"``/``"ask"``, null when absent) and ``source``. + + Example: + >>> provider = KalshiProvider() + >>> trades = provider.fetch_trades("KXFEDDECISION-26SEP-H25") + """ + rows = [ + self._normalize_trade(trade) + for trade in self.iter_trades( + ticker, + min_ts=min_ts, + max_ts=max_ts, + include_historical=include_historical, + page_size=page_size, + max_pages=max_pages, + ) + ] + if not rows: + return empty_frame(KALSHI_TRADE_SCHEMA) + return pl.DataFrame(rows, schema=KALSHI_TRADE_SCHEMA, orient="row").sort( + ["timestamp", "trade_id"] + ) + + def _normalize_trade(self, trade: dict[str, Any]) -> tuple[Any, ...]: + trade_id = trade.get("trade_id") + try: + timestamp = parse_utc_timestamp(trade.get("created_time")) + price = self._dollars(trade, "yes_price") + count = self._fixed_point(trade, "count") + except (TypeError, ValueError) as err: + raise DataValidationError( + provider="kalshi", + message=f"Malformed trade record {trade_id}: {err}", + field="trades", + value=trade_id, + ) from err + if not trade_id or timestamp is None or price is None or count is None: + raise DataValidationError( + provider="kalshi", + message=f"Trade record missing trade_id, created_time, price or count: {trade}", + field="trades", + ) + taker_side = trade.get("taker_side") or trade.get("taker_outcome_side") + return ( + str(trade_id), + trade.get("ticker"), + timestamp, + price, + count, + taker_side, + bool(trade.get("is_block_trade", False)), + trade.get("taker_outcome_side"), + trade.get("taker_book_side"), + trade.get("_source"), + ) + def close(self) -> None: """Close HTTP client.""" if hasattr(self, "session"): diff --git a/src/ml4t/data/providers/polymarket.py b/src/ml4t/data/providers/polymarket.py index d54e406e..bb1bc401 100644 --- a/src/ml4t/data/providers/polymarket.py +++ b/src/ml4t/data/providers/polymarket.py @@ -6,6 +6,7 @@ API Documentation: - CLOB Timeseries: https://docs.polymarket.com/developers/CLOB/timeseries - Gamma Markets: https://docs.polymarket.com/developers/gamma-markets-api/get-markets +- Data API trades: https://docs.polymarket.com/api-reference/core/get-trades-for-a-user-or-markets Rate Limits: - CLOB API: ~60 requests per minute (estimated) @@ -26,11 +27,14 @@ >>> provider = PolymarketProvider() # No auth required >>> data = provider.fetch_ohlcv("will-bitcoin-exceed-100k-2025", "2024-01-01", "2024-12-31") >>> markets = provider.list_markets(active=True) + >>> resolved = provider.fetch_markets(min_volume=100_000, end_date_min="2025-01-01") >>> provider.close() """ +import hashlib import json -from datetime import datetime, timedelta +from collections.abc import Iterator +from datetime import UTC, date, datetime, timedelta from math import isfinite from typing import Any, ClassVar @@ -45,9 +49,96 @@ SymbolNotFoundError, ) from ml4t.data.providers.base import BaseProvider +from ml4t.data.providers.prediction_markets import ( + RESOLUTION_SCHEMA, + RESULT_NO, + RESULT_OTHER, + RESULT_VOID, + RESULT_YES, + TRADE_SCHEMA, + UTC_DATETIME, + empty_frame, + parse_utc_timestamp, + to_unix_seconds, +) logger = structlog.get_logger() +TimestampArg = int | float | str | date | datetime | None + +# Normalized market columns returned by PolymarketProvider.fetch_markets. +MARKET_SCHEMA: dict[str, pl.DataType] = { + "ticker": pl.Utf8(), + "market_id": pl.Utf8(), + "slug": pl.Utf8(), + "question": pl.Utf8(), + "event_slug": pl.Utf8(), + "event_title": pl.Utf8(), + "category": pl.Utf8(), + "tags": pl.List(pl.Utf8()), + "yes_outcome": pl.Utf8(), + "no_outcome": pl.Utf8(), + "yes_token_id": pl.Utf8(), + "no_token_id": pl.Utf8(), + "closed": pl.Boolean(), + "open_time": UTC_DATETIME, + "close_time": UTC_DATETIME, + **RESOLUTION_SCHEMA, + "uma_resolution_status": pl.Utf8(), + "outcome_prices": pl.List(pl.Float64()), + "volume": pl.Float64(), + "neg_risk": pl.Boolean(), + "neg_risk_market_id": pl.Utf8(), + "fees_enabled": pl.Boolean(), + "fee_type": pl.Utf8(), + "maker_base_fee": pl.Float64(), + "taker_base_fee": pl.Float64(), + "fee_schedule": pl.Utf8(), +} + +# Price points returned by PolymarketProvider.fetch_candles. +CANDLE_SCHEMA: dict[str, pl.DataType] = { + "timestamp": UTC_DATETIME, + "token_id": pl.Utf8(), + "price": pl.Float64(), +} + +# Normalized trade columns returned by PolymarketProvider.fetch_trades. +POLYMARKET_TRADE_SCHEMA: dict[str, pl.DataType] = { + **TRADE_SCHEMA, + "outcome": pl.Utf8(), + "outcome_index": pl.Int64(), + "side": pl.Utf8(), + "outcome_price": pl.Float64(), + "asset": pl.Utf8(), + "proxy_wallet": pl.Utf8(), + "transaction_hash": pl.Utf8(), +} + + +def _json_list(value: Any) -> list[Any] | None: + """Parse a Gamma list field, which arrives as a JSON-encoded string or a list.""" + if value is None or value == "": + return None + if isinstance(value, list): + return value + parsed = json.loads(value) + if not isinstance(parsed, list): + raise ValueError(f"expected a JSON list, got {value!r}") + return parsed + + +def _optional_float(value: Any) -> float | None: + if value is None or value == "": + return None + if isinstance(value, bool): + raise ValueError(f"boolean where a number was expected: {value!r}") + return float(value) + + +def _iso_utc(seconds: int) -> str: + return datetime.fromtimestamp(seconds, UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + class PolymarketProvider(BaseProvider): """Polymarket prediction market data provider. @@ -75,6 +166,18 @@ class PolymarketProvider(BaseProvider): # API base URLs CLOB_URL: ClassVar[str] = "https://clob.polymarket.com" GAMMA_URL: ClassVar[str] = "https://gamma-api.polymarket.com" + DATA_API_URL: ClassVar[str] = "https://data-api.polymarket.com" + + # GET /markets/keyset returns at most 100 markets per page whatever ``limit`` asks for. + KEYSET_PAGE_SIZE: ClassVar[int] = 100 + + # GET /prices-history rejects a startTs..endTs span longer than 15 days (HTTP 400). + PRICE_HISTORY_MAX_WINDOW_SECONDS: ClassVar[int] = 15 * 86_400 + + # GET /trades serves up to 10000 trades per request and rejects offset > 10000 (HTTP 400). + TRADES_MAX_LIMIT: ClassVar[int] = 10_000 + TRADES_MAX_OFFSET: ClassVar[int] = 10_000 + TRADES_PAGE_SIZE: ClassVar[int] = 1_000 # Map common frequency names to Polymarket interval values INTERVAL_MAP: ClassVar[dict[str, str]] = { @@ -378,11 +481,11 @@ def _fetch_price_history( List of price points [{t: timestamp, p: price}, ...] """ try: - start_dt = datetime.strptime(start, "%Y-%m-%d") - end_dt = datetime.strptime(end, "%Y-%m-%d") + start_dt = datetime.strptime(start, "%Y-%m-%d").replace(tzinfo=UTC) + end_dt = datetime.strptime(end, "%Y-%m-%d").replace(tzinfo=UTC) history: list[dict[str, Any]] = [] - chunk_days = self.HISTORY_CHUNK_DAYS if interval != "max" else self.MAX_MARKET_SCAN + chunk_days = self.HISTORY_CHUNK_DAYS chunk_start = start_dt while chunk_start <= end_dt: @@ -436,13 +539,17 @@ def _fetch_price_history_chunk( end_ts: int, interval: str, ) -> list[dict[str, Any]]: - """Fetch a single chunk of raw price history from the CLOB API.""" + """Fetch a single chunk of raw price history from the CLOB API. + + ``interval`` names a range ending now and overrides ``startTs``/``endTs``, so past + ranges are requested by their bounds and the interval's step as ``fidelity``. + """ endpoint = f"{self.CLOB_URL}/prices-history" params = { "market": token_id, "startTs": start_ts, "endTs": end_ts, - "interval": interval, + "fidelity": self.FIDELITY_MAP.get(interval, self.FIDELITY_MAP["1d"]), } self._acquire_rate_limit() @@ -968,6 +1075,633 @@ def get_token_prices(self, symbol: str) -> dict[str, float]: return prices + # ------------------------------------------------------------------ + # Resolved-market history: market listing, price history, trades + # ------------------------------------------------------------------ + + def iter_markets( + self, + closed: bool = True, + min_volume: float | None = None, + end_date_min: TimestampArg = None, + end_date_max: TimestampArg = None, + page_size: int = KEYSET_PAGE_SIZE, + max_pages: int | None = None, + ) -> Iterator[dict[str, Any]]: + """Iterate over every Gamma market matching the filters. + + Pages through ``GET /markets/keyset`` (``GET /markets`` refuses offsets beyond 2000), + passing ``next_cursor`` back as ``after_cursor``; markets come in ascending Gamma id + order. The server applies ``closed``, ``volume_num_min``, ``end_date_min`` and + ``end_date_max`` (verified 2026-10-08); the same filters are applied again on the + client, so a filter the server stopped honouring cannot leak rows. A cursor the + server repeats raises instead of looping forever. + + Args: + closed: True for closed (resolved or awaiting resolution) markets, False for + markets still trading. + min_volume: Keep markets whose lifetime ``volumeNum`` (USDC) is at least this. + end_date_min: Keep markets whose scheduled ``endDate`` is at or after this time + (Unix seconds, ISO string, date or datetime; naive values are UTC). + end_date_max: Keep markets whose ``endDate`` is at or before this time. + page_size: Markets per request (1-100). + max_pages: Optional cap on the number of pages, for sampling. + + Yields: + Raw Gamma market dictionaries, including the market's ``tags``. + """ + limit = max(1, min(int(page_size), self.KEYSET_PAGE_SIZE)) + min_end = to_unix_seconds(end_date_min) + max_end = to_unix_seconds(end_date_max) + params: dict[str, Any] = { + "limit": limit, + "closed": str(bool(closed)).lower(), + "include_tag": "true", + } + if min_volume is not None: + params["volume_num_min"] = float(min_volume) + if min_end is not None: + params["end_date_min"] = _iso_utc(min_end) + if max_end is not None: + params["end_date_max"] = _iso_utc(max_end) + + def selected(market: dict[str, Any]) -> bool: + if market.get("closed") is not None and bool(market["closed"]) != bool(closed): + return False + if min_volume is not None: + volume = self._market_volume(market) + if volume is None or volume < float(min_volume): + return False + if min_end is None and max_end is None: + return True + end = self._lenient_timestamp(market, "endDate") + if end is None: + return False + end_seconds = end.timestamp() + if min_end is not None and end_seconds < min_end: + return False + return not (max_end is not None and end_seconds > max_end) + + cursor: str | None = None + seen_cursors: set[str] = set() + pages = 0 + while True: + page_params = dict(params) + if cursor: + page_params["after_cursor"] = cursor + payload = self._request_json( + f"{self.GAMMA_URL}/markets/keyset", resource="markets", params=page_params + ) + markets = payload.get("markets") + if not isinstance(markets, list): + raise DataValidationError( + provider="polymarket", + message="Keyset response has no 'markets' list", + field="markets", + ) + for market in markets: + if not isinstance(market, dict): + raise DataValidationError( + provider="polymarket", + message="Non-object entry in keyset 'markets'", + field="markets", + ) + if selected(market): + yield market + pages += 1 + cursor = payload.get("next_cursor") + if not cursor or not markets or (max_pages is not None and pages >= max_pages): + return + if not isinstance(cursor, str): + raise DataValidationError( + provider="polymarket", message="Non-string next_cursor", field="next_cursor" + ) + if cursor in seen_cursors: + raise DataValidationError( + provider="polymarket", + message="Keyset cursor repeated; the server ignored after_cursor", + field="next_cursor", + value=cursor, + ) + seen_cursors.add(cursor) + + def fetch_markets( + self, + closed: bool = True, + min_volume: float | None = None, + end_date_min: TimestampArg = None, + end_date_max: TimestampArg = None, + page_size: int = KEYSET_PAGE_SIZE, + max_pages: int | None = None, + ) -> pl.DataFrame: + """Return every matching market with its normalized resolution outcome. + + Accepts the same filters as ``iter_markets``; see ``iter_market_frames`` for the + columns. + + Example: + >>> provider = PolymarketProvider() + >>> big = provider.fetch_markets(min_volume=100_000, end_date_min="2026-01-01") + >>> big.select("ticker", "question", "result", "settlement_ts") + """ + frames = list( + self.iter_market_frames( + closed=closed, + min_volume=min_volume, + end_date_min=end_date_min, + end_date_max=end_date_max, + page_size=page_size, + max_pages=max_pages, + ) + ) + if not frames: + return empty_frame(MARKET_SCHEMA) + return pl.concat(frames, how="vertical") + + def iter_market_frames( + self, + chunk_size: int = 100_000, + closed: bool = True, + min_volume: float | None = None, + end_date_min: TimestampArg = None, + end_date_max: TimestampArg = None, + page_size: int = KEYSET_PAGE_SIZE, + max_pages: int | None = None, + ) -> Iterator[pl.DataFrame]: + """Stream normalized markets in frames of at most ``chunk_size`` rows. + + Takes the filters of ``iter_markets``. Each frame can be written out before the next + page is requested; the closed listing runs to hundreds of thousands of markets. + + Gamma markets are binary: two outcomes, two CLOB tokens and two final prices, all + in the same order. The first outcome is treated as the YES side and the second as + NO, whatever their labels: in ``["Trump", "Boden"]`` "Trump" is ``yes_outcome``, + and ``result == "yes"`` means the first outcome won. + + ``result`` comes from the final ``outcomePrices`` of a closed market: ``[1, 0]`` is + ``"yes"``, ``[0, 1]`` ``"no"`` and ``[0.5, 0.5]`` (a 50/50 resolution) ``"void"``. A + market whose ``umaResolutionStatus`` is ``"resolved"`` with any other prices is + ``"other"``; everything else (open markets, closed markets without final prices) is + null. Closed markets snap to final prices even while the UMA status is still + ``"proposed"``; filter on ``uma_resolution_status == "resolved"`` to exclude them. + + Yields: + Non-empty DataFrames with ``MARKET_SCHEMA`` columns: ``ticker`` (condition id), + ``market_id`` (Gamma id), ``slug``, ``question``, ``event_slug``, + ``event_title``, ``category`` (the market's or event's category, mostly null + since 2024), ``tags`` (tag labels, the reliable topic field), ``yes_outcome``, + ``no_outcome``, ``yes_token_id``, ``no_token_id``, ``closed``, ``open_time`` + (``startDate``), ``close_time`` (scheduled ``endDate``), ``result``, + ``settlement_value`` (payout of the first outcome token), ``settlement_ts`` + (``closedTime``, else ``umaEndDate``; null while unresolved), + ``uma_resolution_status``, ``outcome_prices``, ``volume`` (lifetime USDC), + ``neg_risk``, ``neg_risk_market_id``, ``fees_enabled``, ``fee_type``, + ``maker_base_fee``, ``taker_base_fee`` (Gamma's raw base-fee fields) and + ``fee_schedule`` (Gamma's fee schedule as JSON text). Timestamps Gamma cannot + parse (``umaEndDate`` holds ``"NOW*()"`` on some 2024 markets) are null. Markets + without a condition id are skipped with a warning. + """ + if chunk_size < 1: + raise DataValidationError( + provider="polymarket", + message=f"chunk_size must be positive, got {chunk_size}", + field="chunk_size", + value=chunk_size, + ) + rows: list[tuple[Any, ...]] = [] + for market in self.iter_markets( + closed=closed, + min_volume=min_volume, + end_date_min=end_date_min, + end_date_max=end_date_max, + page_size=page_size, + max_pages=max_pages, + ): + row = self._normalize_market(market) + if row is None: + continue + rows.append(row) + if len(rows) >= chunk_size: + yield pl.DataFrame(rows, schema=MARKET_SCHEMA, orient="row") + rows = [] + if rows: + yield pl.DataFrame(rows, schema=MARKET_SCHEMA, orient="row") + + @staticmethod + def _market_volume(market: dict[str, Any]) -> float | None: + volume = market.get("volumeNum") + if volume is None or volume == "": + volume = market.get("volume") + return _optional_float(volume) + + def _lenient_timestamp(self, market: dict[str, Any], key: str) -> datetime | None: + """Parse a Gamma timestamp field; an unparseable value is logged and read as null.""" + try: + return parse_utc_timestamp(market.get(key)) + except (TypeError, ValueError): + self.logger.warning( + "Unparseable Polymarket timestamp", + field=key, + value=market.get(key), + market_id=market.get("id"), + ) + return None + + @staticmethod + def _market_result( + closed: bool, uma_status: str | None, prices: list[float] | None + ) -> str | None: + if closed and prices is not None and len(prices) == 2: + first, second = prices + if first == 1.0 and second == 0.0: + return RESULT_YES + if first == 0.0 and second == 1.0: + return RESULT_NO + if first == 0.5 and second == 0.5: + return RESULT_VOID + if uma_status == "resolved": + return RESULT_OTHER + return None + + def _normalize_market(self, market: dict[str, Any]) -> tuple[Any, ...] | None: + condition_id = market.get("conditionId") + market_id = market.get("id") + if not condition_id: + self.logger.warning("Skipping Polymarket market without conditionId", id=market_id) + return None + try: + outcomes = _json_list(market.get("outcomes")) or [] + raw_prices = _json_list(market.get("outcomePrices")) + prices = [float(price) for price in raw_prices] if raw_prices else None + token_ids = _json_list(market.get("clobTokenIds")) or [] + closed = bool(market.get("closed")) + uma_status = market.get("umaResolutionStatus") or None + result = self._market_result(closed, uma_status, prices) + binary = len(outcomes) == 2 + events = market.get("events") or [] + event = events[0] if events and isinstance(events[0], dict) else {} + tags = [ + str(tag["label"]) + for tag in market.get("tags") or event.get("tags") or [] + if isinstance(tag, dict) and tag.get("label") + ] + settlement_ts = None + if result is not None: + settlement_ts = self._lenient_timestamp( + market, "closedTime" + ) or self._lenient_timestamp(market, "umaEndDate") + fee_schedule = market.get("feeSchedule") + fees_enabled = market.get("feesEnabled") + neg_risk = market.get("negRisk") + row = { + "ticker": str(condition_id), + "market_id": None if market_id is None else str(market_id), + "slug": market.get("slug"), + "question": market.get("question"), + "event_slug": event.get("slug"), + "event_title": event.get("title"), + "category": market.get("category") or event.get("category"), + "tags": tags, + "yes_outcome": str(outcomes[0]) if binary else None, + "no_outcome": str(outcomes[1]) if binary else None, + "yes_token_id": str(token_ids[0]) if len(token_ids) == 2 else None, + "no_token_id": str(token_ids[1]) if len(token_ids) == 2 else None, + "closed": closed, + "open_time": self._lenient_timestamp(market, "startDate") + or self._lenient_timestamp(market, "createdAt"), + "close_time": self._lenient_timestamp(market, "endDate"), + "result": result, + "settlement_value": prices[0] if result is not None and prices else None, + "settlement_ts": settlement_ts, + "uma_resolution_status": uma_status, + "outcome_prices": prices, + "volume": self._market_volume(market), + "neg_risk": None if neg_risk is None else bool(neg_risk), + "neg_risk_market_id": market.get("negRiskMarketID") or None, + "fees_enabled": None if fees_enabled is None else bool(fees_enabled), + "fee_type": market.get("feeType"), + "maker_base_fee": _optional_float(market.get("makerBaseFee")), + "taker_base_fee": _optional_float(market.get("takerBaseFee")), + "fee_schedule": None + if fee_schedule is None + else json.dumps(fee_schedule, sort_keys=True), + } + except (TypeError, ValueError, KeyError) as err: + raise DataValidationError( + provider="polymarket", + message=f"Malformed market record {market_id} ({condition_id}): {err}", + field="markets", + value=market_id, + ) from err + return tuple(row[column] for column in MARKET_SCHEMA) + + def fetch_candles( + self, + token_id: str, + start: TimestampArg, + end: TimestampArg, + fidelity_minutes: int = 60, + ) -> pl.DataFrame: + """Return the CLOB price history of one outcome token between ``start`` and ``end``. + + ``GET /prices-history`` with ``interval=max`` returns nothing for a resolved market + at fidelities finer than one day, and rejects a ``startTs``..``endTs`` span longer + than 15 days. This method therefore always sends explicit bounds and splits the + range into windows of at most 15 days, which serves resolved markets at any + fidelity down to one minute (verified 2026-10-08). + + Args: + token_id: CLOB token id (``yes_token_id`` or ``no_token_id`` of + ``fetch_markets``). + start: Range start (Unix seconds, ISO string, date or datetime; naive is UTC). + end: Range end, inclusive. + fidelity_minutes: Sampling step in minutes (1 or more). + + Returns: + DataFrame with ``CANDLE_SCHEMA`` columns: ``timestamp`` (UTC), ``token_id`` and + ``price`` (the token's price, 0-1), one row per sample, sorted. These are price + samples, not OHLC bars, and carry no volume. An unknown token or a range without + trading yields an empty frame (the CLOB answers an empty history, not 404). + """ + start_seconds = to_unix_seconds(start) + end_seconds = to_unix_seconds(end) + if start_seconds is None or end_seconds is None or end_seconds < start_seconds: + raise DataValidationError( + provider="polymarket", + message="fetch_candles needs start and end, with end >= start", + field="start", + ) + fidelity = int(fidelity_minutes) + if fidelity < 1: + raise DataValidationError( + provider="polymarket", + message=f"fidelity_minutes must be at least 1, got {fidelity_minutes}", + field="fidelity_minutes", + value=fidelity_minutes, + ) + resource = f"price history for token {token_id}" + points: dict[int, float] = {} + chunk_start = start_seconds + while chunk_start <= end_seconds: + chunk_end = min(chunk_start + self.PRICE_HISTORY_MAX_WINDOW_SECONDS, end_seconds) + payload = self._request_json( + f"{self.CLOB_URL}/prices-history", + resource=resource, + params={ + "market": token_id, + "startTs": chunk_start, + "endTs": chunk_end, + "fidelity": fidelity, + }, + ) + history = payload.get("history") + if not isinstance(history, list): + raise DataValidationError( + provider="polymarket", + message=f"Response for {resource} has no 'history' list", + field="history", + ) + for point in history: + try: + timestamp = point["t"] + price = point["p"] + if isinstance(timestamp, bool) or isinstance(price, bool): + raise ValueError("boolean field") + seconds = int(timestamp) + value = float(price) + if seconds != timestamp or not isfinite(value): + raise ValueError("non-integer timestamp or non-finite price") + except (KeyError, TypeError, ValueError) as err: + raise DataValidationError( + provider="polymarket", + message=f"Malformed price point for {resource}: {point!r}", + field="history", + ) from err + if start_seconds <= seconds <= end_seconds: + points[seconds] = value + chunk_start = chunk_end + 1 + if not points: + return empty_frame(CANDLE_SCHEMA) + ordered = sorted(points) + return pl.DataFrame( + { + "timestamp": [datetime.fromtimestamp(t, UTC) for t in ordered], + "token_id": [str(token_id)] * len(ordered), + "price": [points[t] for t in ordered], + }, + schema=CANDLE_SCHEMA, + ) + + @staticmethod + def _trade_key(trade: dict[str, Any]) -> str: + fields = ("transactionHash", "asset", "proxyWallet", "side", "size", "price", "timestamp") + return hashlib.sha1( + "|".join(str(trade.get(field)) for field in fields).encode(), usedforsecurity=False + ).hexdigest() + + def iter_trades( + self, + condition_id: str, + start: TimestampArg = None, + end: TimestampArg = None, + page_size: int = TRADES_PAGE_SIZE, + max_pages: int | None = None, + ) -> Iterator[dict[str, Any]]: + """Iterate over the taker trades of one market, newest first. + + ``GET https://data-api.polymarket.com/trades`` returns trades newest first, takes + inclusive ``start``/``end`` bounds in Unix seconds, serves up to 10000 trades per + request and rejects ``offset`` above 10000. To reach older trades this method pages + by offset until the cap, then moves ``end`` to the oldest second seen and starts + again at offset 0, dropping the trades of that boundary second it already yielded. + Records carry no trade id; a trade is identified by its transaction hash, token, + wallet, side, size, price and time, so two otherwise identical fills in one + transaction are yielded once. + + Args: + condition_id: Market condition id (``ticker`` of ``fetch_markets``). + start: Keep trades at or after this time (Unix seconds, ISO string, date or + datetime; naive is UTC). + end: Keep trades at or before this time. + page_size: Trades per request (1-10000). + max_pages: Optional cap on the number of requests, for sampling. + + Yields: + Raw data-api trade dictionaries. + + Raises: + DataValidationError: The server repeated a page (offset ignored) or one second + holds more trades than one offset window can reach. + """ + limit = max(1, min(int(page_size), self.TRADES_MAX_LIMIT)) + start_seconds = to_unix_seconds(start) + window_end = to_unix_seconds(end) + resource = f"trades for {condition_id}" + boundary_keys: set[str] = set() + pages = 0 + while True: + offset = 0 + window_keys = set(boundary_keys) + oldest: int | None = None + oldest_keys: set[str] = set() + previous_page: list[str] | None = None + while True: + params: dict[str, Any] = {"market": condition_id, "limit": limit, "offset": offset} + if start_seconds is not None: + params["start"] = start_seconds + if window_end is not None: + params["end"] = window_end + response = self._request( + f"{self.DATA_API_URL}/trades", resource=resource, params=params + ) + try: + trades = response.json() + except ValueError as err: + raise DataValidationError( + provider="polymarket", message=f"Malformed JSON response for {resource}" + ) from err + if not isinstance(trades, list): + raise DataValidationError( + provider="polymarket", + message=f"Expected a JSON list for {resource}, got {type(trades).__name__}", + ) + pages += 1 + page_keys: list[str] = [] + for trade in trades: + if not isinstance(trade, dict) or isinstance(trade.get("timestamp"), bool): + raise DataValidationError( + provider="polymarket", + message=f"Malformed trade entry for {resource}: {trade!r}", + field="trades", + ) + try: + seconds = int(trade["timestamp"]) + except (KeyError, TypeError, ValueError) as err: + raise DataValidationError( + provider="polymarket", + message=f"Trade without a timestamp for {resource}: {trade!r}", + field="timestamp", + ) from err + key = self._trade_key(trade) + page_keys.append(key) + if oldest is None or seconds < oldest: + oldest, oldest_keys = seconds, {key} + elif seconds == oldest: + oldest_keys.add(key) + if key in window_keys: + continue + window_keys.add(key) + if start_seconds is not None and seconds < start_seconds: + continue + if window_end is not None and seconds > window_end: + continue + yield trade + if len(trades) < limit or (max_pages is not None and pages >= max_pages): + return + if offset > 0 and page_keys == previous_page: + raise DataValidationError( + provider="polymarket", + message=f"Trade page repeated for {resource}; the server ignored offset", + field="offset", + value=offset, + ) + previous_page = page_keys + offset += len(trades) + if offset > self.TRADES_MAX_OFFSET: + break + if oldest is None or (window_end is not None and oldest >= window_end): + raise DataValidationError( + provider="polymarket", + message=( + f"More than {self.TRADES_MAX_OFFSET + limit} trades at second " + f"{window_end} for {resource}; cannot page past it" + ), + field="end", + value=window_end, + ) + window_end = oldest + boundary_keys = oldest_keys + + def fetch_trades( + self, + condition_id: str, + start: TimestampArg = None, + end: TimestampArg = None, + page_size: int = TRADES_PAGE_SIZE, + max_pages: int | None = None, + ) -> pl.DataFrame: + """Return the taker trades of one market, sorted by time. + + Accepts the arguments of ``iter_trades``. The data API's default ``takerOnly=true`` + view is used: one record per taker fill, without the matching maker records. + + Returns: + DataFrame with ``POLYMARKET_TRADE_SCHEMA`` columns: ``trade_id`` (hash of the + identifying fields), ``ticker`` (condition id), ``timestamp`` (UTC, whole + seconds), ``price`` (YES price: the traded token's price, or one minus it for + the second outcome), ``count`` (shares), ``taker_side`` (``"yes"`` when the + taker bought the first outcome or sold the second, else ``"no"``), + ``is_block_trade`` (always False), ``outcome`` (traded outcome label), + ``outcome_index`` (0 or 1), ``side`` (taker ``BUY``/``SELL`` of that outcome), + ``outcome_price`` (price of the traded token), ``asset`` (token id), + ``proxy_wallet`` (taker wallet) and ``transaction_hash``. + + Example: + >>> provider = PolymarketProvider() + >>> trades = provider.fetch_trades( + ... "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + ... start="2025-12-31", + ... ) + """ + rows = [ + self._normalize_trade(trade, condition_id) + for trade in self.iter_trades( + condition_id, start=start, end=end, page_size=page_size, max_pages=max_pages + ) + ] + if not rows: + return empty_frame(POLYMARKET_TRADE_SCHEMA) + return pl.DataFrame(rows, schema=POLYMARKET_TRADE_SCHEMA, orient="row").sort( + ["timestamp", "trade_id"] + ) + + def _normalize_trade(self, trade: dict[str, Any], condition_id: str) -> tuple[Any, ...]: + try: + outcome_index = trade["outcomeIndex"] + outcome_price = float(trade["price"]) + size = float(trade["size"]) + side = str(trade["side"]).upper() + timestamp = datetime.fromtimestamp(int(trade["timestamp"]), UTC) + if isinstance(outcome_index, bool) or outcome_index not in (0, 1): + raise ValueError(f"outcomeIndex {outcome_index!r} is not 0 or 1") + if side not in ("BUY", "SELL"): + raise ValueError(f"side {side!r} is not BUY or SELL") + except (KeyError, TypeError, ValueError) as err: + raise DataValidationError( + provider="polymarket", + message=f"Malformed trade record for {condition_id}: {err}", + field="trades", + ) from err + first_outcome = outcome_index == 0 + yes_price = outcome_price if first_outcome else round(1.0 - outcome_price, 10) + taker_yes = first_outcome == (side == "BUY") + return ( + self._trade_key(trade), + trade.get("conditionId") or condition_id, + timestamp, + yes_price, + size, + RESULT_YES if taker_yes else RESULT_NO, + False, + trade.get("outcome"), + int(outcome_index), + side, + outcome_price, + None if trade.get("asset") is None else str(trade["asset"]), + trade.get("proxyWallet"), + trade.get("transactionHash"), + ) + def close(self) -> None: """Close HTTP client and clear caches.""" self._market_cache.clear() diff --git a/src/ml4t/data/providers/polymarket_us.py b/src/ml4t/data/providers/polymarket_us.py new file mode 100644 index 00000000..0bbccbc7 --- /dev/null +++ b/src/ml4t/data/providers/polymarket_us.py @@ -0,0 +1,431 @@ +"""Polymarket US (QCX LLC) public market data provider. + +Polymarket US is the CFTC-regulated exchange (DCM and DCO) operated by QCX LLC d/b/a Polymarket +US. It is a separate venue from the global, crypto-settled Polymarket served by +``PolymarketProvider``: separate markets, order books and identifiers (market slugs). + +The public gateway ``https://gateway.polymarket.us`` needs no API key: + +- ``GET /v1/markets``: offset-paginated market list with status, ``outcomes`` and final + ``outcomePrices`` (page size capped at 500 by the server). +- ``GET /v1/markets/{slug}/settlement``: settlement price of a resolved market. +- ``GET /v1/price-history``: book-derived YES (``longPrice``) and NO (``shortPrice``) display + prices, 20 requests/second/IP. These are not trades; no public trade history exists. + +Example: + >>> from ml4t.data.providers.polymarket_us import PolymarketUSProvider + >>> provider = PolymarketUSProvider() + >>> resolved = provider.fetch_markets(closed=True, categories=["macro"]) + >>> history = provider.fetch_price_history(resolved["ticker"][0]) + >>> provider.close() +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator, Sequence +from datetime import UTC, date, datetime, timedelta +from typing import Any, ClassVar + +import polars as pl +import structlog + +from ml4t.data.core.exceptions import DataValidationError, SymbolNotFoundError +from ml4t.data.providers.base import BaseProvider +from ml4t.data.providers.prediction_markets import ( + RESOLUTION_SCHEMA, + UTC_DATETIME, + empty_frame, + parse_utc_timestamp, + result_from_payout, + to_unix_seconds, +) + +logger = structlog.get_logger() + +TimestampArg = int | float | str | date | datetime | None + +RESOLVED_STATUS = "MARKET_STATUS_RESOLVED" + +MARKET_SCHEMA: dict[str, pl.DataType] = { + "ticker": pl.Utf8(), + "market_id": pl.Utf8(), + "title": pl.Utf8(), + "question": pl.Utf8(), + "category": pl.Utf8(), + "market_type": pl.Utf8(), + "status": pl.Utf8(), + "start_date": UTC_DATETIME, + "end_date": UTC_DATETIME, + **RESOLUTION_SCHEMA, + "yes_outcome": pl.Utf8(), + "winning_outcome": pl.Utf8(), + "outcomes": pl.List(pl.Utf8()), + "outcome_prices": pl.List(pl.Float64()), + "fee_coefficient": pl.Float64(), +} + +PRICE_HISTORY_SCHEMA: dict[str, pl.DataType] = { + "timestamp": UTC_DATETIME, + "ticker": pl.Utf8(), + "long_price": pl.Float64(), + "short_price": pl.Float64(), +} + + +class PolymarketUSProvider(BaseProvider): + """Polymarket US markets, resolution outcomes and price history from the public gateway.""" + + DEFAULT_RATE_LIMIT: ClassVar[tuple[int, float]] = (20, 1.0) + BASE_URL: ClassVar[str] = "https://gateway.polymarket.us" + MAX_PAGE_SIZE: ClassVar[int] = 500 + MAX_WINDOW: ClassVar[timedelta] = timedelta(hours=24) + + def __init__(self, rate_limit: tuple[int, float] | None = None): + """Initialize the provider. + + Args: + rate_limit: Optional (calls, period_seconds) override. + """ + super().__init__(rate_limit=rate_limit or self.DEFAULT_RATE_LIMIT) + + @property + def name(self) -> str: + """Return provider name.""" + return "polymarket_us" + + def iter_markets( + self, + closed: bool | None = None, + active: bool | None = None, + categories: Sequence[str] | None = None, + slugs: Sequence[str] | None = None, + end_date_min: TimestampArg = None, + end_date_max: TimestampArg = None, + page_size: int = MAX_PAGE_SIZE, + max_pages: int | None = None, + ) -> Iterator[dict[str, Any]]: + """Iterate over every market matching the filters (offset pagination). + + Args: + closed: Filter on the ``closed`` flag (True for finished markets). + active: Filter on the ``active`` flag. + categories: API category values (``"sports"``, ``"macro"``, ``"politics"``, ...). + slugs: Restrict to these market slugs. + end_date_min: Keep markets ending at or after this time. + end_date_max: Keep markets ending at or before this time. + page_size: Markets per request (1-500). + max_pages: Optional cap on pages, for sampling. + + Yields: + Raw market dictionaries from ``GET /v1/markets``. + """ + limit = max(1, min(int(page_size), self.MAX_PAGE_SIZE)) + params: dict[str, Any] = {"limit": limit} + if closed is not None: + params["closed"] = str(closed).lower() + if active is not None: + params["active"] = str(active).lower() + if categories: + params["categories"] = list(categories) + if slugs: + params["slug"] = list(slugs) + for key, value in (("endDateMin", end_date_min), ("endDateMax", end_date_max)): + seconds = to_unix_seconds(value) + if seconds is not None: + params[key] = datetime.fromtimestamp(seconds, UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + offset = 0 + pages = 0 + while True: + payload = self._request_json( + f"{self.BASE_URL}/v1/markets", + resource="markets", + params={**params, "offset": offset}, + ) + markets = payload.get("markets") + if not isinstance(markets, list): + raise DataValidationError( + provider=self.name, + message="Markets response has no 'markets' list", + field="markets", + ) + if not markets: + return + for market in markets: + if not isinstance(market, dict): + raise DataValidationError( + provider=self.name, + message="Non-object entry in 'markets'", + field="markets", + ) + yield market + pages += 1 + # Advance by what the server returned: it caps the page size silently. + offset += len(markets) + if max_pages is not None and pages >= max_pages: + return + + def fetch_markets( + self, + closed: bool | None = None, + active: bool | None = None, + categories: Sequence[str] | None = None, + slugs: Sequence[str] | None = None, + end_date_min: TimestampArg = None, + end_date_max: TimestampArg = None, + page_size: int = MAX_PAGE_SIZE, + max_pages: int | None = None, + ) -> pl.DataFrame: + """Return every matching market with its normalized resolution outcome. + + Accepts the same filters as ``iter_markets``. + + Every market is one instrument: the long market side is the YES contract and the + short side the NO contract. For a market with status ``MARKET_STATUS_RESOLVED`` the + final long-side price in ``marketSides`` is the payout per YES contract. The + ``outcomes`` labels are not always listed in the order of ``outcomePrices`` (e.g. + ``["No","Yes"]`` with long-first prices), so they are never zipped to infer a winner. + + Returns: + DataFrame with ``MARKET_SCHEMA`` columns, including ``ticker`` (slug), ``status``, + ``result`` (``"yes"``/``"no"``, ``"other"`` for a split payout such as 50-50 on a + cancelled game, null until resolved), ``settlement_value`` (YES payout), + ``settlement_ts`` (always null: the gateway publishes no settlement time), + ``yes_outcome`` (name of the long side, e.g. a team) and ``winning_outcome``. + """ + rows = [ + self._normalize_market(market) + for market in self.iter_markets( + closed=closed, + active=active, + categories=categories, + slugs=slugs, + end_date_min=end_date_min, + end_date_max=end_date_max, + page_size=page_size, + max_pages=max_pages, + ) + ] + if not rows: + return empty_frame(MARKET_SCHEMA) + return pl.DataFrame(rows, schema=MARKET_SCHEMA, orient="row") + + @staticmethod + def _json_list(value: Any, field: str) -> list[Any]: + """Decode a field that the gateway serializes as a JSON-encoded list.""" + if value is None or value == "": + return [] + if isinstance(value, str): + value = json.loads(value) + if not isinstance(value, list): + raise ValueError(f"{field} is not a list") + return value + + def _normalize_market(self, market: dict[str, Any]) -> tuple[Any, ...]: + slug = market.get("slug") + if not slug: + raise DataValidationError( + provider=self.name, message="Market entry without a slug", field="slug" + ) + try: + outcomes = [str(item) for item in self._json_list(market.get("outcomes"), "outcomes")] + prices = [ + float(item) + for item in self._json_list(market.get("outcomePrices"), "outcomePrices") + ] + sides = [side for side in market.get("marketSides") or [] if isinstance(side, dict)] + long_side = next((side for side in sides if side.get("long") is True), None) + short_side = next((side for side in sides if side.get("long") is False), None) + yes_outcome = ( + str(long_side["description"]) + if long_side and long_side.get("description") + else (outcomes[0] if outcomes else None) + ) + status = market.get("status") + payout: float | None = None + if status == RESOLVED_STATUS: + # outcomePrices is long-side first, but the outcomes labels are not always in + # the same order, so the payout comes from the long market side when present. + if long_side is not None and long_side.get("price") not in (None, ""): + payout = float(long_side["price"]) + elif prices: + payout = prices[0] + else: + raise ValueError("resolved market without final prices") + result = result_from_payout(payout) + winning_outcome = None + if result == "yes": + winning_outcome = yes_outcome + elif result == "no" and short_side is not None and short_side.get("description"): + winning_outcome = str(short_side["description"]) + fee = market.get("feeCoefficient") + row = ( + str(slug), + None if market.get("id") is None else str(market.get("id")), + market.get("title"), + market.get("question"), + market.get("category"), + market.get("marketType"), + status, + parse_utc_timestamp(market.get("startDate")), + parse_utc_timestamp(market.get("endDate")), + result, + payout, + None, + yes_outcome, + winning_outcome, + outcomes, + prices, + None if fee is None else float(fee), + ) + except (TypeError, ValueError) as err: + raise DataValidationError( + provider=self.name, + message=f"Malformed market record for {slug}: {err}", + field="markets", + value=slug, + ) from err + return row + + def get_settlement(self, slug: str) -> float | None: + """Return the settlement price of a market from ``/v1/markets/{slug}/settlement``. + + Returns: + The settlement price (the YES payout), or None when the gateway answers 404, + which it does for both unknown and not-yet-settled markets. + """ + try: + payload = self._request_json( + f"{self.BASE_URL}/v1/markets/{slug}/settlement", + resource=f"settlement for {slug}", + ) + except SymbolNotFoundError: + return None + value = payload.get("settlement") + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError) as err: + raise DataValidationError( + provider=self.name, + message=f"Non-numeric settlement for {slug}: {value!r}", + field="settlement", + value=value, + ) from err + + def fetch_price_history( + self, + slug: str, + start: TimestampArg = None, + end: TimestampArg = None, + ) -> pl.DataFrame: + """Return the book-derived YES/NO display-price history of one market. + + Without ``start`` and ``end`` the full history is requested + (``fixedInterval=INTERVAL_ALL``, three-hourly points for newer markets and daily + points for longer ones). With both, the range is fetched in 24-hour windows at + one-minute fidelity, the gateway's limit for custom ranges. + + Args: + slug: Market slug. + start: Range start (Unix seconds, ISO string, date or datetime; naive is UTC). + end: Range end. + + Returns: + DataFrame with ``PRICE_HISTORY_SCHEMA`` columns: ``timestamp`` (UTC), ``ticker``, + ``long_price`` (YES display price, from the best ask) and ``short_price`` (NO + display price, one minus the best bid). These are quotes, not trades. + + Raises: + DataValidationError: Only one of ``start`` and ``end`` is given, or end < start. + """ + start_seconds = to_unix_seconds(start) + end_seconds = to_unix_seconds(end) + url = f"{self.BASE_URL}/v1/price-history" + resource = f"price history for {slug}" + points: list[dict[str, Any]] = [] + if start_seconds is None and end_seconds is None: + payload = self._request_json( + url, + resource=resource, + params={"symbol": slug, "fixedInterval": "INTERVAL_ALL", "fidelity": 180}, + ) + points.extend(self._history_points(payload, slug)) + elif start_seconds is None or end_seconds is None or end_seconds < start_seconds: + raise DataValidationError( + provider=self.name, + message="fetch_price_history needs both start and end, with end >= start", + field="start", + ) + else: + window = int(self.MAX_WINDOW.total_seconds()) + window_start = start_seconds + while window_start < end_seconds: + window_end = min(window_start + window, end_seconds) + payload = self._request_json( + url, + resource=resource, + params={ + "symbol": slug, + "timestamp.startTimestamp": window_start, + "timestamp.endTimestamp": window_end, + "fidelity": 1, + }, + ) + points.extend(self._history_points(payload, slug)) + window_start = window_end + + if not points: + return empty_frame(PRICE_HISTORY_SCHEMA) + frame = pl.DataFrame( + { + "timestamp": [point["timestamp"] for point in points], + "ticker": [slug] * len(points), + "long_price": [point["long_price"] for point in points], + "short_price": [point["short_price"] for point in points], + }, + schema=PRICE_HISTORY_SCHEMA, + ) + return frame.unique(subset=["timestamp"], keep="first").sort("timestamp") + + def _history_points(self, payload: dict[str, Any], slug: str) -> list[dict[str, Any]]: + history = payload.get("history") + if history is None: + return [] + if not isinstance(history, list): + raise DataValidationError( + provider=self.name, + message=f"Price history for {slug} is not a list", + field="history", + ) + points: list[dict[str, Any]] = [] + for point in history: + try: + points.append( + { + "timestamp": datetime.fromtimestamp(int(point["timestamp"]), UTC), + "long_price": _optional_float(point.get("longPrice")), + "short_price": _optional_float(point.get("shortPrice")), + } + ) + except (KeyError, TypeError, ValueError) as err: + raise DataValidationError( + provider=self.name, + message=f"Malformed price-history point for {slug}: {point!r}", + field="history", + ) from err + return points + + def close(self) -> None: + """Close HTTP client.""" + if hasattr(self, "session"): + self.session.close() + self._log_close_event("Closed Polymarket US client") + + +def _optional_float(value: Any) -> float | None: + if value is None or value == "": + return None + return float(value) diff --git a/src/ml4t/data/providers/prediction_markets.py b/src/ml4t/data/providers/prediction_markets.py new file mode 100644 index 00000000..ca7f45fb --- /dev/null +++ b/src/ml4t/data/providers/prediction_markets.py @@ -0,0 +1,136 @@ +"""Shared normalization for prediction-market providers. + +Kalshi, ForecastEx and Polymarket US publish market outcomes and trades under different field +names and encodings. The helpers here give their adapters one resolution vocabulary and one +trade schema, so research code can stack markets from several venues. + +Normalized resolution columns: + +- ``result``: ``"yes"`` or ``"no"`` for a binary market resolved to that side, ``"void"`` for a + market cancelled without a winning side, ``"other"`` for a resolved market whose payout is + neither 1 nor 0 (scalar or split settlement), and null while the market is unresolved. +- ``settlement_value``: payout of one YES contract in dollars (1.0, 0.0 or a fraction). +- ``settlement_ts``: UTC time the venue settled the market, when the venue publishes it. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from typing import Any + +import polars as pl + +RESULT_YES = "yes" +RESULT_NO = "no" +RESULT_VOID = "void" +RESULT_OTHER = "other" +RESULT_VALUES = frozenset({RESULT_YES, RESULT_NO, RESULT_VOID, RESULT_OTHER}) + +UTC_DATETIME = pl.Datetime("us", "UTC") + +RESOLUTION_SCHEMA: dict[str, pl.DataType] = { + "result": pl.Utf8(), + "settlement_value": pl.Float64(), + "settlement_ts": UTC_DATETIME, +} + +TRADE_SCHEMA: dict[str, pl.DataType] = { + "trade_id": pl.Utf8(), + "ticker": pl.Utf8(), + "timestamp": UTC_DATETIME, + "price": pl.Float64(), + "count": pl.Float64(), + "taker_side": pl.Utf8(), + "is_block_trade": pl.Boolean(), +} + +_RAW_RESULT_MAP = { + "yes": RESULT_YES, + "no": RESULT_NO, + "void": RESULT_VOID, + "voided": RESULT_VOID, + "cancelled": RESULT_VOID, + "canceled": RESULT_VOID, + "scalar": RESULT_OTHER, +} + + +def result_from_label(raw: Any) -> str | None: + """Map a venue's result label to the normalized vocabulary. + + Args: + raw: Venue label such as ``"yes"``, ``"no"``, ``"scalar"`` or ``""``. + + Returns: + A value in ``RESULT_VALUES``, or None for an empty or missing label (unresolved). + An unrecognized non-empty label maps to ``"other"`` so a resolved market is never + reported as unresolved. + """ + if raw is None: + return None + label = str(raw).strip().lower() + if not label: + return None + return _RAW_RESULT_MAP.get(label, RESULT_OTHER) + + +def result_from_payout(payout: float | None) -> str | None: + """Map the final payout of one YES contract to the normalized vocabulary. + + Args: + payout: Dollars paid per YES contract at settlement, or None if not settled. + + Returns: + ``"yes"`` for 1.0, ``"no"`` for 0.0, ``"other"`` for any other payout, None for None. + """ + if payout is None: + return None + if abs(payout - 1.0) < 1e-9: + return RESULT_YES + if abs(payout) < 1e-9: + return RESULT_NO + return RESULT_OTHER + + +def parse_utc_timestamp(value: Any) -> datetime | None: + """Parse an ISO-8601 timestamp (``Z`` or offset suffix) into an aware UTC datetime. + + Returns None for None or an empty string. + + Raises: + ValueError: The value is not an ISO-8601 timestamp. + """ + if value is None or value == "": + return None + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def to_unix_seconds(value: int | float | str | date | datetime | None) -> int | None: + """Convert a timestamp argument to Unix seconds. + + Accepts Unix seconds, an ISO date or datetime string, or a date/datetime object. Naive + datetimes and dates are interpreted as UTC. + """ + if value is None: + return None + if isinstance(value, bool): + raise TypeError("Timestamp must not be a boolean") + if isinstance(value, int | float): + return int(value) + if isinstance(value, datetime): + moment = value + elif isinstance(value, date): + moment = datetime(value.year, value.month, value.day) + else: + moment = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + if moment.tzinfo is None: + moment = moment.replace(tzinfo=UTC) + return int(moment.timestamp()) + + +def empty_frame(schema: dict[str, pl.DataType]) -> pl.DataFrame: + """Return an empty DataFrame with the given schema.""" + return pl.DataFrame(schema=schema) diff --git a/src/ml4t/data/providers/registry.py b/src/ml4t/data/providers/registry.py index 6589550e..4fe4e1d9 100644 --- a/src/ml4t/data/providers/registry.py +++ b/src/ml4t/data/providers/registry.py @@ -224,6 +224,22 @@ def _spec( "ohlcv", "events", ), + _spec( + "polymarket_us", + "polymarket_us", + "PolymarketUSProvider", + "Polymarket US regulated prediction markets", + "events", + manager_compatible=False, + ), + _spec( + "forecastex", + "forecastex", + "ForecastExProvider", + "ForecastEx event contracts", + "events", + manager_compatible=False, + ), _spec( "coingecko", "coingecko", diff --git a/tests/integration/test_forecastex.py b/tests/integration/test_forecastex.py new file mode 100644 index 00000000..342a207c --- /dev/null +++ b/tests/integration/test_forecastex.py @@ -0,0 +1,34 @@ +"""Live smoke test for the ForecastEx daily files (public S3 bucket, no credentials). + +API calls: four CSV downloads. +""" + +import polars as pl +import pytest + +from ml4t.data.providers.forecastex import ForecastExProvider + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def provider(): + provider = ForecastExProvider() + yield provider + provider.close() + + +def test_resolved_fed_decision_contracts(provider): + markets = provider.fetch_markets("2026-09-15", "2026-09-16", product="FFDEC") + + resolved = markets.filter(pl.col("status") == "resolved") + assert not resolved.is_empty() + by_ticker = {row["ticker"]: row for row in resolved.iter_rows(named=True)} + # FOMC 2026-09-16 raised rates by 25 bp. + assert by_ticker["FFDEC_091626_E25"]["result"] == "yes" + assert by_ticker["FFDEC_091626_E0"]["result"] == "no" + + trades = provider.fetch_trades("2026-09-16", "2026-09-16", product="FFDEC") + assert not trades.is_empty() + print(resolved.select("ticker", "result", "settlement_value", "volume")) + print(f"{len(trades)} FFDEC pairs on 2026-09-16") diff --git a/tests/integration/test_kalshi.py b/tests/integration/test_kalshi.py index d82e68e0..ecefbb1a 100644 --- a/tests/integration/test_kalshi.py +++ b/tests/integration/test_kalshi.py @@ -442,6 +442,72 @@ def test_multiple_requests(self): print(f"✅ 3 requests completed in {elapsed:.2f}s") +class TestKalshiResolvedHistory: + """Live smoke checks for resolved-market enumeration and trades across both tiers. + + API calls: about 8 (one series listing per tier, cutoff, two trade pages). + """ + + def test_resolved_markets_with_outcomes_from_live_and_archive(self, provider): + markets = provider.fetch_markets(status="settled", series_ticker="KXFEDDECISION") + + resolved = markets.filter(pl.col("result").is_in(["yes", "no"])) + assert not resolved.is_empty() + assert set(resolved["source"].to_list()) == {"live", "historical"} + assert resolved["settlement_value"].is_in([0.0, 1.0]).all() + assert resolved["settlement_ts"].null_count() == 0 + print( + resolved.select("ticker", "result", "settlement_value", "settlement_ts", "source") + .sort("settlement_ts") + .tail(5) + ) + + def test_hourly_candles_for_archived_and_live_markets(self, provider): + markets = provider.fetch_markets(status="settled", series_ticker="KXFEDDECISION") + # Markets that resolved yes trade well above a cent near their close. + traded = markets.filter((pl.col("volume") > 0) & (pl.col("result") == "yes")) + traded = traded.sort("close_time") + for source in ("historical", "live"): + tier_markets = traded.filter(pl.col("source") == source) + if source == "live" and tier_markets.is_empty(): + pytest.skip("No settled KXFEDDECISION market is left in the live tier") + market = tier_markets.row(-1, named=True) + close = market["close_time"] + candles = provider.fetch_candles( + market["ticker"], + close.timestamp() - 3 * 86_400, + close.timestamp(), + period="1h", + series_ticker=market["series"], + ) + assert not candles.is_empty() + assert set(candles["source"].to_list()) == {source} + prices = candles.drop_nulls("close")["close"] + assert prices.len() > 0 + assert prices.is_between(0.0, 1.0).all() + assert prices.max() > 0.05 # dollars, not cents scaled down 100x + print(f"{source}: {market['ticker']} {len(candles)} hourly candles") + + def test_resolved_combo_markets_are_reachable(self, provider): + combos = provider.fetch_markets(status="settled", mve_filter="only", max_pages=1) + + assert not combos.is_empty() + assert combos["ticker"].str.starts_with("KXMVE").all() + assert combos["result"].is_in(["yes", "no"]).any() + + def test_archived_market_outcome_and_trades(self, provider): + markets = provider.fetch_markets(event_ticker="FED-23DEC") + row = markets.filter(pl.col("ticker") == "FED-23DEC-T5.25").row(0, named=True) + assert row["result"] == "yes" + assert row["source"] == "historical" + + trades = provider.fetch_trades("FED-22DEC-T4.00", max_pages=2) + assert not trades.is_empty() + assert trades["price"].is_between(0.0, 1.0).all() + assert set(trades["source"].to_list()) == {"historical"} + print(f"FED-23DEC-T5.25 result={row['result']}; {len(trades)} archived trades") + + # Test Summary: # ============== # Total API calls: ~12 calls diff --git a/tests/integration/test_polymarket.py b/tests/integration/test_polymarket.py index c62416bf..10085d0e 100644 --- a/tests/integration/test_polymarket.py +++ b/tests/integration/test_polymarket.py @@ -484,6 +484,63 @@ def test_multiple_requests(self): print(f"✅ 3 requests completed in {elapsed:.2f}s") +class TestPolymarketResolvedHistory: + """Live smoke checks for resolved-market listing, windowed price history and trades. + + Uses "Russia x Ukraine ceasefire in 2025?" (resolved No on 2026-01-01). + API calls: about 8 (one keyset page, two price windows, a few trade pages). + """ + + CONDITION_ID = "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791" + + def _ceasefire(self, provider) -> dict: + markets = provider.fetch_markets( + min_volume=50_000_000, + end_date_min="2025-12-31", + end_date_max="2025-12-31T23:59:59Z", + max_pages=1, + ) + assert not markets.is_empty() + assert (markets["volume"] >= 50_000_000).all() + assert markets["result"].is_in(["yes", "no", "void"]).all() + return markets.filter(pl.col("ticker") == self.CONDITION_ID).row(0, named=True) + + def test_resolved_markets_with_outcomes(self, provider): + market = self._ceasefire(provider) + + assert market["result"] == "no" + assert market["settlement_value"] == 0.0 + assert market["settlement_ts"] is not None + assert market["yes_outcome"] == "Yes" + assert market["yes_token_id"] and market["no_token_id"] + assert market["tags"] + print({key: market[key] for key in ("question", "result", "settlement_ts", "tags")}) + + def test_hourly_prices_before_resolution_span_windows(self, provider): + market = self._ceasefire(provider) + end = market["settlement_ts"] + + candles = provider.fetch_candles( + market["yes_token_id"], end - timedelta(days=20), end, fidelity_minutes=60 + ) + + assert candles.height > 15 * 24 # more than one 15-day window of hourly samples + assert candles["price"].is_between(0.0, 1.0).all() + assert candles["timestamp"].is_sorted() + print(f"{candles.height} hourly prices, last {candles['price'][-1]}") + + def test_trades_near_resolution(self, provider): + trades = provider.fetch_trades( + self.CONDITION_ID, start="2025-12-30", end="2025-12-31", page_size=500, max_pages=3 + ) + + assert not trades.is_empty() + assert trades["price"].is_between(0.0, 1.0).all() + assert trades["taker_side"].is_in(["yes", "no"]).all() + assert trades["trade_id"].n_unique() == trades.height + print(f"{trades.height} trades, YES price {trades['price'].min()}-{trades['price'].max()}") + + # Test Summary: # ============== # Total API calls: ~15-20 calls diff --git a/tests/integration/test_polymarket_us.py b/tests/integration/test_polymarket_us.py new file mode 100644 index 00000000..01c26a0a --- /dev/null +++ b/tests/integration/test_polymarket_us.py @@ -0,0 +1,35 @@ +"""Live smoke test for the Polymarket US public gateway (no credentials). + +API calls: one or two market pages, one settlement, one price history. +""" + +import polars as pl +import pytest + +from ml4t.data.providers.polymarket_us import PolymarketUSProvider + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def provider(): + provider = PolymarketUSProvider() + yield provider + provider.close() + + +def test_resolved_macro_markets_with_outcomes(provider): + markets = provider.fetch_markets(closed=True, categories=["macro"], max_pages=1) + + resolved = markets.filter(pl.col("result").is_in(["yes", "no"])) + assert not resolved.is_empty() + + # FOMC 2026-04-29 held rates; the gateway lists this market's outcome labels reversed. + held = provider.fetch_markets(slugs=["rdc-usfed-fomc-2026-04-29-maintains"]) + assert held["result"].to_list() == ["yes"] + assert provider.get_settlement("rdc-usfed-fomc-2026-04-29-maintains") == 1.0 + + history = provider.fetch_price_history(resolved["ticker"][0]) + assert not history.is_empty() + print(resolved.select("ticker", "result", "settlement_value", "winning_outcome").head(5)) + print(f"{len(resolved)} resolved macro markets; {len(history)} price points") diff --git a/tests/providers/conftest.py b/tests/providers/conftest.py new file mode 100644 index 00000000..b8883242 --- /dev/null +++ b/tests/providers/conftest.py @@ -0,0 +1,43 @@ +"""Shared fixtures for offline provider adapter tests.""" + +from __future__ import annotations + +from collections.abc import Callable +from unittest.mock import MagicMock + +import httpx +import pytest + +from ml4t.data.providers.base import BaseProvider + +Handler = Callable[[httpx.Request], httpx.Response] + + +@pytest.fixture +def retry_sleeps(monkeypatch) -> MagicMock: + """Replace the shared request retry sleep so retry-path tests never wait.""" + sleep = MagicMock() + monkeypatch.setattr(BaseProvider._request.retry, "sleep", sleep) + return sleep + + +@pytest.fixture +def use_transport(retry_sleeps) -> Callable[[BaseProvider, Handler], list[httpx.Request]]: + """Route a provider's HTTP session through an in-process handler. + + Returns a function that installs ``handler`` on the provider and returns the list that + records every request the provider sends. + """ + + def install(provider: BaseProvider, handler: Handler) -> list[httpx.Request]: + requests: list[httpx.Request] = [] + + def recording(request: httpx.Request) -> httpx.Response: + requests.append(request) + return handler(request) + + provider.session.close() + provider.session = httpx.Client(transport=httpx.MockTransport(recording)) + return requests + + return install diff --git a/tests/providers/fixtures/polymarket/clob_prices_history.json b/tests/providers/fixtures/polymarket/clob_prices_history.json new file mode 100644 index 00000000..483cb625 --- /dev/null +++ b/tests/providers/fixtures/polymarket/clob_prices_history.json @@ -0,0 +1,106 @@ +{ + "_recorded": "2026-10-08 GET https://clob.polymarket.com/prices-history?market=&startTs=1748736000&endTs=1748822400&fidelity=60", + "response": { + "history": [ + { + "t": 1748736007, + "p": 0.405 + }, + { + "t": 1748739607, + "p": 0.405 + }, + { + "t": 1748743206, + "p": 0.405 + }, + { + "t": 1748746807, + "p": 0.405 + }, + { + "t": 1748750407, + "p": 0.405 + }, + { + "t": 1748754006, + "p": 0.4 + }, + { + "t": 1748757607, + "p": 0.4 + }, + { + "t": 1748761207, + "p": 0.395 + }, + { + "t": 1748764807, + "p": 0.395 + }, + { + "t": 1748768407, + "p": 0.395 + }, + { + "t": 1748772006, + "p": 0.395 + }, + { + "t": 1748775606, + "p": 0.395 + }, + { + "t": 1748779207, + "p": 0.4 + }, + { + "t": 1748782806, + "p": 0.4 + }, + { + "t": 1748786407, + "p": 0.385 + }, + { + "t": 1748790007, + "p": 0.385 + }, + { + "t": 1748793607, + "p": 0.39 + }, + { + "t": 1748797207, + "p": 0.39 + }, + { + "t": 1748800806, + "p": 0.395 + }, + { + "t": 1748804406, + "p": 0.395 + }, + { + "t": 1748808007, + "p": 0.395 + }, + { + "t": 1748811607, + "p": 0.395 + }, + { + "t": 1748815206, + "p": 0.395 + }, + { + "t": 1748818806, + "p": 0.39 + } + ] + }, + "too_long_window_response": { + "error": "invalid filters: 'startTs' and 'endTs' interval is too long" + } +} diff --git a/tests/providers/fixtures/polymarket/data_api_trades.json b/tests/providers/fixtures/polymarket/data_api_trades.json new file mode 100644 index 00000000..2ed2cd83 --- /dev/null +++ b/tests/providers/fixtures/polymarket/data_api_trades.json @@ -0,0 +1,413 @@ +{ + "_recorded": "2026-10-08 GET https://data-api.polymarket.com/trades?market=&limit=50 (newest first; first 24 kept)", + "trades": [ + { + "proxyWallet": "0x3e426d5b3a5aa4bef22826b5b98de6497c798f95", + "side": "SELL", + "asset": "42351579455604873042957090441032286075880144797259231030267680078160718556927", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 0.02, + "price": 0.999, + "timestamp": 1767247829, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "No", + "outcomeIndex": 1, + "name": "", + "pseudonym": "", + "transactionHash": "0xf8811cc27d1541d419fbfa964411d3ec69af005d577f46b3a32a25b85728cae8" + }, + { + "proxyWallet": "0x678ab195936f3b7994e5c5e875d28e8c408e0722", + "side": "SELL", + "asset": "42351579455604873042957090441032286075880144797259231030267680078160718556927", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 38, + "price": 0.999, + "timestamp": 1767247565, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "No", + "outcomeIndex": 1, + "name": "JerryCheung11", + "pseudonym": "Funny-Bugle", + "transactionHash": "0x993c8ad2d7b217883a738f0124ac50a35409fedbdca8072f17855cb55d366a3e" + }, + { + "proxyWallet": "0xed26861aace9572ea8443a8df39b13e004b424fa", + "side": "SELL", + "asset": "42351579455604873042957090441032286075880144797259231030267680078160718556927", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 5, + "price": 0.999, + "timestamp": 1767247111, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "No", + "outcomeIndex": 1, + "name": "Zxzxzzzz", + "pseudonym": "Limited-Flood", + "transactionHash": "0xf2c8530f485402d3dd600ab7536b370272672c3f16260e30f0470bfba1dd19d4" + }, + { + "proxyWallet": "0x2274814cc20578a6dafa4503164e994ba5b18495", + "side": "SELL", + "asset": "42351579455604873042957090441032286075880144797259231030267680078160718556927", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 11.45, + "price": 0.999, + "timestamp": 1767247077, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "No", + "outcomeIndex": 1, + "name": "DluZ", + "pseudonym": "Zany-Napkin", + "transactionHash": "0x5bb8263d54c1895cda31144e17cbd22b7f5155b7d4db3a14b9bb031e338dc5de" + }, + { + "proxyWallet": "0x477dc1d2d01ae3c70134846ebb89635cae8e8f2d", + "side": "SELL", + "asset": "42351579455604873042957090441032286075880144797259231030267680078160718556927", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 3, + "price": 0.999, + "timestamp": 1767246443, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "No", + "outcomeIndex": 1, + "name": "", + "pseudonym": "", + "transactionHash": "0x795add6c60cdd67d924be558030394259c9160fa31df9c467fbea452b8493650" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245805, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0xb12bfb05b6511693e9a847877d626f14d456c9705409edf069e7bbab71689530" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245803, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0x184a325533fa0dc76bebf10d7d3148f0a0234c4830fa2c8c64c222f8d2ca38e8" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245803, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0xd5319dc869a54832612f21601e6ce77fb62a1361d9afbab49bc1985edf55db80" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245803, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0x7c2d5ec4897e1a694a104dfecf93a6bde6833220335ce9459b248be2462ee5b2" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245803, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0xc635f095bebbde5656513d7e2b1c849da8422667462a3c35b6b6d8c43486f0f3" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245803, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0xad6e2127205c17d7788f9f98dce2c2ff012c83f5624ecd6bdb8003abf109208c" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245803, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0x0c7aca534258a3bd514c22fc01b853d65b2d6af87a0b6391b699077271865ab0" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245803, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0x567a89a769ace383bd18a9cd59451828b1c7510e8fa9a14f5bfb60b8aea49a05" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245803, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0x30f55b880ec62fb4d507f26276de1e25a34b7b4a3778bc63e27729428a956269" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245801, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0xcd5f8844d52d589968a5dca29ccca0b1139a40f115e1f50839e55f52a336dc0b" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245801, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0x7f85f3751e936aa844b8e32c6aff5f340ff3e56a43796fdb105dc47ad6a68593" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245801, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0xa833f05a552bbd69ca622744cfa6657f7cb8e227f42db9f55b0f6c302521a69a" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245801, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0xf8fb39460cc64d8327908f492eaaa92aae655b6562e826ede92d088729ab85fe" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245801, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0x3df8307dbf7f7c6552dcd9eb521b03aedda5d342e2d32fcbc1a705383dd08493" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245801, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0x7a98a432171c98eb44804f1c01130d6c9fc3fc3103edb694420ee366f6a1f47c" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245801, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0x2f313b284d528034f3f4f7d089dd6b67d1ca8667ef2a4d12837884466fd193a6" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245799, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0xbc025284d49a29a55f372590db8c1590e572b932dcef0e50ea07b30b5f349d40" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245799, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0x5813bd1f6ae3618137fb95b341692aa7b93008e5f4ef0fe78e51a78ce85de338" + }, + { + "proxyWallet": "0x28d5bb9577b452cceee81c4dea8490a1ca344141", + "side": "BUY", + "asset": "15974786252393396629980467963784550802583781222733347534844974829144359265969", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "size": 100, + "price": 0.001, + "timestamp": 1767245799, + "title": "Russia x Ukraine ceasefire in 2025?", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "eventSlug": "russia-x-ukraine-ceasefire-in-2025", + "outcome": "Yes", + "outcomeIndex": 0, + "name": "GS13", + "pseudonym": "Ultimate-Solution", + "transactionHash": "0x763931d191a3808d35b9a65aecb4fb3484eae7a3e30d6a9c4b4f4a2e8c1235ca" + } + ] +} diff --git a/tests/providers/fixtures/polymarket/gamma_keyset_active.json b/tests/providers/fixtures/polymarket/gamma_keyset_active.json new file mode 100644 index 00000000..d2508f29 --- /dev/null +++ b/tests/providers/fixtures/polymarket/gamma_keyset_active.json @@ -0,0 +1,198 @@ +{ + "_recorded": "2026-10-08 GET /markets/keyset?closed=false&include_tag=true", + "pages": [ + { + "$schema": "https://gamma-api.polymarket.com/schemas/MarketsKeysetListResponse.json", + "markets": [ + { + "id": "559651", + "question": "Xi Jinping out before 2027?", + "conditionId": "0xa467b14d51f01b957109d9cbb1d6c124fab2a089d52ed8f471d23c2812e743b7", + "slug": "xi-jinping-out-before-2027", + "endDate": "2027-01-01T04:59:00Z", + "liquidity": "597015.87223", + "startDate": "2025-07-03T20:37:00.228Z", + "outcomes": "[\"Yes\", \"No\"]", + "outcomePrices": "[\"0.0265\", \"0.9735\"]", + "volume": "14467978.535614997", + "active": true, + "closed": false, + "createdAt": "2025-07-03T20:25:56.889606Z", + "updatedAt": "2026-10-08T19:15:02.586684Z", + "new": false, + "featured": false, + "archived": false, + "resolvedBy": "0x157Ce2d672854c848c9b79C49a8Cc6cc89176a49", + "restricted": true, + "groupItemTitle": "", + "groupItemThreshold": "0", + "questionID": "0x1d925c6933062c2e38031293612d8680ffa097c5d3ba2f87a8ecc565bd47183e", + "enableOrderBook": true, + "orderPriceMinTickSize": 0.001, + "orderMinSize": 5, + "volumeNum": 14467978.535614997, + "liquidityNum": 597015.87223, + "endDateIso": "2027-01-01", + "startDateIso": "2025-07-03", + "hasReviewedDates": true, + "volume24hr": 42625.55068400003, + "volume1wk": 202303.91317900023, + "volume1mo": 1383747.9125339987, + "volume1yr": 12864620.79898, + "clobTokenIds": "[\"32338220190071351435772801779725302244575775216413325951443816017994629993401\", \"25659310674993675562345759665114759892400026242514633218387667107987341231962\"]", + "positionIds": [ + "895257453734540292493143124621755605615887197499956109032745153687078305792", + "895257453734540292493143124621755605615887197499956109032745153687078305793" + ], + "comboStatus": "enabled", + "umaBond": "500", + "umaReward": "5", + "volume24hrClob": 42625.55068400003, + "volume1wkClob": 202303.91317900023, + "volume1moClob": 1383747.9125339987, + "volume1yrClob": 12864620.79898, + "volumeClob": 14467978.535614997, + "liquidityClob": 597015.87223, + "makerBaseFee": 1000, + "takerBaseFee": 1000, + "customLiveness": 0, + "acceptingOrders": true, + "negRisk": false, + "negRiskRequestID": "", + "events": [ + { + "id": "30828", + "ticker": "xi-jinping-out-before-2027", + "slug": "xi-jinping-out-before-2027", + "title": "Xi Jinping out before 2027?", + "startDate": "2025-07-03T20:37:00.228Z", + "creationDate": "2025-07-03T20:37:17.339044Z", + "endDate": "2027-01-01T04:59:00Z", + "active": true, + "closed": false, + "archived": false, + "new": false, + "featured": false, + "restricted": true, + "liquidity": 596808.08713, + "volume": 14467978.535614997, + "openInterest": 4083737.774491, + "createdAt": "2025-07-03T20:25:55.760196Z", + "updatedAt": "2026-10-08T19:15:11.075055Z", + "competitive": 0.8168584888648914, + "volume24hr": 42625.55068400003, + "volume1wk": 202303.91317900023, + "volume1mo": 1383747.9125339987, + "volume1yr": 12864620.79898, + "enableOrderBook": true, + "liquidityClob": 596808.08713, + "commentCount": 709, + "series": [ + { + "id": "10150", + "ticker": "xi-jinping-out", + "slug": "xi-jinping-out", + "title": "Xi Jinping out", + "seriesType": "single", + "image": "", + "icon": "", + "active": true, + "closed": false, + "archived": false, + "featured": false, + "restricted": true, + "createdAt": "2025-07-03T20:26:34.992616Z", + "updatedAt": "2026-10-08T19:15:33.973549Z", + "volume24hr": 42625.55068400003, + "volume": 14467978.535614997, + "liquidity": 596808.08713, + "commentCount": 819 + } + ], + "cyom": false, + "showAllOutcomes": true, + "showMarketImages": true, + "enableNegRisk": false, + "automaticallyActive": true, + "seriesSlug": "xi-jinping-out", + "negRiskAugmented": false, + "cumulativeMarkets": false, + "pendingDeployment": false, + "deploying": false, + "eventMetadata": { + "context_requires_regen": false + }, + "version": "v1" + } + ], + "ready": false, + "funded": false, + "acceptingOrdersTimestamp": "2025-07-03T20:36:33Z", + "tags": [ + { + "id": "103715", + "label": "HFC", + "slug": "hfc" + }, + { + "id": "366", + "label": "world affairs", + "slug": "world-affairs" + }, + { + "id": "100265", + "label": "Geopolitics", + "slug": "geopolitics" + }, + { + "id": "101970", + "label": "World", + "slug": "world" + }, + { + "id": "102458", + "label": "Earn 3.25%", + "slug": "earn-4" + }, + { + "id": "101253", + "label": "Macro Geopolitics", + "slug": "macro-geopolitics" + } + ], + "cyom": false, + "competitive": 0.8168584888648914, + "approved": true, + "rewardsMinSize": 200, + "rewardsMaxSpread": 3.5, + "spread": 0.001, + "oneWeekPriceChange": -0.0015, + "oneMonthPriceChange": -0.012, + "oneYearPriceChange": -0.1285, + "lastTradePrice": 0.027, + "bestBid": 0.026, + "bestAsk": 0.027, + "automaticallyActive": true, + "clearBookOnStart": true, + "manualActivation": false, + "negRiskOther": false, + "umaResolutionStatuses": "[]", + "pendingDeployment": false, + "deploying": false, + "deployingTimestamp": "2025-07-03T20:35:48.270879Z", + "rfqEnabled": false, + "holdingRewardsEnabled": true, + "feesEnabled": true, + "feeType": "politics_fees", + "feeSchedule": { + "exponent": 1, + "rate": 0.04, + "takerOnly": true, + "rebateRate": 0.25 + }, + "version": "v1" + } + ] + } + ] +} diff --git a/tests/providers/fixtures/polymarket/gamma_keyset_closed.json b/tests/providers/fixtures/polymarket/gamma_keyset_closed.json new file mode 100644 index 00000000..690e0244 --- /dev/null +++ b/tests/providers/fixtures/polymarket/gamma_keyset_closed.json @@ -0,0 +1,1224 @@ +{ + "_recorded": "2026-10-08 GET https://gamma-api.polymarket.com/markets/keyset?closed=true&include_tag=true (records via /markets?id=..; long text fields trimmed)", + "pages": [ + { + "$schema": "https://gamma-api.polymarket.com/schemas/MarketsKeysetListResponse.json", + "markets": [ + { + "id": "240379", + "question": "[Single Market] Will Ron DeSantis win the U.S. 2024 Republican presidential nomination?", + "conditionId": "0x12a0cb60174abc437bf1178367c72d11f069e1a3add20b148fb0ab4279b772b2", + "slug": "will-ron-desantis-win-the-us-2024-republican-presidential-nomination", + "endDate": "2024-09-10T00:00:00Z", + "category": "US-current-affairs", + "startDate": "2023-02-23T16:36:44.22Z", + "fee": "18000000000000000", + "outcomes": "[\"Yes\", \"No\"]", + "outcomePrices": "[\"0\", \"1\"]", + "volume": "490480.591708", + "active": true, + "marketType": "normal", + "closed": true, + "updatedBy": 15, + "createdAt": "2022-01-11T02:07:03.024Z", + "updatedAt": "2024-07-18T15:38:43.630074Z", + "closedTime": "2024-07-18 00:10:37.491895+00", + "wideFormat": false, + "new": false, + "sentDiscord": false, + "featured": false, + "twitterCardLocation": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-ron-desantis-win-the-us-2024-republican-presidential-nomination.png?1661168669254", + "twitterCardLastRefreshed": "1661168669254", + "twitterCardLastValidated": "1659021711.8772585", + "archived": false, + "resolvedBy": "0xCB1822859cEF82Cd2Eb4E6276C7916e692995130", + "restricted": true, + "groupItemTitle": "Ron DeSantis", + "groupItemThreshold": "2", + "questionID": "0x71489e408cd07bfb310f01af681ed3503295ffefdb09a644c34fa3f9fe3f420c", + "umaEndDate": "NOW*()", + "enableOrderBook": true, + "orderPriceMinTickSize": 0.001, + "orderMinSize": 5, + "umaResolutionStatus": "resolved", + "volumeNum": 490480.59, + "endDateIso": "2024-09-10", + "startDateIso": "2022-01-12", + "umaEndDateIso": "2024-09-30", + "hasReviewedDates": true, + "readyForCron": true, + "commentsEnabled": true, + "volume1wk": 0, + "volume1mo": 0, + "volume1yr": 0, + "clobTokenIds": "[\"10715043962341292242163832255523703116280692082153281340642602592290195499485\", \"80085489243215790077454313513728486445319747473476157602131800551595373270758\"]", + "comboStatus": "disabled", + "disqusThread": "rnom", + "fpmmLive": true, + "volume1wkAmm": 0, + "volume1moAmm": 0, + "volume1yrAmm": 0, + "volume1wkClob": 0, + "volume1moClob": 0, + "volume1yrClob": 0, + "volumeAmm": 93982.967916, + "volumeClob": 396497.623792, + "liquidityAmm": 39.126132, + "acceptingOrders": false, + "events": [ + { + "id": "5828", + "ticker": "who-will-win-the-us-2024-republican-presidential-nomination", + "slug": "who-will-win-the-us-2024-republican-presidential-nomination", + "title": "[Single Markets] Republican Nominee 2024", + "startDate": "2023-02-23T16:36:43.906Z", + "creationDate": "2022-01-11T00:00:00Z", + "endDate": "2024-09-10T00:00:00Z", + "active": true, + "closed": true, + "archived": false, + "new": false, + "featured": false, + "restricted": true, + "volume": 7614716.103852, + "openInterest": 0, + "sortBy": "ascending", + "category": "US-current-affairs", + "published_at": "2022-07-27 14:44:29.925+00", + "updatedBy": "15", + "createdAt": "2022-07-27T14:44:29.929Z", + "updatedAt": "2024-07-18T22:22:42.767192Z", + "commentsEnabled": false, + "volume1wk": 0, + "volume1mo": 0, + "volume1yr": 0, + "featuredImage": "https://polymarket-upload.s3.us-east-2.amazonaws.com/trump_r_nom_cp.jpg", + "disqusThread": "rnom", + "enableOrderBook": true, + "liquidityAmm": 1624.65156, + "commentCount": 33, + "cyom": false, + "closedTime": "2024-07-18T00:10:37.491895Z", + "showAllOutcomes": true, + "showMarketImages": true, + "automaticallyResolved": true, + "enableNegRisk": false, + "negRiskAugmented": false, + "pendingDeployment": false, + "deploying": false, + "version": "v1" + } + ], + "creator": "", + "ready": false, + "funded": false, + "tags": [ + { + "id": "443", + "label": "2024 republican presidential nomination", + "slug": "2024-republican-presidential-nomination" + }, + { + "id": "143", + "label": "u.s. 2024 republican presidential nomination", + "slug": "uptspt-2024-republican-presidential-nomination" + }, + { + "id": "189", + "label": "presidential nomination", + "slug": "presidential-nomination" + }, + { + "id": "56", + "label": "republicans", + "slug": "republicans" + }, + { + "id": "274", + "label": "presidential election 2024", + "slug": "presidential-election-2024" + }, + { + "id": "432", + "label": "mike pence", + "slug": "mike-pence" + }, + { + "id": "25", + "label": "tim scott", + "slug": "tim-scott" + }, + { + "id": "185", + "label": "2024 election", + "slug": "2024-election" + }, + { + "id": "187", + "label": "kanye west", + "slug": "kanye-west" + }, + { + "id": "144", + "label": "Elections", + "slug": "elections" + }, + { + "id": "292", + "label": "glenn youngkin", + "slug": "glenn-youngkin" + }, + { + "id": "126", + "label": "Trump", + "slug": "trump" + }, + { + "id": "99", + "label": "u.s. 2024 elections", + "slug": "uptspt-2024-elections" + }, + { + "id": "186", + "label": "joe rogan", + "slug": "joe-rogan" + }, + { + "id": "382", + "label": "chris christie", + "slug": "chris-christie" + }, + { + "id": "55", + "label": "vivek ramaswamy", + "slug": "vivek-ramaswamy" + }, + { + "id": "188", + "label": "U.S. Politics", + "slug": "uptspt-politics" + }, + { + "id": "433", + "label": "peter thiel", + "slug": "peter-thiel" + }, + { + "id": "230", + "label": "donald trump jr.", + "slug": "donald-trump-jrpt" + }, + { + "id": "184", + "label": "ben shapiro", + "slug": "ben-shapiro" + }, + { + "id": "183", + "label": "u.s. presidential election", + "slug": "uptspt-presidential-election" + }, + { + "id": "442", + "label": "u.s. presidency", + "slug": "uptspt-presidency" + }, + { + "id": "100215", + "label": "All", + "slug": "all" + }, + { + "id": "2", + "label": "Politics", + "slug": "politics" + }, + { + "id": "160", + "label": "ron desantis", + "slug": "ron-desantis" + }, + { + "id": "26", + "label": "republican party", + "slug": "republican-party" + }, + { + "id": "57", + "label": "2024 presidential election", + "slug": "2024-presidential-election" + } + ], + "cyom": false, + "approved": true, + "rewardsMinSize": 0, + "rewardsMaxSpread": 0, + "spread": 0.005, + "automaticallyResolved": true, + "oneDayPriceChange": 0, + "oneHourPriceChange": 0, + "oneWeekPriceChange": 0, + "oneMonthPriceChange": 0, + "oneYearPriceChange": 0, + "lastTradePrice": 0, + "bestBid": 0, + "bestAsk": 1, + "clearBookOnStart": true, + "manualActivation": false, + "negRiskOther": false, + "umaResolutionStatuses": "[]", + "pendingDeployment": false, + "deploying": false, + "rfqEnabled": false, + "holdingRewardsEnabled": false, + "feesEnabled": false, + "feeType": null, + "version": "v1" + }, + { + "id": "240613", + "question": "Which party wins 2024 US Presidential Election?", + "conditionId": "0x26ee82bee2493a302d21283cb578f7e2fff2dd15743854f53034d12420863b55", + "slug": "which-party-will-win-the-2024-united-states-presidential-election", + "endDate": "2024-11-08T12:00:00Z", + "category": "US-current-affairs", + "startDate": "2022-01-12T21:22:33.938Z", + "fee": "18000000000000000", + "outcomes": "[\"Democratic\", \"Republican\"]", + "outcomePrices": "[\"0\", \"1\"]", + "volume": "8828319.27610304", + "active": true, + "marketType": "normal", + "closed": true, + "updatedBy": 15, + "createdAt": "2022-01-12T11:02:46.648Z", + "updatedAt": "2024-11-16T02:57:00.77111Z", + "closedTime": "2024-11-14 23:58:16+00", + "wideFormat": true, + "new": false, + "sentDiscord": false, + "featured": false, + "twitterCardLocation": "https://polymarket-upload.s3.us-east-2.amazonaws.com/which-party-will-win-the-2024-united-states-presidential-election.png?1661169276568", + "twitterCardLastRefreshed": "1661169276568", + "twitterCardLastValidated": "1659020346.5099592", + "archived": false, + "resolvedBy": "0xCB1822859cEF82Cd2Eb4E6276C7916e692995130", + "restricted": true, + "groupItemThreshold": "0", + "questionID": "0x22b180e61b0628d5a2c3fc05ad0e3bf19a499c492e4e620178519cdea1dacec2", + "umaEndDate": "2024-11-14T18:58:16-05:00", + "enableOrderBook": true, + "orderPriceMinTickSize": 0.001, + "orderMinSize": 5, + "umaResolutionStatus": "resolved", + "volumeNum": 8828319.27610304, + "endDateIso": "2024-11-08", + "startDateIso": "2022-01-12", + "umaEndDateIso": "2024-12-10", + "hasReviewedDates": true, + "readyForCron": true, + "volume1wk": 0, + "volume1mo": 0, + "volume1yr": 0, + "clobTokenIds": "[\"11015470973684177829729219287262166995141465048508201953575582100565462316088\", \"65444287174436666395099524416802980027579283433860283898747701594488689243696\"]", + "comboStatus": "disabled", + "umaBond": "500", + "umaReward": "5", + "fpmmLive": true, + "volume1wkAmm": 0, + "volume1moAmm": 0, + "volume1yrAmm": 0, + "volume1wkClob": 0, + "volume1moClob": 0, + "volume1yrClob": 0, + "volumeAmm": 46624.491994, + "volumeClob": 8781694.78410904, + "liquidityAmm": 133.787243, + "acceptingOrders": false, + "negRisk": false, + "events": [ + { + "id": "4076", + "ticker": "which-party-will-win-the-2024-united-states-presidential-election", + "slug": "which-party-will-win-the-2024-united-states-presidential-election", + "title": "Which party wins 2024 US Presidential Election?", + "startDate": "2022-01-12T21:22:33.938Z", + "creationDate": "2022-01-12T00:00:00Z", + "endDate": "2024-11-08T12:00:00Z", + "active": true, + "closed": true, + "archived": false, + "new": false, + "featured": false, + "restricted": true, + "volume": 8828319.27610304, + "openInterest": 0, + "sortBy": "ascending", + "category": "US-current-affairs", + "published_at": "2022-07-27 14:40:48.983+00", + "updatedBy": "15", + "createdAt": "2022-07-27T14:40:48.986Z", + "updatedAt": "2026-06-09T17:49:42.118921Z", + "volume1wk": 0, + "volume1mo": 0, + "volume1yr": 0, + "enableOrderBook": true, + "liquidityAmm": 133.787243, + "commentCount": 113, + "cyom": false, + "closedTime": "2024-11-15T03:41:06.18757Z", + "showAllOutcomes": true, + "showMarketImages": true, + "enableNegRisk": false, + "automaticallyActive": true, + "negRiskAugmented": false, + "pendingDeployment": false, + "deploying": false, + "eventMetadata": { + "context_requires_regen": true + }, + "version": "v1" + } + ], + "creator": "", + "ready": false, + "funded": false, + "tags": [ + { + "id": "100215", + "label": "All", + "slug": "all" + }, + { + "id": "24", + "label": "deprec USA Election", + "slug": "deprec-us-election" + }, + { + "id": "2", + "label": "Politics", + "slug": "politics" + }, + { + "id": "57", + "label": "2024 presidential election", + "slug": "2024-presidential-election" + }, + { + "id": "165", + "label": "United States", + "slug": "united-states" + }, + { + "id": "144", + "label": "Elections", + "slug": "elections" + } + ], + "cyom": false, + "approved": true, + "rewardsMinSize": 200, + "rewardsMaxSpread": 1.5, + "spread": 0.001, + "oneHourPriceChange": 0, + "oneWeekPriceChange": 0, + "oneMonthPriceChange": 0, + "oneYearPriceChange": 0, + "lastTradePrice": 1, + "bestBid": 0.001, + "bestAsk": 0.002, + "automaticallyActive": true, + "clearBookOnStart": true, + "manualActivation": false, + "negRiskOther": false, + "umaResolutionStatuses": "[]", + "pendingDeployment": false, + "deploying": false, + "rfqEnabled": false, + "holdingRewardsEnabled": false, + "feesEnabled": false, + "feeType": null, + "version": "v1" + }, + { + "id": "500853", + "question": "Trump vs. Boden: First to 1B", + "conditionId": "0x5372a3ff6b0ca44d7fa7eff126faf594517a50a3ce233ddc154442a4097c2411", + "slug": "trump-vs-boden-first-to-1b", + "endDate": "2024-12-31T12:00:00Z", + "startDate": "2024-04-15T15:37:22.034Z", + "outcomes": "[\"Trump\", \"Boden\"]", + "outcomePrices": "[\"0.5\", \"0.5\"]", + "volume": "255812.759575002", + "active": true, + "closed": true, + "createdAt": "2024-04-09T22:35:48.796997Z", + "updatedAt": "2026-01-20T05:12:15.206029Z", + "closedTime": "2025-01-01 09:21:52+00", + "new": true, + "featured": false, + "archived": false, + "resolvedBy": "0x6A9D222616C90FcA5754cd1333cFD9b7fb6a4F74", + "restricted": true, + "groupItemTitle": "", + "groupItemThreshold": "0", + "questionID": "0x556987b9e5b6fd7e6a01f03fbb2e03667aeda77a13ef4ed689319dffa61e85eb", + "umaEndDate": "2025-01-01T09:21:52Z", + "enableOrderBook": true, + "orderPriceMinTickSize": 0.01, + "orderMinSize": 5, + "umaResolutionStatus": "resolved", + "volumeNum": 255812.759575002, + "endDateIso": "2024-12-31", + "startDateIso": "2024-04-15", + "hasReviewedDates": true, + "volume1wk": 0, + "volume1mo": 0, + "volume1yr": 0, + "clobTokenIds": "[\"85464247525674436907818943481553906889196072524811373633635970307829189878286\", \"79130183691080064644955278643092233061002239391067511792986002638937026320712\"]", + "comboStatus": "disabled", + "umaBond": "500", + "umaReward": "5", + "fpmmLive": true, + "volume1wkAmm": 0, + "volume1moAmm": 0, + "volume1yrAmm": 0, + "volume1wkClob": 0, + "volume1moClob": 0, + "volume1yrClob": 0, + "volumeClob": 255812.759575002, + "acceptingOrders": false, + "negRisk": false, + "events": [ + { + "id": "10310", + "ticker": "trump-vs-boden-first-to-1b", + "slug": "trump-vs-boden-first-to-1b", + "title": "Trump vs. Boden: First to 1B", + "startDate": "2024-04-15T15:40:17.765916Z", + "creationDate": "2024-04-15T15:40:17.765912Z", + "endDate": "2024-12-31T12:00:00Z", + "active": true, + "closed": true, + "archived": false, + "new": false, + "featured": false, + "restricted": false, + "volume": 255812.759575002, + "createdAt": "2024-04-09T22:35:48.613029Z", + "updatedAt": "2026-01-20T05:12:14.538682Z", + "volume1wk": 0, + "volume1mo": 0, + "volume1yr": 0, + "enableOrderBook": true, + "negRisk": false, + "commentCount": 21, + "cyom": false, + "closedTime": "2025-01-01T09:21:52Z", + "showAllOutcomes": true, + "showMarketImages": true, + "automaticallyResolved": true, + "enableNegRisk": false, + "automaticallyActive": true, + "negRiskAugmented": false, + "pendingDeployment": false, + "deploying": false, + "version": "v1" + } + ], + "ready": false, + "funded": false, + "tags": [ + { + "id": "100126", + "label": "TREMP", + "slug": "tremp" + }, + { + "id": "100150", + "label": "Memecoins", + "slug": "memecoins" + }, + { + "id": "1312", + "label": "Crypto Prices", + "slug": "crypto-prices" + }, + { + "id": "100114", + "label": "Boden", + "slug": "boden" + }, + { + "id": "1384", + "label": "Prices", + "slug": "prices" + }, + { + "id": "100215", + "label": "All", + "slug": "all" + } + ], + "cyom": false, + "approved": true, + "rewardsMinSize": 200, + "rewardsMaxSpread": 3.5, + "spread": 0.01, + "automaticallyResolved": true, + "oneHourPriceChange": 0, + "oneWeekPriceChange": 0, + "oneMonthPriceChange": 0, + "oneYearPriceChange": 0, + "lastTradePrice": 1, + "bestBid": 0.5, + "bestAsk": 0.51, + "automaticallyActive": true, + "clearBookOnStart": true, + "manualActivation": false, + "negRiskOther": false, + "umaResolutionStatuses": "[]", + "pendingDeployment": false, + "deploying": false, + "rfqEnabled": false, + "holdingRewardsEnabled": false, + "feesEnabled": false, + "feeType": null, + "version": "v1" + }, + { + "id": "501517", + "question": "Bieber Baby: Boy or Girl?", + "conditionId": "0x3a20c47fa364a47ede415db68367b5ad7f21fde0434ec6fd12bf41ef5ce6426e", + "slug": "bieber-baby-boy-or-girl", + "endDate": "2024-12-31T12:00:00Z", + "startDate": "2024-05-10T16:28:07.608Z", + "outcomes": "[\"Boy\", \"Girl\"]", + "outcomePrices": "[\"1\", \"0\"]", + "volume": "2003581.897254", + "active": true, + "closed": true, + "createdAt": "2024-05-10T15:48:19.794748Z", + "updatedAt": "2024-08-30T02:33:12.101743Z", + "closedTime": "2024-08-29 02:34:32+00", + "new": false, + "featured": false, + "archived": false, + "resolvedBy": "0x6A9D222616C90FcA5754cd1333cFD9b7fb6a4F74", + "restricted": true, + "groupItemTitle": "", + "groupItemThreshold": "0", + "questionID": "0x5569da1a2c03452d307ac02272994ced01afd45503bcf76ad5e9fd92e4f7b8dd", + "umaEndDate": "2024-08-29T02:34:32Z", + "enableOrderBook": true, + "orderPriceMinTickSize": 0.001, + "orderMinSize": 5, + "umaResolutionStatus": "resolved", + "volumeNum": 2003581.9, + "endDateIso": "2024-12-31", + "startDateIso": "2024-05-10", + "hasReviewedDates": true, + "volume1wk": 0, + "volume1mo": 0, + "volume1yr": 0, + "gameStartTime": "2024-08-28 15:36:00+00", + "clobTokenIds": "[\"32061822660648505190439749582730144962219385395373294493994461427078511278014\", \"91604276568589808052951353038079585121990225849623224127636003468536249443467\"]", + "comboStatus": "disabled", + "umaBond": "500", + "umaReward": "5", + "volume1wkAmm": 0, + "volume1moAmm": 0, + "volume1yrAmm": 0, + "volume1wkClob": 0, + "volume1moClob": 0, + "volume1yrClob": 0, + "volumeClob": 2003581.897254, + "acceptingOrders": false, + "negRisk": false, + "events": [ + { + "id": "10627", + "ticker": "bieber-baby-boy-or-girl", + "slug": "bieber-baby-boy-or-girl", + "title": "Bieber Baby: Boy or Girl?", + "startDate": "2024-05-10T16:29:04.855164Z", + "creationDate": "2024-05-10T16:29:04.855159Z", + "endDate": "2024-12-31T12:00:00Z", + "active": true, + "closed": true, + "archived": false, + "new": false, + "featured": false, + "restricted": true, + "volume": 2003581.897254, + "createdAt": "2024-05-10T15:48:17.994759Z", + "updatedAt": "2024-08-30T02:33:17.403063Z", + "volume1wk": 0, + "volume1mo": 0, + "volume1yr": 0, + "enableOrderBook": true, + "commentCount": 297, + "cyom": false, + "closedTime": "2024-08-29T02:34:32Z", + "showAllOutcomes": true, + "showMarketImages": true, + "automaticallyResolved": true, + "enableNegRisk": false, + "automaticallyActive": true, + "negRiskAugmented": false, + "pendingDeployment": false, + "deploying": false, + "version": "v1" + } + ], + "ready": false, + "funded": false, + "acceptingOrdersTimestamp": "2024-05-10T16:26:54Z", + "tags": [ + { + "id": "596", + "label": "Culture", + "slug": "pop-culture" + }, + { + "id": "286", + "label": "Celebrities", + "slug": "celebrities" + }, + { + "id": "1535", + "label": "celebrity", + "slug": "celebrity" + }, + { + "id": "100215", + "label": "All", + "slug": "all" + } + ], + "cyom": false, + "approved": true, + "rewardsMinSize": 50, + "rewardsMaxSpread": 4.5, + "spread": 0.001, + "automaticallyResolved": true, + "oneDayPriceChange": 0.0085, + "oneHourPriceChange": 0, + "oneWeekPriceChange": 0, + "oneMonthPriceChange": 0, + "oneYearPriceChange": 0, + "lastTradePrice": 1, + "bestBid": 0.999, + "bestAsk": 1, + "automaticallyActive": true, + "clearBookOnStart": true, + "manualActivation": false, + "negRiskOther": false, + "umaResolutionStatuses": "[]", + "pendingDeployment": false, + "deploying": false, + "rfqEnabled": false, + "holdingRewardsEnabled": false, + "feesEnabled": false, + "feeType": null, + "version": "v1" + } + ], + "next_cursor": "MzMfo31_7wsqXsjvfkCKpxjnLEQksP9BvChtCJBJg9B7InYiOjEsImsiOiJtYXJrZXRzIiwib2giOiI0ZjUzY2RhMThjMmJhYTBjMDM1NGJiNWY5YTNlY2JlNWVkMTJhYjRkOGUxMWJhODczYzJmMTExNjEyMDJiOTQ1Iiwia2V5cyI6W3sidCI6InN0cmluZyIsInYiOiIxNyJ9XX0" + }, + { + "$schema": "https://gamma-api.polymarket.com/schemas/MarketsKeysetListResponse.json", + "markets": [ + { + "id": "516719", + "question": "Russia x Ukraine ceasefire in 2025?", + "conditionId": "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "endDate": "2025-12-31T12:00:00Z", + "startDate": "2024-12-29T22:51:33.18Z", + "outcomes": "[\"Yes\", \"No\"]", + "outcomePrices": "[\"0\", \"1\"]", + "volume": "73752993.267722", + "active": true, + "closed": true, + "createdAt": "2024-12-29T18:47:35.140745Z", + "updatedAt": "2026-01-02T08:33:28.010856Z", + "closedTime": "2026-01-01 07:18:23+00", + "new": false, + "featured": true, + "archived": false, + "resolvedBy": "0x6A9D222616C90FcA5754cd1333cFD9b7fb6a4F74", + "restricted": true, + "groupItemTitle": "", + "groupItemThreshold": "0", + "questionID": "0x9c5e79f6f5071d2dd10596c382fb5dc459cef4ee4d34f45e5af3fda326f42ae0", + "umaEndDate": "2026-01-01T07:18:23Z", + "enableOrderBook": true, + "orderPriceMinTickSize": 0.001, + "orderMinSize": 5, + "umaResolutionStatus": "resolved", + "volumeNum": 73752993.267722, + "endDateIso": "2025-12-31", + "startDateIso": "2024-12-29", + "hasReviewedDates": true, + "volume1wk": 8839352.265745996, + "volume1mo": 26965777.784983076, + "volume1yr": 73728282.60743912, + "gameStartTime": "2025-08-18 19:00:00+00", + "clobTokenIds": "[\"15974786252393396629980467963784550802583781222733347534844974829144359265969\", \"42351579455604873042957090441032286075880144797259231030267680078160718556927\"]", + "comboStatus": "disabled", + "umaBond": "500", + "umaReward": "5", + "volume1wkClob": 8839352.265745996, + "volume1moClob": 26965777.784983076, + "volume1yrClob": 73728282.60743912, + "volumeClob": 73752993.267722, + "acceptingOrders": false, + "negRisk": false, + "events": [ + { + "id": "16108", + "ticker": "russia-x-ukraine-ceasefire-in-2025", + "slug": "russia-x-ukraine-ceasefire-in-2025", + "title": "Russia x Ukraine ceasefire in 2025?", + "startDate": "2024-12-29T22:52:46.877592Z", + "creationDate": "2024-12-29T22:52:46.877588Z", + "endDate": "2025-12-31T12:00:00Z", + "active": true, + "closed": true, + "archived": false, + "new": false, + "featured": false, + "restricted": true, + "volume": 73752993.267722, + "openInterest": 0, + "createdAt": "2024-12-29T18:47:34.400889Z", + "updatedAt": "2026-05-13T08:54:13.377451Z", + "volume1wk": 8839352.265745996, + "volume1mo": 26965777.784983076, + "volume1yr": 73728282.60743912, + "enableOrderBook": true, + "commentCount": 20371, + "series": [ + { + "id": "10058", + "ticker": "russia-x-ukraine-ceasefire", + "slug": "russia-x-ukraine-ceasefire", + "title": "Russia x Ukraine Ceasefire", + "seriesType": "single", + "recurrence": "", + "image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-trump-issue-an-executive-order-on-february-14-IRIODX3MAqwP.jpg", + "icon": "https://polymarket-upload.s3.us-east-2.amazonaws.com/how-many-executive-orders-will-trump-sign-in-february-yNd2eOKrgBfO.jpg", + "active": true, + "closed": false, + "archived": false, + "featured": false, + "restricted": true, + "createdAt": "2025-03-13T05:04:52.248289Z", + "updatedAt": "2026-09-30T13:44:40.617933Z", + "commentCount": 13582 + } + ], + "cyom": false, + "closedTime": "2026-01-01T07:18:23Z", + "showAllOutcomes": true, + "showMarketImages": true, + "automaticallyResolved": true, + "enableNegRisk": false, + "automaticallyActive": true, + "eventDate": "2025-08-17", + "startTime": "2025-08-18T19:00:00Z", + "seriesSlug": "russia-x-ukraine-ceasefire", + "negRiskAugmented": false, + "featuredOrder": 21, + "pendingDeployment": false, + "deploying": false, + "eventMetadata": { + "context_requires_regen": true + }, + "version": "v1" + } + ], + "ready": false, + "funded": false, + "acceptingOrdersTimestamp": "2024-12-29T22:50:19Z", + "tags": [ + { + "id": "2", + "label": "Politics", + "slug": "politics" + }, + { + "id": "100265", + "label": "Geopolitics", + "slug": "geopolitics" + }, + { + "id": "101588", + "label": "2025 Predictions", + "slug": "2025-predictions" + }, + { + "id": "96", + "label": "Ukraine", + "slug": "ukraine" + }, + { + "id": "101794", + "label": "Foreign Policy", + "slug": "foreign-policy" + }, + { + "id": "101970", + "label": "World", + "slug": "world" + }, + { + "id": "102477", + "label": "Trump-Putin", + "slug": "trump-putin" + } + ], + "cyom": false, + "approved": true, + "rewardsMinSize": 200, + "rewardsMaxSpread": 3.5, + "spread": 0.001, + "automaticallyResolved": true, + "oneDayPriceChange": -0.002, + "oneWeekPriceChange": -0.0105, + "oneMonthPriceChange": -0.0845, + "oneYearPriceChange": -0.7045, + "lastTradePrice": 1, + "bestAsk": 0.001, + "automaticallyActive": true, + "clearBookOnStart": true, + "manualActivation": false, + "negRiskOther": false, + "umaResolutionStatuses": "[\"proposed\"]", + "pendingDeployment": false, + "deploying": false, + "rfqEnabled": false, + "holdingRewardsEnabled": false, + "feesEnabled": false, + "feeType": null, + "version": "v1" + }, + { + "id": "516723", + "question": "Will no Fed rate cuts happen in 2025?", + "conditionId": "0xebfda84c91d3dafde3f49bd132aadc9e762b88023021806e7345cd526fc7e1fa", + "slug": "will-no-fed-rate-cuts-happen-in-2025", + "endDate": "2025-12-10T12:00:00Z", + "startDate": "2024-12-29T22:44:16.86863Z", + "outcomes": "[\"Yes\", \"No\"]", + "outcomePrices": "[\"0\", \"1\"]", + "volume": "3257922.893311", + "active": true, + "closed": true, + "createdAt": "2024-12-29T19:49:27.907976Z", + "updatedAt": "2025-09-19T00:51:22.673698Z", + "closedTime": "2025-09-17 21:39:36+00", + "new": false, + "featured": false, + "archived": false, + "resolvedBy": "0x2F5e3684cb1F318ec51b00Edba38d79Ac2c0aA9d", + "restricted": true, + "groupItemTitle": "0", + "groupItemThreshold": "0", + "questionID": "0x1bdcc22f99efd4a0af525806c64f7e6ee0a1640517b1d0a4a731367b6554be00", + "umaEndDate": "2025-09-17T21:39:36Z", + "enableOrderBook": true, + "orderPriceMinTickSize": 0.001, + "orderMinSize": 5, + "umaResolutionStatus": "resolved", + "volumeNum": 3257922.893311, + "endDateIso": "2025-12-10", + "startDateIso": "2024-12-29", + "hasReviewedDates": true, + "volume1wk": 236059.86612599998, + "volume1mo": 729035.9635269996, + "volume1yr": 3257922.893311002, + "clobTokenIds": "[\"18020161444416260195750543688224692006431301166904957549340226313298366177208\", \"22815929348267525865179400270560699202940891762662686118783091319501232508008\"]", + "comboStatus": "disabled", + "umaBond": "500", + "umaReward": "5", + "volume1wkClob": 236059.86612599998, + "volume1moClob": 729035.9635269996, + "volume1yrClob": 3257922.893311002, + "volumeClob": 3257922.893311, + "acceptingOrders": false, + "negRisk": true, + "negRiskMarketID": "0x1bdcc22f99efd4a0af525806c64f7e6ee0a1640517b1d0a4a731367b6554be00", + "negRiskRequestID": "0x4dd53c9b0d616686cea42b92e8886696ea98740d1b249052e6d3b801de11ca77", + "events": [ + { + "id": "16085", + "ticker": "how-many-fed-rate-cuts-in-2025", + "slug": "how-many-fed-rate-cuts-in-2025", + "title": "How many Fed rate cuts in 2025?", + "startDate": "2024-12-29T22:50:43.348587Z", + "creationDate": "2024-12-29T22:50:43.348584Z", + "endDate": "2025-12-31T12:00:00Z", + "active": true, + "closed": true, + "archived": false, + "new": false, + "featured": false, + "restricted": true, + "volume": 31441120.049939, + "openInterest": 0, + "createdAt": "2024-12-29T17:39:19.350409Z", + "updatedAt": "2025-12-12T00:34:20.302367Z", + "volume1wk": 5737995.316126002, + "volume1mo": 9220726.018662002, + "volume1yr": 31441120.049939025, + "enableOrderBook": true, + "negRisk": true, + "negRiskMarketID": "0x1bdcc22f99efd4a0af525806c64f7e6ee0a1640517b1d0a4a731367b6554be00", + "commentCount": 86, + "cyom": false, + "closedTime": "2025-12-10T22:20:58Z", + "showAllOutcomes": true, + "showMarketImages": false, + "automaticallyResolved": true, + "enableNegRisk": true, + "automaticallyActive": true, + "gmpChartMode": "default", + "negRiskAugmented": false, + "pendingDeployment": false, + "deploying": false, + "version": "v1" + } + ], + "ready": false, + "funded": false, + "acceptingOrdersTimestamp": "2024-12-29T22:42:48Z", + "tags": [ + { + "id": "107", + "label": "Business", + "slug": "business" + }, + { + "id": "100196", + "label": "Fed Rates", + "slug": "fed-rates" + }, + { + "id": "101588", + "label": "2025 Predictions", + "slug": "2025-predictions" + }, + { + "id": "101800", + "label": "Economic Policy", + "slug": "economic-policy" + }, + { + "id": "159", + "label": "Fed", + "slug": "fed" + }, + { + "id": "101550", + "label": "Jerome Powell", + "slug": "jerome-powell" + }, + { + "id": "100328", + "label": "Economy", + "slug": "economy" + } + ], + "cyom": false, + "approved": true, + "rewardsMinSize": 100, + "rewardsMaxSpread": 3.5, + "spread": 0.001, + "automaticallyResolved": true, + "oneDayPriceChange": -0.003, + "oneWeekPriceChange": -0.0115, + "oneMonthPriceChange": -0.0945, + "lastTradePrice": 1, + "bestAsk": 0.001, + "automaticallyActive": true, + "clearBookOnStart": true, + "seriesColor": "", + "manualActivation": false, + "negRiskOther": false, + "umaResolutionStatuses": "[\"proposed\", \"resolved\"]", + "pendingDeployment": false, + "deploying": false, + "rfqEnabled": false, + "holdingRewardsEnabled": false, + "feesEnabled": false, + "feeType": null, + "version": "v1" + }, + { + "id": "516926", + "question": "MicroStrategy sells any Bitcoin in 2025?", + "conditionId": "0x19ee98e348c0ccb341d1b9566fa14521566e9b2ea7aed34dc407a0ec56be36a2", + "slug": "microstrategy-sell-any-bitcoin-in-2025", + "endDate": "2025-12-31T12:00:00Z", + "startDate": "2024-12-31T18:51:34.648Z", + "outcomes": "[\"Yes\", \"No\"]", + "outcomePrices": "[\"0\", \"1\"]", + "volume": "17976157.529867", + "active": true, + "closed": true, + "createdAt": "2024-12-31T16:02:33.362936Z", + "updatedAt": "2026-06-03T15:20:32.694253Z", + "closedTime": "2026-01-05 04:22:25+00", + "new": false, + "featured": false, + "archived": false, + "resolvedBy": "0x6A9D222616C90FcA5754cd1333cFD9b7fb6a4F74", + "restricted": true, + "groupItemTitle": "December 31, 2025", + "groupItemThreshold": "0", + "questionID": "0xd5bc605ed30b62d5bba74174a7c59fc2b6716a9d7327be9158a70c08011d41c6", + "umaEndDate": "2026-01-05T04:22:25Z", + "enableOrderBook": true, + "orderPriceMinTickSize": 0.001, + "orderMinSize": 5, + "umaResolutionStatus": "resolved", + "volumeNum": 17976157.529867, + "endDateIso": "2025-12-31", + "startDateIso": "2024-12-31", + "hasReviewedDates": true, + "volume1wk": 9044201.708677975, + "volume1mo": 15487391.095966011, + "volume1yr": 17964243.64944699, + "clobTokenIds": "[\"93592949212798121127213117304912625505836768562433217537850469496310204567695\", \"3074539347152748632858978545166555332546941892131779352477699494423276162345\"]", + "comboStatus": "disabled", + "umaBond": "500", + "umaReward": "5", + "volume1wkClob": 9044201.708677975, + "volume1moClob": 15487391.095966011, + "volume1yrClob": 17964243.64944699, + "volumeClob": 17976157.529867, + "makerBaseFee": 1000, + "takerBaseFee": 1000, + "customLiveness": 0, + "acceptingOrders": false, + "negRisk": false, + "events": [ + { + "id": "16167", + "ticker": "microstrategy-sell-any-bitcoin-in-2025", + "slug": "microstrategy-sell-any-bitcoin-in-2025", + "title": "MicroStrategy sells any Bitcoin by ___ ?", + "startDate": "2024-12-31T18:51:45.506005Z", + "creationDate": "2024-12-31T18:51:45.506002Z", + "endDate": "2027-01-01T05:00:00Z", + "active": true, + "closed": true, + "archived": false, + "new": false, + "featured": false, + "restricted": true, + "volume": 407170412.1888655, + "openInterest": 216802060.06480902, + "sortBy": "ascending", + "createdAt": "2024-12-31T16:02:31.965903Z", + "updatedAt": "2026-06-05T00:53:35.666005Z", + "volume1wk": 9044405.248677975, + "volume1mo": 15487665.391580012, + "volume1yr": 17964517.94506099, + "enableOrderBook": true, + "negRisk": false, + "commentCount": 9492, + "cyom": false, + "closedTime": "2026-06-04T00:34:19Z", + "showAllOutcomes": true, + "showMarketImages": false, + "automaticallyResolved": true, + "enableNegRisk": false, + "automaticallyActive": true, + "gmpChartMode": "default", + "negRiskAugmented": false, + "estimateValue": false, + "cumulativeMarkets": false, + "pendingDeployment": false, + "deploying": false, + "eventMetadata": { + "context_requires_regen": true + }, + "version": "v1" + } + ], + "ready": false, + "funded": false, + "acceptingOrdersTimestamp": "2024-12-31T18:50:22Z", + "tags": [ + { + "id": "107", + "label": "Business", + "slug": "business" + }, + { + "id": "100328", + "label": "Economy", + "slug": "economy" + }, + { + "id": "101588", + "label": "2025 Predictions", + "slug": "2025-predictions" + }, + { + "id": "21", + "label": "Crypto", + "slug": "crypto" + }, + { + "id": "101473", + "label": "MicroStrategy", + "slug": "microstrategy" + }, + { + "id": "604", + "label": "Stocks", + "slug": "stocks" + } + ], + "cyom": false, + "approved": true, + "rewardsMinSize": 100, + "rewardsMaxSpread": 3.5, + "spread": 0.001, + "automaticallyResolved": true, + "oneDayPriceChange": -0.001, + "oneWeekPriceChange": -0.006, + "oneMonthPriceChange": -0.0235, + "oneYearPriceChange": -0.1445, + "lastTradePrice": 1, + "bestAsk": 0.001, + "automaticallyActive": true, + "clearBookOnStart": true, + "seriesColor": "", + "showGmpSeries": false, + "showGmpOutcome": false, + "manualActivation": false, + "negRiskOther": false, + "umaResolutionStatuses": "[\"proposed\", \"disputed\", \"proposed\", \"disputed\"]", + "pendingDeployment": false, + "deploying": false, + "rfqEnabled": false, + "holdingRewardsEnabled": false, + "feesEnabled": true, + "feeType": "finance_prices_fees", + "feeSchedule": { + "exponent": 1, + "rate": 0.04, + "takerOnly": true, + "rebateRate": 0.25 + }, + "version": "v1" + } + ] + } + ] +} diff --git a/tests/providers/test_forecastex_provider.py b/tests/providers/test_forecastex_provider.py new file mode 100644 index 00000000..f9ab0b9b --- /dev/null +++ b/tests/providers/test_forecastex_provider.py @@ -0,0 +1,295 @@ +"""Offline tests for the ForecastEx daily-file provider. + +CSV fixtures copy the headers and value formats of the public files probed on 2026-10-08 +(``prices/daily_prices_YYYYMMDD.csv``, ``pairs/pairs_YYYYMMDD.csv``, +``daily_summary/summary_YYYYMMDD.csv``). A missing S3 key answers 404 with an XML body. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime + +import httpx +import polars as pl +import pytest + +from ml4t.data.core.exceptions import DataValidationError, NetworkError, RateLimitError +from ml4t.data.providers.forecastex import ( + FORECASTEX_TRADE_SCHEMA, + MARKET_SCHEMA, + PRICES_SCHEMA, + PRODUCTS_SCHEMA, + ForecastExProvider, +) + +PRICES_HEADER = ( + "event_contract,subtype,expiration_date,date,start_price,high_price,low_price," + "end_price,settlement_price,pair_quantity,open_interest,vwap" +) +PAIRS_HEADER = "pair_id,event_contract,expiration_date,quantity,yes_price,no_price,pair_time" +FED_EXP = "2026-09-16T13:00:00-05:00" +LATER_EXP = "2026-12-09T13:00:00-06:00" +HOURLY_EXP = "2026-09-16T15:00:00-05:00" +NOT_FOUND = ( + 'NoSuchKey' + "The specified key does not exist." +) + + +def _price_rows(contract: str, exp: str, day: str, yes_settle: str, no_settle: str) -> list[str]: + return [ + f"{contract},YES,{exp},{day},0.86,0.90,0.82,0.85,{yes_settle},94393,0,0.87", + f"{contract},NO,{exp},{day},0.14,0.18,0.10,0.15,{no_settle},94393,0,0.13", + ] + + +def _csv(header: str, rows: list[str]) -> str: + return "\n".join([header, *rows]) + "\n" + + +PRICES = { + "20260915": _csv( + PRICES_HEADER, + [ + *_price_rows("FFDEC_091626_E25", FED_EXP, "2026-09-15", "0.86", "0.14"), + *_price_rows("FFDEC_091626_E0", FED_EXP, "2026-09-15", "0.13", "0.87"), + # Delisted after 09-15 with a final payout of 0.00. + *_price_rows("FFDEC_091626_H50", FED_EXP, "2026-09-15", "0.00", "1.00"), + *_price_rows("FFDEC_120926_E0", LATER_EXP, "2026-09-15", "0.40", "0.60"), + ], + ), + "20260916": _csv( + PRICES_HEADER, + [ + *_price_rows("FFDEC_091626_E25", FED_EXP, "2026-09-16", "1.00", "0.00"), + *_price_rows("FFDEC_091626_E0", FED_EXP, "2026-09-16", "0.00", "1.00"), + # Intraday contract: drops out with its daily mark, not its payout. + *_price_rows("HRULAS_09162615_89", HOURLY_EXP, "2026-09-16", "0.90", "0.10"), + *_price_rows("FFDEC_120926_E0", LATER_EXP, "2026-09-16", "0.41", "0.59"), + '"FEDRO_1128_Senate-R,House-R",YES,2029-01-08T15:00:00-06:00,2026-09-16,' + "0.00,0.00,0.00,0.00,,0,0,0.00", + ], + ), + "20260917": _csv( + PRICES_HEADER, + [ + *_price_rows("FFDEC_120926_E0", LATER_EXP, "2026-09-17", "0.42", "0.58"), + '"FEDRO_1128_Senate-R,House-R",YES,2029-01-08T15:00:00-06:00,2026-09-17,' + "0.00,0.00,0.00,0.00,,0,0,0.00", + ], + ), +} + +PAIRS = { + "20260916": _csv( + PAIRS_HEADER, + [ + "CGXVN0KXM07Q,FFDEC_091626_E0,2026-09-16T13:00:00-05:00,25,0.12,0.88," + "2026-09-15T16:18:18.905911443-05:00", + "CGXTRZKZY07Q,YXHBT_123126_100000,2026-12-31T23:59:00-06:00,11,0.19,0.81," + "2026-09-15T16:15:26.12331110-05:00", + "CGXVN0KXM07Q,FFDEC_091626_E0,2026-09-16T13:00:00-05:00,25,0.12,0.88," + "2026-09-15T16:18:18.905911443-05:00", + ], + ), + "20260917": _csv( + PAIRS_HEADER, + [ + "CGXX6TSBG07Q,FFDEC_091626_E25,2026-09-16T13:00:00-05:00,100,0.87,0.13," + "2026-09-16T09:26:10.944740-05:00", + ], + ), +} + +SUMMARY = _csv( + "product_id,product_name,product_category,total_pairs", + ["FFDEC,Fed Decision,Financial Markets,1200", "AXXME,Maine Senate,Elections,50"], +) + + +class S3Fake: + """In-process S3 bucket serving a dict of object keys.""" + + def __init__(self, objects: dict[str, str]) -> None: + self.objects = dict(objects) + self.overrides: dict[str, list[httpx.Response]] = {} + self.requests: list[httpx.Request] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + key = request.url.path.lstrip("/") + queued = self.overrides.get(key) + if queued: + return queued.pop(0) + if key in self.objects: + return httpx.Response(200, text=self.objects[key]) + return httpx.Response(404, text=NOT_FOUND, headers={"content-type": "application/xml"}) + + +def _objects(prices: dict[str, str] = PRICES, pairs: dict[str, str] = PAIRS) -> dict[str, str]: + objects = {f"prices/daily_prices_{day}.csv": text for day, text in prices.items()} + objects.update({f"pairs/pairs_{day}.csv": text for day, text in pairs.items()}) + objects["daily_summary/summary_20260916.csv"] = SUMMARY + return objects + + +@pytest.fixture +def provider(): + provider = ForecastExProvider(rate_limit=(10_000, 1.0)) + yield provider + provider.close() + + +@pytest.fixture +def bucket(provider, use_transport): + fake = S3Fake(_objects()) + fake.requests = use_transport(provider, fake) + return fake + + +class TestResolutionOutcome: + def test_expired_contracts_resolve_from_final_settlement_price(self, provider, bucket): + markets = provider.fetch_markets("2026-09-15", "2026-09-16") + + assert markets.schema == pl.Schema(MARKET_SCHEMA) + rows = {row["ticker"]: row for row in markets.iter_rows(named=True)} + assert rows["FFDEC_091626_E25"]["status"] == "resolved" + assert rows["FFDEC_091626_E25"]["result"] == "yes" + assert rows["FFDEC_091626_E25"]["settlement_value"] == 1.0 + assert rows["FFDEC_091626_E25"]["settlement_ts"] == datetime(2026, 9, 16, 18, 0, tzinfo=UTC) + assert rows["FFDEC_091626_E0"]["result"] == "no" + assert rows["FFDEC_091626_E0"]["settlement_value"] == 0.0 + assert rows["FFDEC_091626_H50"]["result"] == "no" + assert rows["FFDEC_091626_H50"]["last_date"] == date(2026, 9, 15) + # Still listed on 09-17: open, whatever its daily mark. + assert rows["FFDEC_120926_E0"]["status"] == "open" + assert rows["FFDEC_120926_E0"]["result"] is None + assert rows["FFDEC_120926_E0"]["settlement_value"] is None + # Dropped out with a 0.90 mark: expired, but the files do not give the outcome. + assert rows["HRULAS_09162615_89"]["status"] == "expired" + assert rows["HRULAS_09162615_89"]["result"] is None + assert rows["FEDRO_1128_Senate-R,House-R"]["status"] == "open" + assert bucket.requests[-1].url.path == "/prices/daily_prices_20260917.csv" + + def test_unpublished_next_day_leaves_final_day_contracts_open(self, provider, use_transport): + prices = {day: text for day, text in PRICES.items() if day != "20260917"} + fake = S3Fake(_objects(prices=prices)) + use_transport(provider, fake) + + markets = provider.fetch_markets("2026-09-15", "2026-09-16") + + rows = {row["ticker"]: row for row in markets.iter_rows(named=True)} + assert rows["FFDEC_091626_E25"]["status"] == "open" + assert rows["FFDEC_091626_E25"]["result"] is None + # Absent from a later file inside the range: still resolved. + assert rows["FFDEC_091626_H50"]["result"] == "no" + + def test_product_filter(self, provider, bucket): + markets = provider.fetch_markets("2026-09-15", "2026-09-16", product="ffdec") + + assert set(markets["product"].to_list()) == {"FFDEC"} + assert len(markets) == 4 + + +class TestPricesAndTrades: + def test_prices_are_typed_and_quoted_contracts_survive(self, provider, bucket): + prices = provider.fetch_prices("2026-09-16", "2026-09-16") + + assert prices.schema == pl.Schema(PRICES_SCHEMA) + fedro = prices.filter(pl.col("ticker") == "FEDRO_1128_Senate-R,House-R").row(0, named=True) + assert fedro["product"] == "FEDRO" + assert fedro["settlement_price"] is None + e25 = prices.filter( + (pl.col("ticker") == "FFDEC_091626_E25") & (pl.col("side") == "YES") + ).row(0, named=True) + assert e25["expiration"] == datetime(2026, 9, 16, 18, 0, tzinfo=UTC) + assert (e25["open"], e25["high"], e25["low"], e25["close"]) == (0.86, 0.90, 0.82, 0.85) + assert e25["volume"] == 94393.0 + assert e25["vwap"] == 0.87 + + def test_trades_parse_nanosecond_offsets_and_deduplicate(self, provider, bucket): + trades = provider.fetch_trades("2026-09-16", "2026-09-17") + + assert trades.schema == pl.Schema(FORECASTEX_TRADE_SCHEMA) + assert trades["trade_id"].to_list() == ["CGXTRZKZY07Q", "CGXVN0KXM07Q", "CGXX6TSBG07Q"] + first = trades.row(1, named=True) + assert first["timestamp"] == datetime(2026, 9, 15, 21, 18, 18, 905911, tzinfo=UTC) + assert first["price"] == 0.12 + assert first["no_price"] == 0.88 + assert first["count"] == 25.0 + assert first["taker_side"] is None + assert first["is_block_trade"] is False + + def test_trade_filters(self, provider, bucket): + by_product = provider.fetch_trades("2026-09-16", "2026-09-17", product="FFDEC") + by_ticker = provider.fetch_trades("2026-09-16", "2026-09-17", ticker="FFDEC_091626_E25") + + assert set(by_product["product"].to_list()) == {"FFDEC"} + assert by_ticker["trade_id"].to_list() == ["CGXX6TSBG07Q"] + + def test_products(self, provider, bucket): + products = provider.fetch_products("2026-09-16") + + assert products.schema == pl.Schema(PRODUCTS_SCHEMA) + assert products["product"].to_list() == ["FFDEC", "AXXME"] + assert provider.fetch_products("2024-01-01").is_empty() + + +class TestEmptyAndFailing: + def test_missing_files_give_typed_empty_frames(self, provider, bucket): + assert provider.fetch_prices("2025-01-01", "2025-01-02").schema == pl.Schema(PRICES_SCHEMA) + assert provider.fetch_trades("2025-01-01", "2025-01-02").is_empty() + markets = provider.fetch_markets("2025-01-01", "2025-01-02") + assert markets.is_empty() + assert markets.schema == pl.Schema(MARKET_SCHEMA) + + def test_dates_before_first_file_make_no_requests(self, provider, bucket): + assert provider.fetch_prices("2024-01-01", "2024-07-31").is_empty() + assert bucket.requests == [] + + def test_reversed_range_is_rejected(self, provider, bucket): + with pytest.raises(DataValidationError, match="before start"): + provider.fetch_prices("2026-09-17", "2026-09-16") + + def test_missing_column_is_rejected(self, provider, use_transport): + broken = PRICES["20260916"].replace("settlement_price", "settle", 1) + use_transport(provider, S3Fake(_objects(prices={"20260916": broken}))) + + with pytest.raises(DataValidationError, match="lacks columns"): + provider.fetch_prices("2026-09-16", "2026-09-16") + + def test_unparseable_value_is_rejected(self, provider, use_transport): + broken = PRICES["20260916"].replace("0.86,0.90", "n/a,0.90", 1) + use_transport(provider, S3Fake(_objects(prices={"20260916": broken}))) + + with pytest.raises(DataValidationError, match="Unparseable"): + provider.fetch_prices("2026-09-16", "2026-09-16") + + def test_empty_body_is_rejected(self, provider, use_transport): + use_transport(provider, S3Fake(_objects(prices={"20260916": ""}))) + + with pytest.raises(DataValidationError, match="Malformed CSV"): + provider.fetch_prices("2026-09-16", "2026-09-16") + + def test_throttling_is_retried(self, provider, bucket, retry_sleeps): + bucket.overrides["prices/daily_prices_20260916.csv"] = [ + httpx.Response(429, text="slow down", headers={"Retry-After": "2"}) + ] + + prices = provider.fetch_prices("2026-09-16", "2026-09-16") + + assert not prices.is_empty() + retry_sleeps.assert_called_once_with(4.0) + + def test_persistent_throttling_raises(self, provider, bucket): + bucket.overrides["prices/daily_prices_20260916.csv"] = [ + httpx.Response(429) for _ in range(3) + ] + + with pytest.raises(RateLimitError): + provider.fetch_prices("2026-09-16", "2026-09-16") + + def test_server_error_is_retried_then_raised(self, provider, bucket): + bucket.overrides["pairs/pairs_20260916.csv"] = [httpx.Response(503) for _ in range(3)] + + with pytest.raises(NetworkError, match="HTTP 503"): + provider.fetch_trades("2026-09-16", "2026-09-16") diff --git a/tests/providers/test_kalshi_history.py b/tests/providers/test_kalshi_history.py new file mode 100644 index 00000000..7a96a3d5 --- /dev/null +++ b/tests/providers/test_kalshi_history.py @@ -0,0 +1,819 @@ +"""Offline tests for Kalshi market enumeration, resolution outcomes and trade history. + +The fixtures copy the field layout of live responses probed on 2026-10-08: live ``/markets`` +and ``/historical/markets`` records use ``*_dollars``/``*_fp`` fields, and the archive tier +holds markets settled before ``/historical/cutoff``. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Any + +import httpx +import polars as pl +import pytest + +from ml4t.data.core.exceptions import ( + DataValidationError, + NetworkError, + RateLimitError, + SymbolNotFoundError, +) +from ml4t.data.providers.kalshi import ( + CANDLE_SCHEMA, + KALSHI_TRADE_SCHEMA, + MARKET_SCHEMA, + KalshiProvider, +) + +CUTOFF = { + "market_settled_ts": "2026-08-09T00:00:00Z", + "trades_created_ts": "2026-08-09T00:00:00Z", + "orders_updated_ts": "2026-09-24T00:00:00Z", +} +CUTOFF_SECONDS = int(datetime(2026, 8, 9, tzinfo=UTC).timestamp()) + + +def _market(ticker: str, **overrides: Any) -> dict[str, Any]: + market = { + "ticker": ticker, + "event_ticker": ticker.rsplit("-", 1)[0], + "title": f"Market {ticker}", + "market_type": "binary", + "status": "finalized", + "open_time": "2025-09-29T14:00:00Z", + "close_time": "2026-09-16T17:59:00Z", + "result": "yes", + "settlement_value_dollars": "1.0000", + "settlement_ts": "2026-09-16T18:08:08.647338Z", + "expiration_value": "Hike 25bps", + "last_price_dollars": "0.8800", + "volume_fp": "18770408.08", + "open_interest_fp": "10984000.31", + } + market.update(overrides) + return market + + +def _trade(trade_id: str, created: str, **overrides: Any) -> dict[str, Any]: + trade = { + "trade_id": trade_id, + "ticker": "KXFEDDECISION-26SEP-H25", + "created_time": created, + "count_fp": "450.75", + "yes_price_dollars": "0.8800", + "no_price_dollars": "0.1200", + "taker_side": "yes", + "taker_outcome_side": "yes", + "taker_book_side": "bid", + "is_block_trade": False, + } + trade.update(overrides) + return trade + + +def _json(payload: Any, status: int = 200, headers: dict[str, str] | None = None): + return httpx.Response(status, json=payload, headers=headers) + + +class KalshiFake: + """In-process Kalshi API keyed by path; each path serves pages by cursor.""" + + def __init__(self) -> None: + self.pages: dict[str, dict[str, dict[str, Any]]] = {} + self.cutoff: dict[str, Any] | None = CUTOFF + self.overrides: dict[str, list[httpx.Response]] = {} + self.requests: list[httpx.Request] = [] + self.routes: dict[str, dict[str, Any]] = {} + + def add_pages(self, path: str, key: str, pages: list[list[dict[str, Any]]]) -> None: + served: dict[str, dict[str, Any]] = {} + for index, items in enumerate(pages): + cursor_in = "" if index == 0 else f"{path}-c{index}" + cursor_out = f"{path}-c{index + 1}" if index + 1 < len(pages) else "" + served[cursor_in] = {key: items, "cursor": cursor_out} + self.pages[path] = served + + def __call__(self, request: httpx.Request) -> httpx.Response: + path = request.url.path.removeprefix("/trade-api/v2") + queued = self.overrides.get(path) + if queued: + return queued.pop(0) + if path == "/historical/cutoff": + if self.cutoff is None: + return _json({"error": "not found"}, status=404) + return _json(self.cutoff) + if path in self.routes: + return _json(self.routes[path]) + if path not in self.pages: + if path.startswith(("/series/", "/historical/markets/", "/markets/", "/events/")): + return _json({"error": {"code": "not_found"}}, status=404) + return _json({"error": f"unexpected path {path}"}, status=500) + cursor = request.url.params.get("cursor", "") + return _json(self.pages[path][cursor]) + + +@pytest.fixture +def provider(): + provider = KalshiProvider(rate_limit=(10_000, 1.0)) + yield provider + provider.close() + + +@pytest.fixture +def fake(provider, use_transport): + venue = KalshiFake() + venue.requests = use_transport(provider, venue) + return venue + + +def _paths(fake: KalshiFake) -> list[str]: + return [request.url.path.removeprefix("/trade-api/v2") for request in fake.requests] + + +class TestResolutionOutcome: + """The normalized outcome is the reason the market listing exists.""" + + def test_settled_markets_carry_normalized_outcome_from_both_tiers(self, provider, fake): + fake.add_pages( + "/markets", + "markets", + [ + [ + _market("KXFEDDECISION-26SEP-H25"), + _market( + "KXFEDDECISION-26SEP-H0", + result="no", + settlement_value_dollars="0.0000", + ), + ] + ], + ) + fake.add_pages( + "/historical/markets", + "markets", + [ + [ + _market( + "FED-23DEC-T5.25", + close_time="2023-12-13T18:55:00Z", + settlement_ts="2023-12-13T22:09:00.327987Z", + ), + _market( + "KXHIGHNY-24JAN01-T50", + result="scalar", + settlement_value_dollars="0.4000", + ), + ] + ], + ) + + markets = provider.fetch_markets(status="settled") + + assert markets.schema == pl.Schema(MARKET_SCHEMA) + by_ticker = {row["ticker"]: row for row in markets.iter_rows(named=True)} + assert by_ticker["KXFEDDECISION-26SEP-H25"]["result"] == "yes" + assert by_ticker["KXFEDDECISION-26SEP-H25"]["settlement_value"] == 1.0 + assert by_ticker["KXFEDDECISION-26SEP-H25"]["settlement_ts"] == datetime( + 2026, 9, 16, 18, 8, 8, 647338, tzinfo=UTC + ) + assert by_ticker["KXFEDDECISION-26SEP-H0"]["result"] == "no" + assert by_ticker["KXFEDDECISION-26SEP-H0"]["settlement_value"] == 0.0 + assert by_ticker["FED-23DEC-T5.25"]["result"] == "yes" + assert by_ticker["FED-23DEC-T5.25"]["source"] == "historical" + assert by_ticker["KXHIGHNY-24JAN01-T50"]["result"] == "other" + assert by_ticker["KXHIGHNY-24JAN01-T50"]["result_raw"] == "scalar" + assert by_ticker["KXHIGHNY-24JAN01-T50"]["settlement_value"] == 0.4 + + def test_unresolved_market_has_null_outcome(self, provider, fake): + fake.add_pages( + "/markets", + "markets", + [ + [ + _market( + "KXFEDDECISION-26DEC-H0", + status="active", + result="", + settlement_value_dollars=None, + settlement_ts=None, + ) + ] + ], + ) + + markets = provider.fetch_markets(status="open") + + row = markets.row(0, named=True) + assert row["result"] is None + assert row["settlement_value"] is None + assert row["settlement_ts"] is None + + def test_legacy_unsuffixed_fields_are_normalized(self, provider, fake): + """Integer cents (legacy API) and unsuffixed dollar strings both map to dollars.""" + legacy = _market("FED-21SEP-T0.25", result="no", settlement_value=0, last_price=3) + for key in ("settlement_value_dollars", "last_price_dollars", "volume_fp"): + legacy.pop(key) + legacy["volume"] = 120 + archive_strings = _market("FED-22DEC-T4.00", settlement_value="1.0000") + archive_strings.pop("settlement_value_dollars") + fake.add_pages("/markets", "markets", [[]]) + fake.add_pages("/historical/markets", "markets", [[legacy, archive_strings]]) + + markets = provider.fetch_markets() + + rows = {row["ticker"]: row for row in markets.iter_rows(named=True)} + assert rows["FED-21SEP-T0.25"]["settlement_value"] == 0.0 + assert rows["FED-21SEP-T0.25"]["last_price"] == pytest.approx(0.03) + assert rows["FED-21SEP-T0.25"]["volume"] == 120.0 + assert rows["FED-22DEC-T4.00"]["settlement_value"] == 1.0 + + +class TestMarketPagination: + def test_pages_live_then_archive_and_drops_archive_duplicates(self, provider, fake): + fake.add_pages( + "/markets", + "markets", + [[_market("KXA-1")], [_market("KXA-2")]], + ) + fake.add_pages( + "/historical/markets", + "markets", + [[_market("KXA-2", result="no")], [_market("OLD-1")]], + ) + + markets = provider.fetch_markets(status="settled", series_ticker="kxa") + + assert markets["ticker"].to_list() == ["KXA-1", "KXA-2", "OLD-1"] + assert markets["source"].to_list() == ["live", "live", "historical"] + # The live copy wins over the archive duplicate. + assert markets.filter(pl.col("ticker") == "KXA-2")["result"].item() == "yes" + live = [r for r in fake.requests if r.url.path.endswith("/trade-api/v2/markets")] + assert [dict(r.url.params).get("cursor") for r in live] == [None, "/markets-c1"] + assert all(r.url.params["status"] == "settled" for r in live) + assert all(r.url.params["series_ticker"] == "KXA" for r in live) + archive = [r for r in fake.requests if r.url.path.endswith("/historical/markets")] + assert len(archive) == 2 + assert "status" not in archive[0].url.params + assert archive[0].url.params["series_ticker"] == "KXA" + + def test_open_status_never_queries_archive(self, provider, fake): + fake.add_pages("/markets", "markets", [[_market("KXA-1", status="active", result="")]]) + + provider.fetch_markets(status="open") + + assert _paths(fake) == ["/markets"] + + def test_min_close_after_cutoff_skips_archive(self, provider, fake): + fake.add_pages("/markets", "markets", [[_market("KXA-1")]]) + + provider.fetch_markets(status="settled", min_close_ts="2026-09-01") + + assert _paths(fake) == ["/markets", "/historical/cutoff"] + + def test_close_window_is_enforced_on_both_tiers(self, provider, fake): + fake.add_pages( + "/markets", + "markets", + [ + [ + _market("KXA-IN", close_time="2026-09-16T17:59:00Z"), + _market("KXA-LATE", close_time="2026-10-01T00:00:00Z"), + ] + ], + ) + fake.add_pages( + "/historical/markets", + "markets", + [ + [ + _market("OLD-IN", close_time="2026-08-01T00:00:00Z"), + _market("OLD-EARLY", close_time="2023-12-13T18:55:00Z"), + ] + ], + ) + + markets = provider.fetch_markets( + status="settled", + min_close_ts=datetime(2026, 7, 1, tzinfo=UTC), + max_close_ts="2026-09-30", + ) + + assert sorted(markets["ticker"].to_list()) == ["KXA-IN", "OLD-IN"] + live_params = fake.requests[0].url.params + # Kalshi rejects close-time filters combined with status=settled. + assert "min_close_ts" not in live_params + assert "max_close_ts" not in live_params + + def test_close_window_is_sent_to_live_tier_when_compatible(self, provider, fake): + fake.add_pages("/markets", "markets", [[]]) + fake.add_pages("/historical/markets", "markets", [[]]) + + provider.fetch_markets(min_close_ts=1_700_000_000, max_close_ts=1_800_000_000) + + live_params = fake.requests[0].url.params + assert live_params["min_close_ts"] == "1700000000" + assert live_params["max_close_ts"] == "1800000000" + + def test_archive_receives_one_filter_and_the_rest_is_client_side(self, provider, fake): + fake.add_pages("/markets", "markets", [[]]) + fake.add_pages( + "/historical/markets", + "markets", + [ + [ + _market("FED-23DEC-T5.25", event_ticker="FED-23DEC"), + _market( + "KXMVECROSSCATEGORY-S1-X", + event_ticker="FED-23DEC", + mve_collection_ticker="KXMVECROSSCATEGORY", + ), + ] + ], + ) + + markets = provider.fetch_markets( + series_ticker="KXFED", event_ticker="FED-23DEC", mve_filter="exclude" + ) + + assert markets["ticker"].to_list() == ["FED-23DEC-T5.25"] + archive = next(r for r in fake.requests if r.url.path.endswith("/historical/markets")) + assert dict(archive.url.params) == {"limit": "1000", "event_ticker": "FED-23DEC"} + live = fake.requests[0].url.params + assert live["mve_filter"] == "exclude" + assert live["event_ticker"] == "FED-23DEC" + + def test_mve_filter_only_selects_combo_markets_on_both_tiers(self, provider, fake): + combo = {"mve_collection_ticker": "KXMVECROSSCATEGORY"} + fake.add_pages( + "/markets", + "markets", + [[_market("KXMVECROSSCATEGORY-S2026B4-1", **combo), _market("KXA-1")]], + ) + fake.add_pages( + "/historical/markets", + "markets", + [[_market("KXMVESPORTS-S2026-2"), _market("OLD-1")]], + ) + + markets = provider.fetch_markets(status="settled", mve_filter="only") + + assert markets["ticker"].to_list() == [ + "KXMVECROSSCATEGORY-S2026B4-1", + "KXMVESPORTS-S2026-2", + ] + assert fake.requests[0].url.params["mve_filter"] == "only" + archive = next(r for r in fake.requests if r.url.path.endswith("/historical/markets")) + # The archive rejects mve_filter=only (HTTP 400), so it is applied on the client. + assert "mve_filter" not in archive.url.params + + def test_mve_filter_exclude_is_sent_to_archive_without_ticker_filter(self, provider, fake): + fake.add_pages("/markets", "markets", [[]]) + fake.add_pages("/historical/markets", "markets", [[_market("OLD-1")]]) + + provider.fetch_markets(mve_filter="exclude") + + archive = next(r for r in fake.requests if r.url.path.endswith("/historical/markets")) + assert archive.url.params["mve_filter"] == "exclude" + + def test_invalid_mve_filter_is_rejected(self, provider, fake): + with pytest.raises(DataValidationError, match="mve_filter"): + provider.fetch_markets(mve_filter="include") + assert fake.requests == [] + + def test_series_previous_price_and_volume_columns(self, provider, fake): + fake.add_pages( + "/markets", + "markets", + [[_market("KXFEDDECISION-26SEP-H25", previous_price_dollars="0.8700")]], + ) + fake.add_pages( + "/historical/markets", + "markets", + [[_market("FED-23DEC-T5.25", event_ticker="FED-23DEC")]], + ) + + derived = provider.fetch_markets(status="settled") + filtered = provider.fetch_markets(status="settled", series_ticker="kxfed") + + rows = {row["ticker"]: row for row in derived.iter_rows(named=True)} + assert rows["KXFEDDECISION-26SEP-H25"]["series"] == "KXFEDDECISION" + assert rows["KXFEDDECISION-26SEP-H25"]["previous_price"] == 0.87 + assert rows["KXFEDDECISION-26SEP-H25"]["volume"] == 18770408.08 + assert rows["FED-23DEC-T5.25"]["series"] == "FED" + assert set(filtered["series"].to_list()) == {"KXFED"} + + def test_missing_cutoff_endpoint_queries_both_tiers(self, provider, fake): + fake.cutoff = None + fake.add_pages("/markets", "markets", [[_market("KXA-1")]]) + fake.add_pages( + "/historical/markets", + "markets", + [[_market("OLD-1", close_time="2023-12-13T18:55:00Z")]], + ) + + markets = provider.fetch_markets(min_close_ts="2026-09-01", status="settled") + + assert provider.get_historical_cutoff() is None + assert "/historical/markets" in _paths(fake) + assert markets["ticker"].to_list() == ["KXA-1"] + + def test_empty_tiers_return_typed_empty_frame(self, provider, fake): + fake.add_pages("/markets", "markets", [[]]) + fake.add_pages("/historical/markets", "markets", [[]]) + + markets = provider.fetch_markets() + + assert markets.is_empty() + assert markets.schema == pl.Schema(MARKET_SCHEMA) + + def test_max_pages_limits_each_tier(self, provider, fake): + fake.add_pages("/markets", "markets", [[_market("KXA-1")], [_market("KXA-2")]]) + fake.add_pages("/historical/markets", "markets", [[_market("O-1")], [_market("O-2")]]) + + markets = provider.fetch_markets(max_pages=1) + + assert markets["ticker"].to_list() == ["KXA-1", "O-1"] + + +class TestMalformedAndFailingResponses: + def test_missing_markets_list_is_rejected(self, provider, fake): + fake.overrides["/markets"] = [_json({"cursor": ""})] + + with pytest.raises(DataValidationError, match="no 'markets' list"): + provider.fetch_markets(status="open") + + def test_non_json_body_is_rejected(self, provider, fake): + fake.overrides["/markets"] = [httpx.Response(200, text="maintenance")] + + with pytest.raises(DataValidationError, match="Malformed JSON"): + provider.fetch_markets(status="open") + + def test_bad_timestamp_is_rejected(self, provider, fake): + fake.add_pages("/markets", "markets", [[_market("KXA-1", settlement_ts="yesterday")]]) + + with pytest.raises(DataValidationError, match="Malformed market record for KXA-1"): + provider.fetch_markets(status="open") + + def test_repeated_cursor_aborts(self, provider, fake): + page = _json({"markets": [_market("KXA-1")], "cursor": "same"}) + fake.overrides["/markets"] = [page, _json({"markets": [], "cursor": "same"})] + + with pytest.raises(DataValidationError, match="cursor repeated"): + provider.fetch_markets(status="open") + + def test_rate_limit_is_retried_with_server_delay(self, provider, fake, retry_sleeps): + fake.overrides["/markets"] = [ + _json({"error": "too many"}, status=429, headers={"Retry-After": "7"}) + ] + fake.add_pages("/markets", "markets", [[_market("KXA-1")]]) + + markets = provider.fetch_markets(status="open") + + assert markets["ticker"].to_list() == ["KXA-1"] + retry_sleeps.assert_called_once_with(7.0) + + def test_persistent_rate_limit_raises(self, provider, fake): + fake.overrides["/markets"] = [_json({}, status=429) for _ in range(3)] + + with pytest.raises(RateLimitError): + provider.fetch_markets(status="open") + assert len(fake.requests) == 3 + + def test_html_edge_403_is_retried(self, provider, fake): + fake.overrides["/markets"] = [ + httpx.Response(403, text="blocked") + ] + fake.add_pages("/markets", "markets", [[_market("KXA-1")]]) + + markets = provider.fetch_markets(status="open") + + assert len(markets) == 1 + + def test_transport_failure_is_retried_then_raised(self, provider, use_transport): + attempts: list[httpx.Request] = [] + + def refuse(request: httpx.Request) -> httpx.Response: + attempts.append(request) + raise httpx.ConnectError("connection refused", request=request) + + use_transport(provider, refuse) + + with pytest.raises(NetworkError, match="connection refused"): + provider.fetch_markets(status="open") + assert len(attempts) == 3 + + def test_client_error_is_not_retried(self, provider, fake): + fake.overrides["/markets"] = [_json({"error": "bad request"}, status=400)] + + with pytest.raises(NetworkError, match="HTTP 400"): + provider.fetch_markets(status="open") + assert len(fake.requests) == 1 + + +class TestTradeHistory: + def test_trades_span_live_and_archive_with_normalized_columns(self, provider, fake): + fake.add_pages( + "/markets/trades", + "trades", + [ + [_trade("t3", "2026-09-16T17:58:57.123098Z")], + [ + _trade( + "t2", + "2026-08-10T12:00:00Z", + taker_side=None, + taker_outcome_side="no", + count_fp="62.92", + yes_price_dollars="0.8700", + is_block_trade=True, + ) + ], + ], + ) + legacy = { + "trade_id": "t1", + "ticker": "KXFEDDECISION-26SEP-H25", + "created_time": "2026-08-01T09:30:00Z", + "count": 5, + "yes_price": 41, + "taker_side": "no", + } + fake.add_pages( + "/historical/trades", + "trades", + [[legacy, _trade("t2", "2026-08-10T12:00:00Z")]], + ) + + trades = provider.fetch_trades("kxfeddecision-26sep-h25") + + assert trades.schema == pl.Schema(KALSHI_TRADE_SCHEMA) + assert trades["trade_id"].to_list() == ["t1", "t2", "t3"] + first, second, third = trades.iter_rows(named=True) + assert first["price"] == pytest.approx(0.41) + assert first["count"] == 5.0 + assert first["source"] == "historical" + assert first["timestamp"] == datetime(2026, 8, 1, 9, 30, tzinfo=UTC) + assert second["taker_side"] == "no" + assert second["taker_outcome_side"] == "no" + assert second["taker_book_side"] == "bid" + assert second["is_block_trade"] is True + assert first["taker_book_side"] is None + assert second["source"] == "live" + assert third["price"] == 0.88 + assert third["count"] == 450.75 + assert all(r.url.params["ticker"] == "KXFEDDECISION-26SEP-H25" for r in fake.requests[1:]) + + def test_min_ts_after_cutoff_skips_archive(self, provider, fake): + fake.add_pages("/markets/trades", "trades", [[_trade("t1", "2026-09-16T17:00:00Z")]]) + + trades = provider.fetch_trades("KXA-1", min_ts=CUTOFF_SECONDS) + + assert len(trades) == 1 + assert "/historical/trades" not in _paths(fake) + live = next(r for r in fake.requests if r.url.path.endswith("/markets/trades")) + assert live.url.params["min_ts"] == str(CUTOFF_SECONDS) + + def test_max_ts_before_cutoff_skips_live(self, provider, fake): + fake.add_pages("/historical/trades", "trades", [[_trade("t1", "2022-12-12T20:57:45Z")]]) + + trades = provider.fetch_trades("FED-22DEC-T4.00", max_ts="2023-01-01") + + assert len(trades) == 1 + assert "/markets/trades" not in _paths(fake) + + def test_no_trades_returns_typed_empty_frame(self, provider, fake): + fake.add_pages("/markets/trades", "trades", [[]]) + fake.add_pages("/historical/trades", "trades", [[]]) + + trades = provider.fetch_trades("KXA-1") + + assert trades.is_empty() + assert trades.schema == pl.Schema(KALSHI_TRADE_SCHEMA) + + def test_trade_without_price_is_rejected(self, provider, fake): + broken = _trade("t1", "2026-09-16T17:00:00Z") + broken.pop("yes_price_dollars") + fake.add_pages("/markets/trades", "trades", [[broken]]) + + with pytest.raises(DataValidationError, match="missing"): + provider.fetch_trades("KXA-1", include_historical=False) + + def test_trades_rate_limit_is_retried(self, provider, fake, retry_sleeps): + fake.overrides["/markets/trades"] = [_json({}, status=429)] + fake.add_pages("/markets/trades", "trades", [[_trade("t1", "2026-09-16T17:00:00Z")]]) + + trades = provider.fetch_trades("KXA-1", include_historical=False) + + assert len(trades) == 1 + assert retry_sleeps.call_count == 1 + + +def test_existing_list_markets_contract_is_unchanged(provider, fake): + """list_markets keeps returning one page of raw dictionaries.""" + fake.add_pages("/markets", "markets", [[_market("KXA-1")], [_market("KXA-2")]]) + + markets = provider.list_markets(status="settled", limit=5) + + assert [m["ticker"] for m in markets] == ["KXA-1"] + assert json.loads(json.dumps(markets))[0]["result"] == "yes" + + +# Archive candle as served by /historical/markets/{ticker}/candlesticks (probed 2026-10-08): +# unsuffixed dollar strings, null trade prices when nothing traded. +ARCHIVE_CANDLE = { + "end_period_ts": 1702098000, + "open_interest": "7758.00", + "price": { + "close": "0.9900", + "high": "0.9900", + "low": "0.9800", + "mean": "0.9850", + "open": "0.9800", + "previous": "0.9800", + }, + "volume": "114.00", + "yes_ask": {"close": "1.0000", "high": "1.0000", "low": "0.9900", "open": "1.0000"}, + "yes_bid": {"close": "0.9900", "high": "0.9900", "low": "0.9800", "open": "0.9900"}, +} +QUIET_ARCHIVE_CANDLE = { + **ARCHIVE_CANDLE, + "end_period_ts": 1702184400, + "price": { + "close": None, + "high": None, + "low": None, + "mean": None, + "open": None, + "previous": "0.9900", + }, + "volume": "0.00", +} +# Live candle as served by /series/{series}/markets/{ticker}/candlesticks. +LIVE_CANDLE = { + "end_period_ts": 1789581600, + "open_interest_fp": "10984000.31", + "price": { + "close_dollars": "0.8800", + "high_dollars": "0.8800", + "low_dollars": "0.8400", + "mean_dollars": "0.8610", + "open_dollars": "0.8800", + "previous_dollars": "0.8700", + }, + "volume_fp": "577503.04", + "yes_ask": {"close_dollars": "1.0000", "open_dollars": "0.8800"}, + "yes_bid": {"close_dollars": "0.0000", "open_dollars": "0.8700"}, +} +ARCHIVE_PATH = "/historical/markets/FED-23DEC-T5.25/candlesticks" + + +class TestCandles: + def test_archive_candles_are_dollars_not_cents(self, provider, fake): + fake.routes[ARCHIVE_PATH] = {"candlesticks": [ARCHIVE_CANDLE, QUIET_ARCHIVE_CANDLE]} + + candles = provider.fetch_candles( + "FED-23DEC-T5.25", "2023-12-08", "2023-12-11", period="1h", tier="historical" + ) + + assert candles.schema == pl.Schema(CANDLE_SCHEMA) + first, quiet = candles.iter_rows(named=True) + assert (first["open"], first["close"], first["mean"]) == (0.98, 0.99, 0.985) + assert (first["yes_bid_close"], first["yes_ask_close"]) == (0.99, 1.0) + assert (first["volume"], first["open_interest"]) == (114.0, 7758.0) + assert first["timestamp"] == datetime(2023, 12, 9, 5, tzinfo=UTC) + assert quiet["close"] is None + assert quiet["previous"] == 0.99 + + def test_fetch_ohlcv_keeps_archive_candles_in_dollars(self, provider, fake): + fake.routes[ARCHIVE_PATH] = {"candlesticks": [ARCHIVE_CANDLE]} + + bars = provider.fetch_ohlcv("FED-23DEC-T5.25", "2023-12-08", "2023-12-10", "hourly") + + assert bars["close"].to_list() == [0.99] + + def test_archived_market_is_served_by_the_archive_endpoint(self, provider, fake): + fake.routes[ARCHIVE_PATH] = {"candlesticks": [ARCHIVE_CANDLE]} + + candles = provider.fetch_candles("FED-23DEC-T5.25", 1702000000, 1702600000) + + assert candles["source"].to_list() == ["historical"] + assert [r.url.path.removeprefix("/trade-api/v2") for r in fake.requests] == [ + "/series/FED/markets/FED-23DEC-T5.25/candlesticks", + ARCHIVE_PATH, + ] + + def test_live_market_with_series_unlike_its_prefix_is_resolved(self, provider, fake): + live_path = "/series/KXFED/markets/FED-26DEC-T4.00/candlesticks" + fake.routes[live_path] = {"candlesticks": [LIVE_CANDLE]} + fake.routes["/markets/FED-26DEC-T4.00"] = {"market": {"event_ticker": "FED-26DEC"}} + fake.routes["/events/FED-26DEC"] = {"event": {"series_ticker": "KXFED"}} + + candles = provider.fetch_candles("FED-26DEC-T4.00", 1789500000, 1789600000) + + assert candles["source"].to_list() == ["live"] + assert candles["close"].to_list() == [0.88] + assert fake.requests[-1].url.path.endswith(live_path) + + def test_explicit_series_goes_straight_to_the_live_endpoint(self, provider, fake): + live_path = "/series/KXFED/markets/FED-26DEC-T4.00/candlesticks" + fake.routes[live_path] = {"candlesticks": [LIVE_CANDLE]} + + candles = provider.fetch_candles( + "FED-26DEC-T4.00", 1789500000, 1789600000, series_ticker="kxfed" + ) + + assert len(fake.requests) == 1 + row = candles.row(0, named=True) + assert (row["close"], row["previous"], row["yes_bid_close"]) == (0.88, 0.87, 0.0) + assert (row["volume"], row["open_interest"]) == (577503.04, 10984000.31) + + def test_legacy_integer_cents_are_scaled(self, provider, fake): + live_path = "/series/KXINFL/markets/KXINFL-25JAN/candlesticks" + fake.routes[live_path] = { + "candlesticks": [ + { + "end_period_ts": 1736000000, + "price": {"open": 45, "high": 48, "low": 44, "close": 47}, + "volume": 10, + } + ] + } + + candles = provider.fetch_candles("KXINFL-25JAN", 1735900000, 1736100000, period="1d") + bars = provider.fetch_ohlcv("KXINFL-25JAN", "2025-01-03", "2025-01-05", "daily") + + assert candles["close"].to_list() == [0.47] + assert bars["close"].to_list() == [0.47] + + def test_wrong_tier_is_not_masked(self, provider, fake): + with pytest.raises(SymbolNotFoundError): + provider.fetch_candles("FED-23DEC-T5.25", 1702000000, 1702600000, tier="live") + assert len(fake.requests) == 1 + + def test_unknown_market_raises(self, provider, fake): + with pytest.raises(SymbolNotFoundError): + provider.fetch_candles("NOPE-26DEC-X", 1702000000, 1702600000) + + def test_long_ranges_are_split_under_the_candle_limit(self, provider, fake): + fake.routes[ARCHIVE_PATH] = {"candlesticks": [ARCHIVE_CANDLE]} + start = 1_700_000_000 + end = start + 6000 * 3600 + + candles = provider.fetch_candles("FED-23DEC-T5.25", start, end, tier="historical") + + windows = [ + (int(r.url.params["start_ts"]), int(r.url.params["end_ts"])) for r in fake.requests + ] + assert windows == [(start, start + 4999 * 3600), (start + 4999 * 3600, end)] + assert all(r.url.params["period_interval"] == "60" for r in fake.requests) + assert len(candles) == 1 + + def test_empty_and_malformed_candles(self, provider, fake): + fake.routes[ARCHIVE_PATH] = {"candlesticks": []} + empty = provider.fetch_candles("FED-23DEC-T5.25", 1, 2, tier="historical") + assert empty.is_empty() + assert empty.schema == pl.Schema(CANDLE_SCHEMA) + + fake.routes[ARCHIVE_PATH] = {"candlesticks": [{"price": {"close": "0.5"}}]} + with pytest.raises(DataValidationError, match="Malformed candlestick"): + provider.fetch_candles("FED-23DEC-T5.25", 1, 2, tier="historical") + + def test_invalid_period_and_tier(self, provider, fake): + with pytest.raises(DataValidationError, match="Unsupported frequency"): + provider.fetch_candles("FED-23DEC-T5.25", 1, 2, period=5) + with pytest.raises(DataValidationError, match="tier"): + provider.fetch_candles("FED-23DEC-T5.25", 1, 2, tier="archive") + + def test_rate_limited_candles_are_retried(self, provider, fake, retry_sleeps): + fake.overrides[ARCHIVE_PATH] = [_json({}, status=429, headers={"Retry-After": "5"})] + fake.routes[ARCHIVE_PATH] = {"candlesticks": [ARCHIVE_CANDLE]} + + candles = provider.fetch_candles("FED-23DEC-T5.25", 1, 2, tier="historical") + + assert len(candles) == 1 + retry_sleeps.assert_called_once_with(5.0) + + +class TestMarketFrames: + def test_frames_stream_in_chunks(self, provider, fake): + fake.add_pages("/markets", "markets", [[_market("KXA-1"), _market("KXA-2")]]) + fake.add_pages("/historical/markets", "markets", [[_market("OLD-1")]]) + + frames = list(provider.iter_market_frames(chunk_size=2)) + + assert [len(frame) for frame in frames] == [2, 1] + assert all(frame.schema == pl.Schema(MARKET_SCHEMA) for frame in frames) + assert pl.concat(frames)["ticker"].to_list() == ["KXA-1", "KXA-2", "OLD-1"] + + def test_no_matches_yield_nothing(self, provider, fake): + fake.add_pages("/markets", "markets", [[]]) + fake.add_pages("/historical/markets", "markets", [[]]) + + assert list(provider.iter_market_frames()) == [] + + def test_chunk_size_must_be_positive(self, provider, fake): + with pytest.raises(DataValidationError, match="chunk_size"): + list(provider.iter_market_frames(chunk_size=0)) diff --git a/tests/providers/test_polymarket_history.py b/tests/providers/test_polymarket_history.py new file mode 100644 index 00000000..813fd700 --- /dev/null +++ b/tests/providers/test_polymarket_history.py @@ -0,0 +1,408 @@ +"""Offline tests for Polymarket resolved-market listing, price history and trades. + +The fixtures under ``fixtures/polymarket/`` are responses recorded on 2026-10-08 from the +Gamma keyset listing, the CLOB price history and the data-API trades endpoint, with long text +fields trimmed. The fake servers below implement the behaviour verified against the live +endpoints that day: ``/markets/keyset`` pages by ``after_cursor`` (any other name returns page 1 +again), ``/prices-history`` rejects spans over 15 days with HTTP 400 and returns an empty +history for ``interval`` ranges of resolved markets, and ``/trades`` returns trades newest first +with inclusive ``start``/``end`` bounds and rejects ``offset`` above its cap. +""" + +from __future__ import annotations + +import copy +import json +import time +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import httpx +import polars as pl +import pytest + +from ml4t.data.core.exceptions import DataValidationError, NetworkError +from ml4t.data.providers.polymarket import ( + CANDLE_SCHEMA, + MARKET_SCHEMA, + POLYMARKET_TRADE_SCHEMA, + PolymarketProvider, +) + +FIXTURES = Path(__file__).parent / "fixtures" / "polymarket" +KEYSET = json.loads((FIXTURES / "gamma_keyset_closed.json").read_text())["pages"] +KEYSET_ACTIVE = json.loads((FIXTURES / "gamma_keyset_active.json").read_text())["pages"] +TRADES = json.loads((FIXTURES / "data_api_trades.json").read_text())["trades"] +PRICES = json.loads((FIXTURES / "clob_prices_history.json").read_text()) +CURSOR = KEYSET[0]["next_cursor"] +CONDITION_ID = "0x8ee2f1640386310eb5e7ffa596ba9335f2d324e303d21b0dfea6998874445791" +TOKEN_ID = json.loads(KEYSET[1]["markets"][0]["clobTokenIds"])[0] # YES token of 516719 + + +class CallLimitExceeded(AssertionError): + """Raised by a fake server when a client keeps requesting past any sane page count.""" + + +def _keyset_server(pages: list[dict[str, Any]], max_calls: int = 20): + """Serve ``pages`` by ``after_cursor``; unknown or missing cursors get page 1, like Gamma.""" + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] > max_calls: + raise CallLimitExceeded(f"more than {max_calls} keyset requests") + assert request.url.path == "/markets/keyset" + cursor = request.url.params.get("after_cursor") + page = pages[1] if cursor == CURSOR and len(pages) > 1 else pages[0] + return httpx.Response(200, json=page) + + return handler + + +@pytest.fixture +def provider(): + provider = PolymarketProvider(rate_limit=(10_000, 1.0)) + yield provider + provider.close() + + +def _by_id(frame: pl.DataFrame, market_id: str) -> dict[str, Any]: + return frame.filter(pl.col("market_id") == market_id).row(0, named=True) + + +class TestMarketListing: + def test_keyset_pagination_passes_cursor_as_after_cursor(self, provider, use_transport): + """Prevents: sending the cursor under another name, which Gamma ignores (page 1 again).""" + requests = use_transport(provider, _keyset_server(KEYSET)) + + markets = provider.fetch_markets() + + assert markets.height == 7 + assert markets["market_id"].n_unique() == 7 + assert requests[0].url.params.get("after_cursor") is None + assert requests[1].url.params["after_cursor"] == CURSOR + assert len(requests) == 2 + + def test_repeated_cursor_raises_instead_of_looping(self, provider, use_transport): + """Prevents: an endless loop when the server keeps answering page 1 and its cursor.""" + stuck = [KEYSET[0]] # the server ignores the cursor and always returns page 1 + use_transport(provider, _keyset_server(stuck, max_calls=5)) + + with pytest.raises(DataValidationError, match="cursor repeated"): + list(provider.iter_markets()) + + def test_filters_are_sent_and_reapplied_on_the_client(self, provider, use_transport): + """Prevents: rows outside the volume or end-date window leaking if Gamma ignores them.""" + requests = use_transport(provider, _keyset_server(KEYSET)) + + markets = provider.fetch_markets( + min_volume=1_000_000, end_date_min="2025-01-01", end_date_max="2025-12-31T23:59:59Z" + ) + + params = requests[0].url.params + assert params["closed"] == "true" + assert params["include_tag"] == "true" + assert float(params["volume_num_min"]) == 1_000_000 + assert params["end_date_min"] == "2025-01-01T00:00:00Z" + assert params["end_date_max"] == "2025-12-31T23:59:59Z" + # The fake server ignores the filters; the client must drop the 2024 and low-volume rows. + assert sorted(markets["market_id"].to_list()) == ["516719", "516723", "516926"] + assert (markets["volume"] >= 1_000_000).all() + + def test_outcomes_map_by_position_not_label(self, provider, use_transport): + """Prevents: team-name markets losing their result, and 50/50 resolutions not void.""" + use_transport(provider, _keyset_server(KEYSET)) + + markets = provider.fetch_markets() + + party = _by_id(markets, "240613") # ["Democratic", "Republican"], Republican won + assert (party["yes_outcome"], party["no_outcome"]) == ("Democratic", "Republican") + assert party["result"] == "no" + assert party["settlement_value"] == 0.0 + baby = _by_id(markets, "501517") # ["Boy", "Girl"], Boy won + assert (baby["yes_outcome"], baby["result"], baby["settlement_value"]) == ( + "Boy", + "yes", + 1.0, + ) + boden = _by_id(markets, "500853") # Trump vs. Boden, resolved 50/50 + assert boden["result"] == "void" + assert boden["settlement_value"] == 0.5 + assert boden["outcome_prices"] == [0.5, 0.5] + ceasefire = _by_id(markets, "516719") + tokens = json.loads(KEYSET[1]["markets"][0]["clobTokenIds"]) + assert (ceasefire["yes_token_id"], ceasefire["no_token_id"]) == tuple(tokens) + assert ceasefire["result"] == "no" + + def test_settlement_time_is_closed_time(self, provider, use_transport): + """Prevents: settlement time read from the scheduled endDate instead of closedTime.""" + use_transport(provider, _keyset_server(KEYSET)) + + markets = provider.fetch_markets() + + nomination = _by_id(markets, "240379") # closed in July, scheduled end in September + assert nomination["settlement_ts"] == datetime(2024, 7, 18, 0, 10, 37, 491895, tzinfo=UTC) + assert nomination["close_time"] == datetime(2024, 9, 10, tzinfo=UTC) + assert markets["settlement_ts"].null_count() == 0 + assert markets.schema["settlement_ts"] == MARKET_SCHEMA["settlement_ts"] + + def test_open_market_is_unresolved(self, provider, use_transport): + """Prevents: last-trade prices of a market still trading being read as a resolution.""" + requests = use_transport(provider, _keyset_server(KEYSET_ACTIVE)) + + markets = provider.fetch_markets(closed=False) + + assert requests[0].url.params["closed"] == "false" + row = markets.row(0, named=True) + assert row["closed"] is False + assert row["outcome_prices"] == [0.0265, 0.9735] + assert (row["result"], row["settlement_value"], row["settlement_ts"]) == (None, None, None) + + def test_tags_fees_and_neg_risk_columns(self, provider, use_transport): + """Prevents: topic tags or fee terms going missing (category is null on recent markets).""" + use_transport(provider, _keyset_server(KEYSET)) + + markets = provider.fetch_markets() + + fees = _by_id(markets, "516926") + assert fees["fees_enabled"] is True + assert fees["fee_type"] == "finance_prices_fees" + assert (fees["maker_base_fee"], fees["taker_base_fee"]) == (1000.0, 1000.0) + assert json.loads(fees["fee_schedule"])["rate"] == 0.04 + assert "Crypto" in fees["tags"] + assert fees["category"] is None + fed = _by_id(markets, "516723") + assert fed["neg_risk"] is True + assert fed["neg_risk_market_id"].startswith("0x") + assert fed["event_slug"] == "how-many-fed-rate-cuts-in-2025" + assert _by_id(markets, "240379")["category"] == "US-current-affairs" + + def test_market_frames_keep_one_schema_across_chunks(self, provider, use_transport): + """Prevents: per-chunk schema inference (all-null columns as Null) breaking concat.""" + use_transport(provider, _keyset_server(KEYSET)) + + frames = list(provider.iter_market_frames(chunk_size=3)) + + assert [frame.height for frame in frames] == [3, 3, 1] + assert all(frame.schema == pl.Schema(MARKET_SCHEMA) for frame in frames) + assert pl.concat(frames).height == 7 + + def test_empty_listing_returns_typed_empty_frame(self, provider, use_transport): + """Prevents: an untyped empty frame when no market matches the filters.""" + empty = [{"$schema": KEYSET[0]["$schema"], "markets": []}] + use_transport(provider, _keyset_server(empty)) + + markets = provider.fetch_markets(min_volume=1e12) + + assert markets.is_empty() + assert markets.schema == pl.Schema(MARKET_SCHEMA) + + +def _prices_server(history: list[dict[str, Any]], max_window: int = 15 * 86_400): + """Serve the recorded history filtered to the window, enforcing the 15-day span limit.""" + + def handler(request: httpx.Request) -> httpx.Response: + params = request.url.params + if "interval" in params: + return httpx.Response(200, json={"history": []}) # resolved market, interval range + start, end = int(params["startTs"]), int(params["endTs"]) + if end - start > max_window: + return httpx.Response(400, json=PRICES["too_long_window_response"]) + points = [point for point in history if start <= point["t"] <= end] + return httpx.Response(200, json={"history": points}) + + return handler + + +class TestCandles: + def test_long_ranges_are_split_into_15_day_windows(self, provider, use_transport): + """Prevents: one request over 15 days (HTTP 400) or interval=max (empty when resolved).""" + history = PRICES["response"]["history"] + requests = use_transport(provider, _prices_server(history)) + start, end = history[0]["t"] - 20 * 86_400, history[-1]["t"] + 20 * 86_400 + + candles = provider.fetch_candles(TOKEN_ID, start, end, fidelity_minutes=60) + + assert candles.height == len(history) + windows = [(int(r.url.params["startTs"]), int(r.url.params["endTs"])) for r in requests] + assert len(windows) == 3 # 41 days + assert windows[0][0] == start and windows[-1][1] == end + assert all(b - a <= 15 * 86_400 for a, b in windows) + assert all(nxt[0] == prev[1] + 1 for prev, nxt in zip(windows, windows[1:])) + assert all(r.url.params["fidelity"] == "60" for r in requests) + assert all("interval" not in r.url.params for r in requests) + + def test_recorded_history_parses_to_sorted_utc_prices(self, provider, use_transport): + """Prevents: seconds read as milliseconds, naive timestamps or points outside the range.""" + history = PRICES["response"]["history"] + use_transport(provider, _prices_server(history)) + start, end = history[2]["t"], history[5]["t"] + + candles = provider.fetch_candles(TOKEN_ID, start, end) + + assert candles.schema == pl.Schema(CANDLE_SCHEMA) + assert candles["timestamp"].to_list() == [ + datetime.fromtimestamp(point["t"], UTC) for point in history[2:6] + ] + assert candles["price"].to_list() == [point["p"] for point in history[2:6]] + assert set(candles["token_id"].to_list()) == {TOKEN_ID} + + def test_window_too_long_surfaces_as_error(self, provider, use_transport, monkeypatch): + """Prevents: a widened window constant silently passing; the server's 400 must surface.""" + use_transport(provider, _prices_server(PRICES["response"]["history"])) + monkeypatch.setattr(provider, "PRICE_HISTORY_MAX_WINDOW_SECONDS", 16 * 86_400) + + with pytest.raises(NetworkError, match="interval is too long"): + provider.fetch_candles(TOKEN_ID, 0, 30 * 86_400) + + def test_no_history_returns_typed_empty_frame(self, provider, use_transport): + """Prevents: an untyped frame for unknown tokens (the CLOB answers 200 and []).""" + use_transport(provider, _prices_server([])) + + candles = provider.fetch_candles(TOKEN_ID, "2025-06-01", "2025-06-02") + + assert candles.is_empty() + assert candles.schema == pl.Schema(CANDLE_SCHEMA) + + def test_malformed_point_raises(self, provider, use_transport): + """Prevents: a point without a price being stored as a valid sample.""" + use_transport(provider, lambda _: httpx.Response(200, json={"history": [{"t": 1}]})) + + with pytest.raises(DataValidationError, match="Malformed price point"): + provider.fetch_candles(TOKEN_ID, 0, 10) + + +class TestFetchOhlcvPastRanges: + @pytest.fixture + def new_york_time(self, monkeypatch): + """Run in a non-UTC local zone so naive-datetime conversions shift the range.""" + if not hasattr(time, "tzset"): + pytest.skip("time.tzset is unavailable on Windows") + monkeypatch.setenv("TZ", "America/New_York") + time.tzset() + yield + monkeypatch.undo() + time.tzset() + + def test_past_range_is_requested_by_bounds_and_fidelity(self, provider, use_transport): + """Prevents: sending interval with startTs/endTs, which returns no history for the past.""" + requests = use_transport(provider, _prices_server(PRICES["response"]["history"])) + + bars = provider.fetch_ohlcv(TOKEN_ID, "2025-06-01", "2025-06-01", frequency="hourly") + + assert bars.height == 24 + assert all("interval" not in request.url.params for request in requests) + assert {request.url.params["fidelity"] for request in requests} == {"60"} + + @pytest.mark.usefixtures("new_york_time") + def test_dates_are_utc_days_whatever_the_local_zone(self, provider, use_transport): + """Prevents: YYYY-MM-DD bounds read in the machine's local zone, shifting the window.""" + requests = use_transport(provider, _prices_server(PRICES["response"]["history"])) + + bars = provider.fetch_ohlcv(TOKEN_ID, "2025-06-01", "2025-06-01", frequency="hourly") + + assert bars.height == 24 + assert int(requests[0].url.params["startTs"]) == 1748736000 # 2025-06-01T00:00Z + + +def _trades_server(trades: list[dict[str, Any]], max_offset: int, ignore_offset: bool = False): + """Serve trades newest first with inclusive start/end and an offset cap, like the data API.""" + ordered = sorted(trades, key=lambda trade: -trade["timestamp"]) + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] > 50: + raise CallLimitExceeded("more than 50 trade requests") + params = request.url.params + assert params["market"] == CONDITION_ID + limit, offset = int(params["limit"]), int(params.get("offset", 0)) + if offset > max_offset: + return httpx.Response( + 400, json={"error": f"max historical trades offset of {max_offset} exceeded"} + ) + rows = [ + trade + for trade in ordered + if ("start" not in params or trade["timestamp"] >= int(params["start"])) + and ("end" not in params or trade["timestamp"] <= int(params["end"])) + ] + first = 0 if ignore_offset else offset + return httpx.Response(200, json=rows[first : first + limit]) + + return handler + + +class TestTrades: + def test_yes_price_and_taker_side_from_outcome_and_side(self, provider, use_transport): + """Prevents: a NO-token price reported as the YES price, or the taker side inverted.""" + use_transport(provider, _trades_server(TRADES, max_offset=10_000)) + + trades = provider.fetch_trades(CONDITION_ID) + + assert trades.schema == pl.Schema(POLYMARKET_TRADE_SCHEMA) + assert trades.height == len(TRADES) + sell_no = trades.filter((pl.col("outcome_index") == 1) & (pl.col("side") == "SELL")) + assert not sell_no.is_empty() + row = sell_no.row(0, named=True) + assert row["price"] == pytest.approx(1.0 - row["outcome_price"]) + assert row["taker_side"] == "yes" + buy_yes = trades.filter((pl.col("outcome_index") == 0) & (pl.col("side") == "BUY")) + assert (buy_yes["price"] == buy_yes["outcome_price"]).all() + assert (buy_yes["taker_side"] == "yes").all() + assert trades["timestamp"].is_sorted() + assert trades["ticker"].unique().to_list() == [CONDITION_ID] + + def test_paging_past_the_offset_cap_moves_the_end_bound(self, provider, use_transport): + """Prevents: HTTP 400 past the offset cap, or trades lost/duplicated at the boundary.""" + requests = use_transport(provider, _trades_server(TRADES, max_offset=8)) + provider.TRADES_MAX_OFFSET = 8 + + trades = provider.fetch_trades(CONDITION_ID, page_size=4) + + expected = {provider._trade_key(trade) for trade in TRADES} + assert trades.height == len(TRADES) + assert set(trades["trade_id"].to_list()) == expected + assert any("end" in request.url.params for request in requests) + assert max(int(request.url.params["offset"]) for request in requests) <= 8 + + def test_start_and_end_are_sent_and_enforced(self, provider, use_transport): + """Prevents: trades outside the requested window, or bounds not passed to the API.""" + requests = use_transport(provider, _trades_server(TRADES, max_offset=10_000)) + start, end = 1767245799, 1767245803 + + trades = provider.fetch_trades(CONDITION_ID, start=start, end=end) + + assert requests[0].url.params["start"] == str(start) + assert requests[0].url.params["end"] == str(end) + inside = [trade for trade in TRADES if start <= trade["timestamp"] <= end] + assert trades.height == len(inside) == 18 + + def test_repeated_page_raises_instead_of_looping(self, provider, use_transport): + """Prevents: an endless loop when the server ignores offset and repeats one page.""" + use_transport(provider, _trades_server(TRADES, max_offset=10_000, ignore_offset=True)) + + with pytest.raises(DataValidationError, match="ignored offset"): + provider.fetch_trades(CONDITION_ID, page_size=4) + + def test_one_second_with_more_trades_than_a_window_raises(self, provider, use_transport): + """Prevents: an endless loop when one second holds more trades than offsets can reach.""" + same_second = [] + for index in range(12): + trade = copy.deepcopy(TRADES[6]) + trade["transactionHash"] = f"0x{index:064x}" + same_second.append(trade) + use_transport(provider, _trades_server(same_second, max_offset=4)) + provider.TRADES_MAX_OFFSET = 4 + + with pytest.raises(DataValidationError, match="cannot page past"): + provider.fetch_trades(CONDITION_ID, page_size=2) + + def test_malformed_trade_raises(self, provider, use_transport): + """Prevents: a trade with an unknown outcome index being priced as YES or NO.""" + bad = dict(TRADES[0], outcomeIndex=2) + use_transport(provider, _trades_server([bad], max_offset=10_000)) + + with pytest.raises(DataValidationError, match="outcomeIndex"): + provider.fetch_trades(CONDITION_ID) diff --git a/tests/providers/test_polymarket_us_provider.py b/tests/providers/test_polymarket_us_provider.py new file mode 100644 index 00000000..e13802cc --- /dev/null +++ b/tests/providers/test_polymarket_us_provider.py @@ -0,0 +1,323 @@ +"""Offline tests for the Polymarket US public-gateway provider. + +Fixtures copy the layout of ``gateway.polymarket.us`` responses probed on 2026-10-08. One quirk +is load-bearing: ``outcomes`` labels are not always in ``outcomePrices`` order (the FOMC +"maintains" market lists ``["No","Yes"]`` with long-first prices ``["1","0"]``), so the +outcome must come from the long market side. +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Any + +import httpx +import polars as pl +import pytest + +from ml4t.data.core.exceptions import DataValidationError, RateLimitError +from ml4t.data.providers.polymarket_us import ( + MARKET_SCHEMA, + PRICE_HISTORY_SCHEMA, + PolymarketUSProvider, +) + + +def _market( + slug: str, + long_price: str, + *, + outcomes: list[str] | None = None, + outcome_prices: list[str] | None = None, + status: str = "MARKET_STATUS_RESOLVED", + long_label: str = "Yes", + short_label: str = "No", + **overrides: Any, +) -> dict[str, Any]: + short_price = "" if long_price == "" else str(round(1 - float(long_price), 4)).rstrip("0") + market = { + "id": "11307", + "question": "CPI year-over-year in April", + "slug": slug, + "title": "Exactly 3.0", + "category": "macro", + "marketType": "value", + "startDate": "2026-04-07T14:10:44Z", + "endDate": "2026-08-14T03:59:00Z", + "closed": status == "MARKET_STATUS_RESOLVED", + "status": status, + "marketSides": [ + {"description": long_label, "price": long_price, "long": True}, + {"description": short_label, "price": short_price, "long": False}, + ], + "outcomes": json.dumps(outcomes or [long_label, short_label]), + "outcomePrices": json.dumps(outcome_prices or [long_price, short_price]), + "feeCoefficient": 0.0695, + } + market.update(overrides) + return market + + +class GatewayFake: + """In-process gateway: market pages by offset, settlements and price history.""" + + def __init__(self, markets: list[dict[str, Any]], page_cap: int = 500) -> None: + self.markets = markets + self.page_cap = page_cap + self.settlements: dict[str, float] = {} + self.history: dict[str, Any] = {"history": []} + self.overrides: dict[str, list[httpx.Response]] = {} + self.requests: list[httpx.Request] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + queued = self.overrides.get(path) + if queued: + return queued.pop(0) + if path == "/v1/markets": + offset = int(request.url.params.get("offset", "0")) + limit = min(int(request.url.params.get("limit", "100")), self.page_cap) + return httpx.Response(200, json={"markets": self.markets[offset : offset + limit]}) + if path.endswith("/settlement"): + slug = path.split("/")[3] + if slug not in self.settlements: + return httpx.Response(404, json={}) + return httpx.Response(200, json={"slug": slug, "settlement": self.settlements[slug]}) + if path == "/v1/price-history": + return httpx.Response(200, json=self.history) + return httpx.Response(500, json={"error": path}) + + +RESOLVED = [ + # Reversed labels: zipping outcomes with outcomePrices would say "No" won. + _market( + "rdc-usfed-fomc-2026-04-29-maintains", + "1", + outcomes=["No", "Yes"], + outcome_prices=["1", "0"], + ), + _market( + "cpic-uscpi-apr2026yoy-2026-05-12-2pt9pct", + "0", + outcomes=["No", "Yes"], + outcome_prices=["0", "1"], + ), + _market("cpic-uscpi-july-yoy-2026-08-12-2pt7pct", "0.5"), + _market( + "aec-nfl-lac-ten-2025-11-02", + "1", + long_label="Chargers", + short_label="Titans", + category="sports", + ), + _market("usfed-fomc-2026-12-09-hike50", "0.04", status="MARKET_STATUS_OPEN"), +] + + +@pytest.fixture +def provider(): + provider = PolymarketUSProvider(rate_limit=(10_000, 1.0)) + yield provider + provider.close() + + +@pytest.fixture +def gateway(provider, use_transport): + fake = GatewayFake(RESOLVED) + fake.requests = use_transport(provider, fake) + return fake + + +class TestResolutionOutcome: + def test_outcome_comes_from_the_long_market_side(self, provider, gateway): + markets = provider.fetch_markets(closed=True) + + assert markets.schema == pl.Schema(MARKET_SCHEMA) + rows = {row["ticker"]: row for row in markets.iter_rows(named=True)} + maintains = rows["rdc-usfed-fomc-2026-04-29-maintains"] + assert maintains["result"] == "yes" + assert maintains["settlement_value"] == 1.0 + assert maintains["winning_outcome"] == "Yes" + cpi = rows["cpic-uscpi-apr2026yoy-2026-05-12-2pt9pct"] + assert cpi["result"] == "no" + assert cpi["settlement_value"] == 0.0 + assert cpi["winning_outcome"] == "No" + split = rows["cpic-uscpi-july-yoy-2026-08-12-2pt7pct"] + assert split["result"] == "other" + assert split["settlement_value"] == 0.5 + assert split["winning_outcome"] is None + game = rows["aec-nfl-lac-ten-2025-11-02"] + assert (game["result"], game["yes_outcome"], game["winning_outcome"]) == ( + "yes", + "Chargers", + "Chargers", + ) + assert game["settlement_ts"] is None + open_market = rows["usfed-fomc-2026-12-09-hike50"] + assert open_market["result"] is None + assert open_market["settlement_value"] is None + + def test_parsed_fields(self, provider, gateway): + row = provider.fetch_markets().row(0, named=True) + + assert row["market_id"] == "11307" + assert row["outcomes"] == ["No", "Yes"] + assert row["outcome_prices"] == [1.0, 0.0] + assert row["start_date"] == datetime(2026, 4, 7, 14, 10, 44, tzinfo=UTC) + assert row["fee_coefficient"] == 0.0695 + + def test_resolved_market_without_sides_falls_back_to_first_price(self, provider, gateway): + bare = _market("bare-market", "1") + bare.pop("marketSides") + gateway.markets = [bare] + + row = provider.fetch_markets().row(0, named=True) + + assert row["result"] == "yes" + assert row["yes_outcome"] == "Yes" + + def test_settlement_endpoint(self, provider, gateway): + gateway.settlements["rdc-usfed-fomc-2026-04-29-maintains"] = 1 + + assert provider.get_settlement("rdc-usfed-fomc-2026-04-29-maintains") == 1.0 + assert provider.get_settlement("usfed-fomc-2026-12-09-hike50") is None + + +class TestMarketPagination: + def test_offsets_follow_returned_rows_until_an_empty_page(self, provider, use_transport): + fake = GatewayFake(RESOLVED, page_cap=2) + fake.requests = use_transport(provider, fake) + + markets = provider.fetch_markets(closed=True, categories=["macro", "sports"]) + + assert len(markets) == len(RESOLVED) + offsets = [r.url.params["offset"] for r in fake.requests] + assert offsets == ["0", "2", "4", "5"] + first = fake.requests[0].url.params + assert first.get_list("categories") == ["macro", "sports"] + assert first["closed"] == "true" + assert first["limit"] == "500" + + def test_filters_are_encoded(self, provider, gateway): + provider.fetch_markets( + active=False, + slugs=["a", "b"], + end_date_min="2026-09-01", + end_date_max=datetime(2026, 9, 2, tzinfo=UTC), + page_size=50, + max_pages=1, + ) + + params = gateway.requests[0].url.params + assert params["active"] == "false" + assert params.get_list("slug") == ["a", "b"] + assert params["endDateMin"] == "2026-09-01T00:00:00Z" + assert params["endDateMax"] == "2026-09-02T00:00:00Z" + assert params["limit"] == "50" + assert len(gateway.requests) == 1 + + def test_empty_listing_returns_typed_empty_frame(self, provider, use_transport): + use_transport(provider, GatewayFake([])) + + markets = provider.fetch_markets(closed=True) + + assert markets.is_empty() + assert markets.schema == pl.Schema(MARKET_SCHEMA) + + +class TestMalformedAndFailing: + def test_missing_markets_list_is_rejected(self, provider, gateway): + gateway.overrides["/v1/markets"] = [httpx.Response(200, json={"items": []})] + + with pytest.raises(DataValidationError, match="no 'markets' list"): + provider.fetch_markets() + + def test_bad_outcome_prices_are_rejected(self, provider, gateway): + gateway.markets = [_market("broken", "1", outcomePrices="[1, oops")] + + with pytest.raises(DataValidationError, match="Malformed market record for broken"): + provider.fetch_markets() + + def test_resolved_market_without_any_price_is_rejected(self, provider, gateway): + bare = _market("no-price", "1", outcomePrices="[]") + bare.pop("marketSides") + gateway.markets = [bare] + + with pytest.raises(DataValidationError, match="without final prices"): + provider.fetch_markets() + + def test_rate_limit_is_retried(self, provider, gateway, retry_sleeps): + gateway.overrides["/v1/markets"] = [httpx.Response(429, headers={"Retry-After": "9"})] + + markets = provider.fetch_markets() + + assert len(markets) == len(RESOLVED) + retry_sleeps.assert_called_once_with(9.0) + + def test_persistent_rate_limit_raises(self, provider, gateway): + gateway.overrides["/v1/markets"] = [httpx.Response(429) for _ in range(3)] + + with pytest.raises(RateLimitError): + provider.fetch_markets() + + def test_non_numeric_settlement_is_rejected(self, provider, gateway): + gateway.overrides["/v1/markets/x/settlement"] = [ + httpx.Response(200, json={"slug": "x", "settlement": "n/a"}) + ] + + with pytest.raises(DataValidationError, match="Non-numeric settlement"): + provider.get_settlement("x") + + +class TestPriceHistory: + def test_full_history_uses_fixed_interval(self, provider, gateway): + gateway.history = { + "history": [ + {"timestamp": 1775606400, "longPrice": 0.15, "shortPrice": 0.99}, + {"timestamp": 1775520000, "longPrice": 0.5, "shortPrice": 0.99}, + ] + } + + history = provider.fetch_price_history("cpic-uscpi-apr2026yoy-2026-05-12-3pt0pct") + + assert history.schema == pl.Schema(PRICE_HISTORY_SCHEMA) + assert history["timestamp"].to_list() == [ + datetime(2026, 4, 7, tzinfo=UTC), + datetime(2026, 4, 8, tzinfo=UTC), + ] + assert history["long_price"].to_list() == [0.5, 0.15] + params = gateway.requests[0].url.params + assert params["fixedInterval"] == "INTERVAL_ALL" + assert params["fidelity"] == "180" + + def test_custom_range_is_split_into_24_hour_windows(self, provider, gateway): + gateway.history = {"history": [{"timestamp": 1778457600, "longPrice": 0.01}]} + + history = provider.fetch_price_history("slug", start="2026-05-11", end="2026-05-12T06:00") + + windows = [ + (r.url.params["timestamp.startTimestamp"], r.url.params["timestamp.endTimestamp"]) + for r in gateway.requests + ] + assert windows == [("1778457600", "1778544000"), ("1778544000", "1778565600")] + assert all(r.url.params["fidelity"] == "1" for r in gateway.requests) + # The same point returned by both windows appears once; a missing side is null. + assert len(history) == 1 + assert history["short_price"].to_list() == [None] + + def test_empty_history_returns_typed_empty_frame(self, provider, gateway): + history = provider.fetch_price_history("slug") + + assert history.is_empty() + assert history.schema == pl.Schema(PRICE_HISTORY_SCHEMA) + + def test_half_open_range_is_rejected(self, provider, gateway): + with pytest.raises(DataValidationError, match="both start and end"): + provider.fetch_price_history("slug", start="2026-05-11") + + def test_malformed_point_is_rejected(self, provider, gateway): + gateway.history = {"history": [{"longPrice": 0.5}]} + + with pytest.raises(DataValidationError, match="Malformed price-history point"): + provider.fetch_price_history("slug") diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index e74b370a..66d4e09a 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -180,7 +180,11 @@ def test_public_provider_integrations_are_manually_reachable() -> None: "test_binance_public.py::TestBinancePublicProvider::test_fetch_daily_spot_btc", "test_coingecko.py::TestCoinGeckoProvider::test_fetch_ohlcv_btc", "test_kalshi.py::TestKalshiProvider::test_list_markets", + "test_kalshi.py::TestKalshiResolvedHistory", + "test_forecastex.py", + "test_polymarket_us.py", "test_polymarket.py::TestPolymarketProvider::test_get_market_by_slug", + "test_polymarket.py::TestPolymarketResolvedHistory", "test_yahoo.py::TestYahooFinanceProvider::test_fetch_ohlcv_stock_daily", "test_release_provider_contracts.py", ): diff --git a/uv.lock b/uv.lock index 6e86b6a9..78629b70 100644 --- a/uv.lock +++ b/uv.lock @@ -1662,101 +1662,97 @@ wheels = [ [[package]] name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +version = "6.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, ] [[package]] @@ -2705,14 +2701,14 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.5.1" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/b7/1581a8103855c43567776aa34135e5ec3c597346c23bfd10c7eb5e0b10a4/python_discovery-1.5.1.tar.gz", hash = "sha256:e2ea8b884cd1701f386eda8cf327b87743f1dc21b7f784470799537d95635384", size = 77200, upload-time = "2026-07-31T22:06:02.48Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/57/250bd238b966cece44328235eb85290045d059265fdaf7527a3a958123db/python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", size = 84338, upload-time = "2026-09-18T01:31:53.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl", hash = "sha256:ac07f44cade589d954e9d6a1e1468539fdddd2cf676beb51da73e0f156b7c932", size = 35752, upload-time = "2026-07-31T22:06:01.116Z" }, + { url = "https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3", size = 38664, upload-time = "2026-09-18T01:31:52.448Z" }, ] [[package]] @@ -3118,26 +3114,27 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] name = "virtualenv" -version = "21.7.1" +version = "21.14.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, + { name = "packaging" }, { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/fa/18004e5cb15541ad2a68ff219c755233b012b12d4ec8663d06a258082bec/virtualenv-21.7.1.tar.gz", hash = "sha256:d0dbfaa5483487baea28d7210ef8d24c9d1bd0f10f449eeb215568825a9b334e", size = 5525237, upload-time = "2026-07-30T15:40:36.36Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/f3/589727d02bc832750cfa00ced4315d127535a22a6d7cfcb369b2fe219a2e/virtualenv-21.14.6.tar.gz", hash = "sha256:c6b74616e64bffa9532cfa9dfd54ffbab0dc9b999df2bb8ec0d75e559b983661", size = 5467961, upload-time = "2026-10-08T14:49:57.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/a7/ded126c19495158a05c7202b3389139839d4cf78d622d453867778e0f7a8/virtualenv-21.7.1-py3-none-any.whl", hash = "sha256:6394973f990536e34c05157179146c020284c42fe01da1dfeb0ba16c345280d9", size = 5504576, upload-time = "2026-07-30T15:40:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/e5182163bcee149dda4eec823d88b0f51f2c0c531b12da896f666f0b80c1/virtualenv-21.14.6-py3-none-any.whl", hash = "sha256:eb1a20c5878569b69667f6a1af03458a8512b3afa7be771a2dafe92fdca05bb9", size = 5487469, upload-time = "2026-10-08T14:49:54.988Z" }, ] [[package]]